From e77258126d0e23d1ff9e4b61d2f50b413aec84c3 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Wed, 21 Oct 2026 13:37:00 +0200 Subject: [PATCH 01/10] =?UTF-8?q?contract:=20advisory-gate-diff-v1=20?= =?UTF-8?q?=E2=80=94=20PR=20gate=20fails=20only=20on=20advisories=20the=20?= =?UTF-8?q?PR=20introduces=20(#5792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from #5793 for the REBASE merge queue. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/advisory-andon.yml | 45 ++++++ .github/workflows/ci.yml | 29 +++- .github/workflows/rearm-ready.yml | 29 ++++ README.md | 4 +- ci/sections.yml | 31 +++- ci/vendor/sovereign-ci.yml | 66 ++++++-- contracts/advisory-gate-diff-v1.yaml | 145 ++++++++++++++++++ contracts/contracts.nt | 5 + ...5f0be2f90923120a3027c176ad56723d0d850.json | 2 +- docs/roadmaps/entries/PMAT-5792.yaml | 19 +++ docs/roadmaps/roadmap.yaml | 19 +++ scripts/check_deny_exemptions_live.sh | 7 +- scripts/ci/advisory_andon.sh | 82 ++++++++++ scripts/ci/advisory_diff.sh | 125 +++++++++++++++ scripts/ci/advisory_gate.sh | 69 +++++++++ scripts/ci/advisory_gate_selftest.sh | 89 +++++++++++ scripts/ci/advisory_step_selftest.sh | 78 ++++++++++ scripts/ci/check_sov_advisories_off.sh | 47 ++++++ scripts/ci/rearm_ready.sh | 107 +++++++++++++ scripts/ci_mg_reuse.sh | 2 +- 20 files changed, 978 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/advisory-andon.yml create mode 100644 .github/workflows/rearm-ready.yml create mode 100644 contracts/advisory-gate-diff-v1.yaml create mode 100644 docs/roadmaps/entries/PMAT-5792.yaml create mode 100644 scripts/ci/advisory_andon.sh create mode 100755 scripts/ci/advisory_diff.sh create mode 100755 scripts/ci/advisory_gate.sh create mode 100644 scripts/ci/advisory_gate_selftest.sh create mode 100755 scripts/ci/advisory_step_selftest.sh create mode 100644 scripts/ci/check_sov_advisories_off.sh create mode 100755 scripts/ci/rearm_ready.sh diff --git a/.github/workflows/advisory-andon.yml b/.github/workflows/advisory-andon.yml new file mode 100644 index 0000000000..4b5479ee55 --- /dev/null +++ b/.github/workflows/advisory-andon.yml @@ -0,0 +1,45 @@ +name: advisory-andon +# PMAT-5792: an advisory already on main is an andon and a P0 issue within 4h; it never blocks a PR. +# Scheduled and push-to-main only -- never a PR or merge_group job (C324: no third state on that path). +on: + schedule: + - cron: '17 */2 * * *' + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read + issues: write +concurrency: + group: advisory-andon + cancel-in-progress: false +jobs: + andon: + runs-on: [self-hosted, Linux, X64, clean-room] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - name: Install cargo-deny (if absent) + env: + CARGO_INSTALL_ROOT: ${{ runner.temp }}/cargo-deny + run: | + if ! command -v cargo-deny > /dev/null 2>&1; then + cargo install cargo-deny --locked + echo "$CARGO_INSTALL_ROOT/bin" >> "$GITHUB_PATH" + export PATH="$CARGO_INSTALL_ROOT/bin:$PATH" + fi + cargo deny --version + - name: Scan main and open or age the P0 issues + env: + GITHUB_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ github.token }} + run: | + 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' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + export GIT_CONFIG_VALUE_0 + bash scripts/ci/advisory_andon.sh --self-test + bash scripts/ci/rearm_ready.sh --self-test + out="${RUNNER_TEMP:?}/andon.ndjson" + rc=0 + cargo deny --locked --all-features --format json check advisories >/dev/null 2>"$out" || rc=$? + bash scripts/ci/advisory_andon.sh emit "$out" "$rc" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bf828a5b4..9c8036c16f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,6 +320,13 @@ jobs: contents: read steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Pin origin/main to this run's build base (PMAT-5792) + env: + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + MG_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: bash scripts/ci/pin_build_base.sh - name: Install cargo-deny (if absent) env: CARGO_INSTALL_ROOT: ${{ runner.temp }}/cargo-deny @@ -333,11 +340,31 @@ jobs: - name: Advisories must pass, with deny.toml exemptions honoured env: GITHUB_TOKEN: ${{ github.token }} + MG_BASE_SHA: ${{ github.event.merge_group.base_sha }} run: | 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' "$GITHUB_TOKEN" | base64 | tr -d '\n')" export GIT_CONFIG_VALUE_0 - cargo deny check advisories + # The gate runs from the BASE ref, never the PR tree (PMAT-5792): a PR cannot edit its own judge. + # Bootstrap only: the merge that introduces the scripts has no base copy, so it uses its own. + S="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/advgate-scripts-$$" + if git cat-file -e refs/remotes/origin/main:scripts/ci/advisory_gate.sh 2>/dev/null; then + mkdir -p "$S" + git archive refs/remotes/origin/main scripts/ci | tar -x -C "$S" + G="$S/scripts/ci" + else + G=scripts/ci + fi + # One command per line under -e: the first non-zero exit ends the step with that status (no && chain, no saved rc). + trap 'rm -rf -- "${S:?}"' EXIT + set -e + bash "$G/advisory_diff.sh" --self-test + bash scripts/ci/check_sov_advisories_off.sh --self-test + bash scripts/ci/check_sov_advisories_off.sh + bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" + bash scripts/ci/advisory_step_selftest.sh + bash scripts/ci/rearm_ready.sh --self-test + bash "$G/advisory_gate.sh" - name: Every deny.toml exemption must still be live env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/rearm-ready.yml b/.github/workflows/rearm-ready.yml new file mode 100644 index 0000000000..3b4fd19469 --- /dev/null +++ b/.github/workflows/rearm-ready.yml @@ -0,0 +1,29 @@ +name: rearm-ready +# PMAT-5792 (ADG-INV-005): after main moves or on a timer, re-arm ready PRs whose head is the one that was approved. +# Never a PR or merge_group job. A PR whose head moved after approval is left alone (needs a fresh review). +on: + schedule: + - cron: '41 * * * *' + push: + branches: [main] + workflow_dispatch: +permissions: + contents: read + pull-requests: write +concurrency: + group: rearm-ready + cancel-in-progress: false +jobs: + rearm: + runs-on: [self-hosted, Linux, X64, clean-room] + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Self-test, then re-arm approved un-armed PRs + env: + GH_TOKEN: ${{ github.token }} + REARM_REVIEWERS: ${{ vars.REARM_REVIEWERS }} + run: | + set -euo pipefail + bash scripts/ci/rearm_ready.sh --self-test + bash scripts/ci/rearm_ready.sh run diff --git a/README.md b/README.md index 68106cff29..01c71588c7 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ publishing — all backed by YAML provable contracts that fail CI on drift. | Metric | Count | Source of truth | |-------:|------:|---| | Workspace crates | **81** workspace crates | `cargo metadata --no-deps` (NOT `ls crates/` — 4 are `exclude`d, 1 has no Cargo.toml) | -| Provable contracts | **1996** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | +| Provable contracts | **1997** provable contracts | `contracts/census.json` `.n_files` — the set `pv lint` walks (`pv census`, ONT-001 ONT-1; regenerated by `make contracts`, written by `make readme-sync`, guarded by `scripts/check_readme_claims.sh`) | | CLI commands | **113** CLI commands | `contracts/apr-cli-commands-v1.yaml` §`commands` (parse the list; `apr --help` also lists `help` itself, and `grep -c '^ - name:'` gives 119 — other same-indent `name:` keys exist in the file) | | Book CLI chapters | **114** chapters | `ls book/src/cli/*.md` | | Book lib chapters | **73** chapters | `ls book/src/lib/*.md` (parity with `pub mod`) | @@ -403,7 +403,7 @@ falsification_tests: prediction: apr validate bad-model.apr exits non-zero ``` -The tree carries 1996 contracts across inference, training, quantization, attention, FFN, +The tree carries 1997 contracts across inference, training, quantization, attention, FFN, tokenization, model formats, CLI safety — and this README itself. ## Migration from old crates diff --git a/ci/sections.yml b/ci/sections.yml index 4cc17744ef..12fe07ef06 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -18,12 +18,14 @@ # The old `ci:` job, verbatim: `uses:` is the pin ci/vendor/sovereign-ci.yml was # copied from (scripts/check_ci_reusable_workflow_pinned.sh checks both agree). sovereign-ci: - uses: paiml/.github/.github/workflows/sovereign-ci.yml@f713290c86fcd70d6f26a0faab14e67e6713586f + uses: paiml/.github/.github/workflows/sovereign-ci.yml@9078879912d106237a38137dee06be6ac6c04ac7 with: repo: ${{ github.event.repository.name }} enable_sccache: true use_nextest: true coverage_on: 'tag' + # PMAT-5792: the guard-cargo diff gate is the scanner of record (check_sov_advisories_off.sh) + advisories_diff_only: true # A matrix job becomes one section per pinned combination. workspace-test-shard # runs as TWELVE shards (ONE count: the `shards:` pins below; ci.yml's matrix lists must match, check_shard_count_single_source.sh), each its own ci.yml job on its own runner: as one shard @@ -1901,6 +1903,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }} + MG_BASE_SHA: ${{ github.event.merge_group.base_sha }} run: bash scripts/ci_guards.sh guard-cargo guard-cargo-steps: @@ -2034,7 +2037,7 @@ jobs: # `ci / security` from the reusable workflow runs `cargo audit`, which does # NOT read deny.toml -- so this covers licences, bans and sources, which # nothing else does. - - name: cargo-deny (licences, bans, sources, advisories) + - name: cargo-deny (licences, bans, sources) env: GITHUB_TOKEN: ${{ github.token }} run: | @@ -2054,7 +2057,7 @@ jobs: -e GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \ -e "GIT_CONFIG_VALUE_0=AUTHORIZATION: basic $B64" \ "$IMAGE" \ - cargo deny check + cargo deny check licenses bans sources # check_apr_bin_pinned asserts CALLERS pin the binary; it says nothing # about which binary the resolver hands them. apr_bin.sh returned # target/release/apr whenever it existed, so a stale release build beside @@ -2691,7 +2694,27 @@ jobs: 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' "$GITHUB_TOKEN" | base64 | tr -d '\n')" export GIT_CONFIG_VALUE_0 - cargo deny check advisories + # MG_BASE_SHA comes from the process env of the outer guard-cargo step (the manifest runner resolves no merge_group context). + # The gate runs from the BASE ref, never the PR tree (PMAT-5792): a PR cannot edit its own judge. + # Bootstrap only: the merge that introduces the scripts has no base copy, so it uses its own. + S="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/advgate-scripts-$$" + if git cat-file -e refs/remotes/origin/main:scripts/ci/advisory_gate.sh 2>/dev/null; then + mkdir -p "$S" + git archive refs/remotes/origin/main scripts/ci | tar -x -C "$S" + G="$S/scripts/ci" + else + G=scripts/ci + fi + # One command per line under -e: the first non-zero exit ends the step with that status (no && chain, no saved rc). + trap 'rm -rf -- "${S:?}"' EXIT + set -e + bash "$G/advisory_diff.sh" --self-test + bash scripts/ci/check_sov_advisories_off.sh --self-test + bash scripts/ci/check_sov_advisories_off.sh + bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" + bash scripts/ci/advisory_step_selftest.sh + bash scripts/ci/rearm_ready.sh --self-test + bash "$G/advisory_gate.sh" # Separate from the check above on purpose: a newly-FIXED upstream must # never fail anyone's build. It fails only this guard, whose remedy is # deleting a line. diff --git a/ci/vendor/sovereign-ci.yml b/ci/vendor/sovereign-ci.yml index a5647865d8..2522882439 100644 --- a/ci/vendor/sovereign-ci.yml +++ b/ci/vendor/sovereign-ci.yml @@ -1,5 +1,5 @@ -# VENDORED (#4433): paiml/.github .github/workflows/sovereign-ci.yml @ f713290c86fcd70d6f26a0faab14e67e6713586f -# sha256 of the upstream file: 7d93385d5f4327862e765302e9ea692e80388609563e83d0aa80e33f4497e502 +# VENDORED (#4433): paiml/.github .github/workflows/sovereign-ci.yml @ 9078879912d106237a38137dee06be6ac6c04ac7 +# sha256 of the upstream file: 40ab5f65bfe48630b475d124b1a492a7fcbaa4a9181b4af151889579ceb19c30 # Not a workflow here: scripts/ci/fat_driver.py runs its jobs as sections sov. of # the x86-main fat job. Do not edit by hand -- re-vendor from a new pin, and move the # pin in ci/sections.yml (sovereign-ci.uses) in the same commit. @@ -75,6 +75,14 @@ on: required: false default: 'always' type: string + advisories_diff_only: + description: >- + true: the caller judges advisories itself, as a diff against its base (aprender + PMAT-5792), so the plain `cargo deny check advisories` in `lint` and `cargo audit` + in `security` are skipped; licenses and sources still run. Default false: unchanged. + required: false + default: false + type: boolean run_benchmarks: description: 'Run criterion benchmarks (slow, use for nightly only)' required: false @@ -220,7 +228,7 @@ jobs: # uid:gid because the runner user has no entry in the image's passwd. chown -R 1000:1000 "$WORK_ROOT" 2>/dev/null || true echo "pre-checkout ownership restored under $WORK_ROOT" - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install extra packages @@ -360,16 +368,29 @@ jobs: # workspace-member lib tests are exercised. Default stays `--lib` (root only) for # back-compat: many repos have workspace members that don't build in the sovereign-ci # container. Opt-in callers pair this with `test_args` exclusions as needed. + # BIN-ONLY FALLBACK (2026-07-30) — the last link in each chain drops + # `--lib`. Every branch above passes it, and `cargo test --lib` does not + # merely fail on a crate without a [lib]: it ERRORS with "no library + # targets found in package " (exit 101). A bin-only repo could + # therefore never pass this job. Dropping `--lib` covers bins, + # integration tests and doctests, with or without a [lib]. + # + # Purely additive: a crate that HAS a lib target satisfies an earlier + # branch and never reaches these lines, so nothing changes for the repos + # that work today. if [ "$USE_NEXTEST" = "true" ]; then cargo nextest run $TEST_SCOPE $TEST_ARGS 2>&1 || \ cargo nextest run --lib -p "$REPO_NAME" $TEST_ARGS 2>&1 || \ + cargo nextest run $TEST_ARGS 2>&1 || \ { echo "::warning::nextest failed — falling back to cargo test"; \ cargo test $TEST_SCOPE $TEST_ARGS 2>&1 || \ cargo test --lib -p "$REPO_NAME" $TEST_ARGS 2>&1 || \ + cargo test $TEST_ARGS 2>&1 || \ { echo "::error::Tests failed — check workspace path dependencies"; exit 1; }; } else cargo test $TEST_SCOPE $TEST_ARGS 2>&1 || \ cargo test --lib -p "$REPO_NAME" $TEST_ARGS 2>&1 || \ + cargo test $TEST_ARGS 2>&1 || \ { echo "::error::Tests failed — check workspace path dependencies"; exit 1; } fi - name: Record sccache stats @@ -467,7 +488,7 @@ jobs: # uid:gid because the runner user has no entry in the image's passwd. chown -R 1000:1000 "$WORK_ROOT" 2>/dev/null || true echo "pre-checkout ownership restored under $WORK_ROOT" - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install extra packages @@ -614,6 +635,7 @@ jobs: # the stricter check. env: GITHUB_TOKEN: ${{ github.token }} + SOV_ADVISORIES_DIFF_ONLY: ${{ inputs.advisories_diff_only }} run: | # forjar#422: the advisory-db clone is an anonymous git-over-HTTPS # fetch of a public repo, and GitHub answers the fleet's IP with 401 @@ -631,7 +653,13 @@ jobs: GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_TERMINAL_PROMPT=0 if [ -f deny.toml ]; then - cargo deny check advisories licenses sources + # advisories_diff_only: true: the caller judges advisories as a diff against its + # base (aprender PMAT-5792), so a main-resident advisory must not stop its PRs. + if [ "${SOV_ADVISORIES_DIFF_ONLY:-}" = true ]; then + cargo deny check licenses sources + else + cargo deny check advisories licenses sources + fi else echo "::warning::No deny.toml — skipping supply chain audit" fi @@ -722,7 +750,7 @@ jobs: # uid:gid because the runner user has no entry in the image's passwd. chown -R 1000:1000 "$WORK_ROOT" 2>/dev/null || true echo "pre-checkout ownership restored under $WORK_ROOT" - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install extra packages @@ -854,6 +882,17 @@ jobs: # test job comment for back-compat rationale. cargo llvm-cov test $TEST_SCOPE --no-cfg-coverage --no-cfg-coverage-nightly --lcov --output-path lcov.info $TEST_ARGS 2>&1 || \ cargo llvm-cov test --lib --no-cfg-coverage --no-cfg-coverage-nightly -p "$REPO_NAME" --lcov --output-path lcov.info 2>&1 || \ + # BIN-ONLY FALLBACK (2026-07-30). Every branch above passes `--lib`, and + # `cargo test --lib` does not merely fail on a crate without a [lib] — it + # ERRORS: "no library targets found in package " (exit 101). So a + # bin-only repo could never produce coverage; cohete's job went red for + # this and nothing else. Dropping `--lib` covers bins, integration tests + # and doctests, and works with or without a [lib]. + # + # Purely additive: a crate that HAS a lib target satisfies one of the + # branches above and never reaches this line, so behaviour is unchanged + # for every repo that works today. + cargo llvm-cov test --no-cfg-coverage --no-cfg-coverage-nightly --lcov --output-path lcov.info 2>&1 || \ { echo "::error::Coverage failed — check workspace path dependencies"; exit 1; } - name: Enforce coverage floor (OPT-IN ratchet — PMAT build-system audit gap #1) # ZERO BEHAVIOR CHANGE GUARANTEE: @@ -1014,7 +1053,7 @@ jobs: # uid:gid because the runner user has no entry in the image's passwd. chown -R 1000:1000 "$WORK_ROOT" 2>/dev/null || true echo "pre-checkout ownership restored under $WORK_ROOT" - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install extra packages @@ -1152,7 +1191,7 @@ jobs: 2>/dev/null || echo "::warning::sccache stats unavailable" - name: Upload benchmark results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: bench-${{ inputs.repo }} path: bench-results.txt @@ -1241,7 +1280,7 @@ jobs: else echo "pre-checkout ownership restored under $WORK_ROOT" fi - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: roadmap-valid @@ -1472,6 +1511,7 @@ jobs: - name: Audit env: GITHUB_TOKEN: ${{ github.token }} + SOV_ADVISORIES_DIFF_ONLY: ${{ inputs.advisories_diff_only }} run: | # forjar#422: same throttle as the cargo-deny step — authenticate the # advisory-db fetch with the job's own token via a url rewrite. @@ -1512,7 +1552,11 @@ jobs: fi fi - cargo audit $IGNORE_FLAGS + if [ "${SOV_ADVISORIES_DIFF_ONLY:-}" = true ]; then + echo "cargo audit skipped: the caller's diff gate is the scanner of record (advisories_diff_only: true)" + else + cargo audit $IGNORE_FLAGS + fi # Symmetry with the container jobs (paiml/.github#42): every clean-room # job both tolerates poison on entry and refuses to leave any on exit, # so the invariant is uniform and greppable rather than per-job folklore. @@ -1568,7 +1612,7 @@ jobs: attestations: write contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Generate SLSA provenance diff --git a/contracts/advisory-gate-diff-v1.yaml b/contracts/advisory-gate-diff-v1.yaml new file mode 100644 index 0000000000..92ca7f7c11 --- /dev/null +++ b/contracts/advisory-gate-diff-v1.yaml @@ -0,0 +1,145 @@ +metadata: + version: 1.0.0 + created: '2026-10-20' + author: PAIML Engineering + description: "Advisory gate: a PR fails only on advisories it introduces; an advisory already on main is an andon with a P0 4 h clock and does not block PRs; one scanner of record. Goal: 0 h total stop on a RustSec publication. DRAFT; gate-accept (one-way door), solo + non-author sign-off + operator yes before arming; any .github/workflows edit goes to a quorum first." + kind: pattern + references: + - "paiml/aprender#5792 (PMAT-5792) -- this ticket" + - "paiml/aprender#5791 -- cargo deny advisories is blind to RUSTSEC-2026-0378/0379 on main's lock; only cargo audit sees them" + - "paiml/aprender#5789 -- lock-only wasmtime bump that unblocked the queue" + - "MG run 39413691864 (#5781) -- P0 red on a publication, not a PR change" + +five_whys: + symptom: "Every PR in the merge queue went red when RUSTSEC-2026-0378/0379 were published against wasmtime 48.0.6 already on main." + why_1: "The security step fails on ANY advisory in the lock, not on advisories the PR adds." + why_2: "The gate compares the lock to the advisory DB, never to main's lock, so a new DB entry and a new dependency look identical." + why_3: "Two scanners (cargo deny, cargo audit) read different graphs (deny: all-features=false) and disagree; neither is the scanner of record, so the queue's red came from whichever saw it last." + why_4: "Nothing routes a main-resident advisory to a fix ticket with a clock; the only response available is to stop merging." + why_5: "Gate design treated 'the tree is clean' and 'this PR is safe to merge' as one question." + root_cause: "The merge decision is coupled to the global advisory state of main instead of the delta of the PR." + +equations: + pr_gate_verdict: + formula: "fail(PR) iff A(lock_PR, db) \\ A(lock_main, db) != {} ; A(lock, db) = findings keyed code|id|crate@version (yanked included) in db that hit a crate@version in lock, minus ignored ids" + domain: "lock_PR at head, lock_main at the PR merge base (merge_group base_sha), scanned from a base-commit worktree with the BASE deny.toml and scripts; db is ONE pinned snapshot for both sides" + codomain: "pass | fail" + invariants: + - "Both sides use the same advisory-db snapshot (otherwise a DB refresh between the runs reads as a PR-introduced advisory)" + - "A PR that does not touch Cargo.lock has an empty delta and passes" + - "A PR that removes an advisory-hit crate passes" + main_resident_advisory: + formula: "A(lock_main, db) != {} => andon raised AND P0 ticket with due <= 4h AND PR gate unaffected" + domain: "scheduled or push-to-main scan only (never the PR path: C324 rule 6, read-only token, GH-1); due is a label plus created_at" + codomain: "ticket id + due time, or none" + invariants: + - "The andon fires within one scan period of the DB publishing the advisory" + - "The ticket carries advisory ids, affected crate@version, and the clock start" + - "A main-resident advisory never turns the PR gate or the merge queue red" + one_scanner_of_record: + formula: "exactly one scanner decides A(); the other is retired or run as a LAB cross-check that cannot block" + domain: "cargo deny check advisories, cargo audit" + codomain: "the scanner name recorded in deny.toml / the gate script header" + invariants: + - "The scanner of record sees the whole Cargo.lock (all features), proven on the 48.0.6 lock as the positive control" + - "Exemptions live in one file" + - "Findings are keyed code|advisory-id|crate@version over every graph, so a new crate under an id main already carries, and a yanked crate with no id, are both PR-introduced" + - "Both sides are scanned with the BASE deny.toml; a PR that adds an advisories ignore id fails (ignores-added), so a PR cannot hide its own finding" + - "deny.toml advisories ignores are id-wide: cargo-deny has no per-crate ignore. They stay bounded, not scoped: ignores-added fails any PR that adds one, and check_deny_exemptions_live.sh (now --all-features, same as the gate) fails a dead one. Per-crate scoping needs an upstream cargo-deny feature and is out of this PR" + - "The base is pinned: merge_group uses merge_group.base_sha, pull_request uses pull_request.base.sha, anything else (or a non-sha) is COULD-NOT-CHECK rc 75, never a pass" + auto_rearm: + formula: "when a gate fix lands, every PR that was ready (green, approved) before the stop is re-armed without a human" + domain: "PRs with auto-merge armed or ready at the time of the stop" + codomain: "armed set restored" + invariants: + - "Re-arm never arms a PR whose head changed after its approval" + +proof_obligations: + - id: ADG-INV-001 + type: invariant + property: "A PR that adds no advisory passes while main holds one" + formal: "∀ pr: A(lock_PR) = A(lock_main) ∧ A(lock_main) ≠ ∅ ⇒ verdict = pass" + applies_to: pr_gate_verdict + - id: ADG-INV-002 + type: invariant + property: "A PR that introduces an advisory fails" + formal: "∀ pr: A(lock_PR) \\ A(lock_main) ≠ ∅ ⇒ verdict = fail" + applies_to: pr_gate_verdict + - id: ADG-INV-003 + type: invariant + property: "A main-resident advisory raises an andon and a <=4h P0 ticket" + formal: "∀ main: A(lock_main) ≠ ∅ ⇒ ticket(P0) ∧ due ≤ 4h" + applies_to: main_resident_advisory + - id: ADG-INV-004 + type: invariant + property: "The scanner of record is blind to nothing the other scanner sees" + formal: "∀ lock_48_0_6: A_record(lock) ∋ RUSTSEC-2026-0378 ∧ A_record(lock) ∋ RUSTSEC-2026-0379" + applies_to: one_scanner_of_record + - id: ADG-INV-005 + type: invariant + property: "Ready PRs are re-armed after a gate fix" + formal: "∀ pr: head_unchanged ⇒ armed_after ≥ armed_before" + applies_to: auto_rearm + +falsification_tests: + - id: FALSIFY-ADG-001 + rule: "Main-resident advisory does not block a PR" + prediction: "with lock_PR == lock_main (wasmtime 48.0.6) and a DB holding 0378/0379, the PR verdict is pass and a main-advisory andon row is emitted" + test: "planted-fixture case table of the diff gate script scripts/ci/advisory_diff.sh --self-test, 12 cases: same lock both sides" + if_fails: "the gate still compares lock to DB, not PR to main" + mutation: "compare lock_PR to the empty set; the case must turn RED" + - id: FALSIFY-ADG-002 + rule: "A PR-introduced advisory blocks" + prediction: "with lock_main = 48.0.7 and lock_PR = 48.0.6, the verdict is fail naming 0378 and 0379" + test: "same case table: lock_PR adds the vulnerable version" + if_fails: "the gate cannot turn RED on a real regression (a gate that cannot fail)" + mutation: "in scripts/ci/advisory_diff.sh verdict() subtract the PR keys from themselves instead of main keys (comm -23 PR PR); self-test cases (incl. an error finding with graphs:[] must still block; a mutant yielding no key for it turns RED) PR adds a new id, PR adds a crate under an id main has, and PR adds a yanked crate must turn RED (measured 2026-10-20)" + - id: FALSIFY-ADG-003 + rule: "Scanner of record sees the optional-feature crate" + prediction: "on the 48.0.6 lock the scanner of record exits non-zero and names 0378 and 0379" + test: "scripts/ci/advisory_gate_selftest.sh runs advisory_gate.sh against a stub cargo; every advisories scan must carry --all-features (the 48.0.6 lock fixture is covered by ADG-001 verdicts)" + if_fails: "#5791 is not resolved: the record scanner is as blind as deny with all-features=false" + mutation: "sed out --all-features from advisory_gate.sh scan(); the 'ADG-003' mutant case in advisory_gate_selftest.sh must turn RED (measured 2026-10-20)" + - id: FALSIFY-ADG-004 + rule: "Same DB snapshot on both sides" + prediction: "a DB entry published between the main run and the PR run does not appear in the delta" + test: "advisory_gate_selftest.sh: exactly one `cargo deny fetch`, both scans --disable-fetch with one --config" + if_fails: "DB refreshes read as PR-introduced advisories and re-create the stop" + mutation: "sed out --disable-fetch, or add a second fetch before the base scan; the two 'ADG-004' mutant cases must turn RED (measured 2026-10-20)" + - id: FALSIFY-ADG-005 + rule: "Andon clock" + prediction: "the P0 ticket's due time is <= 4 h after the advisory first appears on main" + test: "case table over the ticket emitter" + if_fails: "main-resident advisories sit unowned" + mutation: "set the due time to 8 h; the case must turn RED" + - id: FALSIFY-ADG-006 + rule: "Re-arm never arms a changed head (ADG-INV-005)" + prediction: "a ready PR whose head sha moved after approval is not re-armed; one whose head is unchanged is" + test: "scripts/ci/rearm_ready.sh --self-test, 4 cases (head unchanged arms, moved or unapproved does not, empty arms nothing, and `run` against a stub gh enqueues exactly the PRs whose LATEST review is an anchored APPROVE (not `NOT APPROVED`) by an allowlisted reviewer other than the PR author, each with expectedHeadOid = the review's commit.oid; any later non-approval review cancels it; the fallback gh pr merge carries --match-head-commit ; a mutant without expectedHeadOid, the anchor, the allowlist, the author exclusion, the cancel, commit.oid or --match-head-commit turns it RED); .github/workflows/rearm-ready.yml calls `rearm_ready.sh run`" + if_fails: "a gate fix arms an unreviewed head into the queue" + mutation: "drop the approved_sha == current_sha test in select_; the moved-head case must turn RED; replace select_ in run_ with cat|cut and the stub-gh case must turn RED (measured 2026-10-20)" + - id: FALSIFY-ADG-007 + rule: "No second scanner blocks a PR on main's advisories" + prediction: "sov.lint and sov.security skip plain cargo deny advisories / cargo audit (advisories_diff_only: true in the vendored sovereign-ci)" + test: "scripts/ci/check_sov_advisories_off.sh --self-test, 4 cases, then the real tree" + if_fails: "the vendored scans still stop every PR on an advisory already on main (the #5793 REJECT)" + mutation: "set advisories_diff_only: false in ci/sections.yml, or change the vendored '= true' test; the matching case must turn RED (measured 2026-10-20)" + + - id: FALSIFY-ADG-008 + rule: "The CI step fails closed on the gate's exit status" + prediction: "the 'Advisories must pass' step of ci.yml exits with the gate's (or any earlier self-test's) non-zero status; a step without the gate line does not" + test: "scripts/ci/advisory_step_selftest.sh, 4 cases + 1 mutant: the step's run block is extracted from ci.yml and run against stub scripts on a throwaway origin/main; gate rc 75 gives 75, gate rc 1 gives 1, a failing self-test gives its rc, all green gives 0" + if_fails: "a red or could-not-check gate leaves the step green and a PR merges on an unjudged advisory" + mutation: "delete the advisory_gate.sh invocation from the step; the mutant case must stay green on a failing gate, which the cases above forbid (measured 2026-10-20)" + +qa_gate: + id: F-ADG-001 + name: Advisory gate diff contract + description: "PR delta gate, main andon, one scanner of record, auto re-arm" + checks: + - pr_gate_verdict + - main_resident_advisory + - one_scanner_of_record + - auto_rearm + pass_criteria: "All 8 falsification tests pass; the scanner script has a must-fail/must-pass case table" + falsification: "Revert the gate to lock-vs-DB; FALSIFY-ADG-001 must turn RED" diff --git a/contracts/contracts.nt b/contracts/contracts.nt index 87447a2049..bb93209bb0 100644 --- a/contracts/contracts.nt +++ b/contracts/contracts.nt @@ -4691,6 +4691,11 @@ . "contracts/adamw-kernel-v1.yaml"^^ . "adamw-kernel-v1"^^ . + . + . + "contracts/advisory-gate-diff-v1.yaml"^^ . + "advisory-gate-diff-v1"^^ . + "pattern"^^ . . . . diff --git a/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json b/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json index 5ca9abb1de..daee4d60ee 100644 --- a/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json +++ b/contracts/witness/811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850.json @@ -1,5 +1,5 @@ { - "census_id_set_sha256": "db20faa02774eeffec52d05d3e579c0ec7047e2f0bed655de26b7e3eeff34151", + "census_id_set_sha256": "08a3fa4fb2b2814e04d0f19f101025396cf31cd9b20b3291402539b7edf94921", "relations_sha256": "811c78184eb2609244c59534f015f0be2f90923120a3027c176ad56723d0d850", "checkable_n": 87, "result": { diff --git a/docs/roadmaps/entries/PMAT-5792.yaml b/docs/roadmaps/entries/PMAT-5792.yaml new file mode 100644 index 0000000000..fd2d76887a --- /dev/null +++ b/docs/roadmaps/entries/PMAT-5792.yaml @@ -0,0 +1,19 @@ +- id: PMAT-5792 + github_issue: 5792 + item_type: task + title: 'Advisory gate: PR fails only on advisories it introduces; main-resident advisory = andon, not a block (0h stop)' + status: planned + priority: critical + assigned_to: null + created: 2026-10-20T12:27:03Z + updated: 2026-10-20T12:27:03Z + spec: null + acceptance_criteria: + - 'Contract contracts/advisory-gate-diff-v1.yaml. Refs #5791. Gate-accept: solo + non-author sign-off + Noah''s yes; workflows edits go to quorum first.' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 3863 diff --git a/docs/roadmaps/roadmap.yaml b/docs/roadmaps/roadmap.yaml index ef47935f92..4051d9c74c 100644 --- a/docs/roadmaps/roadmap.yaml +++ b/docs/roadmaps/roadmap.yaml @@ -25340,6 +25340,25 @@ Not done, pending the ruling: moving the 31 fixture files to an obviously synthe - kind:code notes: null epic: 5510 +- id: PMAT-5792 + github_issue: 5792 + item_type: task + title: 'Advisory gate: PR fails only on advisories it introduces; main-resident advisory = andon, not a block (0h stop)' + status: planned + priority: critical + assigned_to: null + created: 2026-10-20T12:27:03Z + updated: 2026-10-20T12:27:03Z + spec: null + acceptance_criteria: + - 'Contract contracts/advisory-gate-diff-v1.yaml. Refs #5791. Gate-accept: solo + non-author sign-off + Noah''s yes; workflows edits go to quorum first.' + phases: [] + subtasks: [] + estimated_effort: null + labels: + - kind:code + notes: null + epic: 3863 - id: PMAT-5817 github_issue: 5817 item_type: task diff --git a/scripts/check_deny_exemptions_live.sh b/scripts/check_deny_exemptions_live.sh index 71fcd21c04..adf7177427 100755 --- a/scripts/check_deny_exemptions_live.sh +++ b/scripts/check_deny_exemptions_live.sh @@ -35,7 +35,7 @@ trap 'rm -f "${LOG:?}"' EXIT # Redirect, never pipe: reading this through a pipe would report the exit status # of the last stage instead of cargo-deny's. -cargo deny check advisories > "$LOG" 2>&1 +cargo deny --all-features check advisories > "$LOG" 2>&1 rc=$? declared="$(grep -c 'id = "RUSTSEC' deny.toml || true)" @@ -64,7 +64,10 @@ if [ "$dead" -gt 0 ]; then exit 1 fi -if [ "$rc" -ne 0 ]; then +if [ "$rc" -ne 0 ] && grep -qE '^error\[(vulnerability|unmaintained|unsound|yanked|notice)\]' "$LOG"; then + # PMAT-5792: findings are advisory-diff territory (scripts/ci/advisory_gate.sh), not this guard's. + printf '\nNOTE: advisory findings present; judged by advisory_gate.sh, not here.\n' +elif [ "$rc" -ne 0 ]; then printf '\nFAIL: `cargo deny check advisories` exited %s. Its last lines:\n' "$rc" tail -n 8 "$LOG" | sed 's/^/ /' tail -20 "$LOG" | sed 's|^| |' diff --git a/scripts/ci/advisory_andon.sh b/scripts/ci/advisory_andon.sh new file mode 100644 index 0000000000..08dc7837fa --- /dev/null +++ b/scripts/ci/advisory_andon.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# advisory_andon.sh -- main-resident advisories are an andon plus a P0 ticket, never a PR block +# (PMAT-5792, contracts/advisory-gate-diff-v1.yaml main_resident_advisory). Runs ONLY on a +# schedule or push to main, never on a PR or merge_group path (C324: no third state there). +# +# advisory_andon.sh keys FILE finding keys of a cargo-deny NDJSON file (advisory_diff.sh keys) +# advisory_andon.sh due CREATED NOW exit 0 if the ticket is older than 4h (label p0-overdue), else 1 +# advisory_andon.sh emit FILE RC open one P0 issue per main-resident finding; label p0-overdue past 4h +# advisory_andon.sh --self-test +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DUE_SECS=$((4 * 3600)) + +due() { # ISO created, ISO now + local c n + c=$(date -u -d "$1" +%s) n=$(date -u -d "$2" +%s) # bashrs disable-line=DET002 (elapsed-time arithmetic, not a stored timestamp) + [ $((n - c)) -gt "$DUE_SECS" ] +} + +GH="${GH:-gh}" +emit() { # NDJSON-FILE cargo-deny-rc + case "$2" in 0 | 1) ;; *) echo "COULD-NOT-CHECK: cargo deny rc $2" >&2; return 75 ;; esac + local keys k found now + keys=$(bash "$HERE/advisory_diff.sh" checked-keys "$1" "$2") || return 75 + now=$(date -u +%Y-%m-%dT%H:%M:%SZ) + "$GH" label create advisory-andon --color B60205 2>/dev/null || true + "$GH" label create P0 --color B60205 2>/dev/null || true + "$GH" label create p0-overdue --color B60205 2>/dev/null || true + while IFS= read -r k; do + [ -n "$k" ] || continue + found=$("$GH" issue list --label advisory-andon --state open --limit 200 --json number,createdAt,labels,title \ + | jq -r --arg t "ADVISORY ANDON: $k" 'map(select(.title == $t)) | .[0] + | if . == null then "" else "\(.number) \(.createdAt) \([.labels[].name] | index("p0-overdue") != null)" end') || return 75 + if [ -z "$found" ]; then + "$GH" issue create --label advisory-andon --label P0 --title "ADVISORY ANDON: $k" \ + --body "Advisory $k is on main. It does not block PRs (PMAT-5792); fix within 4h of this issue's creation." || return 75 + else + set -- $found + if due "$2" "$now" && [ "$3" = false ]; then + "$GH" issue edit "$1" --add-label p0-overdue || return 75 + "$GH" issue comment "$1" --body "Open past the 4h P0 window." || return 75 + fi + fi + done <<<"$keys" +} + +self_test() { + local bad=0 rc + chk() { rc=0; due "$2" "$3" || rc=$?; [ "$rc" -eq "$4" ] || { echo "SELF-TEST FAIL: $1 want $4 got $rc" >&2; bad=1; }; } + chk 'under 4h is not due' 2026-10-20T10:00:00Z 2026-10-20T13:59:00Z 1 + chk 'exactly 4h is not due' 2026-10-20T10:00:00Z 2026-10-20T14:00:00Z 1 + chk 'over 4h is due' 2026-10-20T10:00:00Z 2026-10-20T14:00:01Z 0 + local d; d=$(mktemp -d) + # stub gh: logs argv; `issue list` answers from $STUB_LIST + printf '#!/usr/bin/env bash\necho "$*" >>"%s/calls"\n[ "$1 $2" = "issue list" ] && printf "%%s" "${STUB_LIST:-[]}"\nexit 0\n' "$d" >"$d/gh"; chmod +x "$d/gh" + printf '{"type":"diagnostic","fields":{"code":"vulnerability","severity":"error","advisory":{"id":"RUSTSEC-1"},"graphs":[{"Krate":{"name":"c","version":"1"}}]}}\n' >"$d/f.json" + ec() { # name want-rc want-grep(or -) rc list + : >"$d/calls"; rc=0; GH="$d/gh" STUB_LIST="$5" emit "$d/f.json" "$4" >/dev/null 2>&1 || rc=$? + [ "$rc" -eq "$2" ] || { echo "SELF-TEST FAIL: $1 rc want $2 got $rc" >&2; bad=1; } + if [ "$3" != - ]; then grep -q -- "$3" "$d/calls" || { echo "SELF-TEST FAIL: $1 missing call $3" >&2; bad=1; }; fi + } + ec 'new finding opens a P0 issue' 0 'issue create' 1 '' + ec 'a different open issue does not match' 0 'issue create' 1 '[{"number":9,"createdAt":"2020-01-01T00:00:00Z","labels":[],"title":"ADVISORY ANDON: other"}]' + : >"$d/empty.json"; rc=0; GH="$d/gh" emit "$d/empty.json" 1 >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 75 ] || { echo "SELF-TEST FAIL: rc1 with no findings must be 75, got $rc" >&2; bad=1; } + ec 'tool error is cannot-check' 75 - 2 '' + ec 'old open issue gets p0-overdue' 0 'add-label p0-overdue' 1 '[{"number":7,"createdAt":"2020-01-01T00:00:00Z","labels":[],"title":"ADVISORY ANDON: vulnerability|RUSTSEC-1|c@1"}]' + ec 'fresh open issue is left alone' 0 - 1 "[{\"number\":7,\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"labels\":[],\"title\":\"ADVISORY ANDON: vulnerability|RUSTSEC-1|c@1\"}]" # bashrs disable-line=DET002 + : >"$d/calls"; GH="$d/gh" STUB_LIST="[{\"number\":7,\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"labels\":[],\"title\":\"ADVISORY ANDON: vulnerability|RUSTSEC-1|c@1\"}]" emit "$d/f.json" 1 >/dev/null 2>&1 || true # bashrs disable-line=DET002 + if grep -q 'issue create\|add-label' "$d/calls"; then echo "SELF-TEST FAIL: fresh issue was touched" >&2; bad=1; fi + rm -rf -- "${d:?}" + [ "$bad" -eq 0 ] && echo "advisory_andon self-test: ok" + return "$bad" +} + +case "${1:-}" in + keys) bash "$HERE/advisory_diff.sh" keys "$2" ;; + due) due "$2" "$3" ;; + emit) emit "$2" "$3" ;; + --self-test) self_test ;; + *) echo "usage: $0 keys FILE | due CREATED NOW | emit FILE RC | --self-test" >&2; exit 2 ;; +esac diff --git a/scripts/ci/advisory_diff.sh b/scripts/ci/advisory_diff.sh new file mode 100755 index 0000000000..20a7643bd6 --- /dev/null +++ b/scripts/ci/advisory_diff.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# advisory_diff.sh -- PR advisory verdict = findings the PR introduces (PMAT-5792, contract advisory-gate-diff-v1). +# +# advisory_diff.sh keys FILE print sorted finding keys "code|id|crate@version" from cargo-deny NDJSON +# advisory_diff.sh verdict PR_JSON PR_RC MAIN_JSON MAIN_RC +# exit 0: nothing new; 1: PR introduces findings; 75: could not check +# advisory_diff.sh --self-test case table +# +# Fail closed (exit 75, never a pass): rc 0 with findings, rc 1 with none, rc other than 0/1, unparsable JSON, +# a missing file. The key is code|advisory-id|crate@version so a new crate under an id main already carries, +# and a no-id finding (yanked at error severity, as deny.toml sets it), are both seen. cargo-deny writes NDJSON diagnostics to STDERR; the caller +# redirects it to FILE. +set -euo pipefail + +CANT=75 + +keys() { # FILE -> keys on stdout (use side/checked-keys to fail closed on unparsable input) + jq -R -r 'fromjson? | select(.type == "diagnostic" or has("fields")) + | .fields | select(.severity == "error") + | (.code // "?") as $c | (.advisory.id // "-") as $i | (((.graphs // []) | if length == 0 then [{}] else . end)[] | .Krate? // {}) | "\($c)|\($i)|\(.name // "?")@\(.version // "?")"' "$1" | LC_ALL=C sort -u +} + +parses() { # every non-blank line is JSON + jq -R -n -e '[inputs | select(length > 0) | fromjson] | length >= 0' "$1" >/dev/null 2>&1 +} + +side() { # JSON RC -> echoes keys; returns CANT when the side cannot be trusted + local f=$1 rc=$2 k + [ -f "$f" ] || { echo "COULD-NOT-CHECK: $f missing" >&2; return "$CANT"; } + case "$rc" in 0 | 1) ;; *) echo "COULD-NOT-CHECK: cargo-deny rc=$rc" >&2; return "$CANT" ;; esac + parses "$f" || { echo "COULD-NOT-CHECK: $f is not NDJSON" >&2; return "$CANT"; } + k=$(keys "$f") + local e; e=$(jq -R -r 'fromjson? | .fields? | select(.severity == "error") | .code' "$f" | head -c1) + if [ "$rc" -eq 1 ] && [ -z "$e" ]; then + echo "COULD-NOT-CHECK: rc=1 but no findings parsed; cargo-deny stderr (first 20 lines of $f):" >&2 + head -n 20 "$f" | cut -c1-300 >&2 + return "$CANT" + fi + if [ "$rc" -eq 0 ] && [ -n "$e" ]; then echo "COULD-NOT-CHECK: rc=0 but error findings parsed" >&2; return "$CANT"; fi + printf '%s\n' "$k" +} + +base_ref() { # EVENT MG_BASE_SHA PR_BASE_SHA -> the pinned base sha; 75 when it cannot be pinned + local ev=$1 mg=${2:-} pr=${3:-} + case "$ev" in + merge_group) [[ "$mg" =~ ^[0-9a-f]{40}$ ]] || { echo "COULD-NOT-CHECK: merge_group base_sha missing" >&2; return "$CANT"; }; echo "$mg" ;; + pull_request) [[ "$pr" =~ ^[0-9a-f]{40}$ ]] || { echo "COULD-NOT-CHECK: pull_request base sha missing" >&2; return "$CANT"; }; echo "$pr" ;; + *) echo "COULD-NOT-CHECK: event $ev has no merge base (push-to-main runs the full check)" >&2; return "$CANT" ;; + esac +} + +ignores_added() { # BASE_deny.toml PR_deny.toml -> ids the PR adds to [advisories] ignore; exit 1 when any + local add + add=$(comm -13 <(grep -o "RUSTSEC-[0-9]*-[0-9]*" "$1" | sort -u) <(grep -o "RUSTSEC-[0-9]*-[0-9]*" "$2" | sort -u)) + [ -z "$add" ] || { echo "PR widens exemptions:" >&2; printf "%s\n" "$add" >&2; return 1; } +} + +verdict() { + local pr main new + pr=$(side "$1" "$2") || return "$CANT" + main=$(side "$3" "$4") || return "$CANT" + new=$(comm -23 <(printf '%s\n' "$pr" | sed '/^$/d') <(printf '%s\n' "$main" | sed '/^$/d')) + if [ -n "$new" ]; then + echo "PR introduces advisories:" >&2 + printf '%s\n' "$new" >&2 + return 1 + fi + if [ -n "$(printf '%s' "$main" | tr -d '\n')" ]; then + echo "ANDON: advisories already on main (not blocking this PR): $(printf '%s' "$main" | tr '\n' ' ')" >&2 + fi + return 0 +} + +self_test() { + local d bad=0 + d=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf -- '${d:?}'" RETURN + row() { printf '{"type":"diagnostic","fields":{"code":"%s","severity":"%s","advisory":{"id":"%s"},"graphs":[{"Krate":{"name":"%s","version":"%s"}}]}}\n' "$@"; } + row vulnerability error RUSTSEC-2026-0378 wasmtime 48.0.6 >"$d/a.json" + row vulnerability error RUSTSEC-2026-0378 wasmtime 48.0.6 >"$d/a2.json"; row vulnerability error RUSTSEC-2026-0379 wasmtime-x 48.0.6 >>"$d/a2.json" + row vulnerability error RUSTSEC-2026-0378 wasmtime 48.0.6 >"$d/a3.json"; row vulnerability error RUSTSEC-2026-0378 wasmtime-y 1.0.0 >>"$d/a3.json" + : >"$d/empty.json" + printf 'garbage\n' >"$d/bad.json" + printf '{"type":"diagnostic","fields":{"code":"yanked","severity":"warning","graphs":[{"Krate":{"name":"y","version":"1"}}]}}\n' >"$d/yank.json" + t() { # name want pr-file pr-rc main-file main-rc + local rc=0 + verdict "$d/$3" "$4" "$d/$5" "$6" >/dev/null 2>&1 || rc=$? + [ "$rc" -eq "$2" ] || { echo "SELF-TEST FAIL: $1 wanted rc=$2 got $rc" >&2; bad=1; } + } + t 'same advisory on both sides: pass' 0 a.json 1 a.json 1 + t 'both clean: pass' 0 empty.json 0 empty.json 0 + t 'PR adds a new id: block' 1 a2.json 1 a.json 1 + t 'PR adds a crate under an id main has: block' 1 a3.json 1 a.json 1 + t 'PR adds a yanked crate at warn (deny.toml default): pass' 0 yank.json 0 empty.json 0 + printf '{"type":"diagnostic","fields":{"code":"yanked","severity":"error","graphs":[{"Krate":{"name":"y","version":"1"}}]}}\n' >"$d/yanke.json" + t 'PR adds a yanked crate at error: block' 1 yanke.json 1 empty.json 0 + t 'main has it, PR fixed it: pass' 0 empty.json 0 a.json 1 + t 'rc 0 but findings parsed: cannot check' 75 a.json 0 empty.json 0 + t 'rc 1 but nothing parsed: cannot check' 75 empty.json 1 empty.json 0 + t 'tool error rc 2: cannot check' 75 empty.json 2 empty.json 0 + t 'garbage JSON: cannot check' 75 bad.json 1 empty.json 0 + t 'main side missing: cannot check' 75 a.json 1 nope.json 1 + printf '{"type":"diagnostic","fields":{"code":"vulnerability","severity":"error","advisory":{"id":"RUSTSEC-2026-0999"},"graphs":[]}}\n' >"$d/nog.json" + t 'error finding with empty graphs[] is still a finding: block' 1 nog.json 1 empty.json 0 + t 'same empty-graphs finding on both sides: pass' 0 nog.json 1 nog.json 1 + sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + r() { local w=$1 rc=0; shift; base_ref "$@" >/dev/null 2>&1 || rc=$?; [ "$rc" -eq "$w" ] || { echo "SELF-TEST FAIL: base_ref $* wanted $w got $rc" >&2; bad=1; }; } + r 0 merge_group "$sha" ""; r 75 merge_group "" "$sha"; r 75 merge_group "main" ""; r 0 pull_request "" "$sha"; r 75 push "$sha" "$sha" + printf "ignore = [\"RUSTSEC-2026-0001\"]\n" >"$d/b.toml"; cp "$d/b.toml" "$d/same.toml"; printf "ignore = [\"RUSTSEC-2026-0001\", \"RUSTSEC-2026-0378\"]\n" >"$d/wide.toml" + ignores_added "$d/b.toml" "$d/same.toml" 2>/dev/null || { echo "SELF-TEST FAIL: same ignores must pass" >&2; bad=1; } + ! ignores_added "$d/b.toml" "$d/wide.toml" 2>/dev/null || { echo "SELF-TEST FAIL: added ignore must block" >&2; bad=1; } + [ "$bad" -eq 0 ] && echo "advisory_diff self-test: (14 verdict + 5 base_ref + 2 ignores) ok" + return "$bad" +} + +case "${1:-}" in + --self-test) self_test ;; + keys) keys "$2" ;; + checked-keys) side "$2" "$3" ;; + base-ref) base_ref "$2" "${3:-}" "${4:-}" ;; + ignores-added) ignores_added "$2" "$3" ;; + verdict) verdict "$2" "$3" "$4" "$5" ;; + *) echo "usage: $0 keys F | verdict PR PRRC MAIN MAINRC | --self-test" >&2; exit 2 ;; +esac diff --git a/scripts/ci/advisory_gate.sh b/scripts/ci/advisory_gate.sh new file mode 100755 index 0000000000..ed1a127ed9 --- /dev/null +++ b/scripts/ci/advisory_gate.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# advisory_gate.sh -- the advisories step of guard-cargo (PMAT-5792, contract advisory-gate-diff-v1). +# +# pull_request / merge_group fail only on advisories the PR INTRODUCES against the pinned build base +# (refs/remotes/origin/main, pinned by pin_build_base.sh); an advisory +# already on the base is reported, never a block. +# any other event (push) the full `cargo deny check advisories`, as before: a red there is the +# main-resident andon, not a PR block. +# Fails closed: anything that cannot be checked exits 75 and fails the step. Both sides are scanned with the +# BASE deny.toml, from the BASE worktree's script, over ONE advisory-db fetched once by this job. +set -euo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +EV="${GITHUB_EVENT_NAME:-}" +case "$EV" in + pull_request | merge_group) ;; + *) exec cargo deny --all-features check advisories ;; +esac + +base_sha() { # the build base the pin step named + git rev-parse --verify -q refs/remotes/origin/main^{commit} || { echo "COULD-NOT-CHECK: origin/main is not pinned" >&2; return 75; } +} +BASE=$(base_sha) || exit 75 +# merge_group: the pin must equal merge_group.base_sha; a drifted pin is refused, not guessed around. +if [ "$EV" = merge_group ] && [ -n "${MG_BASE_SHA:-}" ] && [ "$BASE" != "$MG_BASE_SHA" ]; then + echo "COULD-NOT-CHECK: pinned base $BASE != merge_group base_sha $MG_BASE_SHA" >&2 + exit 75 +fi +bash "$HERE/advisory_diff.sh" base-ref "$EV" "${MG_BASE_SHA:-}" "$BASE" >/dev/null || exit 75 + +W="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/advgate-base-$$" +trap 'rm -f "${W:?}.pr.ndjson" "${W:?}.base.ndjson"; git worktree remove --force "${W:?}" >/dev/null 2>&1 || true' EXIT +git worktree add --detach "$W" "$BASE" >/dev/null 2>&1 || { echo "COULD-NOT-CHECK: no worktree for base $BASE (shallow fetch?)" >&2; exit 75; } + +# (before the subcommand) and replaced --disable-fetch with the global --offline, which `check` needs so both scans read the ONE advisory-db +# fetched above (without it 0.20 `check` may re-fetch the db and the two scans see different heads). --offline also makes cargo metadata need +# every crate in the registry cache (a cold runner exits rc=1 with log lines only, m70 #5793, measured 2026-10-21 on 0.20.2 with an empty +# CARGO_HOME), so `cargo fetch --locked` warms the cache for BOTH trees before either scan. Probe once, never assume the runner's version. +if cargo deny check --help 2>&1 | grep -q -- '--config'; then DENY_OLD=1; else DENY_OLD=0; fi + +# One DB for both scans: fetched with the BASE config (the db-path both scans read), HEAD logged. +if [ "$DENY_OLD" = 1 ]; then cargo deny fetch --config "$W/deny.toml" db; else cargo deny --config "$W/deny.toml" fetch db; fi \ + || { echo "COULD-NOT-CHECK: advisory-db fetch failed" >&2; exit 75; } +for d in "$HOME"/.cargo/advisory-dbs/*; do + [ -d "$d/.git" ] && echo "advisory-db $(basename "$d") HEAD $(git -C "$d" rev-parse --short HEAD 2>/dev/null) $(git -C "$d" log -1 --format=%cI 2>/dev/null)" +done + +# The BASE script when it has one; the PR's copy only to bootstrap the merge that introduces the script. +GATE="$W/scripts/ci/advisory_diff.sh" +[ -f "$GATE" ] || GATE="$HERE/advisory_diff.sh" + +bash "$GATE" ignores-added "$W/deny.toml" deny.toml || exit 1 + +scan() { # TREE OUT.ndjson -> prints the cargo-deny rc + local rc=0 + if [ "$DENY_OLD" = 1 ]; then + (cd "$1" && cargo deny --locked --all-features --format json check --config "$W/deny.toml" --disable-fetch advisories) \ + >/dev/null 2>"$2" || rc=$? + else + (cd "$1" && cargo deny --config "$W/deny.toml" --locked --offline --all-features --format json check advisories) \ + >/dev/null 2>"$2" || rc=$? + fi + echo "$rc" +} +# warm the registry cache for both trees (network allowed here, nothing else reads it); the scans then run offline +for t in "$PWD" "$W"; do (cd "$t" && cargo fetch --locked) >/dev/null 2>&1 || { echo "COULD-NOT-CHECK: cargo fetch --locked failed in $t" >&2; exit 75; }; done +PRRC=$(scan "$PWD" "$W.pr.ndjson") +BASERC=$(scan "$W" "$W.base.ndjson") +bash "$GATE" verdict "$W.pr.ndjson" "$PRRC" "$W.base.ndjson" "$BASERC" diff --git a/scripts/ci/advisory_gate_selftest.sh b/scripts/ci/advisory_gate_selftest.sh new file mode 100644 index 0000000000..ba64bf0e5d --- /dev/null +++ b/scripts/ci/advisory_gate_selftest.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# advisory_gate_selftest.sh -- FALSIFY-ADG-003/004 (contracts/advisory-gate-diff-v1.yaml): run +# advisory_gate.sh against a stub `cargo` and read what it asked cargo to do. +# ADG-003 every advisories scan carries --all-features (the optional-feature crate is in scope) +# ADG-004 ONE advisory-db fetch; both scans read it with --disable-fetch and the same --config +# usage: advisory_gate_selftest.sh [GATE_SCRIPT] (default: the sibling advisory_gate.sh) +set -euo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +GATE_SRC="${1:-$HERE/advisory_gate.sh}" + +run_gate() { # prints the stub's argv log + local d repo log + d=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf -- '${d:?}'" RETURN + repo="$d/repo"; log="$d/cargo.log"; mkdir -p "$repo" "$d/bin" "$d/home" "$d/scripts" + cp "$GATE_SRC" "$d/scripts/advisory_gate.sh"; cp "$HERE/advisory_diff.sh" "$d/scripts/advisory_diff.sh" + # `cargo deny check --help` is the version probe: it is answered, never logged (STUB_OLD=1 plays cargo-deny <= 0.19) + printf '#!/usr/bin/env bash\ncase "$*" in *--help*) [ "${STUB_OLD:-0}" = 1 ] && echo -- --config; exit 0;; esac\necho "$*" >>"%s"\nexit 0\n' "$log" >"$d/bin/cargo"; chmod +x "$d/bin/cargo" + ( + cd "$repo" + git init -q . && printf '[advisories]\n' >deny.toml && git add deny.toml + # plumbing, not `git commit`: a throwaway repo must not run the global commit hooks + git update-ref refs/remotes/origin/main "$(git -c user.name=t -c user.email=t@t commit-tree -m base "$(git write-tree)")" + git update-ref HEAD refs/remotes/origin/main + PATH="$d/bin:$PATH" HOME="$d/home" TMPDIR="$d" GITHUB_EVENT_NAME=pull_request \ + bash "$d/scripts/advisory_gate.sh" >/dev/null 2>&1 + ) || { echo "gate-did-not-pass"; return 0; } + cat "$log" +} + +verdict_of() { # [old|new] (cargo-deny flavour the stub played); stdin: stub log -> "ok" or the first defect + local log fetch checks nall nfd ncfg + log=$(cat) + [ "$log" != gate-did-not-pass ] || { echo "gate did not pass against the stub"; return; } + fetch=$(grep -cE '^deny (--config [^ ]+ )?fetch' <<<"$log" || true) + checks=$(grep -c 'check' <<<"$(grep -vE '^deny (--config [^ ]+ )?fetch' <<<"$log")" || true) + nall=$(grep -c -- '--all-features' <<<"$(grep -vE '^deny (--config [^ ]+ )?fetch' <<<"$log")" || true) + nfd=$(grep -cE -- '--disable-fetch' <<<"$(grep -vE '^deny (--config [^ ]+ )?fetch' <<<"$log")" || true) + ncfg=$(grep -vE '^deny (--config [^ ]+ )?fetch' <<<"$log" | grep -o -- '--config [^ ]*' | sort -u | wc -l) + [ "$checks" -eq 2 ] || { echo "expected 2 scans, saw $checks"; return; } + [ "$nall" -eq 2 ] || { echo "ADG-003: $nall of 2 scans carry --all-features"; return; } + [ "$fetch" -eq 1 ] || { echo "ADG-004: $fetch advisory-db fetches, want 1"; return; } + if [ "${1:-new}" = old ]; then [ "$nfd" -eq 2 ] || { echo "ADG-004: $nfd of 2 scans carry --disable-fetch"; return; }; fi + # 0.20 (m70, #5793): both scans run --offline (one advisory-db head), and `cargo fetch --locked` runs on BOTH trees BEFORE the first scan, + # else --offline on a cold registry cache exits rc=1 with no diagnostics. The stub log is in call order. + if [ "${1:-new}" != old ]; then + [ "$(grep -c -- '--offline' <<<"$log")" -eq 2 ] || { echo "ADG-005: not both scans carry --offline"; return; } + [ "$(grep -c '^fetch --locked$' <<<"$log")" -eq 2 ] || { echo "ADG-005: cargo fetch --locked is not run once per tree"; return; } + [ "$(grep -n '^fetch --locked$' <<<"$log" | tail -1 | cut -d: -f1)" -lt "$(grep -n -- '--offline' <<<"$log" | head -1 | cut -d: -f1)" ] || { echo "ADG-005: cargo fetch --locked runs after a scan"; return; } + fi + [ "$ncfg" -eq 1 ] || { echo "ADG-004: scans use $ncfg different --config files"; return; } + echo ok +} + +main() { + local bad=0 d m got + d=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf -- '${d:?}'" EXIT + got=$(run_gate | verdict_of) + [ "$got" = ok ] || { echo "SELF-TEST FAIL: real gate: $got" >&2; bad=1; } + got=$(STUB_OLD=1 run_gate | verdict_of old) + [ "$got" = ok ] || { echo "SELF-TEST FAIL: real gate vs cargo-deny <= 0.19 flags: $got" >&2; bad=1; } + mutant() { # name sed-expr want-substring + sed -e "$2" "$GATE_SRC" >"$d/m.sh" + cmp -s "$d/m.sh" "$GATE_SRC" && { echo "SELF-TEST FAIL: mutant '$1' changed nothing" >&2; bad=1; return; } + m=$(STUB_OLD="${5:-0}" run_gate_with "$d/m.sh" | verdict_of "${4:-new}") + [[ "$m" == *"$3"* ]] || { echo "SELF-TEST FAIL: mutant '$1' stayed green: $m" >&2; bad=1; } + } + mutant 'scan without --all-features' 's/ --all-features//' 'ADG-003' + mutant 'scan reads the net (no --disable-fetch)' 's/ --disable-fetch//' 'ADG-004' old 1 + mutant 'scan not offline (0.20)' 's/--locked --offline --all-features/--locked --all-features/' 'ADG-005' + mutant 'no cargo fetch --locked before the scans (0.20)' 's/cargo fetch --locked)/true)/' 'ADG-005' + mutant 'second fetch before the base scan' 's|^BASERC=|cargo deny fetch --config "$W/deny.toml" db; BASERC=|' 'ADG-004' + # ADG-003 on the other two scan sites: the push path and the exemption-liveness guard. + local f + for f in "$GATE_SRC" "${EXEMPT_SRC:-scripts/check_deny_exemptions_live.sh}"; do + if grep -v printf "$f" | grep -E '^[^#]*cargo deny .*check .*advisories' | grep -qv -- '--all-features'; then + echo "SELF-TEST FAIL: ADG-003: $f scans advisories without --all-features" >&2; bad=1 + fi + done + [ "$bad" -eq 0 ] && echo "advisory_gate self-test: 2 real (cargo-deny 0.19 and 0.20 flags) + 5 mutants + 2 sites ok" + return "$bad" +} + +run_gate_with() { GATE_SRC="$1"; run_gate; } +main diff --git a/scripts/ci/advisory_step_selftest.sh b/scripts/ci/advisory_step_selftest.sh new file mode 100755 index 0000000000..7409c91d50 --- /dev/null +++ b/scripts/ci/advisory_step_selftest.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# advisory_step_selftest.sh -- the CI step "Advisories must pass" must go RED when the gate (or any +# self-test before it) exits non-zero (PMAT-5792, ADG-INV-004). Extracts the step's run block from +# .github/workflows/ci.yml, runs it in a throwaway repo whose origin/main holds stub scripts, and +# requires the step's exit status to equal the stub's. A mutant (gate line removed) must NOT be red. +# +# advisory_step_selftest.sh [WORKFLOW] WORKFLOW defaults to .github/workflows/ci.yml +set -euo pipefail + +wf=${1:-.github/workflows/ci.yml} +d=$(mktemp -d) +# shellcheck disable=SC2064 +trap "rm -rf -- '${d:?}'" EXIT + +extract() { # WORKFLOW -> the run block, de-indented + awk '/^ - name: Advisories must pass/ {on=1; next} + on && /^ - name:/ {exit} + on && /^ run: \|/ {run=1; next} + on && run {sub(/^ /, ""); print}' "$1" +} + +# stubs: each script exits with the status in .rc (default 0) +make_repo() { # DIR -> repo with scripts/ci stubs committed on refs/remotes/origin/main + local r=$1 f + mkdir -p "$r/scripts/ci" + for f in advisory_diff advisory_gate advisory_gate_selftest advisory_step_selftest check_sov_advisories_off rearm_ready; do + printf '#!/usr/bin/env bash\nexit "$(cat "$(dirname "$0")/%s.rc" 2>/dev/null || echo 0)"\n' "$f" >"$r/scripts/ci/$f.sh" + done + git -C "$r" init -q + git -C "$r" add -A + git -C "$r" -c user.name=t -c user.email=t@t write-tree >/dev/null + local tree c + tree=$(git -C "$r" write-tree) + c=$(git -C "$r" -c user.name=t -c user.email=t@t commit-tree "$tree" -m stub) + git -C "$r" update-ref refs/remotes/origin/main "$c" +} + +run_step() { # SCRIPTFILE REPO -> step exit status + local rc=0 + (cd "$2" && env -u RUNNER_TEMP TMPDIR="$d" GITHUB_TOKEN=x bash -c "$(cat "$1")" >/dev/null 2>&1) || rc=$? + echo "$rc" +} + +# $1: gate rc, $2: first self-test rc -- set in the BASE copy (what the step actually runs) and in the tree +case_() { # name want gate_rc selftest_rc script + local name=$1 want=$2 got r + r=$(mktemp -d "$d/repo-XXXXXX") + make_repo "$r" + printf '%s' "$3" >"$r/scripts/ci/advisory_gate.rc" + printf '%s' "$4" >"$r/scripts/ci/advisory_diff.rc" + git -C "$r" add -A + local tree c + tree=$(git -C "$r" write-tree) + c=$(git -C "$r" -c user.name=t -c user.email=t@t commit-tree "$tree" -m stub2) + git -C "$r" update-ref refs/remotes/origin/main "$c" + got=$(run_step "$5" "$r") + if [ "$got" -ne "$want" ]; then + echo "SELF-TEST FAIL: $name wanted step rc=$want, got $got" >&2 + bad=1 + fi +} + +bad=0 +extract "$wf" >"$d/step.sh" +[ -s "$d/step.sh" ] || { echo "SELF-TEST FAIL: could not extract the 'Advisories must pass' step from $wf" >&2; exit 1; } +case_ 'gate rc 75 -> step RED with 75' 75 75 0 "$d/step.sh" +case_ 'gate rc 1 -> step RED with 1' 1 1 0 "$d/step.sh" +case_ 'advisory_diff self-test rc 3 -> step RED with 3' 3 0 3 "$d/step.sh" +case_ 'all green -> step green' 0 0 0 "$d/step.sh" +# mutant: the gate invocation removed from the step; a failing gate must then (wrongly) pass -- proving the cases above bind to it +grep -v 'advisory_gate.sh"$' "$d/step.sh" >"$d/mut.sh" +cmp -s "$d/step.sh" "$d/mut.sh" && { echo "SELF-TEST FAIL: mutant did not change the step (gate line not found)" >&2; bad=1; } +case_ 'MUTANT (gate line removed) must NOT be red on a failing gate' 0 75 0 "$d/mut.sh" +# mutant 2: the old `|| rc=$?` shape with the gate dropped from the chain is caught the same way (covered by the case above) +if [ "$bad" -eq 0 ]; then + echo "advisory_step self-test: 4 cases + 1 mutant ok" +fi +exit "$bad" diff --git a/scripts/ci/check_sov_advisories_off.sh b/scripts/ci/check_sov_advisories_off.sh new file mode 100644 index 0000000000..9333c84682 --- /dev/null +++ b/scripts/ci/check_sov_advisories_off.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# check_sov_advisories_off.sh -- PMAT-5792: the vendored sov.lint `cargo deny check advisories` and +# sov.security `cargo audit` stop PRs on advisories already on main. They are off only when BOTH halves hold: +# 1. ci/sections.yml sovereign-ci.with sets advisories_diff_only: true +# 2. the vendored body reads that input in both steps and skips the scan on it +# usage: check_sov_advisories_off.sh [SECTIONS] [VENDORED] | --self-test +set -euo pipefail + +HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd -- "$HERE/../.." && pwd)" + +check() { # SECTIONS VENDORED -> 0 ok, 1 gap + local sec=$1 ven=$2 bad=0 + grep -Eq '^ advisories_diff_only: true$' "$sec" || { echo "GAP: $sec does not set advisories_diff_only: true under sovereign-ci.with" >&2; bad=1; } + [ "$(grep -c 'SOV_ADVISORIES_DIFF_ONLY: \${{ inputs.advisories_diff_only }}' "$ven")" -ge 2 ] \ + || { echo "GAP: $ven does not pass the input to both the deny and the audit step" >&2; bad=1; } + [ "$(grep -c '"${SOV_ADVISORIES_DIFF_ONLY:-}" = true' "$ven")" -ge 2 ] \ + || { echo "GAP: $ven does not skip both scans on the input" >&2; bad=1; } + return "$bad" +} + +self_test() { + local d bad=0 rc + d=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf -- '${d:?}'" RETURN + case_() { # name want sections vendored + rc=0; check "$3" "$4" >/dev/null 2>&1 || rc=$? + [ "$rc" -eq "$2" ] || { echo "SELF-TEST FAIL: $1 wanted rc=$2 got $rc" >&2; bad=1; } + } + S="$ROOT/ci/sections.yml"; V="$ROOT/ci/vendor/sovereign-ci.yml" + case_ 'real tree' 0 "$S" "$V" + sed 's/^ advisories_diff_only: true$/ advisories_diff_only: false/' "$S" >"$d/s.yml" + case_ 'MUTANT input off in sections' 1 "$d/s.yml" "$V" + sed 's/"${SOV_ADVISORIES_DIFF_ONLY:-}" = true/"${SOV_ADVISORIES_DIFF_ONLY:-}" = never/' "$V" >"$d/v.yml" + case_ 'MUTANT vendored body does not honour it' 1 "$S" "$d/v.yml" + sed '0,/SOV_ADVISORIES_DIFF_ONLY: ${{ inputs.advisories_diff_only }}/{//d}' "$V" >"$d/v2.yml" + case_ 'MUTANT one step loses the env' 1 "$S" "$d/v2.yml" + [ "$bad" -eq 0 ] && echo "check_sov_advisories_off self-test: 4 cases ok" + return "$bad" +} + +case "${1:-}" in + --self-test) self_test ;; + "") check "$ROOT/ci/sections.yml" "$ROOT/ci/vendor/sovereign-ci.yml" ;; + *) check "$1" "$2" ;; +esac diff --git a/scripts/ci/rearm_ready.sh b/scripts/ci/rearm_ready.sh new file mode 100755 index 0000000000..e886848a76 --- /dev/null +++ b/scripts/ci/rearm_ready.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# rearm_ready.sh -- which ready PRs a gate fix may re-arm (contract advisory-gate-diff-v1, ADG-INV-005). +# +# rearm_ready.sh select FILE FILE = TSV "pr approved_sha current_sha"; prints PRs to arm +# rearm_ready.sh run list open un-armed PRs (one gh call), select, arm each with plain `gh pr merge` +# rearm_ready.sh --self-test case table, plus `run` against a stub gh (RED when select_ is not called) +# A PR whose head moved after its approval is NOT re-armed (a changed head needs a fresh review). +set -euo pipefail + +select_() { + awk -F'\t' 'NF >= 3 && $1 != "" && $2 ~ /^[0-9a-f]{40}$/ && $2 == $3 { print $1 "\t" $3 "\t" $4 }' "$1" +} + +# stdin: `gh pr list --json id,number,author,headRefOid,autoMergeRequest,reviews`; stdout: TSV "pr approved_sha current_sha node_id" +# approved_sha = commit.oid of the latest anchored APPROVE review by an allowlisted reviewer (REARM_REVIEWERS, never the PR author); +# ANY later review that is not such an approval (negative or a plain comment) cancels it. +plan_() { + jq -r --arg ok "${REARM_REVIEWERS:-}" '($ok | split(" ") | map(select(. != ""))) as $allow + | .[] | select(.autoMergeRequest == null) | . as $p + | def neg: (.state == "CHANGES_REQUESTED") or (.body | test("HOLD|CHANGES-NEEDED|REQUEST_CHANGES")); + def tok: (.body | test("(^|\\n|: |- )APPROVE([^A-Za-z]|$)")); + def pos: (neg | not) and tok and (.author.login as $l | ($allow | index($l)) != null and $l != $p.author.login); + [.reviews | to_entries[]] as $r + | ([$r[] | select(.value | pos) | .key] | last) as $ai + | (if $ai == null or any($r[]; .key > $ai and (.value | pos | not)) then "" + else ($r[] | select(.key == $ai) | .value.commit.oid // "") end) as $sha + | [$p.number, $sha, $p.headRefOid, $p.id] | @tsv' +} + +# Arms atomically against the reviewed head: enqueuePullRequest(expectedHeadOid) is refused by GitHub if the head moved +# after the select. The queue owns the merge method, so none is passed. There is NO `gh pr merge` fallback: #5794 bans +# every flag on it (so no --match-head-commit), and a plain merge after a re-read of the head is a check-then-act race that +# could arm a head nobody reviewed. A refused enqueue (head moved, or checks still pending) is left for the next run. +run_() { + local t n sha id + t=$(mktemp) + # shellcheck disable=SC2064 + trap "rm -f -- '${t:?}'" RETURN + gh pr list --state open --limit 100 --json id,number,author,headRefOid,autoMergeRequest,reviews | plan_ >"$t" + while IFS=$'\t' read -r n sha id; do + if gh api graphql -f query='mutation($id:ID!,$h:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$h}){clientMutationId}}' \ + -f id="$id" -f h="$sha" >/dev/null 2>&1; then + continue + fi + echo "rearm: PR $n enqueue refused at the reviewed head; not armed" >&2 + done < <(select_ "$t") +} + +self_test() { + local d bad=0 a b + d=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf -- '${d:?}'" RETURN + a=$(printf 'a%.0s' {1..40}); b=$(printf 'b%.0s' {1..40}) + printf '1\t%s\t%s\n2\t%s\t%s\n3\t\t%s\n' "$a" "$a" "$a" "$b" "$a" >"$d/t.tsv" + [ "$(select_ "$d/t.tsv" | cut -f1 | tr '\n' ' ')" = "1 " ] || { echo "SELF-TEST FAIL: only PR 1 (head unchanged) may be re-armed" >&2; bad=1; } + printf '' >"$d/e.tsv" + [ -z "$(select_ "$d/e.tsv")" ] || { echo "SELF-TEST FAIL: empty input arms nothing" >&2; bad=1; } + # run_ end to end with a stub gh. PRs: 1 approved+unchanged; 2 head moved; 3 already armed; + # 4 APPROVE then a later CHANGES-NEEDED; 5 APPROVE then a later HOLD; 6 CHANGES-NEEDED then a fresh APPROVE. + mkdir -p "$d/bin" + # rv LOGIN BODY OID -> one review object; pr N HEAD AUTOMERGE REVIEWS -> one PR object + rv() { printf '{"author":{"login":"%s"},"state":"COMMENTED","body":"%s","commit":{"oid":"%s"}}' "$1" "$2" "$3"; } + pr() { printf '{"id":"N%s","number":%s,"author":{"login":"au"},"headRefOid":"%s","autoMergeRequest":%s,"reviews":[%s]}' "$1" "$1" "$2" "$3" "$4"; } + { + printf '[' + pr 1 "$a" null "$(rv s1 'seat 1: APPROVE' "$a")"; printf , + pr 2 "$b" null "$(rv s1 'seat 1: APPROVE' "$a")"; printf , + pr 3 "$a" '{"x":1}' "$(rv s1 'seat 1: APPROVE' "$a")"; printf , + pr 4 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'seat 2: CHANGES-NEEDED' "$a")"; printf , + pr 5 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'HOLD' "$a")"; printf , + pr 6 "$a" null "$(rv s2 'CHANGES-NEEDED' "$a"),$(rv s1 'seat 1: APPROVE' "$a")"; printf , + pr 7 "$a" null "$(rv s1 'seat 1: NOT APPROVED' "$a")"; printf , + pr 8 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'just a comment' "$a")"; printf , + pr 9 "$a" null "$(rv rando 'seat 1: APPROVE' "$a")"; printf , + pr 10 "$a" null "$(rv au 'seat 1: APPROVE' "$a")"; printf , + pr 11 "$a" null "$(rv s1 'seat 1: APPROVE' "$b")" + printf ']' + } >"$d/prs.json" + cat >"$d/bin/gh" <>"$d/armed"; [ -z "\${STUB_REFUSE:-}" ] +else echo "\$*" >>"$d/other"; exit 1 +fi +STUB + chmod +x "$d/bin/gh" + REARM_REVIEWERS="s1 s2 au" PATH="$d/bin:$PATH" run_ >/dev/null 2>&1 || true + got=$(grep 'expectedHeadOid:\$h' "$d/armed" 2>/dev/null | grep -o 'id=N[0-9]* -f h=[0-9a-f]*' | tr '\n' ',') + # 1 ok; 2 head moved; 3 armed; 4/5 later negative; 6 fresh approve after negative -> arms; 7 "NOT APPROVED" is no approval; + # 8 any later non-approval cancels; 9 reviewer off the allowlist; 10 author self-approval; 11 commit.oid (b) != head (a), so not armed. + [ "$got" = "id=N1 -f h=$a,id=N6 -f h=$a," ] || { echo "SELF-TEST FAIL: run must enqueue exactly PR 1 and 6 with expectedHeadOid=commit.oid, got: $got" >&2; bad=1; } + rm -f "${d:?}/armed" "${d:?}/other" + REARM_REVIEWERS="s1 s2 au" STUB_REFUSE=1 PATH="$d/bin:$PATH" run_ >/dev/null 2>&1 || true + if [ -e "$d/other" ]; then + echo "SELF-TEST FAIL: a refused enqueue must not fall back to any other gh call (the head pin is the mutation only)" >&2; bad=1 + fi + [ "$bad" -eq 0 ] && echo "rearm_ready self-test: 4 cases ok" + return "$bad" +} + +case "${1:-}" in + --self-test) self_test ;; + select) select_ "$2" ;; + run) run_ ;; + *) echo "usage: $0 select FILE | run | --self-test" >&2; exit 2 ;; +esac diff --git a/scripts/ci_mg_reuse.sh b/scripts/ci_mg_reuse.sh index 86573f5aff..22de715059 100755 --- a/scripts/ci_mg_reuse.sh +++ b/scripts/ci_mg_reuse.sh @@ -515,7 +515,7 @@ STUB if grep -qF 'outputs.mac' <<<"$(job mg-reuse)"; then fail=$((fail + 1)); echo "FAIL wiring: mg-reuse still exports mac, which no ci.yml job reads"; else pass=$((pass + 1)); fi wire x86-main-advisories "if: \${{ !cancelled() && needs.mg-reuse.outputs.x86 == '1' }}" - wire x86-main-advisories 'cargo deny check advisories' + wire x86-main-advisories 'advisory_gate.sh"' wire x86-main-advisories 'bash scripts/check_deny_exemptions_live.sh' wire ci-gate 'MG-REUSE-VERDICT-BEGIN' wire ci-gate 'needs: [x86-main, mg-reuse, x86-main-advisories]' From c9fdd2b6bedb64f868148b2478f7d803e29aaa02 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 03:16:07 +0200 Subject: [PATCH 02/10] =?UTF-8?q?contract:=20advisory-gate-diff-v1=20?= =?UTF-8?q?=E2=80=94=20PR=20gate=20fails=20only=20on=20advisories=20the=20?= =?UTF-8?q?PR=20introduces;=20re-arm=20split=20out=20(Refs=20#5792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #5849. rearm-ready.yml / rearm_ready.sh and the auto_rearm / ADG-INV-005 / FALSIFY-ADG-006 rows are removed here (quorum NOT AGREED: out of scope for a gate-content PR, and its --match-head-commit citation was wrong); they go in their own PR from w5018/rearm-ready. Pmat-Ticket: PMAT-5792 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/advisory-andon.yml | 1 - .github/workflows/ci.yml | 1 - .github/workflows/rearm-ready.yml | 29 -------- ci/sections.yml | 1 - contracts/advisory-gate-diff-v1.yaml | 22 +----- scripts/ci/advisory_step_selftest.sh | 2 +- scripts/ci/rearm_ready.sh | 107 --------------------------- 7 files changed, 3 insertions(+), 160 deletions(-) delete mode 100644 .github/workflows/rearm-ready.yml delete mode 100755 scripts/ci/rearm_ready.sh diff --git a/.github/workflows/advisory-andon.yml b/.github/workflows/advisory-andon.yml index 4b5479ee55..172a251d06 100644 --- a/.github/workflows/advisory-andon.yml +++ b/.github/workflows/advisory-andon.yml @@ -38,7 +38,6 @@ jobs: GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" export GIT_CONFIG_VALUE_0 bash scripts/ci/advisory_andon.sh --self-test - bash scripts/ci/rearm_ready.sh --self-test out="${RUNNER_TEMP:?}/andon.ndjson" rc=0 cargo deny --locked --all-features --format json check advisories >/dev/null 2>"$out" || rc=$? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c8036c16f..d7d0f8dc5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -363,7 +363,6 @@ jobs: bash scripts/ci/check_sov_advisories_off.sh bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" bash scripts/ci/advisory_step_selftest.sh - bash scripts/ci/rearm_ready.sh --self-test bash "$G/advisory_gate.sh" - name: Every deny.toml exemption must still be live env: diff --git a/.github/workflows/rearm-ready.yml b/.github/workflows/rearm-ready.yml deleted file mode 100644 index 3b4fd19469..0000000000 --- a/.github/workflows/rearm-ready.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: rearm-ready -# PMAT-5792 (ADG-INV-005): after main moves or on a timer, re-arm ready PRs whose head is the one that was approved. -# Never a PR or merge_group job. A PR whose head moved after approval is left alone (needs a fresh review). -on: - schedule: - - cron: '41 * * * *' - push: - branches: [main] - workflow_dispatch: -permissions: - contents: read - pull-requests: write -concurrency: - group: rearm-ready - cancel-in-progress: false -jobs: - rearm: - runs-on: [self-hosted, Linux, X64, clean-room] - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7 - - name: Self-test, then re-arm approved un-armed PRs - env: - GH_TOKEN: ${{ github.token }} - REARM_REVIEWERS: ${{ vars.REARM_REVIEWERS }} - run: | - set -euo pipefail - bash scripts/ci/rearm_ready.sh --self-test - bash scripts/ci/rearm_ready.sh run diff --git a/ci/sections.yml b/ci/sections.yml index 12fe07ef06..e17e0a55db 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -2713,7 +2713,6 @@ jobs: bash scripts/ci/check_sov_advisories_off.sh bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" bash scripts/ci/advisory_step_selftest.sh - bash scripts/ci/rearm_ready.sh --self-test bash "$G/advisory_gate.sh" # Separate from the check above on purpose: a newly-FIXED upstream must # never fail anyone's build. It fails only this guard, whose remedy is diff --git a/contracts/advisory-gate-diff-v1.yaml b/contracts/advisory-gate-diff-v1.yaml index 92ca7f7c11..a2d61329b8 100644 --- a/contracts/advisory-gate-diff-v1.yaml +++ b/contracts/advisory-gate-diff-v1.yaml @@ -47,12 +47,6 @@ equations: - "Both sides are scanned with the BASE deny.toml; a PR that adds an advisories ignore id fails (ignores-added), so a PR cannot hide its own finding" - "deny.toml advisories ignores are id-wide: cargo-deny has no per-crate ignore. They stay bounded, not scoped: ignores-added fails any PR that adds one, and check_deny_exemptions_live.sh (now --all-features, same as the gate) fails a dead one. Per-crate scoping needs an upstream cargo-deny feature and is out of this PR" - "The base is pinned: merge_group uses merge_group.base_sha, pull_request uses pull_request.base.sha, anything else (or a non-sha) is COULD-NOT-CHECK rc 75, never a pass" - auto_rearm: - formula: "when a gate fix lands, every PR that was ready (green, approved) before the stop is re-armed without a human" - domain: "PRs with auto-merge armed or ready at the time of the stop" - codomain: "armed set restored" - invariants: - - "Re-arm never arms a PR whose head changed after its approval" proof_obligations: - id: ADG-INV-001 @@ -75,11 +69,6 @@ proof_obligations: property: "The scanner of record is blind to nothing the other scanner sees" formal: "∀ lock_48_0_6: A_record(lock) ∋ RUSTSEC-2026-0378 ∧ A_record(lock) ∋ RUSTSEC-2026-0379" applies_to: one_scanner_of_record - - id: ADG-INV-005 - type: invariant - property: "Ready PRs are re-armed after a gate fix" - formal: "∀ pr: head_unchanged ⇒ armed_after ≥ armed_before" - applies_to: auto_rearm falsification_tests: - id: FALSIFY-ADG-001 @@ -112,12 +101,6 @@ falsification_tests: test: "case table over the ticket emitter" if_fails: "main-resident advisories sit unowned" mutation: "set the due time to 8 h; the case must turn RED" - - id: FALSIFY-ADG-006 - rule: "Re-arm never arms a changed head (ADG-INV-005)" - prediction: "a ready PR whose head sha moved after approval is not re-armed; one whose head is unchanged is" - test: "scripts/ci/rearm_ready.sh --self-test, 4 cases (head unchanged arms, moved or unapproved does not, empty arms nothing, and `run` against a stub gh enqueues exactly the PRs whose LATEST review is an anchored APPROVE (not `NOT APPROVED`) by an allowlisted reviewer other than the PR author, each with expectedHeadOid = the review's commit.oid; any later non-approval review cancels it; the fallback gh pr merge carries --match-head-commit ; a mutant without expectedHeadOid, the anchor, the allowlist, the author exclusion, the cancel, commit.oid or --match-head-commit turns it RED); .github/workflows/rearm-ready.yml calls `rearm_ready.sh run`" - if_fails: "a gate fix arms an unreviewed head into the queue" - mutation: "drop the approved_sha == current_sha test in select_; the moved-head case must turn RED; replace select_ in run_ with cat|cut and the stub-gh case must turn RED (measured 2026-10-20)" - id: FALSIFY-ADG-007 rule: "No second scanner blocks a PR on main's advisories" prediction: "sov.lint and sov.security skip plain cargo deny advisories / cargo audit (advisories_diff_only: true in the vendored sovereign-ci)" @@ -135,11 +118,10 @@ falsification_tests: qa_gate: id: F-ADG-001 name: Advisory gate diff contract - description: "PR delta gate, main andon, one scanner of record, auto re-arm" + description: "PR delta gate, main andon, one scanner of record" checks: - pr_gate_verdict - main_resident_advisory - one_scanner_of_record - - auto_rearm - pass_criteria: "All 8 falsification tests pass; the scanner script has a must-fail/must-pass case table" + pass_criteria: "All 7 falsification tests pass; the scanner script has a must-fail/must-pass case table" falsification: "Revert the gate to lock-vs-DB; FALSIFY-ADG-001 must turn RED" diff --git a/scripts/ci/advisory_step_selftest.sh b/scripts/ci/advisory_step_selftest.sh index 7409c91d50..f2d1ac48c0 100755 --- a/scripts/ci/advisory_step_selftest.sh +++ b/scripts/ci/advisory_step_selftest.sh @@ -23,7 +23,7 @@ extract() { # WORKFLOW -> the run block, de-indented make_repo() { # DIR -> repo with scripts/ci stubs committed on refs/remotes/origin/main local r=$1 f mkdir -p "$r/scripts/ci" - for f in advisory_diff advisory_gate advisory_gate_selftest advisory_step_selftest check_sov_advisories_off rearm_ready; do + for f in advisory_diff advisory_gate advisory_gate_selftest advisory_step_selftest check_sov_advisories_off; do printf '#!/usr/bin/env bash\nexit "$(cat "$(dirname "$0")/%s.rc" 2>/dev/null || echo 0)"\n' "$f" >"$r/scripts/ci/$f.sh" done git -C "$r" init -q diff --git a/scripts/ci/rearm_ready.sh b/scripts/ci/rearm_ready.sh deleted file mode 100755 index e886848a76..0000000000 --- a/scripts/ci/rearm_ready.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash -# rearm_ready.sh -- which ready PRs a gate fix may re-arm (contract advisory-gate-diff-v1, ADG-INV-005). -# -# rearm_ready.sh select FILE FILE = TSV "pr approved_sha current_sha"; prints PRs to arm -# rearm_ready.sh run list open un-armed PRs (one gh call), select, arm each with plain `gh pr merge` -# rearm_ready.sh --self-test case table, plus `run` against a stub gh (RED when select_ is not called) -# A PR whose head moved after its approval is NOT re-armed (a changed head needs a fresh review). -set -euo pipefail - -select_() { - awk -F'\t' 'NF >= 3 && $1 != "" && $2 ~ /^[0-9a-f]{40}$/ && $2 == $3 { print $1 "\t" $3 "\t" $4 }' "$1" -} - -# stdin: `gh pr list --json id,number,author,headRefOid,autoMergeRequest,reviews`; stdout: TSV "pr approved_sha current_sha node_id" -# approved_sha = commit.oid of the latest anchored APPROVE review by an allowlisted reviewer (REARM_REVIEWERS, never the PR author); -# ANY later review that is not such an approval (negative or a plain comment) cancels it. -plan_() { - jq -r --arg ok "${REARM_REVIEWERS:-}" '($ok | split(" ") | map(select(. != ""))) as $allow - | .[] | select(.autoMergeRequest == null) | . as $p - | def neg: (.state == "CHANGES_REQUESTED") or (.body | test("HOLD|CHANGES-NEEDED|REQUEST_CHANGES")); - def tok: (.body | test("(^|\\n|: |- )APPROVE([^A-Za-z]|$)")); - def pos: (neg | not) and tok and (.author.login as $l | ($allow | index($l)) != null and $l != $p.author.login); - [.reviews | to_entries[]] as $r - | ([$r[] | select(.value | pos) | .key] | last) as $ai - | (if $ai == null or any($r[]; .key > $ai and (.value | pos | not)) then "" - else ($r[] | select(.key == $ai) | .value.commit.oid // "") end) as $sha - | [$p.number, $sha, $p.headRefOid, $p.id] | @tsv' -} - -# Arms atomically against the reviewed head: enqueuePullRequest(expectedHeadOid) is refused by GitHub if the head moved -# after the select. The queue owns the merge method, so none is passed. There is NO `gh pr merge` fallback: #5794 bans -# every flag on it (so no --match-head-commit), and a plain merge after a re-read of the head is a check-then-act race that -# could arm a head nobody reviewed. A refused enqueue (head moved, or checks still pending) is left for the next run. -run_() { - local t n sha id - t=$(mktemp) - # shellcheck disable=SC2064 - trap "rm -f -- '${t:?}'" RETURN - gh pr list --state open --limit 100 --json id,number,author,headRefOid,autoMergeRequest,reviews | plan_ >"$t" - while IFS=$'\t' read -r n sha id; do - if gh api graphql -f query='mutation($id:ID!,$h:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$h}){clientMutationId}}' \ - -f id="$id" -f h="$sha" >/dev/null 2>&1; then - continue - fi - echo "rearm: PR $n enqueue refused at the reviewed head; not armed" >&2 - done < <(select_ "$t") -} - -self_test() { - local d bad=0 a b - d=$(mktemp -d) - # shellcheck disable=SC2064 - trap "rm -rf -- '${d:?}'" RETURN - a=$(printf 'a%.0s' {1..40}); b=$(printf 'b%.0s' {1..40}) - printf '1\t%s\t%s\n2\t%s\t%s\n3\t\t%s\n' "$a" "$a" "$a" "$b" "$a" >"$d/t.tsv" - [ "$(select_ "$d/t.tsv" | cut -f1 | tr '\n' ' ')" = "1 " ] || { echo "SELF-TEST FAIL: only PR 1 (head unchanged) may be re-armed" >&2; bad=1; } - printf '' >"$d/e.tsv" - [ -z "$(select_ "$d/e.tsv")" ] || { echo "SELF-TEST FAIL: empty input arms nothing" >&2; bad=1; } - # run_ end to end with a stub gh. PRs: 1 approved+unchanged; 2 head moved; 3 already armed; - # 4 APPROVE then a later CHANGES-NEEDED; 5 APPROVE then a later HOLD; 6 CHANGES-NEEDED then a fresh APPROVE. - mkdir -p "$d/bin" - # rv LOGIN BODY OID -> one review object; pr N HEAD AUTOMERGE REVIEWS -> one PR object - rv() { printf '{"author":{"login":"%s"},"state":"COMMENTED","body":"%s","commit":{"oid":"%s"}}' "$1" "$2" "$3"; } - pr() { printf '{"id":"N%s","number":%s,"author":{"login":"au"},"headRefOid":"%s","autoMergeRequest":%s,"reviews":[%s]}' "$1" "$1" "$2" "$3" "$4"; } - { - printf '[' - pr 1 "$a" null "$(rv s1 'seat 1: APPROVE' "$a")"; printf , - pr 2 "$b" null "$(rv s1 'seat 1: APPROVE' "$a")"; printf , - pr 3 "$a" '{"x":1}' "$(rv s1 'seat 1: APPROVE' "$a")"; printf , - pr 4 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'seat 2: CHANGES-NEEDED' "$a")"; printf , - pr 5 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'HOLD' "$a")"; printf , - pr 6 "$a" null "$(rv s2 'CHANGES-NEEDED' "$a"),$(rv s1 'seat 1: APPROVE' "$a")"; printf , - pr 7 "$a" null "$(rv s1 'seat 1: NOT APPROVED' "$a")"; printf , - pr 8 "$a" null "$(rv s1 'seat 1: APPROVE' "$a"),$(rv s2 'just a comment' "$a")"; printf , - pr 9 "$a" null "$(rv rando 'seat 1: APPROVE' "$a")"; printf , - pr 10 "$a" null "$(rv au 'seat 1: APPROVE' "$a")"; printf , - pr 11 "$a" null "$(rv s1 'seat 1: APPROVE' "$b")" - printf ']' - } >"$d/prs.json" - cat >"$d/bin/gh" <>"$d/armed"; [ -z "\${STUB_REFUSE:-}" ] -else echo "\$*" >>"$d/other"; exit 1 -fi -STUB - chmod +x "$d/bin/gh" - REARM_REVIEWERS="s1 s2 au" PATH="$d/bin:$PATH" run_ >/dev/null 2>&1 || true - got=$(grep 'expectedHeadOid:\$h' "$d/armed" 2>/dev/null | grep -o 'id=N[0-9]* -f h=[0-9a-f]*' | tr '\n' ',') - # 1 ok; 2 head moved; 3 armed; 4/5 later negative; 6 fresh approve after negative -> arms; 7 "NOT APPROVED" is no approval; - # 8 any later non-approval cancels; 9 reviewer off the allowlist; 10 author self-approval; 11 commit.oid (b) != head (a), so not armed. - [ "$got" = "id=N1 -f h=$a,id=N6 -f h=$a," ] || { echo "SELF-TEST FAIL: run must enqueue exactly PR 1 and 6 with expectedHeadOid=commit.oid, got: $got" >&2; bad=1; } - rm -f "${d:?}/armed" "${d:?}/other" - REARM_REVIEWERS="s1 s2 au" STUB_REFUSE=1 PATH="$d/bin:$PATH" run_ >/dev/null 2>&1 || true - if [ -e "$d/other" ]; then - echo "SELF-TEST FAIL: a refused enqueue must not fall back to any other gh call (the head pin is the mutation only)" >&2; bad=1 - fi - [ "$bad" -eq 0 ] && echo "rearm_ready self-test: 4 cases ok" - return "$bad" -} - -case "${1:-}" in - --self-test) self_test ;; - select) select_ "$2" ;; - run) run_ ;; - *) echo "usage: $0 select FILE | run | --self-test" >&2; exit 2 ;; -esac From 7f92f05d67f5eb9fd82f6e25eb8122dd94091fcb Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 03:18:14 +0200 Subject: [PATCH 03/10] =?UTF-8?q?contract:=20advisory-gate-diff-v1=20?= =?UTF-8?q?=E2=80=94=20reword=20the=20judge-integrity=20invariant=20to=20w?= =?UTF-8?q?hat=20the=20gate=20proves=20(Refs=20#5792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pmat-Ticket: PMAT-5792 Co-Authored-By: Claude Sonnet 5.5 --- contracts/advisory-gate-diff-v1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/advisory-gate-diff-v1.yaml b/contracts/advisory-gate-diff-v1.yaml index a2d61329b8..1aa8799113 100644 --- a/contracts/advisory-gate-diff-v1.yaml +++ b/contracts/advisory-gate-diff-v1.yaml @@ -44,7 +44,7 @@ equations: - "The scanner of record sees the whole Cargo.lock (all features), proven on the 48.0.6 lock as the positive control" - "Exemptions live in one file" - "Findings are keyed code|advisory-id|crate@version over every graph, so a new crate under an id main already carries, and a yanked crate with no id, are both PR-introduced" - - "Both sides are scanned with the BASE deny.toml; a PR that adds an advisories ignore id fails (ignores-added), so a PR cannot hide its own finding" + - "Both sides are scanned with the BASE deny.toml; a PR that adds an advisories ignore id fails (ignores-added), so the one bypass through deny.toml is closed. This is NOT a claim that a PR cannot edit its own judge: the gate scripts, ci/sections.yml and cargo config (a .cargo alias can shadow cargo-deny) run from the PR tree, so a PR that edits them is judged by its own edit. Those paths are gate content and need the arbiter sign-off (non-author quorum); pinning the judge to main is out of this PR" - "deny.toml advisories ignores are id-wide: cargo-deny has no per-crate ignore. They stay bounded, not scoped: ignores-added fails any PR that adds one, and check_deny_exemptions_live.sh (now --all-features, same as the gate) fails a dead one. Per-crate scoping needs an upstream cargo-deny feature and is out of this PR" - "The base is pinned: merge_group uses merge_group.base_sha, pull_request uses pull_request.base.sha, anything else (or a non-sha) is COULD-NOT-CHECK rc 75, never a pass" From 428aaa9a15781b7e76cafe92029110f1657a7bf8 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 03:27:38 +0200 Subject: [PATCH 04/10] =?UTF-8?q?contract:=20advisory-gate-diff-v1=20?= =?UTF-8?q?=E2=80=94=20real=20positive=20control=20for=20the=20scanner=20o?= =?UTF-8?q?f=20record,=20honest=20judge=20wording,=20label=20fixes=20(Refs?= =?UTF-8?q?=20#5792)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lane A findings on 7f92f05d67: (1) 'a PR cannot edit its own judge' reworded in ci.yml and ci/sections.yml; (2) cargo audit is skipped, so a real cargo-deny run on a committed wasmtime 48.0.6 lock (advisory_positive_control.sh, wired into the Advisories step) must report 0378+0379; (3) the ci/vendor pin bump is upstream paiml/.github 5 commits f713290..9078879; advisories_diff_only is the last two and sits ON TOP of #44 (bin-only nextest/cargo-test fallback, additive: reached only by a crate with no lib) and #49 (action pins, checkout v7), so the input cannot be taken without them; (4) labels: 14 cases, --offline, ADG-004/ADG-008; (5) advisory_gate_selftest fails closed on a missing scan-site file. Pmat-Ticket: PMAT-5792 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 3 +- ci/sections.yml | 3 +- contracts/advisory-gate-diff-v1.yaml | 8 +- scripts/ci/advisory_gate_selftest.sh | 11 +- scripts/ci/advisory_positive_control.sh | 29 ++ scripts/ci/advisory_step_selftest.sh | 4 +- .../adv-positive-control/Cargo.lock.fixture | 459 ++++++++++++++++++ .../adv-positive-control/Cargo.toml.fixture | 6 + 8 files changed, 510 insertions(+), 13 deletions(-) create mode 100644 scripts/ci/advisory_positive_control.sh create mode 100644 scripts/ci/fixtures/adv-positive-control/Cargo.lock.fixture create mode 100644 scripts/ci/fixtures/adv-positive-control/Cargo.toml.fixture diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e877781d77..9920d4ba67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -347,7 +347,7 @@ jobs: 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' "$GITHUB_TOKEN" | base64 | tr -d '\n')" export GIT_CONFIG_VALUE_0 - # The gate runs from the BASE ref, never the PR tree (PMAT-5792): a PR cannot edit its own judge. + # The gate SCRIPTS run from the BASE ref copy (PMAT-5792); the workflow yaml and cargo config still come from the PR tree, so this is gate-content-reviewed, not self-judging-proof. # Bootstrap only: the merge that introduces the scripts has no base copy, so it uses its own. S="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/advgate-scripts-$$" if git cat-file -e refs/remotes/origin/main:scripts/ci/advisory_gate.sh 2>/dev/null; then @@ -365,6 +365,7 @@ jobs: bash scripts/ci/check_sov_advisories_off.sh bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" bash scripts/ci/advisory_step_selftest.sh + bash scripts/ci/advisory_positive_control.sh bash "$G/advisory_gate.sh" - name: Every deny.toml exemption must still be live env: diff --git a/ci/sections.yml b/ci/sections.yml index cf5e6273b7..93eef8a840 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -2637,7 +2637,7 @@ jobs: GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" export GIT_CONFIG_VALUE_0 # MG_BASE_SHA comes from the process env of the outer guard-cargo step (the manifest runner resolves no merge_group context). - # The gate runs from the BASE ref, never the PR tree (PMAT-5792): a PR cannot edit its own judge. + # The gate SCRIPTS run from the BASE ref copy (PMAT-5792); the workflow yaml and cargo config still come from the PR tree, so this is gate-content-reviewed, not self-judging-proof. # Bootstrap only: the merge that introduces the scripts has no base copy, so it uses its own. S="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/advgate-scripts-$$" if git cat-file -e refs/remotes/origin/main:scripts/ci/advisory_gate.sh 2>/dev/null; then @@ -2655,6 +2655,7 @@ jobs: bash scripts/ci/check_sov_advisories_off.sh bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" bash scripts/ci/advisory_step_selftest.sh + bash scripts/ci/advisory_positive_control.sh bash "$G/advisory_gate.sh" # Separate from the check above on purpose: a newly-FIXED upstream must # never fail anyone's build. It fails only this guard, whose remedy is diff --git a/contracts/advisory-gate-diff-v1.yaml b/contracts/advisory-gate-diff-v1.yaml index 1aa8799113..b955f8d615 100644 --- a/contracts/advisory-gate-diff-v1.yaml +++ b/contracts/advisory-gate-diff-v1.yaml @@ -74,7 +74,7 @@ falsification_tests: - id: FALSIFY-ADG-001 rule: "Main-resident advisory does not block a PR" prediction: "with lock_PR == lock_main (wasmtime 48.0.6) and a DB holding 0378/0379, the PR verdict is pass and a main-advisory andon row is emitted" - test: "planted-fixture case table of the diff gate script scripts/ci/advisory_diff.sh --self-test, 12 cases: same lock both sides" + test: "planted-fixture case table of the diff gate script scripts/ci/advisory_diff.sh --self-test, 14 cases: same lock both sides" if_fails: "the gate still compares lock to DB, not PR to main" mutation: "compare lock_PR to the empty set; the case must turn RED" - id: FALSIFY-ADG-002 @@ -86,15 +86,15 @@ falsification_tests: - id: FALSIFY-ADG-003 rule: "Scanner of record sees the optional-feature crate" prediction: "on the 48.0.6 lock the scanner of record exits non-zero and names 0378 and 0379" - test: "scripts/ci/advisory_gate_selftest.sh runs advisory_gate.sh against a stub cargo; every advisories scan must carry --all-features (the 48.0.6 lock fixture is covered by ADG-001 verdicts)" + test: "scripts/ci/advisory_gate_selftest.sh runs advisory_gate.sh against a stub cargo; every advisories scan must carry --all-features ; scripts/ci/advisory_positive_control.sh runs a REAL cargo deny --all-features on the committed wasmtime 48.0.6 lock fixture and fails unless 0378 and 0379 are both reported (the positive control that replaces cargo audit)" if_fails: "#5791 is not resolved: the record scanner is as blind as deny with all-features=false" mutation: "sed out --all-features from advisory_gate.sh scan(); the 'ADG-003' mutant case in advisory_gate_selftest.sh must turn RED (measured 2026-10-20)" - id: FALSIFY-ADG-004 rule: "Same DB snapshot on both sides" prediction: "a DB entry published between the main run and the PR run does not appear in the delta" - test: "advisory_gate_selftest.sh: exactly one `cargo deny fetch`, both scans --disable-fetch with one --config" + test: "advisory_gate_selftest.sh: exactly one `cargo deny fetch`, both scans --offline (0.20) or --disable-fetch (older cargo-deny) with one --config" if_fails: "DB refreshes read as PR-introduced advisories and re-create the stop" - mutation: "sed out --disable-fetch, or add a second fetch before the base scan; the two 'ADG-004' mutant cases must turn RED (measured 2026-10-20)" + mutation: "sed out --offline, or add a second fetch before the base scan; the two 'ADG-004' mutant cases must turn RED (measured 2026-10-20)" - id: FALSIFY-ADG-005 rule: "Andon clock" prediction: "the P0 ticket's due time is <= 4 h after the advisory first appears on main" diff --git a/scripts/ci/advisory_gate_selftest.sh b/scripts/ci/advisory_gate_selftest.sh index ba64bf0e5d..594d6f5f00 100644 --- a/scripts/ci/advisory_gate_selftest.sh +++ b/scripts/ci/advisory_gate_selftest.sh @@ -46,9 +46,9 @@ verdict_of() { # [old|new] (cargo-deny flavour the stub played); stdin: stub log # 0.20 (m70, #5793): both scans run --offline (one advisory-db head), and `cargo fetch --locked` runs on BOTH trees BEFORE the first scan, # else --offline on a cold registry cache exits rc=1 with no diagnostics. The stub log is in call order. if [ "${1:-new}" != old ]; then - [ "$(grep -c -- '--offline' <<<"$log")" -eq 2 ] || { echo "ADG-005: not both scans carry --offline"; return; } - [ "$(grep -c '^fetch --locked$' <<<"$log")" -eq 2 ] || { echo "ADG-005: cargo fetch --locked is not run once per tree"; return; } - [ "$(grep -n '^fetch --locked$' <<<"$log" | tail -1 | cut -d: -f1)" -lt "$(grep -n -- '--offline' <<<"$log" | head -1 | cut -d: -f1)" ] || { echo "ADG-005: cargo fetch --locked runs after a scan"; return; } + [ "$(grep -c -- '--offline' <<<"$log")" -eq 2 ] || { echo "ADG-004: not both scans carry --offline"; return; } + [ "$(grep -c '^fetch --locked$' <<<"$log")" -eq 2 ] || { echo "ADG-004: cargo fetch --locked is not run once per tree"; return; } + [ "$(grep -n '^fetch --locked$' <<<"$log" | tail -1 | cut -d: -f1)" -lt "$(grep -n -- '--offline' <<<"$log" | head -1 | cut -d: -f1)" ] || { echo "ADG-004: cargo fetch --locked runs after a scan"; return; } fi [ "$ncfg" -eq 1 ] || { echo "ADG-004: scans use $ncfg different --config files"; return; } echo ok @@ -71,12 +71,13 @@ main() { } mutant 'scan without --all-features' 's/ --all-features//' 'ADG-003' mutant 'scan reads the net (no --disable-fetch)' 's/ --disable-fetch//' 'ADG-004' old 1 - mutant 'scan not offline (0.20)' 's/--locked --offline --all-features/--locked --all-features/' 'ADG-005' - mutant 'no cargo fetch --locked before the scans (0.20)' 's/cargo fetch --locked)/true)/' 'ADG-005' + mutant 'scan not offline (0.20)' 's/--locked --offline --all-features/--locked --all-features/' 'ADG-004' + mutant 'no cargo fetch --locked before the scans (0.20)' 's/cargo fetch --locked)/true)/' 'ADG-004' mutant 'second fetch before the base scan' 's|^BASERC=|cargo deny fetch --config "$W/deny.toml" db; BASERC=|' 'ADG-004' # ADG-003 on the other two scan sites: the push path and the exemption-liveness guard. local f for f in "$GATE_SRC" "${EXEMPT_SRC:-scripts/check_deny_exemptions_live.sh}"; do + [ -f "$f" ] || { echo "SELF-TEST FAIL: ADG-003: $f does not exist, so its scan sites cannot be judged" >&2; bad=1; continue; } if grep -v printf "$f" | grep -E '^[^#]*cargo deny .*check .*advisories' | grep -qv -- '--all-features'; then echo "SELF-TEST FAIL: ADG-003: $f scans advisories without --all-features" >&2; bad=1 fi diff --git a/scripts/ci/advisory_positive_control.sh b/scripts/ci/advisory_positive_control.sh new file mode 100644 index 0000000000..50d80b2b13 --- /dev/null +++ b/scripts/ci/advisory_positive_control.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# advisory_positive_control.sh -- the scanner of record must SEE RUSTSEC-2026-0378 and -0379 on the wasmtime 48.0.6 lock +# (PMAT-5792, ADG-INV-001 / FALSIFY-ADG-003). A real `cargo deny --all-features check advisories` runs against a committed +# lock fixture (fixtures/adv-positive-control/, stored as *.fixture so cargo does not treat the directory as a workspace +# member), with a deny.toml that carries no ignores. Exit 0 only when both ids are reported; cargo-deny missing or a +# scan that names neither id is a failure, never a skip. +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +fx="$here/fixtures/adv-positive-control" +d=$(mktemp -d) +trap 'rm -rf -- "${d:?}"' EXIT + +command -v cargo-deny >/dev/null || { echo "advisory_positive_control: cargo-deny is not installed -- the control cannot run" >&2; exit 1; } +mkdir -p "$d/src" +cp "$fx/Cargo.toml.fixture" "$d/Cargo.toml" +cp "$fx/Cargo.lock.fixture" "$d/Cargo.lock" +: >"$d/src/lib.rs" +printf '[advisories]\nversion = 2\n' >"$d/deny.toml" + +out=$(cd "$d" && cargo deny --locked --all-features --format json check --config "$d/deny.toml" advisories 2>&1) || true +for id in RUSTSEC-2026-0378 RUSTSEC-2026-0379; do + if ! grep -qF -- "$id" <<<"$out"; then + echo "advisory_positive_control: FAIL -- cargo deny on the wasmtime 48.0.6 lock did not report $id" >&2 + printf '%s\n' "$out" | tail -5 >&2 + exit 1 + fi +done +echo "advisory_positive_control: ok -- cargo deny reports RUSTSEC-2026-0378 and -0379 on the 48.0.6 lock" diff --git a/scripts/ci/advisory_step_selftest.sh b/scripts/ci/advisory_step_selftest.sh index f2d1ac48c0..65925ec162 100755 --- a/scripts/ci/advisory_step_selftest.sh +++ b/scripts/ci/advisory_step_selftest.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # advisory_step_selftest.sh -- the CI step "Advisories must pass" must go RED when the gate (or any -# self-test before it) exits non-zero (PMAT-5792, ADG-INV-004). Extracts the step's run block from +# self-test before it) exits non-zero (PMAT-5792, ADG-008). Extracts the step's run block from # .github/workflows/ci.yml, runs it in a throwaway repo whose origin/main holds stub scripts, and # requires the step's exit status to equal the stub's. A mutant (gate line removed) must NOT be red. # @@ -23,7 +23,7 @@ extract() { # WORKFLOW -> the run block, de-indented make_repo() { # DIR -> repo with scripts/ci stubs committed on refs/remotes/origin/main local r=$1 f mkdir -p "$r/scripts/ci" - for f in advisory_diff advisory_gate advisory_gate_selftest advisory_step_selftest check_sov_advisories_off; do + for f in advisory_diff advisory_gate advisory_gate_selftest advisory_step_selftest advisory_positive_control check_sov_advisories_off; do printf '#!/usr/bin/env bash\nexit "$(cat "$(dirname "$0")/%s.rc" 2>/dev/null || echo 0)"\n' "$f" >"$r/scripts/ci/$f.sh" done git -C "$r" init -q diff --git a/scripts/ci/fixtures/adv-positive-control/Cargo.lock.fixture b/scripts/ci/fixtures/adv-positive-control/Cargo.lock.fixture new file mode 100644 index 0000000000..2dfb8cc42e --- /dev/null +++ b/scripts/ci/fixtures/adv-positive-control/Cargo.lock.fixture @@ -0,0 +1,459 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-bforest" +version = "0.135.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23e38972e913f89b465f99d3a1add645d507ad8d2cb32b05161769330b72648c" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.135.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3352069928676bf94f502e352f4d879ba16dcd2fc413fddb9617d72eaf42127" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-entity" +version = "0.135.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce7ae3678365a2fced842f320dd90760816d57e62dc41e65804c7a86ce16dff2" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "pc" +version = "0.0.0" +dependencies = [ + "wasmtime", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "48.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da9e35352d5ed6636a3b18e2099f96cf00e9dcc707f7d9d599a64ec5fabcef7" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", +] + +[[package]] +name = "pulley-macros" +version = "48.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf76d5588cc67bac2ba972c751d4f1b4545eebf2dea8117410dc7f450bae6c97" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasmparser" +version = "0.254.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "serde", +] + +[[package]] +name = "wasmtime" +version = "48.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a26206e1b2f1403d4d17c8fa7e736b693d7e9b1a7b9a5bf860e1c580472704d" +dependencies = [ + "bitflags", + "bumpalo", + "libc", + "log", + "object", + "postcard", + "pulley-interpreter", + "serde", + "serde_derive", + "target-lexicon", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-environ" +version = "48.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca61f6e56877ffa9633e0d1ee3ee01c4b5dd08d8e1609417a245b496163526af" +dependencies = [ + "anyhow", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown", + "indexmap", + "log", + "object", + "postcard", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasmparser", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-core" +version = "48.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84493645ddd62c4615bbe0bd1a6bf9740e69c11b70404f2db8726ee793442b88" +dependencies = [ + "hashbrown", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "48.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff1b5eb4b2838bbbeaadd02c61e307a1fdbd47efca9d96684c04de5721157dca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/scripts/ci/fixtures/adv-positive-control/Cargo.toml.fixture b/scripts/ci/fixtures/adv-positive-control/Cargo.toml.fixture new file mode 100644 index 0000000000..af5ae1fbb7 --- /dev/null +++ b/scripts/ci/fixtures/adv-positive-control/Cargo.toml.fixture @@ -0,0 +1,6 @@ +[package] +name="pc" +version="0.0.0" +edition="2021" +[dependencies] +wasmtime={version="=48.0.6",default-features=false} From f56f208968e898efffd5a1193818cc78758a01c6 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 03:36:49 +0200 Subject: [PATCH 05/10] ci: advisory_positive_control.sh uses the cargo-deny flag-order probe (0.20 puts --config before the subcommand) (Refs #5792) Lane A re-run: --config after check is rejected by cargo-deny 0.20.2. Same probe as advisory_gate.sh, read through a here-string (no new pipe-into-grep-q site). Also corrects the ADG-004 header text in advisory_gate_selftest.sh. Correction to the previous commit message: the upstream bin-only fallback is reached when an earlier branch of the nextest/cargo-test chain fails, not only by a no-lib crate. Pmat-Ticket: PMAT-5792 Co-Authored-By: Claude Sonnet 5.5 --- scripts/ci/advisory_gate_selftest.sh | 2 +- scripts/ci/advisory_positive_control.sh | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/ci/advisory_gate_selftest.sh b/scripts/ci/advisory_gate_selftest.sh index 594d6f5f00..c2a7d13446 100644 --- a/scripts/ci/advisory_gate_selftest.sh +++ b/scripts/ci/advisory_gate_selftest.sh @@ -2,7 +2,7 @@ # advisory_gate_selftest.sh -- FALSIFY-ADG-003/004 (contracts/advisory-gate-diff-v1.yaml): run # advisory_gate.sh against a stub `cargo` and read what it asked cargo to do. # ADG-003 every advisories scan carries --all-features (the optional-feature crate is in scope) -# ADG-004 ONE advisory-db fetch; both scans read it with --disable-fetch and the same --config +# ADG-004 ONE advisory-db fetch; both scans read it (--disable-fetch on cargo-deny <= 0.19, --offline on 0.20) with the same --config # usage: advisory_gate_selftest.sh [GATE_SCRIPT] (default: the sibling advisory_gate.sh) set -euo pipefail diff --git a/scripts/ci/advisory_positive_control.sh b/scripts/ci/advisory_positive_control.sh index 50d80b2b13..39786faa3d 100644 --- a/scripts/ci/advisory_positive_control.sh +++ b/scripts/ci/advisory_positive_control.sh @@ -18,7 +18,13 @@ cp "$fx/Cargo.lock.fixture" "$d/Cargo.lock" : >"$d/src/lib.rs" printf '[advisories]\nversion = 2\n' >"$d/deny.toml" -out=$(cd "$d" && cargo deny --locked --all-features --format json check --config "$d/deny.toml" advisories 2>&1) || true +# Same probe as advisory_gate.sh: cargo-deny <= 0.19 takes --config after `check`, 0.20 takes it before the subcommand. +help=$(cargo deny check --help 2>&1 || true) +if grep -qF -- '--config' <<<"$help"; then + out=$(cd "$d" && cargo deny --locked --all-features --format json check --config "$d/deny.toml" advisories 2>&1) || true +else + out=$(cd "$d" && cargo deny --config "$d/deny.toml" --locked --all-features --format json check advisories 2>&1) || true +fi for id in RUSTSEC-2026-0378 RUSTSEC-2026-0379; do if ! grep -qF -- "$id" <<<"$out"; then echo "advisory_positive_control: FAIL -- cargo deny on the wasmtime 48.0.6 lock did not report $id" >&2 From f25abd2f3a0a2bfdc816cff771dd8132fe506cdb Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 03:41:37 +0200 Subject: [PATCH 06/10] ci: advisory gate probes via here-strings, not pipe-into-grep-q (Refs #5792) advisory_gate.sh:39 and advisory_gate_selftest.sh:81 added two sites to the pipe-into-grep-q ratchet (44 vs ceiling 42 on origin/main). Both now capture then grep a here-string; the guard reads 42. Also quotes 'printf' so bashrs no longer flags SC2059 on that line. Pmat-Ticket: PMAT-5792 Co-Authored-By: Claude Sonnet 5.5 --- scripts/ci/advisory_gate.sh | 3 ++- scripts/ci/advisory_gate_selftest.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ci/advisory_gate.sh b/scripts/ci/advisory_gate.sh index ed1a127ed9..b898dd74cd 100755 --- a/scripts/ci/advisory_gate.sh +++ b/scripts/ci/advisory_gate.sh @@ -36,7 +36,8 @@ git worktree add --detach "$W" "$BASE" >/dev/null 2>&1 || { echo "COULD-NOT-CHEC # fetched above (without it 0.20 `check` may re-fetch the db and the two scans see different heads). --offline also makes cargo metadata need # every crate in the registry cache (a cold runner exits rc=1 with log lines only, m70 #5793, measured 2026-10-21 on 0.20.2 with an empty # CARGO_HOME), so `cargo fetch --locked` warms the cache for BOTH trees before either scan. Probe once, never assume the runner's version. -if cargo deny check --help 2>&1 | grep -q -- '--config'; then DENY_OLD=1; else DENY_OLD=0; fi +deny_help=$(cargo deny check --help 2>&1 || true) +if grep -qF -- '--config' <<<"$deny_help"; then DENY_OLD=1; else DENY_OLD=0; fi # One DB for both scans: fetched with the BASE config (the db-path both scans read), HEAD logged. if [ "$DENY_OLD" = 1 ]; then cargo deny fetch --config "$W/deny.toml" db; else cargo deny --config "$W/deny.toml" fetch db; fi \ diff --git a/scripts/ci/advisory_gate_selftest.sh b/scripts/ci/advisory_gate_selftest.sh index c2a7d13446..159b2e3db8 100644 --- a/scripts/ci/advisory_gate_selftest.sh +++ b/scripts/ci/advisory_gate_selftest.sh @@ -78,7 +78,8 @@ main() { local f for f in "$GATE_SRC" "${EXEMPT_SRC:-scripts/check_deny_exemptions_live.sh}"; do [ -f "$f" ] || { echo "SELF-TEST FAIL: ADG-003: $f does not exist, so its scan sites cannot be judged" >&2; bad=1; continue; } - if grep -v printf "$f" | grep -E '^[^#]*cargo deny .*check .*advisories' | grep -qv -- '--all-features'; then + scans=$(grep -v 'printf' "$f" | grep -E '^[^#]*cargo deny .*check .*advisories' || true) + if [ -n "$scans" ] && grep -qv -- '--all-features' <<<"$scans"; then echo "SELF-TEST FAIL: ADG-003: $f scans advisories without --all-features" >&2; bad=1 fi done From 0a1ef9ce669e17527f25e4fc3fbf31fc2e5bb971 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 03:48:17 +0200 Subject: [PATCH 07/10] ci: advisory gate self-test rows for verdict, ignores-added and base-ref (Refs #5792) Replacing any of the three advisory_diff.sh calls in advisory_gate.sh with true left every self-test green (triage-worker). The self-test now runs the gate under four scenarios (clean, PR-introduced advisory, PR-added ignore, merge_group without base_sha) and three mutants, each of which goes red on its own scenario. Pmat-Ticket: PMAT-5792 Co-Authored-By: Claude Sonnet 5.5 --- contracts/advisory-gate-diff-v1.yaml | 2 +- scripts/ci/advisory_gate_selftest.sh | 56 +++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/contracts/advisory-gate-diff-v1.yaml b/contracts/advisory-gate-diff-v1.yaml index b955f8d615..652dfae7a0 100644 --- a/contracts/advisory-gate-diff-v1.yaml +++ b/contracts/advisory-gate-diff-v1.yaml @@ -92,7 +92,7 @@ falsification_tests: - id: FALSIFY-ADG-004 rule: "Same DB snapshot on both sides" prediction: "a DB entry published between the main run and the PR run does not appear in the delta" - test: "advisory_gate_selftest.sh: exactly one `cargo deny fetch`, both scans --offline (0.20) or --disable-fetch (older cargo-deny) with one --config" + test: "advisory_gate_selftest.sh: exactly one `cargo deny fetch`, both scans --offline (0.20) or --disable-fetch (older cargo-deny) with one --config; behavioural rows: a PR-introduced advisory makes the gate exit 1, a PR-added ignore exits 1, merge_group without base_sha exits 75, and replacing the verdict / ignores-added / base-ref call with `true` turns the matching row RED" if_fails: "DB refreshes read as PR-introduced advisories and re-create the stop" mutation: "sed out --offline, or add a second fetch before the base scan; the two 'ADG-004' mutant cases must turn RED (measured 2026-10-20)" - id: FALSIFY-ADG-005 diff --git a/scripts/ci/advisory_gate_selftest.sh b/scripts/ci/advisory_gate_selftest.sh index 159b2e3db8..7c5ca451af 100644 --- a/scripts/ci/advisory_gate_selftest.sh +++ b/scripts/ci/advisory_gate_selftest.sh @@ -30,6 +30,49 @@ run_gate() { # prints the stub's argv log cat "$log" } +gate_rc() { # prints the gate's EXIT CODE under a scenario: STUB_PR_FIND=1 (the PR tree's scan reports an error finding, rc 1), + # WIDEN=1 (the PR adds an advisory id to deny.toml ignore), EV=, MG= + local d repo rc=0 + d=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf -- '${d:?}'" RETURN + repo="$d/repo"; mkdir -p "$repo" "$d/bin" "$d/home" "$d/scripts" + cp "$GATE_SRC" "$d/scripts/advisory_gate.sh"; cp "$HERE/advisory_diff.sh" "$d/scripts/advisory_diff.sh" + cat >"$d/bin/cargo" <<'STUB' +#!/usr/bin/env bash +case "$*" in *--help*) exit 0;; esac +case "$* " in + *" check "*) + if [ "${STUB_PR_FIND:-0}" = 1 ] && [[ "$PWD" != *advgate-base-* ]]; then + echo '{"type":"diagnostic","fields":{"code":"vulnerability","severity":"error","advisory":{"id":"RUSTSEC-2026-0378"},"graphs":[{"Krate":{"name":"wasmtime","version":"48.0.6"}}]}}' >&2 + exit 1 + fi ;; +esac +exit 0 +STUB + chmod +x "$d/bin/cargo" + ( + cd "$repo" + git init -q . && printf '[advisories]\n' >deny.toml && git add deny.toml + git update-ref refs/remotes/origin/main "$(git -c user.name=t -c user.email=t@t commit-tree -m base "$(git write-tree)")" + git update-ref HEAD refs/remotes/origin/main + if [ "${WIDEN:-0}" = 1 ]; then printf 'ignore = ["RUSTSEC-2026-0378"]\n' >>deny.toml; fi + PATH="$d/bin:$PATH" HOME="$d/home" TMPDIR="$d" GITHUB_EVENT_NAME="${EV:-pull_request}" MG_BASE_SHA="${MG:-}" \ + STUB_PR_FIND="${STUB_PR_FIND:-0}" bash "$d/scripts/advisory_gate.sh" >/dev/null 2>&1 + ) || rc=$? + echo "$rc" +} + +# scenario table: name, wanted exit code, env assignments. Each row also names the advisory_diff.sh call that decides it. +scenarios() { # prints the first defect, or ok + local got + got=$(gate_rc); [ "$got" = 0 ] || { echo "clean PR: gate exit $got, want 0"; return; } + got=$(STUB_PR_FIND=1 gate_rc); [ "$got" = 1 ] || { echo "verdict: PR-introduced advisory: gate exit $got, want 1"; return; } + got=$(WIDEN=1 gate_rc); [ "$got" = 1 ] || { echo "ignores-added: PR widens deny.toml ignore: gate exit $got, want 1"; return; } + got=$(EV=merge_group MG='' gate_rc); [ "$got" = 75 ] || { echo "base-ref: merge_group without base_sha: gate exit $got, want 75"; return; } + echo ok +} + verdict_of() { # [old|new] (cargo-deny flavour the stub played); stdin: stub log -> "ok" or the first defect local log fetch checks nall nfd ncfg log=$(cat) @@ -74,6 +117,17 @@ main() { mutant 'scan not offline (0.20)' 's/--locked --offline --all-features/--locked --all-features/' 'ADG-004' mutant 'no cargo fetch --locked before the scans (0.20)' 's/cargo fetch --locked)/true)/' 'ADG-004' mutant 'second fetch before the base scan' 's|^BASERC=|cargo deny fetch --config "$W/deny.toml" db; BASERC=|' 'ADG-004' + # The gate's three advisory_diff.sh calls (base-ref, ignores-added, verdict) each decide a scenario: replaced by `true`, each must go red. + got=$(scenarios); [ "$got" = ok ] || { echo "SELF-TEST FAIL: real gate scenarios: $got" >&2; bad=1; } + smut() { # name sed-expr want-substring + sed -e "$2" "$GATE_SRC" >"$d/s.sh" + cmp -s "$d/s.sh" "$GATE_SRC" && { echo "SELF-TEST FAIL: mutant '$1' changed nothing" >&2; bad=1; return; } + m=$(GATE_SRC="$d/s.sh" scenarios) + [[ "$m" == *"$3"* ]] || { echo "SELF-TEST FAIL: mutant '$1' stayed green: $m" >&2; bad=1; } + } + smut 'verdict call replaced by true' 's|^bash "\$GATE" verdict .*|true|' 'verdict:' + smut 'ignores-added call replaced by true' 's|^bash "\$GATE" ignores-added .*|true|' 'ignores-added:' + smut 'base-ref call replaced by true' 's|^bash "\$HERE/advisory_diff.sh" base-ref .*|true|' 'base-ref:' # ADG-003 on the other two scan sites: the push path and the exemption-liveness guard. local f for f in "$GATE_SRC" "${EXEMPT_SRC:-scripts/check_deny_exemptions_live.sh}"; do @@ -83,7 +137,7 @@ main() { echo "SELF-TEST FAIL: ADG-003: $f scans advisories without --all-features" >&2; bad=1 fi done - [ "$bad" -eq 0 ] && echo "advisory_gate self-test: 2 real (cargo-deny 0.19 and 0.20 flags) + 5 mutants + 2 sites ok" + [ "$bad" -eq 0 ] && echo "advisory_gate self-test: 2 real (cargo-deny 0.19 and 0.20 flags) + 4 scenarios + 8 mutants + 2 sites ok" return "$bad" } From ce5f4823e1df3722c320dd043b9c9f13171595da Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 03:56:28 +0200 Subject: [PATCH 08/10] ci: advisory step runs the base gate first and refuses a re-pointed origin/main (Refs #5792) Opus-lane findings on #5908: (2) the PR-tree self-tests ran before the base gate in the same step and could write into the base copy; the gate now runs first. (1) merge_group: refs/remotes/origin/main must equal merge_group.base_sha before git archive, else exit 75. (4) advisory_step_selftest.sh now holds ci/sections.yml to the same rows as ci.yml, with new rows and three mutants (gate removed, gate moved last, MG guard removed). Not done here: (3) call cargo-deny directly instead of cargo deny, (5) check_deny_exemptions_live.sh:377 rc tolerance, and the pull_request-path ref check (base.sha lags a moving main). Pmat-Ticket: PMAT-5792 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 8 ++++- ci/sections.yml | 8 ++++- scripts/ci/advisory_step_selftest.sh | 52 ++++++++++++++++++---------- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9920d4ba67..e9b4fdc76b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -350,6 +350,11 @@ jobs: # The gate SCRIPTS run from the BASE ref copy (PMAT-5792); the workflow yaml and cargo config still come from the PR tree, so this is gate-content-reviewed, not self-judging-proof. # Bootstrap only: the merge that introduces the scripts has no base copy, so it uses its own. S="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/advgate-scripts-$$" + # merge_group: the base copy is taken only from the ref the merge queue named; a ref the PR tree re-pointed is refused, never used. + if [ -n "${MG_BASE_SHA:-}" ] && [ "$(git rev-parse -q --verify "refs/remotes/origin/main^{commit}" || true)" != "$MG_BASE_SHA" ]; then + echo "COULD-NOT-CHECK: refs/remotes/origin/main is not merge_group.base_sha $MG_BASE_SHA; the base gate is not taken from it" >&2 + exit 75 + fi if git cat-file -e refs/remotes/origin/main:scripts/ci/advisory_gate.sh 2>/dev/null; then mkdir -p "$S" git archive refs/remotes/origin/main scripts/ci | tar -x -C "$S" @@ -360,13 +365,14 @@ jobs: # One command per line under -e: the first non-zero exit ends the step with that status (no && chain, no saved rc). trap 'rm -rf -- "${S:?}"' EXIT set -e + # The base gate runs FIRST: a PR-tree self-test below can write into $S, so none may run before it. + bash "$G/advisory_gate.sh" bash "$G/advisory_diff.sh" --self-test bash scripts/ci/check_sov_advisories_off.sh --self-test bash scripts/ci/check_sov_advisories_off.sh bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" bash scripts/ci/advisory_step_selftest.sh bash scripts/ci/advisory_positive_control.sh - bash "$G/advisory_gate.sh" - name: Every deny.toml exemption must still be live env: GITHUB_TOKEN: ${{ github.token }} diff --git a/ci/sections.yml b/ci/sections.yml index 93eef8a840..1732424eff 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -2640,6 +2640,11 @@ jobs: # The gate SCRIPTS run from the BASE ref copy (PMAT-5792); the workflow yaml and cargo config still come from the PR tree, so this is gate-content-reviewed, not self-judging-proof. # Bootstrap only: the merge that introduces the scripts has no base copy, so it uses its own. S="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/advgate-scripts-$$" + # merge_group: the base copy is taken only from the ref the merge queue named; a ref the PR tree re-pointed is refused, never used. + if [ -n "${MG_BASE_SHA:-}" ] && [ "$(git rev-parse -q --verify "refs/remotes/origin/main^{commit}" || true)" != "$MG_BASE_SHA" ]; then + echo "COULD-NOT-CHECK: refs/remotes/origin/main is not merge_group.base_sha $MG_BASE_SHA; the base gate is not taken from it" >&2 + exit 75 + fi if git cat-file -e refs/remotes/origin/main:scripts/ci/advisory_gate.sh 2>/dev/null; then mkdir -p "$S" git archive refs/remotes/origin/main scripts/ci | tar -x -C "$S" @@ -2650,13 +2655,14 @@ jobs: # One command per line under -e: the first non-zero exit ends the step with that status (no && chain, no saved rc). trap 'rm -rf -- "${S:?}"' EXIT set -e + # The base gate runs FIRST: a PR-tree self-test below can write into $S, so none may run before it. + bash "$G/advisory_gate.sh" bash "$G/advisory_diff.sh" --self-test bash scripts/ci/check_sov_advisories_off.sh --self-test bash scripts/ci/check_sov_advisories_off.sh bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh" bash scripts/ci/advisory_step_selftest.sh bash scripts/ci/advisory_positive_control.sh - bash "$G/advisory_gate.sh" # Separate from the check above on purpose: a newly-FIXED upstream must # never fail anyone's build. It fails only this guard, whose remedy is # deleting a line. diff --git a/scripts/ci/advisory_step_selftest.sh b/scripts/ci/advisory_step_selftest.sh index 65925ec162..e57b0b8219 100755 --- a/scripts/ci/advisory_step_selftest.sh +++ b/scripts/ci/advisory_step_selftest.sh @@ -4,10 +4,11 @@ # .github/workflows/ci.yml, runs it in a throwaway repo whose origin/main holds stub scripts, and # requires the step's exit status to equal the stub's. A mutant (gate line removed) must NOT be red. # -# advisory_step_selftest.sh [WORKFLOW] WORKFLOW defaults to .github/workflows/ci.yml +# advisory_step_selftest.sh [WORKFLOW] WORKFLOW defaults to .github/workflows/ci.yml AND ci/sections.yml set -euo pipefail -wf=${1:-.github/workflows/ci.yml} +wfs=("${1:-.github/workflows/ci.yml}") +[ -n "${1:-}" ] || wfs+=(ci/sections.yml) # the generated-from copy is held to the same rows d=$(mktemp -d) # shellcheck disable=SC2064 trap "rm -rf -- '${d:?}'" EXIT @@ -37,12 +38,13 @@ make_repo() { # DIR -> repo with scripts/ci stubs committed on refs/remotes/orig run_step() { # SCRIPTFILE REPO -> step exit status local rc=0 - (cd "$2" && env -u RUNNER_TEMP TMPDIR="$d" GITHUB_TOKEN=x bash -c "$(cat "$1")" >/dev/null 2>&1) || rc=$? + (cd "$2" && env -u RUNNER_TEMP TMPDIR="$d" GITHUB_TOKEN=x MG_BASE_SHA="${MG:-}" bash -c "$(cat "$1")" >/dev/null 2>&1) || rc=$? echo "$rc" } # $1: gate rc, $2: first self-test rc -- set in the BASE copy (what the step actually runs) and in the tree -case_() { # name want gate_rc selftest_rc script +# $6: "" | right | wrong -- MG_BASE_SHA the merge queue names: right = origin/main's sha, wrong = another sha +case_() { # name want gate_rc selftest_rc script [mg] local name=$1 want=$2 got r r=$(mktemp -d "$d/repo-XXXXXX") make_repo "$r" @@ -53,26 +55,40 @@ case_() { # name want gate_rc selftest_rc script tree=$(git -C "$r" write-tree) c=$(git -C "$r" -c user.name=t -c user.email=t@t commit-tree "$tree" -m stub2) git -C "$r" update-ref refs/remotes/origin/main "$c" - got=$(run_step "$5" "$r") + case "${6:-}" in right) MG=$c ;; wrong) MG=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb ;; *) MG= ;; esac + got=$(MG=$MG run_step "$5" "$r") if [ "$got" -ne "$want" ]; then - echo "SELF-TEST FAIL: $name wanted step rc=$want, got $got" >&2 + echo "SELF-TEST FAIL: ${wf}: $name wanted step rc=$want, got $got" >&2 bad=1 fi } bad=0 -extract "$wf" >"$d/step.sh" -[ -s "$d/step.sh" ] || { echo "SELF-TEST FAIL: could not extract the 'Advisories must pass' step from $wf" >&2; exit 1; } -case_ 'gate rc 75 -> step RED with 75' 75 75 0 "$d/step.sh" -case_ 'gate rc 1 -> step RED with 1' 1 1 0 "$d/step.sh" -case_ 'advisory_diff self-test rc 3 -> step RED with 3' 3 0 3 "$d/step.sh" -case_ 'all green -> step green' 0 0 0 "$d/step.sh" -# mutant: the gate invocation removed from the step; a failing gate must then (wrongly) pass -- proving the cases above bind to it -grep -v 'advisory_gate.sh"$' "$d/step.sh" >"$d/mut.sh" -cmp -s "$d/step.sh" "$d/mut.sh" && { echo "SELF-TEST FAIL: mutant did not change the step (gate line not found)" >&2; bad=1; } -case_ 'MUTANT (gate line removed) must NOT be red on a failing gate' 0 75 0 "$d/mut.sh" -# mutant 2: the old `|| rc=$?` shape with the gate dropped from the chain is caught the same way (covered by the case above) +for wf in "${wfs[@]}"; do + extract "$wf" >"$d/step.sh" + [ -s "$d/step.sh" ] || { echo "SELF-TEST FAIL: could not extract the 'Advisories must pass' step from $wf" >&2; exit 1; } + case_ 'gate rc 75 -> step RED with 75' 75 75 0 "$d/step.sh" + case_ 'gate rc 1 -> step RED with 1' 1 1 0 "$d/step.sh" + case_ 'advisory_diff self-test rc 3 -> step RED with 3' 3 0 3 "$d/step.sh" + case_ 'all green -> step green' 0 0 0 "$d/step.sh" + # the BASE gate runs before any PR-tree self-test: with both red, the gate's status wins + case_ 'gate runs before the PR-tree self-tests (gate 75, self-test 3 -> 75)' 75 75 3 "$d/step.sh" + # merge_group: origin/main must be the ref the queue named; the PR tree cannot re-point it + case_ 'merge_group with origin/main == base_sha -> green' 0 0 0 "$d/step.sh" right + case_ 'merge_group with origin/main != base_sha -> RED 75' 75 0 0 "$d/step.sh" wrong + # mutant: the gate invocation removed from the step; a failing gate must then (wrongly) pass -- proving the cases above bind to it + grep -v 'advisory_gate.sh"$' "$d/step.sh" >"$d/mut.sh" + cmp -s "$d/step.sh" "$d/mut.sh" && { echo "SELF-TEST FAIL: ${wf}: mutant did not change the step (gate line not found)" >&2; bad=1; } + case_ 'MUTANT (gate line removed) must NOT be red on a failing gate' 0 75 0 "$d/mut.sh" + # mutant 2: the gate moved to the END of the step (the shape the opus lane flagged): with both red, the self-test's 3 now wins + { grep -v 'advisory_gate.sh"$' "$d/step.sh"; grep 'advisory_gate.sh"$' "$d/step.sh"; } >"$d/mut2.sh" + case_ 'MUTANT (gate last) must lose to the self-test status' 3 75 3 "$d/mut2.sh" + # mutant 3: the merge_group base_sha guard removed (the whole if-block): a re-pointed origin/main must then (wrongly) pass + sed '/^if \[ -n "\${MG_BASE_SHA/,/^fi$/d' "$d/step.sh" >"$d/mut3.sh" + cmp -s "$d/step.sh" "$d/mut3.sh" && { echo "SELF-TEST FAIL: ${wf}: mutant 3 did not change the step (MG guard not found)" >&2; bad=1; } + case_ 'MUTANT (MG guard removed) must NOT be red on a wrong base_sha' 0 0 0 "$d/mut3.sh" wrong +done if [ "$bad" -eq 0 ]; then - echo "advisory_step self-test: 4 cases + 1 mutant ok" + echo "advisory_step self-test: ${#wfs[@]} workflow(s) x (7 cases + 3 mutants) ok" fi exit "$bad" From 7b62bfce6b9c3d6d79766176cd5982f09b3baff4 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 04:06:50 +0200 Subject: [PATCH 09/10] ci: re-pin sovereign-ci to paiml/.github main dbd0488efe (was the head of #86) (Refs #5792) paiml/.github#86 merged 02:04:42Z as dbd0488efe2463161b34b2f75002baee5e308d2a. The upstream file hashes the same (sha256 40ab5f65...), so the vendored body is unchanged; only the pin in ci/sections.yml and the vendored header move. Pmat-Ticket: PMAT-5792 Co-Authored-By: Claude Sonnet 5.5 --- ci/sections.yml | 2 +- ci/vendor/sovereign-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ci/sections.yml b/ci/sections.yml index 1732424eff..d94387d4c8 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -18,7 +18,7 @@ # The old `ci:` job, verbatim: `uses:` is the pin ci/vendor/sovereign-ci.yml was # copied from (scripts/check_ci_reusable_workflow_pinned.sh checks both agree). sovereign-ci: - uses: paiml/.github/.github/workflows/sovereign-ci.yml@9078879912d106237a38137dee06be6ac6c04ac7 + uses: paiml/.github/.github/workflows/sovereign-ci.yml@dbd0488efe2463161b34b2f75002baee5e308d2a with: repo: ${{ github.event.repository.name }} enable_sccache: true diff --git a/ci/vendor/sovereign-ci.yml b/ci/vendor/sovereign-ci.yml index 2522882439..f1fc95ad1f 100644 --- a/ci/vendor/sovereign-ci.yml +++ b/ci/vendor/sovereign-ci.yml @@ -1,4 +1,4 @@ -# VENDORED (#4433): paiml/.github .github/workflows/sovereign-ci.yml @ 9078879912d106237a38137dee06be6ac6c04ac7 +# VENDORED (#4433): paiml/.github .github/workflows/sovereign-ci.yml @ dbd0488efe2463161b34b2f75002baee5e308d2a # sha256 of the upstream file: 40ab5f65bfe48630b475d124b1a492a7fcbaa4a9181b4af151889579ceb19c30 # Not a workflow here: scripts/ci/fat_driver.py runs its jobs as sections sov. of # the x86-main fat job. Do not edit by hand -- re-vendor from a new pin, and move the From c98857802600e910a14fe489f7fd94d4c59d7a41 Mon Sep 17 00:00:00 2001 From: t Date: Thu, 22 Oct 2026 04:29:13 +0200 Subject: [PATCH 10/10] fix(contracts): close FALSIFY-ADG test id gap (006) so fail_fast ids and contract_data_integrity pass (Refs #5792) Co-Authored-By: Claude Sonnet 5.5 --- contracts/advisory-gate-diff-v1.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/advisory-gate-diff-v1.yaml b/contracts/advisory-gate-diff-v1.yaml index 652dfae7a0..87be729e14 100644 --- a/contracts/advisory-gate-diff-v1.yaml +++ b/contracts/advisory-gate-diff-v1.yaml @@ -101,14 +101,14 @@ falsification_tests: test: "case table over the ticket emitter" if_fails: "main-resident advisories sit unowned" mutation: "set the due time to 8 h; the case must turn RED" - - id: FALSIFY-ADG-007 + - id: FALSIFY-ADG-006 rule: "No second scanner blocks a PR on main's advisories" prediction: "sov.lint and sov.security skip plain cargo deny advisories / cargo audit (advisories_diff_only: true in the vendored sovereign-ci)" test: "scripts/ci/check_sov_advisories_off.sh --self-test, 4 cases, then the real tree" if_fails: "the vendored scans still stop every PR on an advisory already on main (the #5793 REJECT)" mutation: "set advisories_diff_only: false in ci/sections.yml, or change the vendored '= true' test; the matching case must turn RED (measured 2026-10-20)" - - id: FALSIFY-ADG-008 + - id: FALSIFY-ADG-007 rule: "The CI step fails closed on the gate's exit status" prediction: "the 'Advisories must pass' step of ci.yml exits with the gate's (or any earlier self-test's) non-zero status; a step without the gate line does not" test: "scripts/ci/advisory_step_selftest.sh, 4 cases + 1 mutant: the step's run block is extracted from ci.yml and run against stub scripts on a throwaway origin/main; gate rc 75 gives 75, gate rc 1 gives 1, a failing self-test gives its rc, all green gives 0"