dalek-ed25519-verified/verification/lift-guard.sh

113 lines
6 KiB
Bash
Raw Normal View History

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 11:07:28 +00:00
#!/usr/bin/env bash
# lift-guard.sh <payload> <driver> [<phase-label>]
#
# Every variable the LIFTED PAYLOAD reads must be one the DRIVER defines.
# Prints the offending names and exits 1 if any are missing.
#
# ───────────────────────────────────────────────────────────────────────────
# WHY THIS EXISTS — 2026-08-02
#
# Five of this repository's self-tests work by lifting one phase out of
# check.sh and running it standalone against a deliberately corrupted tree.
# That is the right design: the test then attacks the SHIPPING gate rather
# than a re-implementation of it. But a lifted phase is a fragment, and it
# reads variables its neighbours defined. Each self-test therefore carries a
# hand-written preamble supplying them.
#
# A hand-written preamble is a hand-kept list, and hand-kept lists drift. Twice
# in two days a phase grew a dependency and no preamble was told:
#
# · Phase 2c grew an accounting block reading $KERNLOG, a file Phase 2b
# creates. selftest-shapes.sh died on its first expansion under `set -u`.
# It could not pass on any fork from the moment that block was added.
#
# · Phase 2b changed from globbing Proofs/*.lean to reading the $PROOFS
# membership manifest — the spelling-versus-ownership fix ScalarPackSpec
# forced. selftest-axgate.sh's preamble was never told. Bash does NOT
# error on an unset array expansion under `set -u`; it expands to nothing,
# so `printf '"%s.olean", ' "${PROOFS[@]}"` silently produced
# expected := [".olean"]
# — one entry, empty name — and the gate's own fail-closed absence check
# rejected it. The baseline went red and both attack cases were then
# rejected for the WRONG REASON.
#
# Both failed loudly rather than passing vacuously, which is the only reason
# they were not false assurance. That is luck, not design: a missing variable
# that happens to make an ATTACK case die still looks like the attack being
# caught, and only the substring assertions in each `expect` helper stand
# between that and a green test measuring nothing.
#
# The fix for the CLASS is to stop maintaining the list by hand. This tool
# derives the requirement from the two artifacts themselves, so a phase that
# grows a new dependency fails AT LIFT TIME, naming it, instead of dying
# mid-run or — worse — passing for the wrong reason.
#
# WHAT IT IS NOT. This is a shell-text approximation, not a bash parser: it
# cannot see indirect expansion, `eval`, or a name built at runtime. It is a
# tripwire on the failure mode that actually occurred twice, not a proof of
# closure. Its answer is advisory in one direction only — it can miss a
# dependency, it does not invent one, and every name it reports is a name the
# payload genuinely mentions and the driver genuinely does not set.
# ───────────────────────────────────────────────────────────────────────────
set -euo pipefail
PAYLOAD="${1:?usage: lift-guard.sh <payload> <driver> [phase-label]}"
DRIVER="${2:?usage: lift-guard.sh <payload> <driver> [phase-label]}"
LABEL="${3:-the lifted phase}"
for f in "$PAYLOAD" "$DRIVER"; do
[ -s "$f" ] || { echo "FATAL: lift-guard: '$f' is missing or empty."; exit 1; }
done
UNBOUND=$(python3 - "$PAYLOAD" "$DRIVER" <<'PYGUARD'
import re, sys
payload = open(sys.argv[1]).read()
driver = open(sys.argv[2]).read()
# What the payload READS. Deliberately over-approximates: a name mentioned in a
# comment costs one lifted definition, a name missed costs a broken self-test.
reads = set(re.findall(r'\$\{?([A-Za-z_][A-Za-z0-9_]*)', payload))
# What the DRIVER defines, in every form these scripts actually use.
# An assignment may open a line OR follow `;`, `&&`, `||`, `then`, `do`, `{` —
# `TIMEOUT=$T; CORES="$C"` is one line with two of them, and a start-anchored
# pattern sees only the first. That over-strictness is not harmless: a guard
# that cries wolf gets edited away, and then it guards nothing.
assigns = set(re.findall(
r'(?:^|;|&&|\|\||\bthen\b|\bdo\b|\{)\s*([A-Za-z_][A-Za-z0-9_]*)=',
driver, re.M))
assigns |= set(re.findall(r'\b(?:export|declare|local|readonly)\s+(?:-\w+\s+)*'
r'([A-Za-z_][A-Za-z0-9_]*)', driver))
assigns |= set(re.findall(r'\bfor\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\b', driver))
# `read` binds names too, and it is almost never at end of line: the shape that
# matters here is `while read -r n; do`. An end-anchored pattern misses it and
# the guard then demands a definition for a loop variable the payload binds
# itself — a false alarm, which is the one failure a guard cannot afford.
for m in re.finditer(r'\bread\b((?:\s+-\w+)*(?:\s+[A-Za-z_][A-Za-z0-9_]*)+)', driver):
assigns |= set(re.findall(r'[A-Za-z_][A-Za-z0-9_]*', m.group(1)))
# Names the driver INHERITS rather than defines: the shell's own, and the ones
# `source ~/aeneas-toolchain/env.sh` puts in the environment. Keep this list
# short and justified — every entry is a hole in the guard.
ENV = {'PWD', 'HOME', 'PATH', 'IFS', 'PIPESTATUS', 'BASH_SOURCE', 'FUNCNAME',
'LINENO', 'RANDOM', 'SECONDS', 'OSTYPE', 'HOSTNAME', 'USER', 'SHELL',
'TMPDIR', 'LC_ALL', 'LANG', 'BASH_REMATCH', 'REPLY', 'PS4',
'AENEAS_HOME', 'LEAN_PATH', 'LEAN_MEM_MB', 'LEAN_TIMEOUT',
'LEAN_MAX_CORES'}
print(' '.join(sorted(n for n in reads - assigns - ENV if not n.isdigit())))
PYGUARD
)
if [ -n "$UNBOUND" ]; then
cat <<EOF
FATAL: $LABEL reads variables this lift does not define: $UNBOUND
Either lift their definitions too — VERBATIM from check.sh, with a
sed range, so this test cannot drift away from the button's idea of
them — or end the lift range before the block that uses them.
Do NOT stub them: a stub makes the test measure something the button
never runs, which is how a self-test becomes decoration.
EOF
exit 1
fi