From 55531e9bb2490f0dc462665dd5fff0a8b331e5b8 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 16:56:22 +0200 Subject: [PATCH 01/15] =?UTF-8?q?test(ci):=20RED=20case=20table=20for=20th?= =?UTF-8?q?e=20signed-receipt=20PR=20gate=20=E2=80=94=20signature,=20tree,?= =?UTF-8?q?=20toolchain=20(Refs=20#5797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine rows, each expecting an exact reason; the verifier is a fail-closed stub so every row is RED. Gate change: solo, two-family quorum, non-author sign-off. Co-Authored-By: Claude Sonnet 5.5 --- docs/roadmaps/entries/PMAT-5797.yaml | 18 +++++ scripts/check_receipt_gate.sh | 105 +++++++++++++++++++++++++++ scripts/ci_receipt_gate.sh | 4 + scripts/receipt_toolchain_id.sh | 14 ++++ 4 files changed, 141 insertions(+) create mode 100644 docs/roadmaps/entries/PMAT-5797.yaml create mode 100755 scripts/check_receipt_gate.sh create mode 100755 scripts/ci_receipt_gate.sh create mode 100755 scripts/receipt_toolchain_id.sh diff --git a/docs/roadmaps/entries/PMAT-5797.yaml b/docs/roadmaps/entries/PMAT-5797.yaml new file mode 100644 index 0000000000..64ece10939 --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5797.yaml @@ -0,0 +1,18 @@ +- id: PMAT-5797 + github_issue: 5797 + item_type: task + title: 'PR gate: signed batch receipt verify (sig, tree, toolchain) as tier=reuse extension' + status: planned + priority: high + assigned_to: null + created: 2026-10-20T14:55:28Z + updated: 2026-10-20T14:55:28Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5510 diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh new file mode 100755 index 0000000000..9ed1a6ea37 --- /dev/null +++ b/scripts/check_receipt_gate.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# check_receipt_gate.sh — case table for scripts/ci_receipt_gate.sh (PMAT-5797, #5797). +# The PR gate may skip the full test suite only for a batch whose signed receipt verifies: +# S the signature is valid for the pinned batcher public key, +# T the receipt's tree hash is the PR merge tree, +# C the receipt's toolchain hash is this checkout's pinned toolchain. +# Every row builds a throwaway repo and a throwaway key, so the table is hermetic. +# A row that expects a reason must see exactly that reason: a verifier that fails for +# any other cause does not satisfy it. `bash scripts/check_receipt_gate.sh` exits 0 +# only when every row holds. +set -u +HERE=$(cd "$(dirname "$0")" && pwd) +V="$HERE/ci_receipt_gate.sh" +TID="$HERE/receipt_toolchain_id.sh" +NS="flow009-receipt" +td=$(mktemp -d) || exit 2 +trap 'rm -rf "${td:?}"' EXIT +fail=0 +pass=0 + +command -v ssh-keygen >/dev/null 2>&1 || { echo "ENV: ssh-keygen missing"; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "ENV: jq missing"; exit 2; } + +ssh-keygen -q -t ed25519 -N '' -C batcher -f "$td/key" >/dev/null +ssh-keygen -q -t ed25519 -N '' -C other -f "$td/other" >/dev/null +printf '# batcher public key (test)\n%s\n' "$(cut -d' ' -f1,2 "$td/key.pub")" >"$td/pub" + +repo="$td/repo" +git init -q "$repo" +git -C "$repo" config user.email t@example.invalid +git -C "$repo" config user.name t +printf '[toolchain]\nchannel = "stable"\n' >"$repo/rust-toolchain.toml" +printf 'x\n' >"$repo/a.txt" +git -C "$repo" add -A +git -C "$repo" commit -q -m "base" >/dev/null 2>&1 +printf 'y\n' >>"$repo/a.txt" +git -C "$repo" commit -q -am "batch (PMAT-1)" >/dev/null 2>&1 +tree=$(git -C "$repo" rev-parse 'HEAD^{tree}') +head=$(git -C "$repo" rev-parse HEAD) +tc=$(cd "$repo" && bash "$TID") + +# receipt TREE TOOLCHAIN -> unsigned receipt JSON on stdout +receipt() { + jq -n -S -c --arg tree "$1" --arg tc "$2" --arg sha "$head" ' + {schema:"flow009.receipt/1",repo:"r",batch:"b",base_sha:$sha,tree:$tree, + commits:[{ticket:"PMAT-1",patch_id:"p",sha:$sha}],gate_results:{tests:"pass",coverage:"pass"}, + toolchain:$tc,manifest:"m",steps:"s",host:"h",started:"t0",finished:"t1",sig:""}' +} +# sign KEY RECEIPT_JSON_FILE -> signed JSON (canonical bytes: jq -S -c del(.sig) plus LF) +sign() { + jq -S -c 'del(.sig)' "$2" >"$td/canon" + rm -f "$td/canon.sig" + ssh-keygen -q -Y sign -f "$1" -n "$NS" "$td/canon" >/dev/null 2>&1 + jq -S -c --rawfile sig "$td/canon.sig" '.sig = $sig' "$2" +} +# body FILE JSON -> a PR body holding the one fenced receipt block +body() { printf 'Batch.\n\n```flow009.receipt/1\n%s\n```\n' "$2" >"$1"; } + +# row NAME EXPECT BODY_FILE [PUB] — EXPECT is "ok" or the exact reason +row() { + local name=$1 want=$2 bf=$3 pub=${4:-$td/pub} out + out=$(cd "$repo" && bash "$V" verify "$bf" "$pub" "$head" 2>&1 | head -n1) + if [ "$want" = ok ]; then + [ "$out" = "RECEIPT_OK=1" ] && { pass=$((pass + 1)); echo "ok $name"; return; } + else + [ "$out" = "RECEIPT_OK=0 reason=$want" ] && { pass=$((pass + 1)); echo "ok $name"; return; } + fi + fail=$((fail + 1)) + echo "FAIL $name: want '$want' got '$out'" +} + +good=$(receipt "$tree" "$tc"); printf '%s\n' "$good" >"$td/good.json" +signed=$(sign "$td/key" "$td/good.json") + +body "$td/b-good" "$signed" +row "G1 valid receipt: signature, tree and toolchain all hold" ok "$td/b-good" + +# S: signature +body "$td/b-s1" "$(printf '%s' "$signed" | jq -S -c '.batch = "tampered"')" +row "S1 a field changed after signing -> bad-signature" bad-signature "$td/b-s1" +printf '%s\n' "$good" >"$td/g2.json" +body "$td/b-s2" "$(sign "$td/other" "$td/g2.json")" +row "S2 signed by a key that is not the pinned batcher key -> bad-signature" bad-signature "$td/b-s2" +body "$td/b-s3" "$good" +row "S3 unsigned receipt (empty sig) -> bad-signature" bad-signature "$td/b-s3" +printf '# placeholder, no key yet\n' >"$td/pub-empty" +row "S4 placeholder public key fails closed -> placeholder-pubkey" placeholder-pubkey "$td/b-good" "$td/pub-empty" +printf 'no receipt here\n' >"$td/b-none" +row "S5 no fenced receipt block -> fenced-block" fenced-block "$td/b-none" + +# T: tree hash == merge tree +receipt "0000000000000000000000000000000000000000" "$tc" >"$td/t1.json" +body "$td/b-t1" "$(sign "$td/key" "$td/t1.json")" +row "T1 correctly signed receipt for another tree -> tree-mismatch" tree-mismatch "$td/b-t1" + +# C: toolchain hash +receipt "$tree" "$(printf 'not-this-toolchain' | sha256sum | cut -d' ' -f1)" >"$td/c1.json" +body "$td/b-c1" "$(sign "$td/key" "$td/c1.json")" +row "C1 correctly signed receipt for another toolchain -> toolchain-mismatch" toolchain-mismatch "$td/b-c1" +printf '[toolchain]\nchannel = "nightly"\n' >"$repo/rust-toolchain.toml" +row "C2 the checkout's pinned toolchain changed after signing -> toolchain-mismatch" toolchain-mismatch "$td/b-good" +printf '[toolchain]\nchannel = "stable"\n' >"$repo/rust-toolchain.toml" + +echo "receipt gate rows: $pass passed, $fail failed" +[ "$fail" -eq 0 ] diff --git a/scripts/ci_receipt_gate.sh b/scripts/ci_receipt_gate.sh new file mode 100755 index 0000000000..054f097754 --- /dev/null +++ b/scripts/ci_receipt_gate.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# ci_receipt_gate.sh verify BODY_FILE PUB_FILE PR_HEAD_SHA (PMAT-5797, #5797) +# STUB: fails closed until the three checks (signature, tree, toolchain) land. +printf 'RECEIPT_OK=0 reason=unimplemented\n' diff --git a/scripts/receipt_toolchain_id.sh b/scripts/receipt_toolchain_id.sh new file mode 100755 index 0000000000..638787b620 --- /dev/null +++ b/scripts/receipt_toolchain_id.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Print one sha256 hex identifying the build toolchain: rust-toolchain.toml (nothing when +# absent), the full `rustc -vV`, then the literal line "image=none". Same bytes, same order +# as the batcher's signing side, so a receipt's toolchain field compares equal here. +# A missing rustc exits 1 (the verifier fails closed). +set -u +root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +rustc_v=$(rustc -vV 2>/dev/null) || exit 1 +[ -n "$rustc_v" ] || exit 1 +{ + if [ -f "$root/rust-toolchain.toml" ]; then cat "$root/rust-toolchain.toml"; fi + printf '%s\n' "$rustc_v" + printf 'image=none\n' +} | sha256sum | cut -d' ' -f1 From 1f4127903df9c9fc4a38d7d142e42a53ed89502d Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 16:56:55 +0200 Subject: [PATCH 02/15] =?UTF-8?q?feat(ci):=20signed-receipt=20verifier=20?= =?UTF-8?q?=E2=80=94=20signature,=20tree,=20toolchain;=20fail=20closed=20(?= =?UTF-8?q?Refs=20#5797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All nine rows GREEN. Mutants that drop the signature, tree or toolchain check turn 3, 1 and 2 rows RED. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ci_receipt_gate.sh | 76 +++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/scripts/ci_receipt_gate.sh b/scripts/ci_receipt_gate.sh index 054f097754..093fc7cd54 100755 --- a/scripts/ci_receipt_gate.sh +++ b/scripts/ci_receipt_gate.sh @@ -1,4 +1,76 @@ #!/usr/bin/env bash # ci_receipt_gate.sh verify BODY_FILE PUB_FILE PR_HEAD_SHA (PMAT-5797, #5797) -# STUB: fails closed until the three checks (signature, tree, toolchain) land. -printf 'RECEIPT_OK=0 reason=unimplemented\n' +# +# The PR gate may run check/fmt/clippy instead of the full suite only for a batch whose +# signed receipt verifies. Three checks decide it, in this order: +# signature the armored ssh-keygen signature over the canonical bytes +# (`jq -S -c 'del(.sig)'` plus LF, namespace below) verifies against the +# single pinned batcher public key in PUB_FILE +# tree the receipt's tree == HEAD^{tree}, the PR merge tree (run inside the +# checked-out merge commit) +# toolchain the receipt's toolchain == scripts/receipt_toolchain_id.sh here +# BODY_FILE is the PR body; the receipt is its one fenced block whose info string is +# exactly the schema id. Prints `RECEIPT_OK=1` or `RECEIPT_OK=0 reason=` as the +# first line and exits 0 either way: the caller falls through to the full gate on 0. +# Anything missing or malformed fails closed. Never on a tag ref or a push event: that +# belongs to the caller (the full gate owns those). +# Cases: scripts/check_receipt_gate.sh. +set -u +NS="flow009-receipt" +SIGNER_ID="flow009-batcher" +SCHEMA="flow009.receipt/1" +HERE=$(cd "$(dirname "$0")" && pwd) + +fail() { printf 'RECEIPT_OK=0 reason=%s\n' "$1"; exit 0; } + +canonical() { jq -S -c 'del(.sig)' "$1"; } + +# The fenced block's contents on stdout; non-zero unless there is exactly one. +extract_block() { + tr -d '\r' <"$1" | awk -v info="\`\`\`$SCHEMA" ' + state == 0 && $0 == info { state = 1; n++; next } + state == 1 && $0 == "```" { state = 0; next } + state == 1 { print } + END { if (n != 1 || state != 0) exit 1 } + ' +} + +cmd_verify() { + [ $# -eq 3 ] || fail usage + body=$1; pub=$2 + command -v ssh-keygen >/dev/null 2>&1 || fail no-ssh-keygen + command -v jq >/dev/null 2>&1 || fail no-jq + [ -f "$body" ] || fail no-body + [ -f "$pub" ] || fail no-pubkey + work=$(mktemp -d) || fail no-tmpdir + trap 'rm -rf "${work:?}"' EXIT + # The key: first non-comment line. A comment-only file is the placeholder. + keyline=$(grep -v -E '^[[:space:]]*(#|$)' "$pub" | head -n1 | cut -d' ' -f1,2) + [ -n "$keyline" ] || fail placeholder-pubkey + case "$keyline" in ssh-ed25519\ *) ;; *) fail bad-pubkey ;; esac + printf '%s namespaces="%s" %s\n' "$SIGNER_ID" "$NS" "$keyline" >"$work/allowed" + extract_block "$body" >"$work/receipt.json" || fail fenced-block + jq -e --arg s "$SCHEMA" ' + type == "object" and .schema == $s + and (.sig | type == "string") and (.tree | type == "string") and (.toolchain | type == "string") + ' "$work/receipt.json" >/dev/null 2>&1 || fail shape + # signature + canonical "$work/receipt.json" >"$work/canon" || fail canon + jq -r '.sig' "$work/receipt.json" >"$work/sig" + ssh-keygen -Y verify -f "$work/allowed" -I "$SIGNER_ID" -n "$NS" -s "$work/sig" \ + <"$work/canon" >/dev/null 2>&1 || fail bad-signature + # tree + want_tree=$(git rev-parse 'HEAD^{tree}' 2>/dev/null) + [ -n "$want_tree" ] || fail no-head-tree + [ "$(jq -r .tree "$work/receipt.json")" = "$want_tree" ] || fail tree-mismatch + # toolchain + want_tc=$(bash "$HERE/receipt_toolchain_id.sh" 2>/dev/null) + [ -n "$want_tc" ] || fail no-toolchain-id + [ "$(jq -r .toolchain "$work/receipt.json")" = "$want_tc" ] || fail toolchain-mismatch + printf 'RECEIPT_OK=1\n' +} + +case "${1:-}" in + verify) shift; cmd_verify "$@" ;; + *) fail usage ;; +esac From f67b72c7819df6ec13a6acd8844c35f6374b119b Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 17:01:50 +0200 Subject: [PATCH 03/15] =?UTF-8?q?feat(ci):=20tier=3Dreuse=20for=20a=20PR?= =?UTF-8?q?=20with=20a=20verified=20signed=20receipt=20=E2=80=94=20check/f?= =?UTF-8?q?mt/clippy=20only=20(Refs=20#5797)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pull_request only; push, merge_group, schedule and tags never read the flag. Seven rows plus a mutant row. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ci_test_tier.sh | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/scripts/ci_test_tier.sh b/scripts/ci_test_tier.sh index d52c6e4cd2..f692eb9f2b 100755 --- a/scripts/ci_test_tier.sh +++ b/scripts/ci_test_tier.sh @@ -112,7 +112,7 @@ set -euo pipefail # `unset` also drops the export attribute, so no child can inherit them. unset SELF_TEST FILTERSET FS_TARGETS SELF_TEST=0; FILTERSET=0 -EVENT=""; COMPARAND=""; DIFF_FROM=""; PR_HEAD=""; PR_CONCLUSION=""; MG_RUN=""; MG_SHA=""; MG_CONCLUSION=""; MG_GIVEN=0; REGISTRY="scripts/tree_reader_tests.txt"; ROOT="." +EVENT=""; COMPARAND=""; DIFF_FROM=""; PR_HEAD=""; PR_CONCLUSION=""; MG_RUN=""; MG_SHA=""; MG_CONCLUSION=""; RECEIPT_OK=0; MG_GIVEN=0; REGISTRY="scripts/tree_reader_tests.txt"; ROOT="." # The sibling scripts and the registry always come from the checkout this script # runs in (cwd = repo root, in ci.yml and in the case table alike); --repo-root # re-points only the GIT queries — HEAD, its parents, their trees — which is how @@ -129,6 +129,7 @@ while [ $# -gt 0 ]; do --mg-run) MG_RUN=$2; MG_GIVEN=1; shift 2 ;; --mg-sha) MG_SHA=$2; MG_GIVEN=1; shift 2 ;; --mg-conclusion) MG_CONCLUSION=$2; MG_GIVEN=1; shift 2 ;; + --receipt-ok) RECEIPT_OK=$2; shift 2 ;; --registry) REGISTRY=$2; shift 2 ;; --repo-root) ROOT=$2; shift 2 ;; --tsv) TSV=$2; shift 2 ;; @@ -140,7 +141,7 @@ while [ $# -gt 0 ]; do --filterset) FILTERSET=1; shift; if [ $# -gt 0 ]; then FS_TARGETS=$1; shift; fi ;; # same operand rules as --filterset; prints the cargo BUILD flags for the same tokens --build-flags) BUILDFLAGS=1; shift; if [ $# -gt 0 ]; then FS_TARGETS=$1; shift; fi ;; - *) printf 'usage: %s --event EVENT [--comparand REF] [--diff-from FILE] [--pr-head SHA --pr-head-conclusion C] [--mg-run ID --mg-sha SHA --mg-conclusion C] [--registry FILE] [--repo-root DIR] | --filterset [TARGETS] | --tier-of-record [--tsv FILE] [--union-touched --event pull_request --comparand REF] | --self-test\n' "$0" >&2; exit 2 ;; + *) printf 'usage: %s --event EVENT [--comparand REF] [--diff-from FILE] [--pr-head SHA --pr-head-conclusion C] [--mg-run ID --mg-sha SHA --mg-conclusion C] [--receipt-ok 1] [--registry FILE] [--repo-root DIR] | --filterset [TARGETS] | --tier-of-record [--tsv FILE] [--union-touched --event pull_request --comparand REF] | --self-test\n' "$0" >&2; exit 2 ;; esac done @@ -369,6 +370,13 @@ decide() { selection "merge_group: group diff $mgb..HEAD (quick tier; full only on tag/release)" "$df" || rc=$? rm -f "${df:?}"; return ${rc:-0} ;; pull_request) + # Signed batch receipt (#5797): scripts/ci_receipt_gate.sh verified signature, tree and + # toolchain for THIS merge tree, so the PR owes check/fmt/clippy and no test suite. The + # flag is honoured on pull_request only: push, merge_group and tags never read it. + if [ "$RECEIPT_OK" = 1 ]; then + printf 'tier=reuse\ncite=receipt\ncheck_workspace=1\nreceipt_gates=check,fmt,clippy\nreason=pull_request: a signed batch receipt verified for this merge tree and toolchain -- cargo check --workspace, fmt and clippy only; the batcher already ran the suite\n' + return 0 + fi selection "pull_request" "$DIFF_FROM" || return $? ;; *) printf 'ENV: unknown event "%s" — refusing to guess a tier\n' "$EVENT" >&2; return 2 ;; esac @@ -497,6 +505,17 @@ self_test() { leaf=$(cargo metadata --no-deps --format-version 1 2>/dev/null | jq -r '[.packages[]|select(.manifest_path|test("/crates/"))] | (map(.name) - (map(.dependencies[]?.name)|unique)) | sort | .[0]') printf 'crates/%s/src/lib.rs\n' "$leaf" > "$td/d-leaf.txt" row 0 "pull_request, a leaf crate ($leaf) touched -> quick with that crate" "^crates=.*$leaf" bash "$T" --event pull_request --diff-from "$td/d-leaf.txt" + # Signed batch receipt (#5797): the verdict of scripts/ci_receipt_gate.sh arrives as --receipt-ok 1. + row 0 "pull_request with a verified receipt -> tier=reuse, cite=receipt" '^tier=reuse$' bash -c "bash '$T' --event pull_request --diff-from '$td/d-core.txt' --receipt-ok 1 | head -n1" + row 0 " ...owing check_workspace=1 and exactly check,fmt,clippy (no test suite)" '^check_workspace=1 receipt_gates=check,fmt,clippy$' bash -c "o=\$(bash '$T' --event pull_request --diff-from '$td/d-core.txt' --receipt-ok 1); printf '%s %s\\n' \"\$(grep '^check_workspace=' <<< \"\$o\")\" \"\$(grep '^receipt_gates=' <<< \"\$o\")\"" + row 0 " ...even when the diff touches a root manifest (the receipt vouches for the whole tree)" '^tier=reuse$' bash -c "bash '$T' --event pull_request --diff-from '$td/d-root.txt' --receipt-ok 1 | head -n1" + row 0 "pull_request with --receipt-ok 0 is unchanged: still the quick selection" '^tier=quick$' bash -c "bash '$T' --event pull_request --diff-from '$td/d-leaf.txt' --receipt-ok 0 | head -n1" + row 0 "schedule ignores --receipt-ok: tier=full" '^tier=full$' bash -c "bash '$T' --event schedule --receipt-ok 1 | head -n1" + row 0 "push ignores --receipt-ok: never reuse without a merge_group result" '^NOT-RECEIPT$' bash -c "o=\$(bash '$T' --event push --repo-root '$td/q-leaf' --receipt-ok 1); if grep -q '^cite=receipt' <<< \"\$o\"; then echo RECEIPT; else echo NOT-RECEIPT; fi" + row 0 "merge_group ignores --receipt-ok: the queue still owes its own tier" '^NOT-RECEIPT$' bash -c "o=\$(env -u MG_BASE_SHA bash '$T' --event merge_group --repo-root '$td/q-leaf' --receipt-ok 1); if grep -q '^cite=receipt' <<< \"\$o\"; then echo RECEIPT; else echo NOT-RECEIPT; fi" + sed 's|if \[ "\$RECEIPT_OK" = 1 \]; then|if [ "$RECEIPT_OK" != 7 ]; then|' "$T" > "$td/mutant-receipt.sh" + row 0 " (mutant file really differs from the script)" '^DIFFERS$' bash -c "if cmp -s '$T' '$td/mutant-receipt.sh'; then echo SAME; else echo DIFFERS; fi" + row 0 "MUTANT: a pull_request arm that reuses without a verified receipt -> the --receipt-ok 0 row goes RED" '^MUTANT-REUSE$' bash -c "o=\$(bash '$td/mutant-receipt.sh' --event pull_request --diff-from '$td/d-leaf.txt' --receipt-ok 0 | head -n1); if [ \"\$o\" = tier=reuse ]; then echo MUTANT-REUSE; else echo SURVIVED; fi" # registry drift is ENV for the quick tier cp scripts/tree_reader_tests.txt "$td/reg.bak"; printf 'zeta\t--lib\n' >> scripts/tree_reader_tests.txt row 2 "pull_request with a drifted registry -> ENV (exit 2): no quick tier over a stale list" 'drifted' bash "$T" --event pull_request --diff-from "$td/d-scripts.txt" From 0b0d4a5e677594b31d25751a7fc1e9835b33f7ff Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 17:09:48 +0200 Subject: [PATCH 04/15] feat(ci): wire the receipt gate into the PR tier step; pin the batcher key (Refs #5797) The tier step reads the PR body, scripts/ci/receipt_ok_arg.sh prints --receipt-ok 1 only for a verified receipt (0 for none, a bad one, a missing tool), and a receipt-reuse PR runs cargo check --workspace. .flow009/batcher.pub is the trust root, read from lambda-labs and fingerprint-checked: SHA256:8un1DYXhrsJ0e+6UM3w8KSE9CKBpfDVSRG41yL9wS5o. Rows W1-W10 in check_receipt_gate.sh (nightly LAB) pin the no-receipt and bad-receipt paths to the full gate. Co-Authored-By: Claude Sonnet 5.5 --- .flow009/batcher.pub | 1 + .github/workflows/guards-nightly.yml | 2 ++ ci/sections.yml | 9 +++++++- scripts/check_receipt_gate.sh | 31 ++++++++++++++++++++++++++++ scripts/ci/receipt_ok_arg.sh | 14 +++++++++++++ 5 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 .flow009/batcher.pub create mode 100755 scripts/ci/receipt_ok_arg.sh diff --git a/.flow009/batcher.pub b/.flow009/batcher.pub new file mode 100644 index 0000000000..22337055f9 --- /dev/null +++ b/.flow009/batcher.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINna9SBgvdA0Te1LhyTa6jXbDu8SgJmiDDPhHkLmljw7 flow009-batcher diff --git a/.github/workflows/guards-nightly.yml b/.github/workflows/guards-nightly.yml index af5fe6b1d5..c0c5cb802d 100644 --- a/.github/workflows/guards-nightly.yml +++ b/.github/workflows/guards-nightly.yml @@ -174,6 +174,8 @@ jobs: # ───────────────────────────────────────────────────────────────────── - name: "Test-tier decision case table (BSE-17): quick/full/reuse, drift is ENV" run: bash scripts/ci_test_tier.sh --self-test + - name: Receipt gate case table and mutants (#5797, LAB) + run: setsid --wait bash scripts/check_receipt_gate.sh - name: Fleet-cells gate case table and mutants (#5064, LAB) run: setsid --wait bash scripts/release/fleet_cells_gate.sh --self-test - name: compute row zero-tests case table and mutants (#5048, LAB) diff --git a/ci/sections.yml b/ci/sections.yml index cdb4bea410..ce82feb5bc 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -362,6 +362,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} MG_BASE_SHA: ${{ github.event.merge_group.base_sha }} + PR_BODY: ${{ github.event.pull_request.body }} run: | set -euo pipefail args=(--event "${GITHUB_EVENT_NAME}") @@ -402,6 +403,12 @@ jobs: # lives in the helper. bash scripts/ci/pr_touched_paths.sh "origin/${GITHUB_BASE_REF}" HEAD > "$RUNNER_TEMP/touched.txt" args+=(--diff-from "$RUNNER_TEMP/touched.txt") + # #5797: a signed batch receipt in the PR body (signature, tree hash == this + # merge tree, toolchain hash) lets the PR run check/fmt/clippy instead of the + # suite. receipt_ok_arg.sh prints 0 on anything else -- no receipt, a bad one, + # a missing tool -- and 0 leaves the tier exactly as it was. + printf '%s' "${PR_BODY:-}" > "$RUNNER_TEMP/pr-body.md" + args+=(--receipt-ok "$(bash scripts/ci/receipt_ok_arg.sh "$RUNNER_TEMP/pr-body.md" .flow009/batcher.pub "${GITHUB_SHA}")") ;; merge_group) # Operator 2026-10-17 (cop window), verbatim: "only gating on tags/release". @@ -1058,7 +1065,7 @@ jobs: python3 scripts/ci/sigma_executed.py nextest --kind "quick tree readers" --junit "$sig/quick-tree.junit.xml" --list-json "$sig/quick-tree.list.json" || rc=1 exit "$rc" - name: "Quick tier: cargo check --workspace (over-the-cap integration, BSE-17 rule (i))" - if: steps.tier.outputs.tier == 'quick' && steps.tier.outputs.check_workspace == '1' && matrix.shard == 2 + if: ((steps.tier.outputs.tier == 'quick' && steps.tier.outputs.check_workspace == '1') || (steps.tier.outputs.tier == 'reuse' && steps.tier.outputs.cite == 'receipt')) && matrix.shard == 2 timeout-minutes: 20 env: REASON: ${{ steps.tier.outputs.reason }} diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 9ed1a6ea37..2d0b44ecf9 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -101,5 +101,36 @@ printf '[toolchain]\nchannel = "nightly"\n' >"$repo/rust-toolchain.toml" row "C2 the checkout's pinned toolchain changed after signing -> toolchain-mismatch" toolchain-mismatch "$td/b-good" printf '[toolchain]\nchannel = "stable"\n' >"$repo/rust-toolchain.toml" +# W: the workflow wiring. scripts/ci/receipt_ok_arg.sh prints the ONE word the tier step +# hands to ci_test_tier.sh as --receipt-ok. Anything but a verified receipt must print 0, +# and 0 must leave the PR on the quick/full tier, never on reuse. +W="$HERE/ci/receipt_ok_arg.sh" +T="$HERE/ci_test_tier.sh" +printf 'crates/aprender-core/src/lib.rs\n' >"$td/touched.txt" +arg() { (cd "$repo" && bash "$W" "$1" "${2:-$td/pub}" "$head" 2>/dev/null | head -n1); } +wrow() { + local name=$1 want=$2 got=$3 + if [ "$got" = "$want" ]; then pass=$((pass + 1)); echo "ok $name"; else fail=$((fail + 1)); echo "FAIL $name: want '$want' got '$got'"; fi +} +wrow "W1 verified receipt -> --receipt-ok 1" 1 "$(arg "$td/b-good")" +wrow "W2 no receipt in the PR body -> 0 (full gate)" 0 "$(arg "$td/b-none")" +wrow "W3 tampered receipt -> 0" 0 "$(arg "$td/b-s1")" +wrow "W4 receipt for another tree -> 0" 0 "$(arg "$td/b-t1")" +wrow "W5 placeholder pubkey -> 0" 0 "$(arg "$td/b-good" "$td/pub-empty")" +wrow "W6 PR body file missing -> 0" 0 "$(arg "$td/no-such-body")" +wrow "W7 no pubkey file -> 0" 0 "$(arg "$td/b-good" "$td/no-such-pub")" +tier_of() { (cd "$HERE/.." && bash "$T" --event pull_request --diff-from "$td/touched.txt" --receipt-ok "$1" 2>/dev/null | head -n1); } +t0=$(tier_of 0); case "$t0" in tier=quick|tier=full) t0=notreuse ;; esac +wrow "W8 --receipt-ok 0 on a core diff runs quick or full, never reuse" notreuse "$t0" +wrow "W9 --receipt-ok 1 on the same diff is reuse" tier=reuse "$(tier_of 1)" +# Mutant: a wrapper that prints 1 whatever the verifier said turns W2-W7 RED. +sed 's|if \[ "\$first" = "RECEIPT_OK=1" \]; then|if true; then|' "$W" >"$td/mut-w.sh" +if ! cmp -s "$W" "$td/mut-w.sh"; then + m=$(cd "$repo" && bash "$td/mut-w.sh" "$td/b-none" "$td/pub" "$head" 2>/dev/null | head -n1) + wrow "W10 MUTANT: a wrapper that always prints 1 is caught by the no-receipt row" 1 "$m" +else + fail=$((fail + 1)); echo "FAIL W10: mutant did not apply to $W" +fi + echo "receipt gate rows: $pass passed, $fail failed" [ "$fail" -eq 0 ] diff --git a/scripts/ci/receipt_ok_arg.sh b/scripts/ci/receipt_ok_arg.sh new file mode 100755 index 0000000000..3994c1de7e --- /dev/null +++ b/scripts/ci/receipt_ok_arg.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# receipt_ok_arg.sh BODY_FILE PUB_FILE PR_HEAD_SHA (PMAT-5797, #5797) +# Prints ONE word for the tier step's `--receipt-ok`: 1 only when scripts/ci_receipt_gate.sh +# verified the receipt in the PR body, 0 in every other case (no receipt, a bad one, a +# missing file, a verifier that crashed). Always exits 0: 0 sends the PR to the full gate. +# Cases: scripts/check_receipt_gate.sh (rows W1-W10). +set -u +HERE=$(cd "$(dirname "$0")" && pwd) +first=$(bash "$HERE/../ci_receipt_gate.sh" verify "$@" 2>/dev/null | head -n1) || first="" +if [ "$first" = "RECEIPT_OK=1" ]; then + printf '%s\n' 1 +else + printf '%s\n' 0 +fi From 543795505696378527d7288044798a1ac7c48b93 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 17:45:22 +0200 Subject: [PATCH 05/15] fix(ci): receipt judge read from the base ref; signed pass verdict required (Refs #5797) A forged receipt printed 1 because the pubkey and verifier came from the PR tree. receipt_ok_arg.sh now takes BASE_REF and extracts the verifier, toolchain recipe and pinned key from it via git archive; the workflow fetches the wrapper from the base too. The verifier requires gate_results to be all pass with tests present (reason not-pass). Rows V1-V3, W11-W15 incl. the forged repro and a key-from-PR-tree mutant. Co-Authored-By: Claude Sonnet 5.5 --- ci/sections.yml | 12 ++++++- scripts/check_receipt_gate.sh | 67 ++++++++++++++++++++++++++++------- scripts/ci/receipt_ok_arg.sh | 32 ++++++++++------- scripts/ci_receipt_gate.sh | 5 +++ 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/ci/sections.yml b/ci/sections.yml index ce82feb5bc..9162aa5900 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -408,7 +408,17 @@ jobs: # suite. receipt_ok_arg.sh prints 0 on anything else -- no receipt, a bad one, # a missing tool -- and 0 leaves the tier exactly as it was. printf '%s' "${PR_BODY:-}" > "$RUNNER_TEMP/pr-body.md" - args+=(--receipt-ok "$(bash scripts/ci/receipt_ok_arg.sh "$RUNNER_TEMP/pr-body.md" .flow009/batcher.pub "${GITHUB_SHA}")") + # The judge is the BASE's, never this PR's tree (the #5793 pattern; a PR that + # plants its own key and verifier must not pass itself): the wrapper, the + # verifier and the pinned key all come from the base ref fetched above. A base + # that has no wrapper yet (bootstrap) or a failed read is 0. + base_ref="refs/remotes/origin/${GITHUB_BASE_REF}" + if git show "${base_ref}:scripts/ci/receipt_ok_arg.sh" > "$RUNNER_TEMP/receipt_ok_arg.sh" 2>/dev/null; then + rok="$(bash "$RUNNER_TEMP/receipt_ok_arg.sh" "$RUNNER_TEMP/pr-body.md" "$base_ref" "${GITHUB_SHA}")" || rok=0 + else + rok=0 + fi + args+=(--receipt-ok "${rok:-0}") ;; merge_group) # Operator 2026-10-17 (cop window), verbatim: "only gating on tags/release". diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 2d0b44ecf9..41e5235878 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -101,13 +101,45 @@ printf '[toolchain]\nchannel = "nightly"\n' >"$repo/rust-toolchain.toml" row "C2 the checkout's pinned toolchain changed after signing -> toolchain-mismatch" toolchain-mismatch "$td/b-good" printf '[toolchain]\nchannel = "stable"\n' >"$repo/rust-toolchain.toml" +# V: the signed verdict. A validly signed receipt must also say the batch passed. +receipt "$tree" "$tc" | jq -S -c '.gate_results.tests = "fail"' >"$td/v1.json" +body "$td/b-v1" "$(sign "$td/key" "$td/v1.json")" +row "V1 correctly signed receipt whose tests failed -> not-pass" not-pass "$td/b-v1" +receipt "$tree" "$tc" | jq -S -c 'del(.gate_results)' >"$td/v2.json" +body "$td/b-v2" "$(sign "$td/key" "$td/v2.json")" +row "V2 correctly signed receipt with no gate_results -> not-pass" not-pass "$td/b-v2" +receipt "$tree" "$tc" | jq -S -c '.gate_results = {coverage:"pass"}' >"$td/v3.json" +body "$td/b-v3" "$(sign "$td/key" "$td/v3.json")" +row "V3 correctly signed receipt with no tests verdict -> not-pass" not-pass "$td/b-v3" + # W: the workflow wiring. scripts/ci/receipt_ok_arg.sh prints the ONE word the tier step # hands to ci_test_tier.sh as --receipt-ok. Anything but a verified receipt must print 0, -# and 0 must leave the PR on the quick/full tier, never on reuse. +# and 0 must leave the PR on the quick/full tier, never on reuse. The judge (verifier, +# toolchain recipe, pinned key) comes from the BASE ref, never from the PR's working tree. W="$HERE/ci/receipt_ok_arg.sh" T="$HERE/ci_test_tier.sh" printf 'crates/aprender-core/src/lib.rs\n' >"$td/touched.txt" -arg() { (cd "$repo" && bash "$W" "$1" "${2:-$td/pub}" "$head" 2>/dev/null | head -n1); } +# mkbase REF PUBFILE|- -> a commit under REF holding the judge files (and the key, unless "-") +mkbase() { + local ref=$1 pubf=$2 idx="$td/idx.$$" t f + rm -f "${idx:?}" + mkdir -p "$td/bs/scripts" "$td/bs/.flow009" + cp "$V" "$td/bs/scripts/ci_receipt_gate.sh"; cp "$TID" "$td/bs/scripts/receipt_toolchain_id.sh" + local files="scripts/ci_receipt_gate.sh scripts/receipt_toolchain_id.sh" + if [ "$pubf" != - ]; then cp "$pubf" "$td/bs/.flow009/batcher.pub"; files="$files .flow009/batcher.pub"; fi + for f in $files; do + GIT_INDEX_FILE="$idx" git -C "$repo" update-index --add --cacheinfo \ + "100644,$(git -C "$repo" hash-object -w "$td/bs/$f"),$f" + done + t=$(GIT_INDEX_FILE="$idx" git -C "$repo" write-tree) + git -C "$repo" update-ref "$ref" "$(git -C "$repo" commit-tree "$t" -m judge)" + rm -f "${idx:?}" +} +BASE=refs/remotes/origin/main +mkbase "$BASE" "$td/pub" +mkbase refs/remotes/t/placeholder "$td/pub-empty" +git -C "$repo" update-ref refs/remotes/t/empty "$(git -C "$repo" commit-tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 -m none)" +arg() { (cd "$repo" && bash "${3:-$W}" "$1" "${2:-$BASE}" "$head" 2>/dev/null | head -n1); } wrow() { local name=$1 want=$2 got=$3 if [ "$got" = "$want" ]; then pass=$((pass + 1)); echo "ok $name"; else fail=$((fail + 1)); echo "FAIL $name: want '$want' got '$got'"; fi @@ -116,21 +148,32 @@ wrow "W1 verified receipt -> --receipt-ok 1" 1 "$(arg "$td/b-good")" wrow "W2 no receipt in the PR body -> 0 (full gate)" 0 "$(arg "$td/b-none")" wrow "W3 tampered receipt -> 0" 0 "$(arg "$td/b-s1")" wrow "W4 receipt for another tree -> 0" 0 "$(arg "$td/b-t1")" -wrow "W5 placeholder pubkey -> 0" 0 "$(arg "$td/b-good" "$td/pub-empty")" +wrow "W5 placeholder pubkey at the base -> 0" 0 "$(arg "$td/b-good" refs/remotes/t/placeholder)" wrow "W6 PR body file missing -> 0" 0 "$(arg "$td/no-such-body")" -wrow "W7 no pubkey file -> 0" 0 "$(arg "$td/b-good" "$td/no-such-pub")" +wrow "W7 base has no judge files (bootstrap) -> 0" 0 "$(arg "$td/b-good" refs/remotes/t/empty)" +wrow "W11 base ref does not exist -> 0" 0 "$(arg "$td/b-good" refs/remotes/t/nope)" +wrow "W12 signed receipt whose tests failed -> 0" 0 "$(arg "$td/b-v1")" +wrow "W13 signed receipt with no gate_results -> 0" 0 "$(arg "$td/b-v2")" +# W14: the forged-receipt repro (wsweep, #5798). The PR swaps in its own key and signs its +# own receipt. The base's key is the only trust root, so this must print 0. +mkdir -p "$repo/.flow009"; cp "$td/other.pub" "$repo/.flow009/batcher.pub" +printf '%s\n' "$good" >"$td/f.json" +body "$td/b-forged" "$(sign "$td/other" "$td/f.json")" +wrow "W14 FORGED: receipt signed by a key the PR planted in its own tree -> 0" 0 "$(arg "$td/b-forged")" +# W15/W10 mutants: a wrapper that trusts the PR tree's key, or prints 1 regardless, is caught. +sed 's|"\$j/.flow009/batcher.pub"|.flow009/batcher.pub|' "$W" >"$td/mut-k.sh" +if ! cmp -s "$W" "$td/mut-k.sh"; then + wrow "W15 MUTANT: wrapper reading the key from the PR tree is caught by the forged row" 1 "$(arg "$td/b-forged" "$BASE" "$td/mut-k.sh")" +else fail=$((fail + 1)); echo "FAIL W15: mutant did not apply"; fi +sed 's|if \[ "\$first" = "RECEIPT_OK=1" \]; then|if true; then|' "$W" >"$td/mut-w.sh" +if ! cmp -s "$W" "$td/mut-w.sh"; then + wrow "W10 MUTANT: a wrapper that always prints 1 is caught by the no-receipt row" 1 "$(arg "$td/b-none" "$BASE" "$td/mut-w.sh")" +else fail=$((fail + 1)); echo "FAIL W10: mutant did not apply to $W"; fi +rm -rf "${repo:?}/.flow009" tier_of() { (cd "$HERE/.." && bash "$T" --event pull_request --diff-from "$td/touched.txt" --receipt-ok "$1" 2>/dev/null | head -n1); } t0=$(tier_of 0); case "$t0" in tier=quick|tier=full) t0=notreuse ;; esac wrow "W8 --receipt-ok 0 on a core diff runs quick or full, never reuse" notreuse "$t0" wrow "W9 --receipt-ok 1 on the same diff is reuse" tier=reuse "$(tier_of 1)" -# Mutant: a wrapper that prints 1 whatever the verifier said turns W2-W7 RED. -sed 's|if \[ "\$first" = "RECEIPT_OK=1" \]; then|if true; then|' "$W" >"$td/mut-w.sh" -if ! cmp -s "$W" "$td/mut-w.sh"; then - m=$(cd "$repo" && bash "$td/mut-w.sh" "$td/b-none" "$td/pub" "$head" 2>/dev/null | head -n1) - wrow "W10 MUTANT: a wrapper that always prints 1 is caught by the no-receipt row" 1 "$m" -else - fail=$((fail + 1)); echo "FAIL W10: mutant did not apply to $W" -fi echo "receipt gate rows: $pass passed, $fail failed" [ "$fail" -eq 0 ] diff --git a/scripts/ci/receipt_ok_arg.sh b/scripts/ci/receipt_ok_arg.sh index 3994c1de7e..1b9b4c98bc 100755 --- a/scripts/ci/receipt_ok_arg.sh +++ b/scripts/ci/receipt_ok_arg.sh @@ -1,14 +1,22 @@ #!/usr/bin/env bash -# receipt_ok_arg.sh BODY_FILE PUB_FILE PR_HEAD_SHA (PMAT-5797, #5797) -# Prints ONE word for the tier step's `--receipt-ok`: 1 only when scripts/ci_receipt_gate.sh -# verified the receipt in the PR body, 0 in every other case (no receipt, a bad one, a -# missing file, a verifier that crashed). Always exits 0: 0 sends the PR to the full gate. -# Cases: scripts/check_receipt_gate.sh (rows W1-W10). +# receipt_ok_arg.sh BODY_FILE BASE_REF PR_HEAD_SHA (PMAT-5797, #5797) +# Prints ONE word for the tier step's `--receipt-ok`: 1 only when the receipt in the PR body +# verified, 0 in every other case (no receipt, a bad one, a missing file, a crashed verifier, +# a base that has no judge yet). Always exits 0: 0 sends the PR to the full gate. +# +# A PR cannot edit its own judge (the #5793 pattern): the verifier, the toolchain-id recipe +# and the pinned batcher public key are read from BASE_REF (refs/remotes/origin/main, pinned +# by the caller), never from the working tree. The working tree supplies only what is being +# judged: HEAD^{tree} and rust-toolchain.toml. The caller must itself run THIS script from +# BASE_REF too (`git show BASE_REF:scripts/ci/receipt_ok_arg.sh`). +# Cases: scripts/check_receipt_gate.sh (rows W1-W14). set -u -HERE=$(cd "$(dirname "$0")" && pwd) -first=$(bash "$HERE/../ci_receipt_gate.sh" verify "$@" 2>/dev/null | head -n1) || first="" -if [ "$first" = "RECEIPT_OK=1" ]; then - printf '%s\n' 1 -else - printf '%s\n' 0 -fi +body=${1:-}; base=${2:-}; sha=${3:-} +[ -n "$base" ] && [ -n "$sha" ] || { printf '0\n'; exit 0; } +j=$(mktemp -d) || { printf '0\n'; exit 0; } +trap 'rm -rf "${j:?}"' EXIT +git rev-parse --verify -q "$base^{commit}" >/dev/null 2>&1 || { printf '0\n'; exit 0; } +git archive "$base" scripts/ci_receipt_gate.sh scripts/receipt_toolchain_id.sh .flow009/batcher.pub 2>/dev/null \ + | tar -x -C "$j" 2>/dev/null || { printf '0\n'; exit 0; } +first=$(bash "$j/scripts/ci_receipt_gate.sh" verify "$body" "$j/.flow009/batcher.pub" "$sha" 2>/dev/null | head -n1) || first="" +if [ "$first" = "RECEIPT_OK=1" ]; then printf '1\n'; else printf '0\n'; fi diff --git a/scripts/ci_receipt_gate.sh b/scripts/ci_receipt_gate.sh index 093fc7cd54..32d1a7b62c 100755 --- a/scripts/ci_receipt_gate.sh +++ b/scripts/ci_receipt_gate.sh @@ -59,6 +59,11 @@ cmd_verify() { jq -r '.sig' "$work/receipt.json" >"$work/sig" ssh-keygen -Y verify -f "$work/allowed" -I "$SIGNER_ID" -n "$NS" -s "$work/sig" \ <"$work/canon" >/dev/null 2>&1 || fail bad-signature + # verdict: the signed bytes must say the batch's suite passed. A validly signed receipt for a + # red batch must not reduce the gate. gate_results is an object, `tests` is present, and every + # value is "pass". + jq -e '.gate_results | type == "object" and has("tests") and (to_entries | all(.value == "pass"))' \ + "$work/receipt.json" >/dev/null 2>&1 || fail not-pass # tree want_tree=$(git rev-parse 'HEAD^{tree}' 2>/dev/null) [ -n "$want_tree" ] || fail no-head-tree From aab26c4a3de511f801b7f4da68258b0703ca3a60 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 17:58:39 +0200 Subject: [PATCH 06/15] fix(ci): receipt base fetch is inline git, not a PR-tree script (Refs #5797) A scripts/ helper in the PR tree could rewrite the fetch and point the base ref at the PR's own commit. The receipt step now runs git fetch + git show directly. Rows W16/W17 check the block and catch a mutant routing the fetch through a script; W10/W15 mutants now use a literal substitution. Co-Authored-By: Claude Sonnet 5.5 --- ci/sections.yml | 6 +++++- scripts/check_receipt_gate.sh | 30 ++++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/ci/sections.yml b/ci/sections.yml index 9162aa5900..8b34df261a 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -412,8 +412,12 @@ jobs: # plants its own key and verifier must not pass itself): the wrapper, the # verifier and the pinned key all come from the base ref fetched above. A base # that has no wrapper yet (bootstrap) or a failed read is 0. + # The fetch is inline git, not a scripts/ helper: a helper in the PR tree + # could rewrite it to point the base ref at the PR's own commit. Nothing + # between here and the `git show` below runs code from this tree. base_ref="refs/remotes/origin/${GITHUB_BASE_REF}" - if git show "${base_ref}:scripts/ci/receipt_ok_arg.sh" > "$RUNNER_TEMP/receipt_ok_arg.sh" 2>/dev/null; then + if git fetch --no-tags --depth=1 origin "+refs/heads/${GITHUB_BASE_REF}:${base_ref}" 2>/dev/null && + git show "${base_ref}:scripts/ci/receipt_ok_arg.sh" > "$RUNNER_TEMP/receipt_ok_arg.sh" 2>/dev/null; then rok="$(bash "$RUNNER_TEMP/receipt_ok_arg.sh" "$RUNNER_TEMP/pr-body.md" "$base_ref" "${GITHUB_SHA}")" || rok=0 else rok=0 diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 41e5235878..55cc441efd 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -161,14 +161,36 @@ printf '%s\n' "$good" >"$td/f.json" body "$td/b-forged" "$(sign "$td/other" "$td/f.json")" wrow "W14 FORGED: receipt signed by a key the PR planted in its own tree -> 0" 0 "$(arg "$td/b-forged")" # W15/W10 mutants: a wrapper that trusts the PR tree's key, or prints 1 regardless, is caught. -sed 's|"\$j/.flow009/batcher.pub"|.flow009/batcher.pub|' "$W" >"$td/mut-k.sh" -if ! cmp -s "$W" "$td/mut-k.sh"; then +# mutate FILE FROM TO OUT -> OUT is FILE with the literal FROM replaced by TO; non-zero if FROM is absent +mutate() { + local c; c=$(cat "$1"; printf x); c=${c%x} + case "$c" in *"$2"*) ;; *) return 1 ;; esac + printf '%s' "${c//"$2"/$3}" >"$4" +} +if mutate "$W" '"$j/.flow009/batcher.pub"' '.flow009/batcher.pub' "$td/mut-k.sh"; then wrow "W15 MUTANT: wrapper reading the key from the PR tree is caught by the forged row" 1 "$(arg "$td/b-forged" "$BASE" "$td/mut-k.sh")" else fail=$((fail + 1)); echo "FAIL W15: mutant did not apply"; fi -sed 's|if \[ "\$first" = "RECEIPT_OK=1" \]; then|if true; then|' "$W" >"$td/mut-w.sh" -if ! cmp -s "$W" "$td/mut-w.sh"; then +if mutate "$W" 'if [ "$first" = "RECEIPT_OK=1" ]; then' 'if true; then' "$td/mut-w.sh"; then wrow "W10 MUTANT: a wrapper that always prints 1 is caught by the no-receipt row" 1 "$(arg "$td/b-none" "$BASE" "$td/mut-w.sh")" else fail=$((fail + 1)); echo "FAIL W10: mutant did not apply to $W"; fi +# W16/W17: the trusted read in the workflow runs no code from the PR tree. The receipt block of +# ci/sections.yml (from `base_ref=` to `--receipt-ok`) must fetch with inline git and call no +# scripts/ helper; a block that routes the fetch through a PR-tree script goes RED. +SEC="$HERE/../ci/sections.yml" +blk_ok() { # FILE -> 0 when the receipt block is trustworthy + local blk; blk=$(awk '/base_ref="refs\/remotes/{on=1} on{print} /--receipt-ok/{if(on)exit}' "$1") + [ -n "$blk" ] || return 1 + case "$blk" in *"git fetch "*) ;; *) return 1 ;; esac + case "$blk" in *"git show "*) ;; *) return 1 ;; esac + printf '%s\n' "$blk" | grep -v -E '^[[:space:]]*#' | grep -E 'scripts/' | grep -v -E 'git show "\$\{base_ref\}:scripts/ci/receipt_ok_arg.sh"' | grep -q . && return 1 + return 0 +} +if blk_ok "$SEC"; then wrow "W16 workflow receipt block fetches with inline git and runs no PR-tree script" ok ok +else wrow "W16 workflow receipt block fetches with inline git and runs no PR-tree script" ok bad; fi +if mutate "$SEC" 'git fetch --no-tags --depth=1 origin "+refs/heads/${GITHUB_BASE_REF}:${base_ref}"' 'bash scripts/ci/fetch_p6.sh --no-tags --depth=1 origin "+refs/heads/${GITHUB_BASE_REF}:${base_ref}"' "$td/sec-mut.yml"; then + if blk_ok "$td/sec-mut.yml"; then r=missed; else r=caught; fi + wrow "W17 MUTANT: routing the base fetch through a PR-tree script is caught" caught "$r" +else fail=$((fail + 1)); echo "FAIL W17: mutant did not apply to ci/sections.yml"; fi rm -rf "${repo:?}/.flow009" tier_of() { (cd "$HERE/.." && bash "$T" --event pull_request --diff-from "$td/touched.txt" --receipt-ok "$1" 2>/dev/null | head -n1); } t0=$(tier_of 0); case "$t0" in tier=quick|tier=full) t0=notreuse ;; esac From 81cebebdc804c756cf564575604611cfa617c120 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:18:16 +0200 Subject: [PATCH 07/15] fix(ci): receipt judge read first, in a fresh clone with replace and config off (Refs #5798) The trusted read ran after fetch_p6.sh and pr_touched_paths.sh from the PR tree, which could plant url.insteadOf or refs/replace. It now opens the tier step, clones the base into a fresh dir with GIT_NO_REPLACE_OBJECTS=1 and global/system config off. W16 covers the whole step prefix; W17-W22 are mutants and a refs/replace row. Co-Authored-By: Claude Sonnet 5.5 --- ci/sections.yml | 49 ++++++++++-------- scripts/check_receipt_gate.sh | 94 +++++++++++++++++++++-------------- scripts/ci/receipt_ok_arg.sh | 26 ++++++---- 3 files changed, 103 insertions(+), 66 deletions(-) diff --git a/ci/sections.yml b/ci/sections.yml index 8b34df261a..6af8a3b5bd 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -366,6 +366,35 @@ jobs: run: | set -euo pipefail args=(--event "${GITHUB_EVENT_NAME}") + # #5797: a signed batch receipt in the PR body (signature, tree hash == this + # merge tree, toolchain hash) lets the PR run check/fmt/clippy instead of the + # suite. receipt_ok_arg.sh prints 0 on anything else -- no receipt, a bad one, + # a missing tool -- and 0 leaves the tier exactly as it was. + # The judge is the BASE's, never this PR's tree (the #5793 pattern), and the read + # of it runs FIRST in this step: no script from the PR tree (fetch_p6.sh, + # pr_touched_paths.sh, ...) has run yet, so none can have planted a url.insteadOf + # or a refs/replace entry. The base is cloned into a fresh directory with the + # global and system git config off, replace objects off, and the job token passed + # through per-process GIT_CONFIG_* only. A failed clone, a base with no wrapper + # yet (bootstrap) or a failed read is 0. `git clone` is not a fetch of this + # checkout; the P6 re-read helper lives in the PR tree and may not run here. + rok=0 + if [ "${GITHUB_EVENT_NAME}" = pull_request ]; then + export GIT_NO_REPLACE_OBJECTS=1 + printf '%s' "${PR_BODY:-}" > "$RUNNER_TEMP/pr-body.md" + trust="$RUNNER_TEMP/trusted-base" + rm -rf "${trust:?}" + if GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')" \ + git clone -q --no-tags --depth=1 --branch "${GITHUB_BASE_REF}" \ + "https://github.com/${GITHUB_REPOSITORY}.git" "$trust" 2>/dev/null && + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ + git -C "$trust" show HEAD:scripts/ci/receipt_ok_arg.sh > "$RUNNER_TEMP/receipt_ok_arg.sh" 2>/dev/null; then + rok="$(bash "$RUNNER_TEMP/receipt_ok_arg.sh" "$RUNNER_TEMP/pr-body.md" "$trust" "${GITHUB_SHA}")" || rok=0 + fi + fi + args+=(--receipt-ok "${rok:-0}") # PMAT-1098 67-E2: the queue and a push to main re-derive their own # diff from HEAD's FIRST PARENT (HEAD^1..HEAD is the PR's diff on the # new base). actions/checkout is shallow, so that parent is not in the @@ -403,26 +432,6 @@ jobs: # lives in the helper. bash scripts/ci/pr_touched_paths.sh "origin/${GITHUB_BASE_REF}" HEAD > "$RUNNER_TEMP/touched.txt" args+=(--diff-from "$RUNNER_TEMP/touched.txt") - # #5797: a signed batch receipt in the PR body (signature, tree hash == this - # merge tree, toolchain hash) lets the PR run check/fmt/clippy instead of the - # suite. receipt_ok_arg.sh prints 0 on anything else -- no receipt, a bad one, - # a missing tool -- and 0 leaves the tier exactly as it was. - printf '%s' "${PR_BODY:-}" > "$RUNNER_TEMP/pr-body.md" - # The judge is the BASE's, never this PR's tree (the #5793 pattern; a PR that - # plants its own key and verifier must not pass itself): the wrapper, the - # verifier and the pinned key all come from the base ref fetched above. A base - # that has no wrapper yet (bootstrap) or a failed read is 0. - # The fetch is inline git, not a scripts/ helper: a helper in the PR tree - # could rewrite it to point the base ref at the PR's own commit. Nothing - # between here and the `git show` below runs code from this tree. - base_ref="refs/remotes/origin/${GITHUB_BASE_REF}" - if git fetch --no-tags --depth=1 origin "+refs/heads/${GITHUB_BASE_REF}:${base_ref}" 2>/dev/null && - git show "${base_ref}:scripts/ci/receipt_ok_arg.sh" > "$RUNNER_TEMP/receipt_ok_arg.sh" 2>/dev/null; then - rok="$(bash "$RUNNER_TEMP/receipt_ok_arg.sh" "$RUNNER_TEMP/pr-body.md" "$base_ref" "${GITHUB_SHA}")" || rok=0 - else - rok=0 - fi - args+=(--receipt-ok "${rok:-0}") ;; merge_group) # Operator 2026-10-17 (cop window), verbatim: "only gating on tags/release". diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 55cc441efd..6246101e71 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -115,30 +115,26 @@ row "V3 correctly signed receipt with no tests verdict -> not-pass" not-pass "$t # W: the workflow wiring. scripts/ci/receipt_ok_arg.sh prints the ONE word the tier step # hands to ci_test_tier.sh as --receipt-ok. Anything but a verified receipt must print 0, # and 0 must leave the PR on the quick/full tier, never on reuse. The judge (verifier, -# toolchain recipe, pinned key) comes from the BASE ref, never from the PR's working tree. +# toolchain recipe, pinned key) comes from the trusted clone of the base, never from the PR's working tree. W="$HERE/ci/receipt_ok_arg.sh" T="$HERE/ci_test_tier.sh" printf 'crates/aprender-core/src/lib.rs\n' >"$td/touched.txt" -# mkbase REF PUBFILE|- -> a commit under REF holding the judge files (and the key, unless "-") +# mkbase NAME PUBFILE|- -> a fresh repo $td/NAME whose HEAD holds the judge files (and the key, +# unless "-"): the shape of the trusted clone the workflow makes before any PR code runs. mkbase() { - local ref=$1 pubf=$2 idx="$td/idx.$$" t f - rm -f "${idx:?}" - mkdir -p "$td/bs/scripts" "$td/bs/.flow009" - cp "$V" "$td/bs/scripts/ci_receipt_gate.sh"; cp "$TID" "$td/bs/scripts/receipt_toolchain_id.sh" - local files="scripts/ci_receipt_gate.sh scripts/receipt_toolchain_id.sh" - if [ "$pubf" != - ]; then cp "$pubf" "$td/bs/.flow009/batcher.pub"; files="$files .flow009/batcher.pub"; fi - for f in $files; do - GIT_INDEX_FILE="$idx" git -C "$repo" update-index --add --cacheinfo \ - "100644,$(git -C "$repo" hash-object -w "$td/bs/$f"),$f" - done - t=$(GIT_INDEX_FILE="$idx" git -C "$repo" write-tree) - git -C "$repo" update-ref "$ref" "$(git -C "$repo" commit-tree "$t" -m judge)" - rm -f "${idx:?}" + local d="$td/$1" pubf=$2 files="scripts/ci_receipt_gate.sh scripts/receipt_toolchain_id.sh" f + rm -rf "${d:?}"; git init -q "$d" + mkdir -p "$d/scripts" "$d/.flow009" + cp "$V" "$d/scripts/ci_receipt_gate.sh"; cp "$TID" "$d/scripts/receipt_toolchain_id.sh" + if [ "$pubf" != - ]; then cp "$pubf" "$d/.flow009/batcher.pub"; files="$files .flow009/batcher.pub"; fi + for f in $files; do git -C "$d" add "$f"; done + git -C "$d" -c user.email=t@example.invalid -c user.name=t commit -q -m judge } -BASE=refs/remotes/origin/main -mkbase "$BASE" "$td/pub" -mkbase refs/remotes/t/placeholder "$td/pub-empty" -git -C "$repo" update-ref refs/remotes/t/empty "$(git -C "$repo" commit-tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 -m none)" +BASE="$td/trust-main" +mkbase trust-main "$td/pub" +mkbase trust-placeholder "$td/pub-empty" +git init -q "$td/trust-empty" +git -C "$td/trust-empty" -c user.email=t@example.invalid -c user.name=t commit -q --allow-empty -m none arg() { (cd "$repo" && bash "${3:-$W}" "$1" "${2:-$BASE}" "$head" 2>/dev/null | head -n1); } wrow() { local name=$1 want=$2 got=$3 @@ -148,10 +144,10 @@ wrow "W1 verified receipt -> --receipt-ok 1" 1 "$(arg "$td/b-good")" wrow "W2 no receipt in the PR body -> 0 (full gate)" 0 "$(arg "$td/b-none")" wrow "W3 tampered receipt -> 0" 0 "$(arg "$td/b-s1")" wrow "W4 receipt for another tree -> 0" 0 "$(arg "$td/b-t1")" -wrow "W5 placeholder pubkey at the base -> 0" 0 "$(arg "$td/b-good" refs/remotes/t/placeholder)" +wrow "W5 placeholder pubkey at the base -> 0" 0 "$(arg "$td/b-good" "$td/trust-placeholder")" wrow "W6 PR body file missing -> 0" 0 "$(arg "$td/no-such-body")" -wrow "W7 base has no judge files (bootstrap) -> 0" 0 "$(arg "$td/b-good" refs/remotes/t/empty)" -wrow "W11 base ref does not exist -> 0" 0 "$(arg "$td/b-good" refs/remotes/t/nope)" +wrow "W7 base has no judge files (bootstrap) -> 0" 0 "$(arg "$td/b-good" "$td/trust-empty")" +wrow "W11 base ref does not exist -> 0" 0 "$(arg "$td/b-good" "$td/nope")" wrow "W12 signed receipt whose tests failed -> 0" 0 "$(arg "$td/b-v1")" wrow "W13 signed receipt with no gate_results -> 0" 0 "$(arg "$td/b-v2")" # W14: the forged-receipt repro (wsweep, #5798). The PR swaps in its own key and signs its @@ -173,24 +169,50 @@ else fail=$((fail + 1)); echo "FAIL W15: mutant did not apply"; fi if mutate "$W" 'if [ "$first" = "RECEIPT_OK=1" ]; then' 'if true; then' "$td/mut-w.sh"; then wrow "W10 MUTANT: a wrapper that always prints 1 is caught by the no-receipt row" 1 "$(arg "$td/b-none" "$BASE" "$td/mut-w.sh")" else fail=$((fail + 1)); echo "FAIL W10: mutant did not apply to $W"; fi -# W16/W17: the trusted read in the workflow runs no code from the PR tree. The receipt block of -# ci/sections.yml (from `base_ref=` to `--receipt-ok`) must fetch with inline git and call no -# scripts/ helper; a block that routes the fetch through a PR-tree script goes RED. +# W16-W19: the trusted read in the workflow runs FIRST, before any code from the PR tree. The tier +# step's text up to `--receipt-ok` must clone the base into a fresh dir with replace objects and +# the global/system git config off, and must call no scripts/ helper before it (a PR-tree script +# could plant url.insteadOf or refs/replace). Each defect below must turn blk_ok RED. SEC="$HERE/../ci/sections.yml" -blk_ok() { # FILE -> 0 when the receipt block is trustworthy - local blk; blk=$(awk '/base_ref="refs\/remotes/{on=1} on{print} /--receipt-ok/{if(on)exit}' "$1") +blk_ok() { # FILE -> 0 when the step prefix is trustworthy + local blk; blk=$(awk '/id: tier$/{on=1} on{print} /--receipt-ok/{if(on)exit}' "$1") [ -n "$blk" ] || return 1 - case "$blk" in *"git fetch "*) ;; *) return 1 ;; esac - case "$blk" in *"git show "*) ;; *) return 1 ;; esac - printf '%s\n' "$blk" | grep -v -E '^[[:space:]]*#' | grep -E 'scripts/' | grep -v -E 'git show "\$\{base_ref\}:scripts/ci/receipt_ok_arg.sh"' | grep -q . && return 1 + local code; code=$(printf '%s\n' "$blk" | grep -v -E '^[[:space:]]*#') + case "$code" in *"git clone "*) ;; *) return 1 ;; esac + case "$code" in *"export GIT_NO_REPLACE_OBJECTS=1"*) ;; *) return 1 ;; esac + case "$code" in *"GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null"*) ;; *) return 1 ;; esac + case "$code" in *"show HEAD:scripts/ci/receipt_ok_arg.sh"*) ;; *) return 1 ;; esac + case "$code" in *" fetch "*) return 1 ;; esac + printf '%s\n' "$code" | grep -E 'scripts/' | grep -v -E 'git -C "\$trust" show HEAD:scripts/ci/receipt_ok_arg.sh' | grep -q . && return 1 return 0 } -if blk_ok "$SEC"; then wrow "W16 workflow receipt block fetches with inline git and runs no PR-tree script" ok ok -else wrow "W16 workflow receipt block fetches with inline git and runs no PR-tree script" ok bad; fi -if mutate "$SEC" 'git fetch --no-tags --depth=1 origin "+refs/heads/${GITHUB_BASE_REF}:${base_ref}"' 'bash scripts/ci/fetch_p6.sh --no-tags --depth=1 origin "+refs/heads/${GITHUB_BASE_REF}:${base_ref}"' "$td/sec-mut.yml"; then - if blk_ok "$td/sec-mut.yml"; then r=missed; else r=caught; fi - wrow "W17 MUTANT: routing the base fetch through a PR-tree script is caught" caught "$r" -else fail=$((fail + 1)); echo "FAIL W17: mutant did not apply to ci/sections.yml"; fi +if blk_ok "$SEC"; then wrow "W16 receipt read runs first: fresh clone, replace off, config off, no PR-tree script" ok ok +else wrow "W16 receipt read runs first: fresh clone, replace off, config off, no PR-tree script" ok bad; fi +wmut() { # LABEL FROM TO + if mutate "$SEC" "$2" "$3" "$td/sec-mut.yml"; then + if blk_ok "$td/sec-mut.yml"; then r=missed; else r=caught; fi + wrow "$1" caught "$r" + else fail=$((fail + 1)); echo "FAIL ${1%% *}: mutant did not apply to ci/sections.yml"; fi +} +wmut "W17 MUTANT: a PR-tree script run before the trusted read is caught" 'rok=0 + if [ "${GITHUB_EVENT_NAME}" = pull_request ]; then' 'rok=0 + bash scripts/ci/fetch_p6.sh --no-tags origin + if [ "${GITHUB_EVENT_NAME}" = pull_request ]; then' +wmut "W18 MUTANT: replace objects left on is caught" 'export GIT_NO_REPLACE_OBJECTS=1' ':' +wmut "W19 MUTANT: global/system git config left on is caught" 'GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_TERMINAL_PROMPT=0' 'GIT_TERMINAL_PROMPT=0' +wmut "W20 MUTANT: a fetch into the PR checkout instead of a fresh clone is caught" 'git clone -q --no-tags' 'git fetch -q --no-tags' +# W21/W22: a refs/replace entry in the PR repo must not let a receipt for another tree pass. The +# receipt is signed for the empty tree; the replace ref makes HEAD read as a commit with that tree. +git -C "$repo" -c user.email=t@example.invalid -c user.name=t checkout -q -- . 2>/dev/null +rep=$(git -C "$repo" commit-tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 -m fake) +git -C "$repo" update-ref "refs/replace/$head" "$rep" +receipt 4b825dc642cb6eb9a060e54bf8d69288fbee4904 "$tc" >"$td/r.json" +body "$td/b-repl" "$(sign "$td/key" "$td/r.json")" +wrow "W21 refs/replace on the PR repo cannot make another tree pass -> 0" 0 "$(arg "$td/b-repl")" +if mutate "$W" 'export GIT_NO_REPLACE_OBJECTS=1' ':' "$td/mut-r.sh"; then + wrow "W22 MUTANT: a wrapper that honours replace refs is caught by W21's row" 1 "$(arg "$td/b-repl" "$BASE" "$td/mut-r.sh")" +else fail=$((fail + 1)); echo "FAIL W22: mutant did not apply"; fi +git -C "$repo" update-ref -d "refs/replace/$head" rm -rf "${repo:?}/.flow009" tier_of() { (cd "$HERE/.." && bash "$T" --event pull_request --diff-from "$td/touched.txt" --receipt-ok "$1" 2>/dev/null | head -n1); } t0=$(tier_of 0); case "$t0" in tier=quick|tier=full) t0=notreuse ;; esac diff --git a/scripts/ci/receipt_ok_arg.sh b/scripts/ci/receipt_ok_arg.sh index 1b9b4c98bc..dc85302cb8 100755 --- a/scripts/ci/receipt_ok_arg.sh +++ b/scripts/ci/receipt_ok_arg.sh @@ -1,22 +1,28 @@ #!/usr/bin/env bash -# receipt_ok_arg.sh BODY_FILE BASE_REF PR_HEAD_SHA (PMAT-5797, #5797) +# receipt_ok_arg.sh BODY_FILE TRUST_DIR PR_HEAD_SHA (PMAT-5797, #5797) # Prints ONE word for the tier step's `--receipt-ok`: 1 only when the receipt in the PR body # verified, 0 in every other case (no receipt, a bad one, a missing file, a crashed verifier, # a base that has no judge yet). Always exits 0: 0 sends the PR to the full gate. # # A PR cannot edit its own judge (the #5793 pattern): the verifier, the toolchain-id recipe -# and the pinned batcher public key are read from BASE_REF (refs/remotes/origin/main, pinned -# by the caller), never from the working tree. The working tree supplies only what is being -# judged: HEAD^{tree} and rust-toolchain.toml. The caller must itself run THIS script from -# BASE_REF too (`git show BASE_REF:scripts/ci/receipt_ok_arg.sh`). -# Cases: scripts/check_receipt_gate.sh (rows W1-W14). +# and the pinned batcher public key are read from HEAD of TRUST_DIR, a fresh clone of the base +# branch the caller made BEFORE any code from the PR tree ran (no PR-tree script can have +# planted a url.insteadOf, a refs/replace entry or a config in it), never from the working +# tree. The working tree supplies only what is being judged: HEAD^{tree} and +# rust-toolchain.toml. The caller must itself run THIS script from TRUST_DIR too +# (`git -C TRUST_DIR show HEAD:scripts/ci/receipt_ok_arg.sh`). Replace refs are off for every +# git call here, so a PR cannot make its own HEAD^{tree} read as another tree. +# Cases: scripts/check_receipt_gate.sh (rows W1-W20). set -u -body=${1:-}; base=${2:-}; sha=${3:-} -[ -n "$base" ] && [ -n "$sha" ] || { printf '0\n'; exit 0; } +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES +body=${1:-}; trust=${2:-}; sha=${3:-} +[ -n "$trust" ] && [ -d "$trust" ] && [ -n "$sha" ] || { printf '0\n'; exit 0; } j=$(mktemp -d) || { printf '0\n'; exit 0; } trap 'rm -rf "${j:?}"' EXIT -git rev-parse --verify -q "$base^{commit}" >/dev/null 2>&1 || { printf '0\n'; exit 0; } -git archive "$base" scripts/ci_receipt_gate.sh scripts/receipt_toolchain_id.sh .flow009/batcher.pub 2>/dev/null \ +tg() { GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git -C "$trust" "$@"; } +tg rev-parse --verify -q 'HEAD^{commit}' >/dev/null 2>&1 || { printf '0\n'; exit 0; } +tg archive HEAD scripts/ci_receipt_gate.sh scripts/receipt_toolchain_id.sh .flow009/batcher.pub 2>/dev/null \ | tar -x -C "$j" 2>/dev/null || { printf '0\n'; exit 0; } first=$(bash "$j/scripts/ci_receipt_gate.sh" verify "$body" "$j/.flow009/batcher.pub" "$sha" 2>/dev/null | head -n1) || first="" if [ "$first" = "RECEIPT_OK=1" ]; then printf '1\n'; else printf '0\n'; fi From 14e51c5ffd9788de4a9343893b07fddba282a6a9 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:18:34 +0200 Subject: [PATCH 08/15] test(ci): W19 requires config-off on both the clone and the show (Refs #5798) Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_receipt_gate.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 6246101e71..30579cbddd 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -180,7 +180,8 @@ blk_ok() { # FILE -> 0 when the step prefix is trustworthy local code; code=$(printf '%s\n' "$blk" | grep -v -E '^[[:space:]]*#') case "$code" in *"git clone "*) ;; *) return 1 ;; esac case "$code" in *"export GIT_NO_REPLACE_OBJECTS=1"*) ;; *) return 1 ;; esac - case "$code" in *"GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null"*) ;; *) return 1 ;; esac + # both the clone and the show run with the global/system config off + [ "$(printf '%s\n' "$code" | grep -c 'GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null')" -ge 2 ] || return 1 case "$code" in *"show HEAD:scripts/ci/receipt_ok_arg.sh"*) ;; *) return 1 ;; esac case "$code" in *" fetch "*) return 1 ;; esac printf '%s\n' "$code" | grep -E 'scripts/' | grep -v -E 'git -C "\$trust" show HEAD:scripts/ci/receipt_ok_arg.sh' | grep -q . && return 1 From b6a47ab544653a38e5a800cac9fd397bc5117d9c Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:22:00 +0200 Subject: [PATCH 09/15] ci(receipt): the judge is its own job that never runs the PR tree (Refs #5798) receipt-judge checks out the base only and reads the PR merge commit as a data worktree; the shard job needs it and takes RECEIPT_OK; the tier step only reads that env. W16-W20 turn RED on a PR checkout as code, a PR-tree script step, a step planting PATH, a shard that does not wait, or a tier step running code. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 47 ++++++++++++++++++++++ ci/sections.yml | 31 +++------------ scripts/check_receipt_gate.sh | 73 +++++++++++++++++++++-------------- 3 files changed, 98 insertions(+), 53 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0f3850566..a4bd66858b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -439,10 +439,56 @@ jobs: # The workspace tests, one shard per job (nextest hash partition N/3 plus the # explicit runner's round-robin N/3), in parallel on the X64 clean-room pool. # The section body is ci/sections.yml `workspace-test-shard`, unchanged. + # #5798: the receipt judge. A PR cannot judge itself, and no step that runs the PR tree + # may run before the verdict: this job checks out the BASE only and fetches the PR merge + # commit as data (a detached worktree that is read, never executed). Nothing in it runs a + # script from that worktree, so nothing the PR carries can plant PATH, git, a config or a + # refs/replace entry before the judge reads. scripts/check_receipt_gate.sh (W16-W20) turns + # RED if this job gains a step that runs PR-tree code or checks the PR out as code. + # It always exits 0; the shard job reads `ok` as 1 only on a literal 1. + receipt-judge: + if: github.event_name == 'pull_request' + runs-on: [self-hosted, Linux, clean-room] + timeout-minutes: 10 + permissions: + contents: read + outputs: + ok: ${{ steps.judge.outputs.ok }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.base_ref }} + persist-credentials: false + - name: Judge the receipt in the PR body + id: judge + env: + GH_TOKEN: ${{ github.token }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + export GIT_NO_REPLACE_OBJECTS=1 + ok=0 + printf '%s' "${PR_BODY:-}" > "$RUNNER_TEMP/pr-body.md" + export GIT_TERMINAL_PROMPT=0 GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" + export GIT_CONFIG_VALUE_0 + if git fetch -q --no-tags --depth=1 origin "refs/pull/${PR_NUMBER}/merge" && + [ "$(git rev-parse FETCH_HEAD)" = "${GITHUB_SHA}" ] && + git worktree add -q --detach "$RUNNER_TEMP/prtree" FETCH_HEAD && + [ -f scripts/ci/receipt_ok_arg.sh ]; then + ok="$(cd "$RUNNER_TEMP/prtree" && bash "$GITHUB_WORKSPACE/scripts/ci/receipt_ok_arg.sh" "$RUNNER_TEMP/pr-body.md" "$GITHUB_WORKSPACE" "${GITHUB_SHA}")" || ok=0 + fi + [ "$ok" = 1 ] || ok=0 + printf 'ok=%s\n' "$ok" >> "$GITHUB_OUTPUT" + workspace-test-shard: # same lanes as x86-main: clean-room for pull_request/push, mq for a merge_group entry runs-on: ${{ github.event_name == 'merge_group' && fromJSON('["self-hosted","Linux","X64","mq"]') || fromJSON('["self-hosted","Linux","X64","clean-room"]') }} timeout-minutes: 180 + # The judge is skipped off a pull_request and fails closed: !cancelled() runs the shard + # whatever the judge did, and RECEIPT_OK is 1 only on the judge's literal 1. + needs: [receipt-judge] + if: ${{ !cancelled() }} strategy: fail-fast: false matrix: @@ -450,6 +496,7 @@ jobs: outputs: results: ${{ steps.run.outputs.results }} env: + RECEIPT_OK: ${{ needs.receipt-judge.outputs.ok || '0' }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FAT_VARS_JSON: ${{ toJSON(vars) }} steps: diff --git a/ci/sections.yml b/ci/sections.yml index 6af8a3b5bd..980a5054c6 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -368,32 +368,13 @@ jobs: args=(--event "${GITHUB_EVENT_NAME}") # #5797: a signed batch receipt in the PR body (signature, tree hash == this # merge tree, toolchain hash) lets the PR run check/fmt/clippy instead of the - # suite. receipt_ok_arg.sh prints 0 on anything else -- no receipt, a bad one, - # a missing tool -- and 0 leaves the tier exactly as it was. - # The judge is the BASE's, never this PR's tree (the #5793 pattern), and the read - # of it runs FIRST in this step: no script from the PR tree (fetch_p6.sh, - # pr_touched_paths.sh, ...) has run yet, so none can have planted a url.insteadOf - # or a refs/replace entry. The base is cloned into a fresh directory with the - # global and system git config off, replace objects off, and the job token passed - # through per-process GIT_CONFIG_* only. A failed clone, a base with no wrapper - # yet (bootstrap) or a failed read is 0. `git clone` is not a fetch of this - # checkout; the P6 re-read helper lives in the PR tree and may not run here. + # suite. The verdict is NOT computed here: the `receipt-judge` job in ci.yml + # computes it in a job that never checks out or runs the PR tree (it checks out + # the base, reads the PR merge commit as data), and the shard job hands it in as + # RECEIPT_OK. Anything but a literal 1 -- unset, 0, a skipped or failed judge -- is + # 0, and 0 leaves the tier exactly as it was. Only a pull_request can carry one. rok=0 - if [ "${GITHUB_EVENT_NAME}" = pull_request ]; then - export GIT_NO_REPLACE_OBJECTS=1 - printf '%s' "${PR_BODY:-}" > "$RUNNER_TEMP/pr-body.md" - trust="$RUNNER_TEMP/trusted-base" - rm -rf "${trust:?}" - if GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_TERMINAL_PROMPT=0 \ - GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \ - GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "${GH_TOKEN}" | base64 | tr -d '\n')" \ - git clone -q --no-tags --depth=1 --branch "${GITHUB_BASE_REF}" \ - "https://github.com/${GITHUB_REPOSITORY}.git" "$trust" 2>/dev/null && - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ - git -C "$trust" show HEAD:scripts/ci/receipt_ok_arg.sh > "$RUNNER_TEMP/receipt_ok_arg.sh" 2>/dev/null; then - rok="$(bash "$RUNNER_TEMP/receipt_ok_arg.sh" "$RUNNER_TEMP/pr-body.md" "$trust" "${GITHUB_SHA}")" || rok=0 - fi - fi + if [ "${GITHUB_EVENT_NAME}" = pull_request ] && [ "${RECEIPT_OK:-0}" = 1 ]; then rok=1; fi args+=(--receipt-ok "${rok:-0}") # PMAT-1098 67-E2: the queue and a push to main re-derive their own # diff from HEAD's FIRST PARENT (HEAD^1..HEAD is the PR's diff on the diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 30579cbddd..819896628d 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -169,39 +169,56 @@ else fail=$((fail + 1)); echo "FAIL W15: mutant did not apply"; fi if mutate "$W" 'if [ "$first" = "RECEIPT_OK=1" ]; then' 'if true; then' "$td/mut-w.sh"; then wrow "W10 MUTANT: a wrapper that always prints 1 is caught by the no-receipt row" 1 "$(arg "$td/b-none" "$BASE" "$td/mut-w.sh")" else fail=$((fail + 1)); echo "FAIL W10: mutant did not apply to $W"; fi -# W16-W19: the trusted read in the workflow runs FIRST, before any code from the PR tree. The tier -# step's text up to `--receipt-ok` must clone the base into a fresh dir with replace objects and -# the global/system git config off, and must call no scripts/ helper before it (a PR-tree script -# could plant url.insteadOf or refs/replace). Each defect below must turn blk_ok RED. +# W16-W20: the judge is its own job and no PR-tree code can run before it reads. ci.yml's +# `receipt-judge` job checks out the BASE (ref: github.base_ref), fetches the PR merge commit as +# a data worktree, and runs no script from anywhere but the base checkout; the shard job needs it +# and takes its verdict as RECEIPT_OK; the tier step in ci/sections.yml only reads that env. Each +# defect below must turn judge_ok RED: a PR checkout as code, a PR-tree script step, a step that +# plants PATH or the env, a shard job that does not wait for the judge, a tier step that runs code. +CI="$HERE/../.github/workflows/ci.yml" SEC="$HERE/../ci/sections.yml" -blk_ok() { # FILE -> 0 when the step prefix is trustworthy - local blk; blk=$(awk '/id: tier$/{on=1} on{print} /--receipt-ok/{if(on)exit}' "$1") - [ -n "$blk" ] || return 1 - local code; code=$(printf '%s\n' "$blk" | grep -v -E '^[[:space:]]*#') - case "$code" in *"git clone "*) ;; *) return 1 ;; esac - case "$code" in *"export GIT_NO_REPLACE_OBJECTS=1"*) ;; *) return 1 ;; esac - # both the clone and the show run with the global/system config off - [ "$(printf '%s\n' "$code" | grep -c 'GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null')" -ge 2 ] || return 1 - case "$code" in *"show HEAD:scripts/ci/receipt_ok_arg.sh"*) ;; *) return 1 ;; esac - case "$code" in *" fetch "*) return 1 ;; esac - printf '%s\n' "$code" | grep -E 'scripts/' | grep -v -E 'git -C "\$trust" show HEAD:scripts/ci/receipt_ok_arg.sh' | grep -q . && return 1 +judge_ok() { # CI_FILE SECTIONS_FILE -> 0 when the wiring is trustworthy + local job code shard tier + job=$(awk '/^ receipt-judge:$/{on=1;print;next} on&&/^ [a-z]/{exit} on{print}' "$1") + [ -n "$job" ] || return 1 + code=$(printf '%s\n' "$job" | grep -v -E '^[[:space:]]*#') + case "$code" in *'ref: ${{ github.base_ref }}'*) ;; *) return 1 ;; esac + [ "$(printf '%s\n' "$code" | grep -c 'uses:')" -eq 1 ] || return 1 + printf '%s\n' "$code" | grep -q 'uses: actions/checkout@' || return 1 + printf '%s\n' "$code" | grep -q -E 'GITHUB_PATH|GITHUB_ENV|head_ref|pull_request\.head|github\.sha *$' && return 1 + [ "$(printf '%s\n' "$code" | grep -c '^ - ')" -eq 2 ] || return 1 + printf '%s\n' "$code" | grep -E 'scripts/' | grep -v -E 'GITHUB_WORKSPACE/scripts/ci/receipt_ok_arg\.sh|-f scripts/ci/receipt_ok_arg\.sh' | grep -q . && return 1 + shard=$(awk '/^ workspace-test-shard:$/{on=1;print;next} on&&/^ [a-z]/{exit} on{print}' "$1") + case "$shard" in *'needs: [receipt-judge]'*) ;; *) return 1 ;; esac + case "$shard" in *'RECEIPT_OK: ${{ needs.receipt-judge.outputs.ok'*) ;; *) return 1 ;; esac + tier=$(awk '/id: tier$/{on=1} on{print} /--receipt-ok/{if(on)exit}' "$2" | grep -v -E '^[[:space:]]*#') + case "$tier" in *'"${RECEIPT_OK:-0}" = 1'*) ;; *) return 1 ;; esac + printf '%s\n' "$tier" | grep -E 'scripts/|git (fetch|clone)' | grep -q . && return 1 return 0 } -if blk_ok "$SEC"; then wrow "W16 receipt read runs first: fresh clone, replace off, config off, no PR-tree script" ok ok -else wrow "W16 receipt read runs first: fresh clone, replace off, config off, no PR-tree script" ok bad; fi -wmut() { # LABEL FROM TO - if mutate "$SEC" "$2" "$3" "$td/sec-mut.yml"; then - if blk_ok "$td/sec-mut.yml"; then r=missed; else r=caught; fi +if judge_ok "$CI" "$SEC"; then wrow "W16 receipt judge is its own job: base checkout only, no PR-tree code, shard waits for it" ok ok +else wrow "W16 receipt judge is its own job: base checkout only, no PR-tree code, shard waits for it" ok bad; fi +CHK=' - uses: actions/checkout@v7 + with: + ref: ${{ github.base_ref }}' +wmut() { # LABEL FILE(ci|sec) FROM TO + local src=$CI; [ "$2" = sec ] && src=$SEC + if mutate "$src" "$3" "$4" "$td/mut.yml"; then + if [ "$2" = sec ]; then judge_ok "$CI" "$td/mut.yml" && r=missed || r=caught + else judge_ok "$td/mut.yml" "$SEC" && r=missed || r=caught; fi wrow "$1" caught "$r" - else fail=$((fail + 1)); echo "FAIL ${1%% *}: mutant did not apply to ci/sections.yml"; fi + else fail=$((fail + 1)); echo "FAIL ${1%% *}: mutant did not apply"; fi } -wmut "W17 MUTANT: a PR-tree script run before the trusted read is caught" 'rok=0 - if [ "${GITHUB_EVENT_NAME}" = pull_request ]; then' 'rok=0 - bash scripts/ci/fetch_p6.sh --no-tags origin - if [ "${GITHUB_EVENT_NAME}" = pull_request ]; then' -wmut "W18 MUTANT: replace objects left on is caught" 'export GIT_NO_REPLACE_OBJECTS=1' ':' -wmut "W19 MUTANT: global/system git config left on is caught" 'GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_TERMINAL_PROMPT=0' 'GIT_TERMINAL_PROMPT=0' -wmut "W20 MUTANT: a fetch into the PR checkout instead of a fresh clone is caught" 'git clone -q --no-tags' 'git fetch -q --no-tags' +wmut "W17 MUTANT: the judge job checking out the PR merge as code is caught" ci 'ref: ${{ github.base_ref }}' 'ref: ${{ github.sha }}' +wmut "W18 MUTANT: a PR-tree script step before the judge is caught" ci "$CHK" ' - run: bash scripts/ci/pre.sh +'"$CHK" +wmut "W19 MUTANT: an earlier step planting PATH is caught" ci "$CHK" ' - run: echo "$PWD/bin" >> "$GITHUB_PATH" +'"$CHK" +wmut "W20 MUTANT: a shard job that does not wait for the judge is caught" ci 'needs: [receipt-judge]' 'needs: []' +wmut "W20b MUTANT: a tier step that runs PR-tree code is caught" sec 'rok=0 + if [ "${GITHUB_EVENT_NAME}" = pull_request ]' 'rok=0 + bash scripts/ci/pre.sh + if [ "${GITHUB_EVENT_NAME}" = pull_request ]' # W21/W22: a refs/replace entry in the PR repo must not let a receipt for another tree pass. The # receipt is signed for the empty tree; the replace ref makes HEAD read as a commit with that tree. git -C "$repo" -c user.email=t@example.invalid -c user.name=t checkout -q -- . 2>/dev/null From 13664bb95e8b89b8ea26d3e0ac4e584098613381 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:31:16 +0200 Subject: [PATCH 10/15] ci(receipt): read the toolchain via git show, refuse path=, rustc from the base dir, strip credentials (Refs #5798) Opus lane on b6a47ab544: an absolute path= in rust-toolchain made rustc -vV, run from the PR cwd, execute PR-chosen code and write ok=1. Rows W23-W26 (two RED mutants). Co-Authored-By: Claude Sonnet 5.5 --- scripts/check_receipt_gate.sh | 35 ++++++++++++++++++++++++++++++++- scripts/ci/receipt_ok_arg.sh | 10 ++++++++-- scripts/receipt_toolchain_id.sh | 14 +++++++++++-- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 819896628d..e382be0b51 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -126,6 +126,7 @@ mkbase() { rm -rf "${d:?}"; git init -q "$d" mkdir -p "$d/scripts" "$d/.flow009" cp "$V" "$d/scripts/ci_receipt_gate.sh"; cp "$TID" "$d/scripts/receipt_toolchain_id.sh" + cp "$repo/rust-toolchain.toml" "$d/rust-toolchain.toml"; files="$files rust-toolchain.toml" if [ "$pubf" != - ]; then cp "$pubf" "$d/.flow009/batcher.pub"; files="$files .flow009/batcher.pub"; fi for f in $files; do git -C "$d" add "$f"; done git -C "$d" -c user.email=t@example.invalid -c user.name=t commit -q -m judge @@ -224,7 +225,9 @@ wmut "W20b MUTANT: a tier step that runs PR-tree code is caught" sec 'rok=0 git -C "$repo" -c user.email=t@example.invalid -c user.name=t checkout -q -- . 2>/dev/null rep=$(git -C "$repo" commit-tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 -m fake) git -C "$repo" update-ref "refs/replace/$head" "$rep" -receipt 4b825dc642cb6eb9a060e54bf8d69288fbee4904 "$tc" >"$td/r.json" +# the faked HEAD has an empty tree, so its toolchain id has no toml part +tcE=$({ (cd "$BASE" && rustc -vV); printf 'image=none\n'; } | sha256sum | cut -d' ' -f1) +receipt 4b825dc642cb6eb9a060e54bf8d69288fbee4904 "$tcE" >"$td/r.json" body "$td/b-repl" "$(sign "$td/key" "$td/r.json")" wrow "W21 refs/replace on the PR repo cannot make another tree pass -> 0" 0 "$(arg "$td/b-repl")" if mutate "$W" 'export GIT_NO_REPLACE_OBJECTS=1' ':' "$td/mut-r.sh"; then @@ -232,6 +235,36 @@ if mutate "$W" 'export GIT_NO_REPLACE_OBJECTS=1' ':' "$td/mut-r.sh"; then else fail=$((fail + 1)); echo "FAIL W22: mutant did not apply"; fi git -C "$repo" update-ref -d "refs/replace/$head" rm -rf "${repo:?}/.flow009" +# W23-W26: the rustc/credential hardening (Opus, #5798). A PR toolchain file with an absolute +# `path =` must not reach rustc; the verifier gets no GH_TOKEN/GITHUB_OUTPUT; rustc runs in the +# base's dir. repo2's receipt is signed over a toolchain id computed the honest way, so only the +# path refusal stands between it and ok=1. +rm -rf "${td:?}/repo2"; cp -r "$repo" "$td/repo2" +printf '[toolchain]\npath = "/nonexistent/fake-toolchain"\n' >"$td/repo2/rust-toolchain.toml" +git -C "$td/repo2" -c user.email=t@example.invalid -c user.name=t commit -q -am "path override" +tree2=$(git -C "$td/repo2" rev-parse 'HEAD^{tree}'); head2=$(git -C "$td/repo2" rev-parse HEAD) +tc2=$({ cat "$td/repo2/rust-toolchain.toml"; (cd "$BASE" && rustc -vV); printf 'image=none\n'; } | sha256sum | cut -d' ' -f1) +receipt "$tree2" "$tc2" >"$td/p.json"; body "$td/b-path" "$(sign "$td/key" "$td/p.json")" +arg2() { (cd "$td/repo2" && bash "${3:-$W}" "$1" "${2:-$BASE}" "$head2" 2>/dev/null | head -n1); } +wrow "W23 a PR toolchain file with path= is refused -> 0" 0 "$(arg2 "$td/b-path")" +if mutate "$TID" 'if printf' 'if false && printf' "$td/mut-tid.sh"; then + mkbase trust-mut "$td/pub"; cp "$td/mut-tid.sh" "$td/trust-mut/scripts/receipt_toolchain_id.sh" + git -C "$td/trust-mut" -c user.email=t@example.invalid -c user.name=t commit -q -am mut + wrow "W24 MUTANT: a toolchain id that accepts path= is caught by W23's row" 1 "$(arg2 "$td/b-path" "$td/trust-mut")" +else fail=$((fail + 1)); echo "FAIL W24: mutant did not apply"; fi +# probe judge: records what the verifier can see (token/output vars, and the rustc dir's listing) +mkbase trust-probe "$td/pub" +cat >"$td/trust-probe/scripts/ci_receipt_gate.sh" <<'PROBE' +#!/usr/bin/env bash +printf '%s|%s|%s\n' "${GH_TOKEN:-}${GITHUB_OUTPUT:-}" "${RECEIPT_RUSTC_CWD:-}" "$(ls "${RECEIPT_RUSTC_CWD:-/nonexistent}" 2>/dev/null | tr -d '\n')" >"$PROBEFILE" +printf 'RECEIPT_OK=0\n' +PROBE +git -C "$td/trust-probe" -c user.email=t@example.invalid -c user.name=t commit -q -am probe +probe() { rm -f "${td:?}/probe.out"; (cd "$repo" && GH_TOKEN=secret GITHUB_OUTPUT=/tmp/x PROBEFILE="$td/probe.out" bash "${1:-$W}" "$td/b-good" "$td/trust-probe" "$head" >/dev/null 2>&1); sed -E 's#\|[^|]*/rc\|#|RC|#' "$td/probe.out" 2>/dev/null; } +wrow "W25 the verifier sees no token or output file, and runs rustc in the base's dir" "|RC|rust-toolchain.toml" "$(probe)" +if mutate "$W" '-u GH_TOKEN' '-u XX_TOKEN' "$td/mut-e.sh"; then + wrow "W26 MUTANT: a wrapper that leaves GH_TOKEN in the verifier's env is caught by W25" "secret|RC|rust-toolchain.toml" "$(probe "$td/mut-e.sh")" +else fail=$((fail + 1)); echo "FAIL W26: mutant did not apply"; fi tier_of() { (cd "$HERE/.." && bash "$T" --event pull_request --diff-from "$td/touched.txt" --receipt-ok "$1" 2>/dev/null | head -n1); } t0=$(tier_of 0); case "$t0" in tier=quick|tier=full) t0=notreuse ;; esac wrow "W8 --receipt-ok 0 on a core diff runs quick or full, never reuse" notreuse "$t0" diff --git a/scripts/ci/receipt_ok_arg.sh b/scripts/ci/receipt_ok_arg.sh index dc85302cb8..b368cc542a 100755 --- a/scripts/ci/receipt_ok_arg.sh +++ b/scripts/ci/receipt_ok_arg.sh @@ -12,7 +12,7 @@ # rust-toolchain.toml. The caller must itself run THIS script from TRUST_DIR too # (`git -C TRUST_DIR show HEAD:scripts/ci/receipt_ok_arg.sh`). Replace refs are off for every # git call here, so a PR cannot make its own HEAD^{tree} read as another tree. -# Cases: scripts/check_receipt_gate.sh (rows W1-W20). +# Cases: scripts/check_receipt_gate.sh (rows W1-W26). set -u export GIT_NO_REPLACE_OBJECTS=1 unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES @@ -24,5 +24,11 @@ tg() { GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git -C "$trust" " tg rev-parse --verify -q 'HEAD^{commit}' >/dev/null 2>&1 || { printf '0\n'; exit 0; } tg archive HEAD scripts/ci_receipt_gate.sh scripts/receipt_toolchain_id.sh .flow009/batcher.pub 2>/dev/null \ | tar -x -C "$j" 2>/dev/null || { printf '0\n'; exit 0; } -first=$(bash "$j/scripts/ci_receipt_gate.sh" verify "$body" "$j/.flow009/batcher.pub" "$sha" 2>/dev/null | head -n1) || first="" +# rustc runs in a dir holding the BASE's rust-toolchain.toml, never in the PR cwd (a `path=` +# override there would make `rustc -vV` run PR-chosen code). No toml at the base: an empty dir. +mkdir -p "$j/rc" && { tg show HEAD:rust-toolchain.toml >"$j/rc/rust-toolchain.toml" 2>/dev/null || rm -f "${j:?}/rc/rust-toolchain.toml"; } +export RECEIPT_RUSTC_CWD="$j/rc" +# The verifier never sees the job's credentials or its output/env/path files. +first=$(env -u GH_TOKEN -u GITHUB_TOKEN -u GITHUB_OUTPUT -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \ + bash "$j/scripts/ci_receipt_gate.sh" verify "$body" "$j/.flow009/batcher.pub" "$sha" 2>/dev/null | head -n1) || first="" if [ "$first" = "RECEIPT_OK=1" ]; then printf '1\n'; else printf '0\n'; fi diff --git a/scripts/receipt_toolchain_id.sh b/scripts/receipt_toolchain_id.sh index 638787b620..5a701dbd2e 100755 --- a/scripts/receipt_toolchain_id.sh +++ b/scripts/receipt_toolchain_id.sh @@ -3,12 +3,22 @@ # absent), the full `rustc -vV`, then the literal line "image=none". Same bytes, same order # as the batcher's signing side, so a receipt's toolchain field compares equal here. # A missing rustc exits 1 (the verifier fails closed). +# Hardened (#5798): the toml is read from HEAD's tree (git show), not the working tree; a +# `path =` key (an override that makes rustc run an arbitrary binary) is refused; and rustc runs +# in $RECEIPT_RUSTC_CWD when the judge sets it (a dir holding the BASE's toml), never in the PR cwd. set -u root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) -rustc_v=$(rustc -vV 2>/dev/null) || exit 1 +toml="" +if git rev-parse --verify -q 'HEAD^{tree}' >/dev/null 2>&1; then + toml=$(git show HEAD:rust-toolchain.toml 2>/dev/null; printf x); toml=${toml%x} +elif [ -f "$root/rust-toolchain.toml" ]; then + toml=$(cat "$root/rust-toolchain.toml"; printf x); toml=${toml%x} +fi +if printf '%s\n' "$toml" | grep -q -E '^[[:space:]]*path[[:space:]]*='; then exit 1; fi +rustc_v=$(cd "${RECEIPT_RUSTC_CWD:-.}" && rustc -vV 2>/dev/null) || exit 1 [ -n "$rustc_v" ] || exit 1 { - if [ -f "$root/rust-toolchain.toml" ]; then cat "$root/rust-toolchain.toml"; fi + printf '%s' "$toml" printf '%s\n' "$rustc_v" printf 'image=none\n' } | sha256sum | cut -d' ' -f1 From aed6c45c96a654aa802cbabc48486b5272079248 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:39:19 +0200 Subject: [PATCH 11/15] ci(receipt): judge worktree is --no-checkout with hooks off, token env unset, wrapper read from the base odb (Refs #5798) Opus lane on 13664bb95e: full checkout could fire hooks/filters and put PR executables in the verifier's cwd; GIT_CONFIG_VALUE_0 carried the token. Rows W27/W28 (RED mutants). Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 9 ++++++--- scripts/check_receipt_gate.sh | 7 ++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4bd66858b..3a2683886c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -474,9 +474,12 @@ jobs: export GIT_CONFIG_VALUE_0 if git fetch -q --no-tags --depth=1 origin "refs/pull/${PR_NUMBER}/merge" && [ "$(git rev-parse FETCH_HEAD)" = "${GITHUB_SHA}" ] && - git worktree add -q --detach "$RUNNER_TEMP/prtree" FETCH_HEAD && - [ -f scripts/ci/receipt_ok_arg.sh ]; then - ok="$(cd "$RUNNER_TEMP/prtree" && bash "$GITHUB_WORKSPACE/scripts/ci/receipt_ok_arg.sh" "$RUNNER_TEMP/pr-body.md" "$GITHUB_WORKSPACE" "${GITHUB_SHA}")" || ok=0 + unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GH_TOKEN && + git -c core.hooksPath=/dev/null worktree add -q --no-checkout --detach "$RUNNER_TEMP/prtree" FETCH_HEAD && + git show HEAD:scripts/ci/receipt_ok_arg.sh > "$RUNNER_TEMP/rok.sh"; then + # --no-checkout: the PR tree has no files, so no PR executable can sit in the verifier's + # cwd, and no filter or hook runs. The wrapper is read from the base object database. + ok="$(cd "$RUNNER_TEMP/prtree" && bash "$RUNNER_TEMP/rok.sh" "$RUNNER_TEMP/pr-body.md" "$GITHUB_WORKSPACE" "${GITHUB_SHA}")" || ok=0 fi [ "$ok" = 1 ] || ok=0 printf 'ok=%s\n' "$ok" >> "$GITHUB_OUTPUT" diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index e382be0b51..2437a902b6 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -188,7 +188,10 @@ judge_ok() { # CI_FILE SECTIONS_FILE -> 0 when the wiring is trustworthy printf '%s\n' "$code" | grep -q 'uses: actions/checkout@' || return 1 printf '%s\n' "$code" | grep -q -E 'GITHUB_PATH|GITHUB_ENV|head_ref|pull_request\.head|github\.sha *$' && return 1 [ "$(printf '%s\n' "$code" | grep -c '^ - ')" -eq 2 ] || return 1 - printf '%s\n' "$code" | grep -E 'scripts/' | grep -v -E 'GITHUB_WORKSPACE/scripts/ci/receipt_ok_arg\.sh|-f scripts/ci/receipt_ok_arg\.sh' | grep -q . && return 1 + printf '%s\n' "$code" | grep -E 'scripts/' | grep -v -E 'git show HEAD:scripts/ci/receipt_ok_arg\.sh' | grep -q . && return 1 + case "$code" in *'worktree add -q --no-checkout'*) ;; *) return 1 ;; esac + case "$code" in *'core.hooksPath=/dev/null'*) ;; *) return 1 ;; esac + case "$code" in *'unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0'*) ;; *) return 1 ;; esac shard=$(awk '/^ workspace-test-shard:$/{on=1;print;next} on&&/^ [a-z]/{exit} on{print}' "$1") case "$shard" in *'needs: [receipt-judge]'*) ;; *) return 1 ;; esac case "$shard" in *'RECEIPT_OK: ${{ needs.receipt-judge.outputs.ok'*) ;; *) return 1 ;; esac @@ -215,6 +218,8 @@ wmut "W18 MUTANT: a PR-tree script step before the judge is caught" ci "$CHK" ' '"$CHK" wmut "W19 MUTANT: an earlier step planting PATH is caught" ci "$CHK" ' - run: echo "$PWD/bin" >> "$GITHUB_PATH" '"$CHK" +wmut "W27 MUTANT: a judge whose PR worktree is a full checkout is caught" ci 'worktree add -q --no-checkout' 'worktree add -q' +wmut "W28 MUTANT: a judge that leaves the git token env set is caught" ci 'unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GH_TOKEN' 'true' wmut "W20 MUTANT: a shard job that does not wait for the judge is caught" ci 'needs: [receipt-judge]' 'needs: []' wmut "W20b MUTANT: a tier step that runs PR-tree code is caught" sec 'rok=0 if [ "${GITHUB_EVENT_NAME}" = pull_request ]' 'rok=0 From 3313c5f914d78205663b80676c29877f8034fc20 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:49:01 +0200 Subject: [PATCH 12/15] ci(receipt): absolute tools, env -i verifier, judge on a hosted ephemeral runner (Refs #5798) Opus forged ok=1 with an always-0 ssh-keygen in PATH and with a BASH_ENV function. The gate now uses /usr/bin/ssh-keygen and /usr/bin/jq under a fixed PATH; the wrapper runs it under env -i; the judge job runs on ubuntu-latest (quorum choice (a)). Rows W29-W32 (forgery + mutants). Comments state the merge_group backstop instead of overstating. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 17 +++++++++-------- scripts/check_receipt_gate.sh | 34 +++++++++++++++++++++++++++------ scripts/ci/receipt_ok_arg.sh | 17 +++++++++++------ scripts/ci_receipt_gate.sh | 26 +++++++++++++++---------- scripts/receipt_toolchain_id.sh | 3 ++- 5 files changed, 66 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a2683886c..01febb0e88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -439,16 +439,17 @@ jobs: # The workspace tests, one shard per job (nextest hash partition N/3 plus the # explicit runner's round-robin N/3), in parallel on the X64 clean-room pool. # The section body is ci/sections.yml `workspace-test-shard`, unchanged. - # #5798: the receipt judge. A PR cannot judge itself, and no step that runs the PR tree - # may run before the verdict: this job checks out the BASE only and fetches the PR merge - # commit as data (a detached worktree that is read, never executed). Nothing in it runs a - # script from that worktree, so nothing the PR carries can plant PATH, git, a config or a - # refs/replace entry before the judge reads. scripts/check_receipt_gate.sh (W16-W20) turns - # RED if this job gains a step that runs PR-tree code or checks the PR out as code. + # #5798: the receipt judge. A PR cannot forge a receipt: this job checks out the BASE only + # and fetches the PR merge commit as data (a --no-checkout worktree that is read, never + # executed), on a GitHub-hosted ephemeral runner no PR-code job ever shares, and runs the + # verifier under `env -i` with absolute tools. What it does NOT cover: a PR that edits the CI + # files or the judge itself is caught only by merge_group (which ignores --receipt-ok and runs + # the full gate) and by CI-edit review. scripts/check_receipt_gate.sh (W16-W32) turns RED if + # this job gains a step that runs PR-tree code or checks the PR out as code. # It always exits 0; the shard job reads `ok` as 1 only on a literal 1. receipt-judge: if: github.event_name == 'pull_request' - runs-on: [self-hosted, Linux, clean-room] + runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: read @@ -479,7 +480,7 @@ jobs: git show HEAD:scripts/ci/receipt_ok_arg.sh > "$RUNNER_TEMP/rok.sh"; then # --no-checkout: the PR tree has no files, so no PR executable can sit in the verifier's # cwd, and no filter or hook runs. The wrapper is read from the base object database. - ok="$(cd "$RUNNER_TEMP/prtree" && bash "$RUNNER_TEMP/rok.sh" "$RUNNER_TEMP/pr-body.md" "$GITHUB_WORKSPACE" "${GITHUB_SHA}")" || ok=0 + ok="$(cd "$RUNNER_TEMP/prtree" && env -i HOME="$HOME" PATH=/usr/local/bin:/usr/bin:/bin /usr/bin/bash "$RUNNER_TEMP/rok.sh" "$RUNNER_TEMP/pr-body.md" "$GITHUB_WORKSPACE" "${GITHUB_SHA}")" || ok=0 fi [ "$ok" = 1 ] || ok=0 printf 'ok=%s\n' "$ok" >> "$GITHUB_OUTPUT" diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 2437a902b6..ace3bd1e6e 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -189,6 +189,8 @@ judge_ok() { # CI_FILE SECTIONS_FILE -> 0 when the wiring is trustworthy printf '%s\n' "$code" | grep -q -E 'GITHUB_PATH|GITHUB_ENV|head_ref|pull_request\.head|github\.sha *$' && return 1 [ "$(printf '%s\n' "$code" | grep -c '^ - ')" -eq 2 ] || return 1 printf '%s\n' "$code" | grep -E 'scripts/' | grep -v -E 'git show HEAD:scripts/ci/receipt_ok_arg\.sh' | grep -q . && return 1 + case "$code" in *'runs-on: ubuntu-latest'*) ;; *) return 1 ;; esac + case "$code" in *'env -i HOME='*'/usr/bin/bash "$RUNNER_TEMP/rok.sh"'*) ;; *) return 1 ;; esac case "$code" in *'worktree add -q --no-checkout'*) ;; *) return 1 ;; esac case "$code" in *'core.hooksPath=/dev/null'*) ;; *) return 1 ;; esac case "$code" in *'unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0'*) ;; *) return 1 ;; esac @@ -235,7 +237,7 @@ tcE=$({ (cd "$BASE" && rustc -vV); printf 'image=none\n'; } | sha256sum | cut -d receipt 4b825dc642cb6eb9a060e54bf8d69288fbee4904 "$tcE" >"$td/r.json" body "$td/b-repl" "$(sign "$td/key" "$td/r.json")" wrow "W21 refs/replace on the PR repo cannot make another tree pass -> 0" 0 "$(arg "$td/b-repl")" -if mutate "$W" 'export GIT_NO_REPLACE_OBJECTS=1' ':' "$td/mut-r.sh"; then +if mutate "$W" 'export GIT_NO_REPLACE_OBJECTS=1' ':' "$td/mut-r.sh" && sed -i 's/ GIT_NO_REPLACE_OBJECTS=1 RECEIPT/ RECEIPT/' "$td/mut-r.sh"; then wrow "W22 MUTANT: a wrapper that honours replace refs is caught by W21's row" 1 "$(arg "$td/b-repl" "$BASE" "$td/mut-r.sh")" else fail=$((fail + 1)); echo "FAIL W22: mutant did not apply"; fi git -C "$repo" update-ref -d "refs/replace/$head" @@ -259,17 +261,37 @@ if mutate "$TID" 'if printf' 'if false && printf' "$td/mut-tid.sh"; then else fail=$((fail + 1)); echo "FAIL W24: mutant did not apply"; fi # probe judge: records what the verifier can see (token/output vars, and the rustc dir's listing) mkbase trust-probe "$td/pub" -cat >"$td/trust-probe/scripts/ci_receipt_gate.sh" <<'PROBE' +cat >"$td/trust-probe/scripts/ci_receipt_gate.sh" </dev/null | tr -d '\n')" >"$PROBEFILE" -printf 'RECEIPT_OK=0\n' +printf '%s|%s|%s\\n' "\${GH_TOKEN:-}\${GITHUB_OUTPUT:-}" "\${RECEIPT_RUSTC_CWD:-}" "\$(ls "\${RECEIPT_RUSTC_CWD:-/nonexistent}" 2>/dev/null | tr -d '\\n')" >"$td/probe.out" +printf 'RECEIPT_OK=0\\n' PROBE git -C "$td/trust-probe" -c user.email=t@example.invalid -c user.name=t commit -q -am probe probe() { rm -f "${td:?}/probe.out"; (cd "$repo" && GH_TOKEN=secret GITHUB_OUTPUT=/tmp/x PROBEFILE="$td/probe.out" bash "${1:-$W}" "$td/b-good" "$td/trust-probe" "$head" >/dev/null 2>&1); sed -E 's#\|[^|]*/rc\|#|RC|#' "$td/probe.out" 2>/dev/null; } wrow "W25 the verifier sees no token or output file, and runs rustc in the base's dir" "|RC|rust-toolchain.toml" "$(probe)" -if mutate "$W" '-u GH_TOKEN' '-u XX_TOKEN' "$td/mut-e.sh"; then - wrow "W26 MUTANT: a wrapper that leaves GH_TOKEN in the verifier's env is caught by W25" "secret|RC|rust-toolchain.toml" "$(probe "$td/mut-e.sh")" +if mutate "$W" 'first=$(env -i ' 'first=$(env ' "$td/mut-e.sh"; then + wrow "W26 MUTANT: a wrapper without env -i leaves GH_TOKEN in the verifier's env is caught by W25" "secret/tmp/x|RC|rust-toolchain.toml" "$(probe "$td/mut-e.sh")" else fail=$((fail + 1)); echo "FAIL W26: mutant did not apply"; fi +# W29-W32: forgery by the environment (Opus, #5798). An always-0 ssh-keygen early in PATH, or a +# BASH_ENV file defining ssh-keygen(), would turn a tampered receipt (b-s1: valid in every other +# field) into ok=1. The real wrapper + gate must print 0; a mutant with the defences removed prints 1. +mkdir -p "$td/evil"; printf '#!/bin/sh\nexit 0\n' >"$td/evil/ssh-keygen"; chmod +x "$td/evil/ssh-keygen" +printf 'ssh-keygen() { return 0; }\nexport -f ssh-keygen\n' >"$td/evil.env" +forge() { # WRAPPER BASEDIR -> wrapper output under both forgeries (PATH first, then BASH_ENV) + local a b + a=$(cd "$repo" && PATH="$td/evil:$PATH" bash "$1" "$td/b-s1" "$2" "$head" 2>/dev/null | head -n1) + b=$(cd "$repo" && BASH_ENV="$td/evil.env" bash "$1" "$td/b-s1" "$2" "$head" 2>/dev/null | head -n1) + printf '%s%s' "$a" "$b" +} +wrow "W29 a planted ssh-keygen in PATH or a BASH_ENV function cannot forge ok -> 00" 00 "$(forge "$W" "$BASE")" +wrow "W30 sanity: the tampered receipt really is bad without the forgery -> 0" 0 "$(arg "$td/b-s1")" +if mutate "$W" 'first=$(env -i ' 'first=$(env ' "$td/mut-f.sh" && sed -i 's# PATH=/usr/local/bin:/usr/bin:/bin GIT_NO# GIT_NO#' "$td/mut-f.sh"; then + mkbase trust-forge "$td/pub" + sed -i -e 's#^PATH=/usr/local/bin.*#:#' -e 's#^unset BASH_ENV ENV#:#' -e 's#^SSHKEYGEN=.*#SSHKEYGEN=ssh-keygen#' -e 's#\[ -x "\$SSHKEYGEN" \]#command -v "$SSHKEYGEN" >/dev/null#' "$td/trust-forge/scripts/ci_receipt_gate.sh" + git -C "$td/trust-forge" -c user.email=t@example.invalid -c user.name=t commit -q -am forge + wrow "W31 MUTANT: no env -i, no fixed PATH, bare ssh-keygen -> the PATH forgery gets through" 1 "$(forge "$td/mut-f.sh" "$td/trust-forge" | cut -c1)" + wrow "W32 MUTANT: same, the BASH_ENV forgery gets through" 1 "$(forge "$td/mut-f.sh" "$td/trust-forge" | cut -c2)" +else fail=$((fail + 1)); echo "FAIL W31/W32: mutant did not apply"; fi tier_of() { (cd "$HERE/.." && bash "$T" --event pull_request --diff-from "$td/touched.txt" --receipt-ok "$1" 2>/dev/null | head -n1); } t0=$(tier_of 0); case "$t0" in tier=quick|tier=full) t0=notreuse ;; esac wrow "W8 --receipt-ok 0 on a core diff runs quick or full, never reuse" notreuse "$t0" diff --git a/scripts/ci/receipt_ok_arg.sh b/scripts/ci/receipt_ok_arg.sh index b368cc542a..975003a6a1 100755 --- a/scripts/ci/receipt_ok_arg.sh +++ b/scripts/ci/receipt_ok_arg.sh @@ -4,15 +4,18 @@ # verified, 0 in every other case (no receipt, a bad one, a missing file, a crashed verifier, # a base that has no judge yet). Always exits 0: 0 sends the PR to the full gate. # +# This script is itself a backstop for a PR that carries no CI edits: a PR that edits the CI +# files or this judge is caught only by merge_group (which ignores --receipt-ok, so it runs the +# full gate) and by CI-edit review, not by this script. +# # A PR cannot edit its own judge (the #5793 pattern): the verifier, the toolchain-id recipe -# and the pinned batcher public key are read from HEAD of TRUST_DIR, a fresh clone of the base -# branch the caller made BEFORE any code from the PR tree ran (no PR-tree script can have -# planted a url.insteadOf, a refs/replace entry or a config in it), never from the working +# and the pinned batcher public key are read from HEAD of TRUST_DIR, the base-branch checkout +# of a job that never runs PR-tree code, never from the working # tree. The working tree supplies only what is being judged: HEAD^{tree} and # rust-toolchain.toml. The caller must itself run THIS script from TRUST_DIR too # (`git -C TRUST_DIR show HEAD:scripts/ci/receipt_ok_arg.sh`). Replace refs are off for every # git call here, so a PR cannot make its own HEAD^{tree} read as another tree. -# Cases: scripts/check_receipt_gate.sh (rows W1-W26). +# Cases: scripts/check_receipt_gate.sh (rows W1-W32). set -u export GIT_NO_REPLACE_OBJECTS=1 unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES @@ -29,6 +32,8 @@ tg archive HEAD scripts/ci_receipt_gate.sh scripts/receipt_toolchain_id.sh .flow mkdir -p "$j/rc" && { tg show HEAD:rust-toolchain.toml >"$j/rc/rust-toolchain.toml" 2>/dev/null || rm -f "${j:?}/rc/rust-toolchain.toml"; } export RECEIPT_RUSTC_CWD="$j/rc" # The verifier never sees the job's credentials or its output/env/path files. -first=$(env -u GH_TOKEN -u GITHUB_TOKEN -u GITHUB_OUTPUT -u GITHUB_ENV -u GITHUB_PATH -u GITHUB_STEP_SUMMARY \ - bash "$j/scripts/ci_receipt_gate.sh" verify "$body" "$j/.flow009/batcher.pub" "$sha" 2>/dev/null | head -n1) || first="" +# env -i: no PATH entry, BASH_ENV function, RUSTUP_*/GIT_* variable or credential survives. +first=$(env -i HOME="${HOME:-/nonexistent}" ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} \ + PATH=/usr/local/bin:/usr/bin:/bin GIT_NO_REPLACE_OBJECTS=1 RECEIPT_RUSTC_CWD="$RECEIPT_RUSTC_CWD" \ + /usr/bin/bash "$j/scripts/ci_receipt_gate.sh" verify "$body" "$j/.flow009/batcher.pub" "$sha" 2>/dev/null | head -n1) || first="" if [ "$first" = "RECEIPT_OK=1" ]; then printf '1\n'; else printf '0\n'; fi diff --git a/scripts/ci_receipt_gate.sh b/scripts/ci_receipt_gate.sh index 32d1a7b62c..ac5d48f1e1 100755 --- a/scripts/ci_receipt_gate.sh +++ b/scripts/ci_receipt_gate.sh @@ -4,7 +4,7 @@ # The PR gate may run check/fmt/clippy instead of the full suite only for a batch whose # signed receipt verifies. Three checks decide it, in this order: # signature the armored ssh-keygen signature over the canonical bytes -# (`jq -S -c 'del(.sig)'` plus LF, namespace below) verifies against the +# (`"$JQ" -S -c 'del(.sig)'` plus LF, namespace below) verifies against the # single pinned batcher public key in PUB_FILE # tree the receipt's tree == HEAD^{tree}, the PR merge tree (run inside the # checked-out merge commit) @@ -16,6 +16,12 @@ # belongs to the caller (the full gate owns those). # Cases: scripts/check_receipt_gate.sh. set -u +# Absolute tools and a fixed PATH: a PATH entry or a BASH_ENV function must never stand in for +# ssh-keygen or jq (#5798). Callers also run this under `env -i`. +PATH=/usr/local/bin:/usr/bin:/bin; export PATH +unset BASH_ENV ENV +SSHKEYGEN=/usr/bin/ssh-keygen +JQ=/usr/bin/jq NS="flow009-receipt" SIGNER_ID="flow009-batcher" SCHEMA="flow009.receipt/1" @@ -23,7 +29,7 @@ HERE=$(cd "$(dirname "$0")" && pwd) fail() { printf 'RECEIPT_OK=0 reason=%s\n' "$1"; exit 0; } -canonical() { jq -S -c 'del(.sig)' "$1"; } +canonical() { "$JQ" -S -c 'del(.sig)' "$1"; } # The fenced block's contents on stdout; non-zero unless there is exactly one. extract_block() { @@ -38,8 +44,8 @@ extract_block() { cmd_verify() { [ $# -eq 3 ] || fail usage body=$1; pub=$2 - command -v ssh-keygen >/dev/null 2>&1 || fail no-ssh-keygen - command -v jq >/dev/null 2>&1 || fail no-jq + [ -x "$SSHKEYGEN" ] || fail no-ssh-keygen + [ -x "$JQ" ] || fail no-jq [ -f "$body" ] || fail no-body [ -f "$pub" ] || fail no-pubkey work=$(mktemp -d) || fail no-tmpdir @@ -50,28 +56,28 @@ cmd_verify() { case "$keyline" in ssh-ed25519\ *) ;; *) fail bad-pubkey ;; esac printf '%s namespaces="%s" %s\n' "$SIGNER_ID" "$NS" "$keyline" >"$work/allowed" extract_block "$body" >"$work/receipt.json" || fail fenced-block - jq -e --arg s "$SCHEMA" ' + "$JQ" -e --arg s "$SCHEMA" ' type == "object" and .schema == $s and (.sig | type == "string") and (.tree | type == "string") and (.toolchain | type == "string") ' "$work/receipt.json" >/dev/null 2>&1 || fail shape # signature canonical "$work/receipt.json" >"$work/canon" || fail canon - jq -r '.sig' "$work/receipt.json" >"$work/sig" - ssh-keygen -Y verify -f "$work/allowed" -I "$SIGNER_ID" -n "$NS" -s "$work/sig" \ + "$JQ" -r '.sig' "$work/receipt.json" >"$work/sig" + "$SSHKEYGEN" -Y verify -f "$work/allowed" -I "$SIGNER_ID" -n "$NS" -s "$work/sig" \ <"$work/canon" >/dev/null 2>&1 || fail bad-signature # verdict: the signed bytes must say the batch's suite passed. A validly signed receipt for a # red batch must not reduce the gate. gate_results is an object, `tests` is present, and every # value is "pass". - jq -e '.gate_results | type == "object" and has("tests") and (to_entries | all(.value == "pass"))' \ + "$JQ" -e '.gate_results | type == "object" and has("tests") and (to_entries | all(.value == "pass"))' \ "$work/receipt.json" >/dev/null 2>&1 || fail not-pass # tree want_tree=$(git rev-parse 'HEAD^{tree}' 2>/dev/null) [ -n "$want_tree" ] || fail no-head-tree - [ "$(jq -r .tree "$work/receipt.json")" = "$want_tree" ] || fail tree-mismatch + [ "$("$JQ" -r .tree "$work/receipt.json")" = "$want_tree" ] || fail tree-mismatch # toolchain want_tc=$(bash "$HERE/receipt_toolchain_id.sh" 2>/dev/null) [ -n "$want_tc" ] || fail no-toolchain-id - [ "$(jq -r .toolchain "$work/receipt.json")" = "$want_tc" ] || fail toolchain-mismatch + [ "$("$JQ" -r .toolchain "$work/receipt.json")" = "$want_tc" ] || fail toolchain-mismatch printf 'RECEIPT_OK=1\n' } diff --git a/scripts/receipt_toolchain_id.sh b/scripts/receipt_toolchain_id.sh index 5a701dbd2e..0be67272b1 100755 --- a/scripts/receipt_toolchain_id.sh +++ b/scripts/receipt_toolchain_id.sh @@ -15,7 +15,8 @@ elif [ -f "$root/rust-toolchain.toml" ]; then toml=$(cat "$root/rust-toolchain.toml"; printf x); toml=${toml%x} fi if printf '%s\n' "$toml" | grep -q -E '^[[:space:]]*path[[:space:]]*='; then exit 1; fi -rustc_v=$(cd "${RECEIPT_RUSTC_CWD:-.}" && rustc -vV 2>/dev/null) || exit 1 +rustc=rustc; [ -x "${CARGO_HOME:-$HOME/.cargo}/bin/rustc" ] && rustc="${CARGO_HOME:-$HOME/.cargo}/bin/rustc" +rustc_v=$(cd "${RECEIPT_RUSTC_CWD:-.}" && "$rustc" -vV 2>/dev/null) || exit 1 [ -n "$rustc_v" ] || exit 1 { printf '%s' "$toml" From 64c2971d8e172fe3438729bd51b3d304d7d69c08 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 18:56:56 +0200 Subject: [PATCH 13/15] ci(receipt): state exactly what merge_group does (quick tier, PR's own CI files) (Refs #5798) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 6 ++++-- scripts/ci/receipt_ok_arg.sh | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01febb0e88..ee7e9483e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -443,8 +443,10 @@ jobs: # and fetches the PR merge commit as data (a --no-checkout worktree that is read, never # executed), on a GitHub-hosted ephemeral runner no PR-code job ever shares, and runs the # verifier under `env -i` with absolute tools. What it does NOT cover: a PR that edits the CI - # files or the judge itself is caught only by merge_group (which ignores --receipt-ok and runs - # the full gate) and by CI-edit review. scripts/check_receipt_gate.sh (W16-W32) turns RED if + # files or the judge itself. merge_group ignores --receipt-ok but runs only the QUICK tier + # (full only on tag/release, scripts/ci_test_tier.sh merge_group case), from the PR's own + # CI files, so it does not re-judge such an edit with a trusted copy; CI-edit review is the + # only backstop. scripts/check_receipt_gate.sh (W16-W32) turns RED if # this job gains a step that runs PR-tree code or checks the PR out as code. # It always exits 0; the shard job reads `ok` as 1 only on a literal 1. receipt-judge: diff --git a/scripts/ci/receipt_ok_arg.sh b/scripts/ci/receipt_ok_arg.sh index 975003a6a1..c98daf6ca6 100755 --- a/scripts/ci/receipt_ok_arg.sh +++ b/scripts/ci/receipt_ok_arg.sh @@ -4,9 +4,9 @@ # verified, 0 in every other case (no receipt, a bad one, a missing file, a crashed verifier, # a base that has no judge yet). Always exits 0: 0 sends the PR to the full gate. # -# This script is itself a backstop for a PR that carries no CI edits: a PR that edits the CI -# files or this judge is caught only by merge_group (which ignores --receipt-ok, so it runs the -# full gate) and by CI-edit review, not by this script. +# This does not cover a PR that edits the CI files or this judge: merge_group ignores +# --receipt-ok but runs only the quick tier (full on tag/release), from the PR's own CI files, +# so it does not re-judge such an edit with a trusted copy. CI-edit review is the only backstop. # # A PR cannot edit its own judge (the #5793 pattern): the verifier, the toolchain-id recipe # and the pinned batcher public key are read from HEAD of TRUST_DIR, the base-branch checkout From 1f02016cdd9e4b42f08d76cd976bf6d7910d8c59 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 19:29:55 +0200 Subject: [PATCH 14/15] fix(ci): regenerate roadmap aggregate; bashrs SEC010 annotations on receipt-gate self-test (Refs #5797) Co-Authored-By: Claude Sonnet 5.5 --- docs/roadmaps/roadmap.yaml | 18 ++++++++++++++++++ scripts/check_receipt_gate.sh | 3 +++ 2 files changed, 21 insertions(+) diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index 3bce667faa..96fe7ed6ff 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25340,3 +25340,21 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:code notes: null epic: 5510 +- id: PMAT-5797 + github_issue: 5797 + item_type: task + title: 'PR gate: signed batch receipt verify (sig, tree, toolchain) as tier=reuse extension' + status: planned + priority: high + assigned_to: null + created: 2026-10-20T14:55:28Z + updated: 2026-10-20T14:55:28Z + spec: null + acceptance_criteria: [] + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 5510 diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index ace3bd1e6e..2a28c5f741 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -136,6 +136,7 @@ mkbase trust-main "$td/pub" mkbase trust-placeholder "$td/pub-empty" git init -q "$td/trust-empty" git -C "$td/trust-empty" -c user.email=t@example.invalid -c user.name=t commit -q --allow-empty -m none + # bashrs disable-next-line=SEC010 arg() { (cd "$repo" && bash "${3:-$W}" "$1" "${2:-$BASE}" "$head" 2>/dev/null | head -n1); } wrow() { local name=$1 want=$2 got=$3 @@ -267,6 +268,7 @@ printf '%s|%s|%s\\n' "\${GH_TOKEN:-}\${GITHUB_OUTPUT:-}" "\${RECEIPT_RUSTC_CWD:- printf 'RECEIPT_OK=0\\n' PROBE git -C "$td/trust-probe" -c user.email=t@example.invalid -c user.name=t commit -q -am probe + # bashrs disable-next-line=SEC010 probe() { rm -f "${td:?}/probe.out"; (cd "$repo" && GH_TOKEN=secret GITHUB_OUTPUT=/tmp/x PROBEFILE="$td/probe.out" bash "${1:-$W}" "$td/b-good" "$td/trust-probe" "$head" >/dev/null 2>&1); sed -E 's#\|[^|]*/rc\|#|RC|#' "$td/probe.out" 2>/dev/null; } wrow "W25 the verifier sees no token or output file, and runs rustc in the base's dir" "|RC|rust-toolchain.toml" "$(probe)" if mutate "$W" 'first=$(env -i ' 'first=$(env ' "$td/mut-e.sh"; then @@ -279,6 +281,7 @@ mkdir -p "$td/evil"; printf '#!/bin/sh\nexit 0\n' >"$td/evil/ssh-keygen"; chmod printf 'ssh-keygen() { return 0; }\nexport -f ssh-keygen\n' >"$td/evil.env" forge() { # WRAPPER BASEDIR -> wrapper output under both forgeries (PATH first, then BASH_ENV) local a b + # bashrs disable-next-line=SEC010 a=$(cd "$repo" && PATH="$td/evil:$PATH" bash "$1" "$td/b-s1" "$2" "$head" 2>/dev/null | head -n1) b=$(cd "$repo" && BASH_ENV="$td/evil.env" bash "$1" "$td/b-s1" "$2" "$head" 2>/dev/null | head -n1) printf '%s%s' "$a" "$b" From b5eef8e9b1b70a7e0571cad884241e1c26dad557 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Tue, 20 Oct 2026 19:35:24 +0200 Subject: [PATCH 15/15] ci(receipt): judge on a dedicated self-hosted label, skipped until its runner is ready (Refs #5797) Runner quorum chose a dedicated self-hosted ephemeral runner (2/2). The job is gated on vars.RECEIPT_JUDGE_RUNNER_READY so a label with no runner is skipped, never queued: no receipt, the PR runs the tier its diff selects. Rows W33/W34 are the mutants. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 9 ++++++--- scripts/check_receipt_gate.sh | 6 +++++- scripts/check_runner_labels.sh | 2 +- scripts/ci/receipt_ok_arg.sh | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee7e9483e5..1da8a4cdbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -441,17 +441,20 @@ jobs: # The section body is ci/sections.yml `workspace-test-shard`, unchanged. # #5798: the receipt judge. A PR cannot forge a receipt: this job checks out the BASE only # and fetches the PR merge commit as data (a --no-checkout worktree that is read, never - # executed), on a GitHub-hosted ephemeral runner no PR-code job ever shares, and runs the + # executed), on a dedicated self-hosted ephemeral runner (label receipt-judge) that no PR-code job ever shares, and runs the # verifier under `env -i` with absolute tools. What it does NOT cover: a PR that edits the CI # files or the judge itself. merge_group ignores --receipt-ok but runs only the QUICK tier # (full only on tag/release, scripts/ci_test_tier.sh merge_group case), from the PR's own # CI files, so it does not re-judge such an edit with a trusted copy; CI-edit review is the # only backstop. scripts/check_receipt_gate.sh (W16-W32) turns RED if # this job gains a step that runs PR-tree code or checks the PR out as code. + # Fail closed: the job runs only once the repo variable RECEIPT_JUDGE_RUNNER_READY is 'true' (set + # after the label has a runner). Until then it is skipped, the shard gets ok=0 and the PR runs the + # tier its diff selects, so a judge with no runner never queues and never blocks a PR. # It always exits 0; the shard job reads `ok` as 1 only on a literal 1. receipt-judge: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && vars.RECEIPT_JUDGE_RUNNER_READY == 'true' + runs-on: [self-hosted, Linux, X64, receipt-judge] timeout-minutes: 10 permissions: contents: read diff --git a/scripts/check_receipt_gate.sh b/scripts/check_receipt_gate.sh index 2a28c5f741..4dc7f54c1c 100755 --- a/scripts/check_receipt_gate.sh +++ b/scripts/check_receipt_gate.sh @@ -190,7 +190,9 @@ judge_ok() { # CI_FILE SECTIONS_FILE -> 0 when the wiring is trustworthy printf '%s\n' "$code" | grep -q -E 'GITHUB_PATH|GITHUB_ENV|head_ref|pull_request\.head|github\.sha *$' && return 1 [ "$(printf '%s\n' "$code" | grep -c '^ - ')" -eq 2 ] || return 1 printf '%s\n' "$code" | grep -E 'scripts/' | grep -v -E 'git show HEAD:scripts/ci/receipt_ok_arg\.sh' | grep -q . && return 1 - case "$code" in *'runs-on: ubuntu-latest'*) ;; *) return 1 ;; esac + case "$code" in *'runs-on: [self-hosted, Linux, X64, receipt-judge]'*) ;; *) return 1 ;; esac + # a judge with no runner must be skipped, never queued: the job is gated on the readiness variable + printf '%s\n' "$job" | grep -q -F "vars.RECEIPT_JUDGE_RUNNER_READY == 'true'" || return 1 case "$code" in *'env -i HOME='*'/usr/bin/bash "$RUNNER_TEMP/rok.sh"'*) ;; *) return 1 ;; esac case "$code" in *'worktree add -q --no-checkout'*) ;; *) return 1 ;; esac case "$code" in *'core.hooksPath=/dev/null'*) ;; *) return 1 ;; esac @@ -223,6 +225,8 @@ wmut "W19 MUTANT: an earlier step planting PATH is caught" ci "$CHK" ' - ru '"$CHK" wmut "W27 MUTANT: a judge whose PR worktree is a full checkout is caught" ci 'worktree add -q --no-checkout' 'worktree add -q' wmut "W28 MUTANT: a judge that leaves the git token env set is caught" ci 'unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GH_TOKEN' 'true' +wmut "W33 MUTANT: a judge not gated on the runner-readiness variable (it would queue forever with no runner) is caught" ci " && vars.RECEIPT_JUDGE_RUNNER_READY == 'true'" '' +wmut "W34 MUTANT: the judge back on a GitHub-hosted image is caught" ci 'runs-on: [self-hosted, Linux, X64, receipt-judge]' 'runs-on: ubuntu-latest' wmut "W20 MUTANT: a shard job that does not wait for the judge is caught" ci 'needs: [receipt-judge]' 'needs: []' wmut "W20b MUTANT: a tier step that runs PR-tree code is caught" sec 'rok=0 if [ "${GITHUB_EVENT_NAME}" = pull_request ]' 'rok=0 diff --git a/scripts/check_runner_labels.sh b/scripts/check_runner_labels.sh index f765cfd06d..603585bed5 100755 --- a/scripts/check_runner_labels.sh +++ b/scripts/check_runner_labels.sh @@ -24,7 +24,7 @@ cd "$(dirname "$0")/.." # review-light (#4729): one runner on intel-clean-room-15, same unit and isolation as the # clean-room pool, deliberately WITHOUT clean-room so build jobs cannot queue in front of # pr-review-quorum `present` (a seconds-long check). A one-box label, like perf-solo. -DISCRIM='clean-room|cuda|gpu|rtx4090|ada|blackwell|gb10|apple-silicon|m4|perf-solo|mini-builder|review-light' +DISCRIM='clean-room|cuda|gpu|rtx4090|ada|blackwell|gb10|apple-silicon|m4|perf-solo|mini-builder|review-light|receipt-judge' fail=0 while IFS=: read -r file line sel; do diff --git a/scripts/ci/receipt_ok_arg.sh b/scripts/ci/receipt_ok_arg.sh index c98daf6ca6..1f57494d43 100755 --- a/scripts/ci/receipt_ok_arg.sh +++ b/scripts/ci/receipt_ok_arg.sh @@ -15,7 +15,7 @@ # rust-toolchain.toml. The caller must itself run THIS script from TRUST_DIR too # (`git -C TRUST_DIR show HEAD:scripts/ci/receipt_ok_arg.sh`). Replace refs are off for every # git call here, so a PR cannot make its own HEAD^{tree} read as another tree. -# Cases: scripts/check_receipt_gate.sh (rows W1-W32). +# Cases: scripts/check_receipt_gate.sh (rows W1-W34). set -u export GIT_NO_REPLACE_OBJECTS=1 unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES