Add the migration gate and its differential probe
A migration's failure mode is SILENT DIVERGENCE, so the load-bearing arm compares decisions rather than inspecting code: the probe drives the PUBLIC API over 19 grant configurations x 23 operations, prints one line per observable decision, and the gate runs it twice -- once against this branch's module and once against the base module checked out over it -- requiring the two transcripts to be byte-identical. 460 decisions, 0 disagreements.
The corpus covers what the migration could plausibly break: sibling prefixes that must not leak, a symlink escaping its root, argv[0] exact vs basename, exec:full absorbing a later allowlist in both orders, a comma-bearing real directory, and argv[0] values containing a comma or a colon -- which must DENY rather than raise, since a check that throws where a caller expects #f reads as permitted.
The comparison carries its own positive control: a deliberately altered transcript must be detected, in the same run, or "identical" means only that diff was never able to speak.
PROVENANCE IS PROVEN BEHAVIOURALLY AND FROM BOTH SIDES, because none of this is trustworthy otherwise: (sigil grant) is unreleased, the build needs a dev redirect, dev-redirects.sgl is not picked up automatically, and Sigil is late-binding -- this module compiled clean against a locked sigil-stdlib 0.17.19 that contains no grant.sgl at all. A COMPILE line and a zero exit prove nothing here. So the gate asks the runtime to accept what only the fixed module accepts AND to refuse what it should still refuse; a merely permissive module fails the second.
The suite's one failure is NAMED rather than tolerated by count, and it was measured on the base module, not assumed: it is a missing runtime primitive, process-spawn-pipe-merged, unrelated to grants.
Structural arms hold the migration to its own claims: path-under? is gone rather than adapted, realpath and canonical-target are retained, and the export list is exactly the original 8 -- an addition is as much an API change as a removal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DYn8wxSdjveEgK9NRxFcNA
scripts/gate-system-grant-migration | 280 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
scripts/grant-differential-probe.sgl | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 390 insertions(+)scripts/gate-system-grant-migrationadded
#!/bin/sh# gate-system-grant-migration - the gate for rebuilding# (sigil system grant) as a profile over (sigil grant).## Outcomes, four distinct things:# MIGRATION COMPLETE PASS (0) every check ran and passed# MIGRATION COMPLETE FAIL (1) a check ran and failed# MIGRATION TIMED-OUT (2) a bounded step did not finish# MIGRATION SETUP-FAILED (3) the subject was never established## A MIGRATION'S FAILURE MODE IS SILENT DIVERGENCE, so the load-bearing# arm is a DIFFERENTIAL: the migrated grant-check must agree with the# pre-migration one decision for decision. The agreement count is# printed and asserted non-zero, because "they agreed on nothing because# neither ran" and "they agreed on everything" are otherwise the same# colour.## Scratch goes to /tmp. Nothing is written under the repo except build# output.set -uREPO="$(cd "$(dirname "$0")/.." && pwd)"SCRATCH="$(mktemp -d /tmp/gate-sysgrant.XXXXXX)"FIXTURE="$SCRATCH/fixture"TIMEOUT_S="${GATE_SYSGRANT_TIMEOUT:-900}"SIGIL="${GATE_SYSGRANT_SIGIL:-/home/daviwil/Projects/Code/sigil/task-sigil-grant-p1-algebra/build/dev/bin/sigil}"REDIRECTS="${GATE_SYSGRANT_REDIRECTS:-/tmp/grant-dev-redirects.sgl}"PROBE="${GATE_SYSGRANT_PROBE:-$REPO/scripts/grant-differential-probe.sgl}"MODULE="$REPO/src/sigil/system/grant.sgl"BASE_REF="${GATE_SYSGRANT_BASE:-944b328}"say() { echo "$@"; }rule() { say "------------------------------------------------------------"; }doubt() { say "" say "BEFORE READING THIS AS A PRODUCT DEFECT: suspect this gate." say " Check that the sigil it used ($SIGIL) is the one you think, that" say " the redirect actually applied (step 2 proves it BEHAVIOURALLY --" say " a clean build proves nothing here, Sigil is late-binding), and" say " that the decision counts below are non-zero."}dump_counts() { if [ -s "$SCRATCH/migrated.txt" ] || [ -s "$SCRATCH/baseline.txt" ]; then say "" say "--- instrument state at exit ---" say " baseline decision lines: $(grep -c '^CHK|' "$SCRATCH/baseline.txt" 2>/dev/null || echo 0)" say " migrated decision lines: $(grep -c '^CHK|' "$SCRATCH/migrated.txt" 2>/dev/null || echo 0)" say "--- end ---" fi}setup_failed() { rule; say "SETUP-FAILED: $*" say "Nothing was tested. This is NOT a pass and NOT a failure." dump_counts; doubt say "Logs kept at: $SCRATCH" say "MIGRATION SETUP-FAILED"; exit 3}red() { rule; say "FAILED: $*" dump_counts; doubt say "Logs kept at: $SCRATCH" say "MIGRATION COMPLETE FAIL"; exit 1}timed_out() { rule; say "TIMED-OUT after ${TIMEOUT_S}s: $*" say "" say "CHECKS THAT DID NOT RUN:" say " - the differential (baseline vs migrated decisions)" say " - the existing test-system suite" dump_counts; doubt say "Logs kept at: $SCRATCH" say "MIGRATION TIMED-OUT"; exit 2}restore_module() { git -C "$REPO" checkout HEAD -- src/sigil/system/grant.sgl 2>/dev/null}trap 'restore_module' EXIT HUP INT TERMfor t in awk grep sed timeout git sha256sum diff; do command -v "$t" >/dev/null 2>&1 || setup_failed "required tool '$t' is not on PATH"done[ -x "$SIGIL" ] || setup_failed "no sigil binary at $SIGIL"[ -f "$REDIRECTS" ] || setup_failed "no redirect file at $REDIRECTS -- (sigil grant) is unreleased, so the build needs one"[ -f "$PROBE" ] || setup_failed "no differential probe at $PROBE"[ -f "$MODULE" ] || setup_failed "module source missing: $MODULE"# The module must be COMMITTED before this gate runs: the differential# checks the base version out over it and restores with `git checkout# HEAD --`, which restores to the LAST COMMIT. An uncommitted module# would be destroyed by that restore, and its absence afterwards looks# exactly like a clean restore.[ -z "$(git -C "$REPO" status --porcelain src/sigil/system/grant.sgl)" ] || setup_failed \ "src/sigil/system/grant.sgl has uncommitted changes. This gate swaps it against $BASE_REF and restores with 'git checkout HEAD --', which would revert your work rather than the swap. Commit first."rulesay "GATE gate-system-grant-migration"say " repo: $REPO"say " sigil: $SIGIL"say " version: $("$SIGIL" --version 2>&1 | head -1)"say " redirects: $REDIRECTS"say " base ref: $BASE_REF ($(git -C "$REPO" rev-parse --short "$BASE_REF"))"rulesay "STEP 1/5 fixture"mkdir -p "$FIXTURE/allow" "$FIXTURE/allowed" "$FIXTURE/a,b" "$SCRATCH/outside"ln -sfn "$SCRATCH/outside" "$FIXTURE/link-out"[ -d "$FIXTURE/a,b" ] || setup_failed "could not create the comma-bearing fixture directory"[ -L "$FIXTURE/link-out" ] || setup_failed "could not create the escaping symlink"say " root: $FIXTURE (allow/ allowed/ 'a,b'/ link-out -> outside/)"# PROVENANCE, behaviourally and from BOTH sides. The redirect is not# picked up automatically and has been seen not to apply, silently,# with the build still compiling clean -- Sigil is late-binding, so a# COMPILE line and a zero exit say nothing about which (sigil grant) is# in force. Two-sided so a merely permissive module cannot pass.say "STEP 2/5 provenance of (sigil grant)"P1="$("$SIGIL" eval '(begin (import (sigil grant)) (display (grant->specs (grant-from-specs (list "fs:rw:/x/a,b")))))' 2>&1)"case "$P1" in *"fs:rw:/x/a,b"*) say " accepts a comma-bearing fs path: ok" ;; *) setup_failed "the (sigil grant) in force REFUSES a comma-bearing fs path, so it predates the fix. Got: $P1" ;;esacP2="$("$SIGIL" eval '(begin (import (sigil grant)) (display (guard (e (#t "REFUSED")) (make-grant (list (list (quote exec) (quote allowlist) (list "a,b")))))))' 2>&1)"case "$P2" in *REFUSED*) say " still refuses a comma in an exec command: ok" ;; *) setup_failed "the (sigil grant) in force ACCEPTS a comma in an exec command; it is not the module under test. Got: $P2" ;;esacsay "STEP 3/5 differential vs $BASE_REF"MINE_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"say " migrated module sha256: $MINE_HASH"# `sigil eval` has NO --redirects and resolves the RELEASED dependency,# so it can see neither the redirected (sigil grant) nor this package's# own source. `sigil test <file>` is the package-aware runner that can.run_probe() { GRANT_DIFF_ROOT="$FIXTURE" timeout "$TIMEOUT_S" \ "$SIGIL" test --redirects "$REDIRECTS" "$PROBE" > "$1.raw" 2>&1 rc=$? [ "$rc" -ne 124 ] || timed_out "the differential probe did not finish" # Keep ONLY the probe's own lines, and say why rather than filtering # silently: the runner's chrome carries per-run durations and temp # paths, which differ between two runs of the SAME code and would # manufacture a divergence. The probe's prefixes are exactly the # observable decisions. The surviving count is asserted non-zero # below, so this filter cannot quietly empty the comparison. sed 's/\x1b\[[0-9;]*m//g' "$1.raw" \ | grep -E '^(ADD|CHK|LOG|DECISIONS|GRANTS-PRED|PRINCIPAL)' > "$1" return "$rc"}run_probe "$SCRATCH/migrated.txt" || { say "--- probe output tail ---"; tail -20 "$SCRATCH/migrated.txt" red "the differential probe failed against the MIGRATED module"}git -C "$REPO" checkout "$BASE_REF" -- src/sigil/system/grant.sgl || setup_failed "could not check out the base module"BASE_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"say " baseline module sha256: $BASE_HASH"[ "$BASE_HASH" != "$MINE_HASH" ] || setup_failed \ "the baseline and migrated modules are the SAME FILE. The differential would compare a thing to itself and agree for the wrong reason."run_probe "$SCRATCH/baseline.txt" || { say "--- probe output tail ---"; tail -20 "$SCRATCH/baseline.txt" restore_module red "the differential probe failed against the BASELINE module"}restore_moduleNOW_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"[ "$NOW_HASH" = "$MINE_HASH" ] || red \ "the migrated module was NOT restored after the differential (hash $NOW_HASH, expected $MINE_HASH)"say " module restored, verified against its pre-swap hash"MIG_N="$(grep -c '^CHK|' "$SCRATCH/migrated.txt")"BASE_N="$(grep -c '^CHK|' "$SCRATCH/baseline.txt")"say " decisions compared: migrated $MIG_N / baseline $BASE_N"[ "$MIG_N" -gt 0 ] || red "the migrated probe produced ZERO decisions; the differential compared nothing"[ "$BASE_N" -gt 0 ] || red "the baseline probe produced ZERO decisions; the differential compared nothing"[ "$MIG_N" -eq "$BASE_N" ] || red "the two probes produced different numbers of decisions ($MIG_N vs $BASE_N)"if ! diff -u "$SCRATCH/baseline.txt" "$SCRATCH/migrated.txt" > "$SCRATCH/diff.txt" 2>&1; then say "--- divergence (baseline vs migrated) ---" head -40 "$SCRATCH/diff.txt" red "the migrated engine DISAGREES with the pre-migration engine"fisay " transcripts identical: $MIG_N decisions agree, 0 disagree"# A differential that agrees is meaningful only if it COULD disagree.CTL="$SCRATCH/control.txt"sed 's/^CHK|pty|/CHK|PLANTED|/' "$SCRATCH/migrated.txt" > "$CTL"if diff -q "$SCRATCH/baseline.txt" "$CTL" >/dev/null 2>&1; then red "the differential's comparison is BLIND: a deliberately altered transcript still compared equal"fisay " comparison positive control: an altered transcript IS detected"say "STEP 4/5 existing test-system suite"timeout "$TIMEOUT_S" "$SIGIL" test --redirects "$REDIRECTS" > "$SCRATCH/suite.log" 2>&1SRC=$?[ "$SRC" -ne 124 ] || timed_out "the test-system suite did not finish"SUM="$(sed 's/\x1b\[[0-9;]*m//g' "$SCRATCH/suite.log" | grep -E '^ Tests:' | tail -1)"[ -n "$SUM" ] || red "the suite printed no 'Tests:' summary line"PASSED="$(echo "$SUM" | sed -E 's/.*Tests:[[:space:]]*([0-9]+) passed.*/\1/')"TOTAL="$(echo "$SUM" | sed -E 's/.*\(([0-9]+) total\).*/\1/')"say " $PASSED passed of $TOTAL"[ "$TOTAL" -gt 0 ] || red "the suite reported 0 tests"# The known pre-existing failure is NAMED, not tolerated by count. It is# a missing runtime primitive unrelated to grants, and it was MEASURED# on the base module rather than assumed.KNOWN="process sessions optionally merge stderr into captured output"FAILED=$((TOTAL - PASSED))if [ "$FAILED" -gt 0 ]; then # Take the names from the runner's "Failed tests:" block ONLY. # An earlier version matched `^ <any char> ` and so swallowed every # PASSING line too (they are " <checkmark> name"), reporting 37 # passes as unexpected failures. Anchoring on the block is what makes # this a list of failures rather than a list of lines. UNEXPECTED="$(sed 's/\x1b\[[0-9;]*m//g' "$SCRATCH/suite.log" \ | awk '/^ Failed tests:/{f=1;next} f&&/^━|^$/{f=0} f{print}' \ | sed 's/^[[:space:]]*•[[:space:]]*//' \ | grep -v "$KNOWN")" [ -z "$UNEXPECTED" ] || { say "--- unexpected failures ---"; echo "$UNEXPECTED"; red "$FAILED failing, and not all are the known pre-existing one"; } say " $FAILED failing, all of them the known pre-existing:" say " '$KNOWN'" say " (measured on the BASE module too -- it fails identically there)"fisay "STEP 5/5 structural"CTLHITS="$(grep -c 'grant-check' "$MODULE")"[ "$CTLHITS" -gt 0 ] || setup_failed "structural grep is blind: 0 hits for a token that IS in $MODULE"say " grep positive control: $CTLHITS hits"UNDER="$(grep -c 'path-under?' "$MODULE")"[ "$UNDER" -eq 0 ] || red "path-under? is still present ($UNDER refs). It was to be DELETED, not adapted -- if the algebra does not cover it, say so rather than keeping a second implementation."say " path-under? deleted: ok"for keep in realpath canonical-target; do n="$(grep -c "$keep" "$MODULE")" [ "$n" -gt 0 ] || red "$keep is gone from the profile. Canonicalisation is this module's reason to exist; the algebra is pure and cannot do it."donesay " realpath + canonical-target retained: ok"# Extract the export block into a list of bare symbols and compare by# EXACT STRING. An earlier version pattern-matched `^ <sym>$` and# reported `grant-decisions` missing when it is plainly there — the last# export carries the closing paren (`grant-decisions)`). A false red,# caused by matching layout instead of content; symbols containing `?`# and `!` also make a regex the wrong tool here.# Range runs from `(export` to `(begin`. NOT to the first blank line:# the export block is grouped with blank lines between sections, so a# blank-line terminator captured 4 of 8 symbols and reported the rest# missing — a second false red from the same habit of matching layout.awk '/^ \(export/{f=1} /^ \(begin/{f=0} f' "$MODULE" \ | sed -e 's/;;.*//' -e 's/[()]//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ | grep -v '^$' | grep -v '^export$' > "$SCRATCH/exports.txt"NEXP="$(wc -l < "$SCRATCH/exports.txt")"[ "$NEXP" -gt 0 ] || setup_failed "could not extract any export names from $MODULE"say " exports extracted: $NEXP"for sym in make-grants grant-allow-all grants? grants-principal grant-add! grant-check grant-assert! grant-decisions; do grep -qxF "$sym" "$SCRATCH/exports.txt" || red "export '$sym' is missing from the module's export list"donesay " all 8 public exports present: ok"# And nothing NEW is exported: the API is unchanged in both directions,# so a consumer cannot start depending on something this migration# happened to expose.[ "$NEXP" -eq 8 ] || red "the module exports $NEXP symbols, not 8. The public API was to be UNCHANGED; an addition is as much a change as a removal."say " exactly 8, nothing added: ok"rulesay "Logs kept at: $SCRATCH"say "MIGRATION COMPLETE PASS"exit 0scripts/grant-differential-probe.sgladded
;;; Differential probe for the (sigil system grant) migration.;;;;;; Prints one line per observable decision. Run once against the;;; pre-migration module and once against the migrated one; the two;;; transcripts must be byte-identical. Every line is a decision the;;; PUBLIC API produced -- nothing here reaches inside the module.(import (sigil io) (sigil process) (sigil fs) (sigil path) (sigil string) (sigil system grant))(define ROOT (getenv "GRANT_DIFF_ROOT"))(define (outcome thunk) (guard (e (#t "RAISED")) (let ((v (thunk))) (if v "#t" "#f"))));; --- the grant configurations under test -------------------------;; Each is a list of specs handed to grant-add! in order. Ordering;; matters for the exec-full-absorbs-allowlist case, so both orders;; are present.(define configs (list (cons "empty" '()) (cons "pty" (list "pty:on")) (cons "exec-allow" (list "exec:allowlist:ls,git")) (cons "exec-full" (list "exec:full")) (cons "full-then-al" (list "exec:full" "exec:allowlist:ls")) (cons "al-then-full" (list "exec:allowlist:ls" "exec:full")) (cons "two-allow" (list "exec:allowlist:ls" "exec:allowlist:git")) (cons "fs-ro-root" (list (string-append "fs:ro:" ROOT))) (cons "fs-rw-root" (list (string-append "fs:rw:" ROOT))) (cons "fs-rw-allow" (list (string-append "fs:rw:" (path-join ROOT "allow")))) (cons "fs-both" (list (string-append "fs:ro:" ROOT) (string-append "fs:rw:" (path-join ROOT "allow")))) (cons "fs-nested" (list (string-append "fs:rw:" ROOT) (string-append "fs:ro:" (path-join ROOT "allow")))) (cons "comma-dir" (list (string-append "fs:rw:" (path-join ROOT "a,b")))) (cons "bad-relative" (list "fs:rw:relative/path")) (cons "bad-missing" (list (string-append "fs:rw:" (path-join ROOT "no-such-dir")))) (cons "bad-mode" (list "fs:xx:/tmp")) (cons "bad-spec" (list "nonsense")) (cons "bad-kind-net" (list "net:connect:*")) (cons "bad-kind-eval" (list "eval:on"))));; --- the operations checked against each ------------------------(define (ops) (list (list 'pty #t) (list 'exec "ls") (list 'exec "git") (list 'exec "rm") (list 'exec "/tmp/evil/ls") ; basename must NOT match (list 'exec "") ; empty argv[0] (list 'exec "we,ird") ; a comma: must DENY, never raise (list 'exec "we:ird") ; a colon: must DENY, never raise (list 'fs-read ROOT) (list 'fs-write ROOT) (list 'fs-read (path-join ROOT "allow")) (list 'fs-write (path-join ROOT "allow" "x")) (list 'fs-read (path-join ROOT "allowed" "x")) ; sibling prefix, must NOT leak (list 'fs-write (path-join ROOT "allowed" "x")) (list 'fs-read (path-join ROOT "a,b" "f")) ; comma-bearing real dir (list 'fs-write (path-join ROOT "a,b" "f")) (list 'fs-read (path-join ROOT "link-out")) ; symlink escaping the root (list 'fs-write (path-join ROOT "link-out" "x")) (list 'fs-read "/etc/hosts") (list 'fs-write "/etc/passwd") (list 'fs-read (path-join ROOT "nope")) ; nonexistent read (list 'fs-write (path-join ROOT "nope")) ; nonexistent write (parent exists) (list 'unknown-op "x")))(define decisions 0)(for-each (lambda (cfg) (let ((name (car cfg)) (specs (cdr cfg))) ;; grant-add! outcomes are observable behaviour too. (let ((g (make-grants principal: "diff"))) (for-each (lambda (s) (println "ADD|~a|~a" name (outcome (lambda () (grant-add! g s) #t)))) specs) (for-each (lambda (o) (set! decisions (+ decisions 1)) (println "CHK|~a|~a|~s|~a" name (car o) (cadr o) (outcome (lambda () (grant-check g (car o) (cadr o)))))) (ops)) ;; the audit log must keep counting the same way (println "LOG|~a|~a" name (length (grant-decisions g)))))) configs);; The allow-all posture is constructed differently and gets its own pass.(let ((g (grant-allow-all principal: "diff"))) (for-each (lambda (o) (set! decisions (+ decisions 1)) (println "CHK|allow-all|~a|~s|~a" (car o) (cadr o) (outcome (lambda () (grant-check g (car o) (cadr o)))))) (ops)))(println "GRANTS-PRED|~a|~a" (outcome (lambda () (grants? (make-grants)))) (outcome (lambda () (grants? (vector 'nope)))))(println "PRINCIPAL|~a" (grants-principal (make-grants principal: "who")))(println "DECISIONS ~a" decisions)