anza-ed25519-verified/verification/selftest-correspondence.sh
mrwulf 6b79ac835c verification: derive lift dependencies instead of hand-keeping them
Round-9 swept all ten instruments per fork instead of only the two buttons,
and found selftest-axgate.sh red in all four. Root cause, identical to the
KERNLOG defect fixed the day before: a self-test lifts one phase out of
check.sh and supplies its variables from a HAND-WRITTEN preamble, and the
phase later grew a dependency nobody told the preamble about.

Phase 2b stopped globbing Proofs/*.olean and started reading $PROOFS by
membership. Bash does not error on an unset array under `set -u` — it
expands to nothing — so the gate silently received

    expected := [".olean"]

one entry with an empty name, and rejected the baseline via its own
fail-closed absence check. Exit code right, reason wrong.

  · lift-guard.sh (new, pinned): derives every variable the lifted payload
    reads, subtracts what the driver defines, and fails AT LIFT TIME naming
    the remainder. Wired into all five lifting self-tests; selftest-shapes.sh
    drops its inline copy. One implementation, not five, since drifting out
    of sync is the entire failure mode.

  · selftest-axgate.sh: lifts PROOFS verbatim, and its case 2 now poisons a
    MANIFESTED leaf module rather than adding a stray file. The stray-file
    attack was a no-op against membership semantics — that is the dead-file
    gate's job, proven by selftest-harness case 8 — and it is also the
    weaker attack, since adding files to Proofs/ must clear a gate that
    editing an already-manifested module does not.

  · Phase 2c's accounting identity moves behind its own marker with its own
    ACCTFAIL verdict, so the phase is liftable by construction rather than
    by a self-test knowing where to stop. Truncating the lift range alone
    would have dropped Phase 2c's own verdict and left the test unable to fail.

  · The other four lifting self-tests assert on the PAYLOAD rather than the
    assembled driver, so a marker in the preamble cannot satisfy a check
    meant to prove the lift landed.

Certified: 8/8 self-tests green in all four forks (~212 assertions, none
failing); check.sh bytes unchanged and still matching the pin written before
the 3h08m round-9 sweep, whose 36 GREEN button rows therefore stand.
--audit-only re-run against the new HARNESS.sha256 in every fork.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 13:07:28 +02:00

174 lines
8.7 KiB
Bash
Executable file

#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# selftest-correspondence.sh — attacks check.sh Phase 0d.
#
# Phase 0d asserts HOW each external the extraction asks for is answered: with
# an assumption in the hand-written model, or with a proof already in the
# extracted corpus. The second class is the one the documents make a claim
# about — the curve calls and curve types are said to resolve to the proven
# model's own definitions rather than to axioms — and that claim was prose
# until this phase existed.
#
# 0 control: the committed table matches the files
# 1 the extraction asks for something NOTHING provides -> UNRESOLVED
# 2 a PROVEN external answered by an axiom in the model instead. This is the
# attack that matters: a proof silently downgraded to an assumption, in a
# name whose spelling does not change anywhere else.
# 3 a row deleted from the committed table -> drift
# 4 a row's verdict edited in the committed table -> drift
#
# No Lean: Phase 0d is pure text over gen/. Seconds, not minutes.
# ─────────────────────────────────────────────────────────────────────────────
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
FAILURES=0
STASH="$(mktemp -d)"
cleanup() {
[ -f "$STASH/corr" ] && cp "$STASH/corr" "$HERE/MODEL-CORRESPONDENCE.txt"
[ -f "$STASH/model" ] && cp "$STASH/model" "$HERE/$MODEL_REL"
[ -f "$STASH/tmpl" ] && cp "$STASH/tmpl" "$HERE/$TMPL_REL"
rm -rf "$STASH"
}
# Derive the victims from this repo rather than naming them: the forks do not
# share a gen/ layout (anza has no CurveSig crate at all, so it has no PROVEN
# rows and case 2 does not apply there).
TMPL_REL=$(cd "$HERE" && ls gen/*/FunsExternal_Template.lean | head -1)
MODEL_REL="${TMPL_REL/_Template/}"
PROVEN_ROW=$(grep -m1 '|PROVEN$' "$HERE/MODEL-CORRESPONDENCE.txt" || true)
trap cleanup EXIT INT TERM
cp "$HERE/MODEL-CORRESPONDENCE.txt" "$STASH/corr"
cp "$HERE/$MODEL_REL" "$STASH/model"
cp "$HERE/$TMPL_REL" "$STASH/tmpl"
# Phase 0d lifted from the shipping button.
awk '/^# ── Phase 0d/{f=1} f&&/^# ── (Phase 1|Phases end)/{exit} f{print}' \
"$HERE/check.sh" > "$STASH/payload.sh"
{ echo 'set -euo pipefail'
echo "HERE=\"$HERE\""
cat "$STASH/payload.sh"
} > "$STASH/p0d.sh"
# Assert on the PAYLOAD, not the concatenation: a marker appearing in the
# preamble would otherwise satisfy a check meant to prove the lift landed.
for want in 'Phase 0d' 'MODEL CORRESPONDENCE' 'model-correspondence.py'; do
grep -qF "$want" "$STASH/payload.sh" || {
echo "FATAL: the lifted driver has no '$want' — check.sh's phase markers moved."; exit 1; }
done
"$HERE/lift-guard.sh" "$STASH/payload.sh" "$STASH/p0d.sh" "check.sh Phase 0d" || exit 1
expect() { # expect <label> <want-rc> <want-substring>
local label="$1" want_rc="$2" want_txt="$3" out rc
out=$(bash "$STASH/p0d.sh" 2>&1); rc=$?
if [ "$rc" -ne "$want_rc" ]; then
echo "$label: exit $rc, expected $want_rc"; tail -6 <<<"$out" | sed 's/^/ /'
FAILURES=$((FAILURES+1)); return
fi
if ! grep -qF "$want_txt" <<<"$out"; then
echo "$label: exit code right, diagnostic wrong (rejected for the wrong reason)"
echo " wanted: $want_txt"; tail -6 <<<"$out" | sed 's/^/ /'
FAILURES=$((FAILURES+1)); return
fi
echo "$label"
}
echo "=== selftest-correspondence: attacking check.sh Phase 0d ==="
echo " template: $TMPL_REL"
expect "control: the committed table matches the files" 0 "answered by PROVEN definitions"
# ── 1. the extraction asks for something nothing provides ──────────────────
printf '\naxiom selftest_unprovided_external : Nat\n' >> "$HERE/$TMPL_REL"
expect "case 1: an external nothing provides" 1 "MODEL CORRESPONDENCE FAILED"
cp "$STASH/tmpl" "$HERE/$TMPL_REL"
# ── 2. a proof silently downgraded to an assumption ────────────────────────
# Answer a PROVEN external with an axiom in the model. The name does not change
# anywhere; only the way it is answered does. Nothing else in the button sees
# this: the byte pins still match their files, the compiler is content because
# the signature is unchanged, and no certificate's cone moves unless something
# happens to depend on it.
if [ -n "$PROVEN_ROW" ]; then
PROVEN_NAME=$(cut -d'|' -f2 <<<"$PROVEN_ROW")
PROVEN_TMPL=$(cut -d'|' -f1 <<<"$PROVEN_ROW")
VICTIM_MODEL="gen/${PROVEN_TMPL}.lean"
cp "$HERE/$VICTIM_MODEL" "$STASH/model2"
printf '\naxiom %s : Nat\n' "$PROVEN_NAME" >> "$HERE/$VICTIM_MODEL"
expect "case 2: a PROVEN external downgraded to an assumption" 1 "MODEL CORRESPONDENCE DRIFT"
cp "$STASH/model2" "$HERE/$VICTIM_MODEL"
else
echo " · case 2 skipped: this fork's extraction has no PROVEN externals"
fi
# ── 3/4. the committed table itself ────────────────────────────────────────
# Delete the FIRST row, whatever its verdict. An earlier draft deleted the
# PROVEN rows, which was vacuous on anza — that fork's extraction has none, so
# nothing was removed, the table still matched, and the case passed by testing
# nothing. Pick a row every fork is guaranteed to have.
sed '0,/|/{/|/d}' "$STASH/corr" > "$HERE/MODEL-CORRESPONDENCE.txt"
if ! diff -q "$STASH/corr" "$HERE/MODEL-CORRESPONDENCE.txt" >/dev/null; then
expect "case 3: a row deleted from the committed table" 1 "MODEL CORRESPONDENCE DRIFT"
else
echo " ✗ case 3: the table was not actually modified — the case is vacuous"
FAILURES=$((FAILURES+1))
fi
cp "$STASH/corr" "$HERE/MODEL-CORRESPONDENCE.txt"
sed -i '0,/|MODEL$/s/|MODEL$/|PROVEN/' "$HERE/MODEL-CORRESPONDENCE.txt"
expect "case 4: a verdict edited in the committed table" 1 "MODEL CORRESPONDENCE DRIFT"
cp "$STASH/corr" "$HERE/MODEL-CORRESPONDENCE.txt"
# ── 5/6. THE ROUND-7 FINDINGS, so they cannot regress ──────────────────────
# Both were real. Case 5 is GPT-5.6's constructive counterexample: a definition
# that exists ONLY inside a block comment was read as a real declaration, so the
# scanner reported PROVEN for a name Lean resolves to an axiom. Case 6 is the
# one that was live in four committed tables: Aeneas wraps long declarations,
# the old scanner required keyword and name on one physical line, and so it
# SILENTLY DROPPED them — nine to ten externals per fork had no row at all.
#
# Case 6 is the more important of the two. A gate that drops what it cannot
# read is worse than no gate: it prints green across a gap that is invisible in
# the diff. The scanner must now FAIL rather than skip.
CX=$(mktemp -d)
mkdir -p "$CX/gen/Forged"
printf 'axiom Forged.value : Nat\n' > "$CX/gen/Forged/FunsExternal_Template.lean"
printf 'axiom\n Forged.value : Nat\n' > "$CX/gen/Forged/FunsExternal.lean"
printf '/-\nnamespace Forged\ndef value : Nat := 0\nend Forged\n-/\n' > "$CX/gen/Forged/Funs.lean"
OUT=$(python3 "$HERE/model-correspondence.py" "$CX" 2>&1)
if grep -q 'Forged.value|MODEL' <<<"$OUT"; then
echo " ✓ case 5: a definition inside a block comment is not read as a declaration"
else
echo " ✗ case 5: comment-only definition mis-read — scanner says:"; sed 's/^/ /' <<<"$OUT"
FAILURES=$((FAILURES+1))
fi
printf 'axiom\n Forged.wrapped\n :\n Nat\n' >> "$CX/gen/Forged/FunsExternal_Template.lean"
OUT=$(python3 "$HERE/model-correspondence.py" "$CX" 2>&1); RC=$?
if [ "$RC" -ne 0 ] && grep -q 'UNRESOLVED\|Forged.wrapped' <<<"$OUT"; then
echo " ✓ case 6: a declaration whose name wraps to the next line is SEEN, not dropped"
else
echo " ✗ case 6: wrapped declaration dropped or mis-handled (rc=$RC):"; sed 's/^/ /' <<<"$OUT"
FAILURES=$((FAILURES+1))
fi
printf 'axiom\n' > "$CX/gen/Forged/FunsExternal_Template.lean"
OUT=$(python3 "$HERE/model-correspondence.py" "$CX" 2>&1); RC=$?
if [ "$RC" -eq 2 ] && grep -q 'fails closed' <<<"$OUT"; then
echo " ✓ case 7: an unparseable declaration stops the scanner (exit 2), never silence"
else
echo " ✗ case 7: unparseable declaration did not fail closed (rc=$RC)"
FAILURES=$((FAILURES+1))
fi
rm -rf "$CX"
expect "restored: the table matches again" 0 "answered by PROVEN definitions"
echo ""
if [ "$FAILURES" -eq 0 ]; then
echo "SELFTEST PASSED — an external cannot change how it is answered, and a"
echo "proof cannot be downgraded to an assumption, without failing the button."
exit 0
fi
echo "SELFTEST FAILED: $FAILURES case(s) did not behave as claimed."
exit 1