#!/bin/bash # session-gate-selftest.sh - prove session-gate.sh against fixture repos. # ASCII only, bash 3.2 safe. Writes only under a mktemp directory. # # Every rule gets at least one repo that must be LET THROUGH beside the repos # that must be REFUSED, because a gate proven only by refusals is a gate that # refuses everything and proves nothing. The live-session count is fed from a # fake `claude` on PATH, so the suite never depends on what happens to be # running on the machine - and the fake records its argv, so the real call # shape (`agents --json --cwd `) is pinned, not assumed. set -u export LC_ALL=C DIR="$(cd "$(dirname "$0")" && pwd)" GATE="$DIR/session-gate.sh" PASS=0; FAIL=0 check() { if [ "$2" -eq 0 ]; then PASS=$((PASS+1)); echo " ok - $1"; else FAIL=$((FAIL+1)); echo " FAIL - $1"; fi; } T="$(mktemp -d)" trap 'chmod -R u+rwx "$T" 2>/dev/null; rm -rf "$T"' EXIT T="$(cd -P "$T" && pwd -P)" FAKEBIN="$T/fakebin" mkdir -p "$FAKEBIN" cat > "$FAKEBIN/claude" <<'FAKE' #!/bin/bash printf '%s\n' "$*" >> "$FAKE_LOG" if [ -n "${FAKE_AGENTS_FILE:-}" ]; then cat "$FAKE_AGENTS_FILE"; fi exit "${FAKE_AGENTS_EXIT:-0}" FAKE chmod +x "$FAKEBIN/claude" FAKE_LOG="$T/fake.log" printf '[]\n' > "$T/ag0" cat > "$T/ag1" <<'J' [ { "pid": 4242, "id": "aaaa1111", "cwd": "/x/demo/.claude/worktrees/w", "kind": "background", "startedAt": 1790406122536, "sessionId": "aaaa1111-0000-0000-0000-000000000000", "name": "demo-w", "status": "busy", "state": "working" } ] J printf '[{"id":"a","cwd":"/x/demo","kind":"background","sessionId":"a-1"},{"cwd":"/x/demo","kind":"interactive","sessionId":"b-2"}]\n' > "$T/ag2" printf 'hello\n' > "$T/agjunk" : > "$T/agempty" # run_gate : env knobs NOW (clock, default 12:00; NOW_UNSET=1 leaves # the variable out), AG (agents fixture, default ag0), AGX (fake exit), # GPATH (PATH for the gate, default fakebin first). run_gate() { : > "$FAKE_LOG" if [ "${NOW_UNSET:-0}" = 1 ]; then OUT="$(env -u SESSION_GATE_NOW PATH="${GPATH:-$FAKEBIN:/usr/bin:/bin}" FAKE_LOG="$FAKE_LOG" \ FAKE_AGENTS_FILE="${AG:-$T/ag0}" FAKE_AGENTS_EXIT="${AGX:-0}" \ /bin/bash "$GATE" "$@" 2>"$T/err")"; RC=$? else OUT="$(env PATH="${GPATH:-$FAKEBIN:/usr/bin:/bin}" SESSION_GATE_NOW="${NOW-12:00}" FAKE_LOG="$FAKE_LOG" \ FAKE_AGENTS_FILE="${AG:-$T/ag0}" FAKE_AGENTS_EXIT="${AGX:-0}" \ /bin/bash "$GATE" "$@" 2>"$T/err")"; RC=$? fi ERR="$(cat "$T/err")" } # denied : exit 2, nothing on stdout, exactly ONE stderr line naming the rule. denied() { [ "$RC" -eq 2 ] && [ -z "$OUT" ] && [ "$(printf '%s\n' "$ERR" | grep -c .)" -eq 1 ] \ && printf '%s' "$ERR" | grep -q "$1" } # allowed: exit 0, one ALLOW line on stdout, stderr empty. allowed() { [ "$RC" -eq 0 ] && [ -z "$ERR" ] && printf '%s' "$OUT" | grep -q '^session-gate: ALLOW' } GATE_OK='' GATE_R1='' GATE_R1M='' LOG_OK='## Logg | Dato | Hva | Bevegelse | Kvote | |---|---|---|---| | 26.09 | x | ja: first | 3% | | 26.09 | y | nei: nothing moved | 4% |' # mkplan : a repo dir holding PLAN.md. mkplan() { mkdir -p "$T/$1" printf '# PLAN - %s\n%s\n\n## Maal\nSomething.\n\n%s\n' "$1" "$2" "$3" > "$T/$1/PLAN.md" } echo "session-gate-selftest" # --- 0. CLI, help and the known-positive baseline -------------------------- run_gate -h [ "$RC" -eq 0 ]; check "-h exits 0" $? printf '%s' "$OUT" | grep -q 'checks that the fields are filled in, not that they were thought through' check "help states the caveat: filled in, not thought through" $? printf '%s' "$OUT" | grep -q 'retelling is the only control with real content' check "help states the caveat: the retelling is the only control with real content" $? printf '%s' "$OUT" | grep -q '0 = allow' && printf '%s' "$OUT" | grep -q '2 = deny' check "help documents exit 0 = allow and 2 = deny" $? run_gate denied usage; check "no arguments: deny (exit 2, one line)" $? run_gate --repo denied usage; check "--repo without a value: deny" $? run_gate --repo "$T" --bogus denied usage; check "unknown argument: deny" $? mkplan base "$GATE_OK" "$LOG_OK" run_gate --repo "$T/base" --kind other denied usage; check "--kind outside build|read: deny" $? run_gate --repo "$T/does-not-exist" denied repo; check "--repo that does not exist: deny" $? run_gate --repo "$T/base" allowed; check "KNOWN-POSITIVE: complete PLAN.md, 0 live sessions, daytime -> allow" $? run_gate --repo "$T/base" --kind build allowed; check "explicit --kind build on the same repo -> allow" $? LC_ALL=C grep -q '[^ -~ ]' "$GATE"; [ $? -eq 1 ] check "session-gate.sh is ASCII-only" $? [ -s "$GATE" ] && { grep -v '^[[:space:]]*#' "$GATE" | grep -Eq '(^|[^A-Za-z_-])(jq|python3?|node)([^A-Za-z_-]|$)'; [ $? -eq 1 ]; } check "no jq/python/node in executable lines (zero dependencies)" $? printf 'x=1\njq .\n' | grep -v '^[[:space:]]*#' | grep -Eq '(^|[^A-Za-z_-])(jq|python3?|node)([^A-Za-z_-]|$)' check "KNOWN-POSITIVE: the dependency grep finds a planted jq call" $? # --- 1. PLAN.md itself: fail-closed on absence and unreadability ----------- mkdir -p "$T/noplan" run_gate --repo "$T/noplan" denied PLAN.md; check "no PLAN.md: deny, naming PLAN.md" $? mkdir -p "$T/lower" printf '%s\n\n%s\n' "$GATE_OK" "$LOG_OK" > "$T/lower/plan.md" [ -f "$T/lower/PLAN.md" ] && echo " note - case-insensitive volume: [ -f PLAN.md ] is TRUE for plan.md here" run_gate --repo "$T/lower" denied PLAN.md; check "only plan.md (wrong case): deny - the name is matched byte for byte" $? mkplan unread "$GATE_OK" "$LOG_OK" chmod 000 "$T/unread/PLAN.md" run_gate --repo "$T/unread" denied PLAN.md; check "unreadable PLAN.md: deny" $? chmod 644 "$T/unread/PLAN.md" mkdir -p "$T/plandir/PLAN.md" run_gate --repo "$T/plandir" denied PLAN.md; check "PLAN.md is a directory: deny" $? mkdir -p "$T/emptyplan"; : > "$T/emptyplan/PLAN.md" run_gate --repo "$T/emptyplan" denied R4; check "empty PLAN.md: deny on R4 (no gate line)" $? mkdir -p "$T/linked-target" printf '%s\n\n%s\n' "$GATE_OK" "$LOG_OK" > "$T/linked-target/real-plan.md" ln -s real-plan.md "$T/linked-target/PLAN.md" run_gate --repo "$T/linked-target" allowed; check "symlinked PLAN.md is read through the link -> allow" $? # --- 2. R4: the retelling and the coverage table -------------------------- mkplan r4-none "" "$LOG_OK" run_gate --repo "$T/r4-none" denied R4; check "R4: no gate line: deny" $? mkplan r4-two "$GATE_OK $GATE_OK" "$LOG_OK" run_gate --repo "$T/r4-two" denied R4; check "R4: two gate lines (ambiguous): deny" $? mkplan r4-prose "See the form \`$GATE_OK\` in the docs." "$LOG_OK" run_gate --repo "$T/r4-prose" denied R4; check "R4: a gate line quoted mid-sentence is prose, not a gate line: deny" $? r4() { # r4 : build and run mkplan "$1" "$2" "$LOG_OK"; run_gate --repo "$T/$1" } r4 r4-nogj '' denied R4; check "R4: gjenfortelling missing: deny" $? r4 r4-gjdash '' denied R4; check "R4: gjenfortelling=- (no yes yet): deny" $? r4 r4-gjdate '' denied R4; check "R4: gjenfortelling with a date but no time: deny" $? r4 r4-gjjunk '' denied R4; check "R4: gjenfortelling=yes (not a time): deny" $? r4 r4-gjmonth '' denied R4; check "R4: gjenfortelling in month 13: deny" $? r4 r4-gjhour '' denied R4; check "R4: gjenfortelling at hour 24: deny" $? r4 r4-dekdel '' denied R4; check "R4: dekning=delvis: deny" $? r4 r4-dekcase '' denied R4; check "R4: dekning=Komplett (exact token only): deny" $? r4 r4-dekpre '' denied R4; check "R4: dekning=komplett2 (prefix is not the token): deny" $? r4 r4-nodek '' denied R4; check "R4: dekning missing: deny" $? r4 r4-noret '' denied R4; check "R4: retning empty: deny" $? r4 r4-retdash '' denied R4; check "R4: retning=- : deny" $? r4 r4-nor1 '' denied R4; check "R4: r1-proeve key missing (must be written, - if not yet): deny" $? r4 r4-r1junk '' denied R4; check "R4: r1-proeve=ja (not a time or -): deny" $? r4 r4-nomaal '' denied R4; check "R4: foerste-maaling key missing: deny" $? r4 r4-typo '' denied R4; check "R4: an unknown key (typo r1-prove beside r1-proeve) is refused, not ignored" $? r4 r4-repeat '' denied R4; check "R4: a repeated key (which one counts?) is refused" $? r4 r4-okz '' allowed; check "R4 control: seconds and Z suffix -> allow" $? r4 r4-okoff '' allowed; check "R4 control: +02:00 offset and a retning with spaces -> allow" $? # --- 3. R3: movement per quota share, over PLAN.md's Logg ----------------- r3() { # r3 mkplan "$1" "$GATE_OK" "$2"; run_gate --repo "$T/$1" } HDR='## Logg | Dato | Hva | Bevegelse | Kvote | |---|---|---|---|' r3 r3-ten "$HDR | 1 | a | ja | 5% | | 2 | b | nei | 6% | | 3 | c | nei | 4% |" denied R3; check "R3: 10% without movement since the last movement: deny (threshold is >= 10)" $? printf '%s' "$ERR" | grep -q '10' check "R3: the denial states the sum" $? r3 r3-nine5 "$HDR | 1 | a | nei | 4,5% | | 2 | b | nei | 5 % |" allowed; check "R3 control: 9.5% (comma decimal, spaced percent) -> allow" $? r3 r3-before "$HDR | 1 | a | nei | 50% | | 2 | b | ja: moved | 1% | | 3 | c | nei | 3% |" allowed; check "R3 control: rows BEFORE the last movement do not count -> allow" $? r3 r3-lastja "$HDR | 1 | a | nei | 40% | | 2 | b | Ja - moved K1 | 2% |" allowed; check "R3 control: last row moved (Ja, any case) -> sum 0 -> allow" $? r3 r3-nomove-ever "$HDR | 1 | a | nei | 6% | | 2 | b | nei | 6% |" denied R3; check "R3: no movement ever, 12%: deny (counted from the first row)" $? r3 r3-unmeasured "$HDR | 1 | a | ja | 1% | | 2 | b | nei | ikke maalt |" denied R3; check "R3: kvote 'ikke maalt' is unmeasured, not zero: deny" $? r3 r3-kdash "$HDR | 1 | a | nei | - |" denied R3; check "R3: kvote '-': deny" $? r3 r3-kneg "$HDR | 1 | a | nei | -3% |" denied R3; check "R3: negative kvote: deny" $? r3 r3-kbare "$HDR | 1 | a | nei | 3 |" denied R3; check "R3: kvote without % (unit unknown): deny" $? r3 r3-bingen "$HDR | 1 | a | ingen mot kriteriene | 1% |" denied R3; check "R3: bevegelse outside ja|nei ('ingen ...'): deny" $? r3 r3-bempty "$HDR | 1 | a | | 1% |" denied R3; check "R3: bevegelse empty: deny" $? r3 r3-bjanei "$HDR | 1 | a | janei | 1% |" denied R3; check "R3: bevegelse 'janei' (prefix is not the token): deny" $? r3 r3-cells "$HDR | 1 | a | nei |" denied R3; check "R3: a row with fewer cells than the header: deny" $? r3 r3-pipe "$HDR | 1 | a \`x|y\` | nei | 1% |" denied R3; check "R3: a row with more cells than the header: deny" $? r3 r3-nolog "" denied R3; check "R3: no Logg section at all: deny" $? r3 r3-notable "## Logg - 26.09 did things, no movement, 3%" denied R3; check "R3: Logg section with no table: deny" $? r3 r3-nokvote "## Logg | Dato | Hva | Bevegelse | |---|---|---| | 1 | a | ja |" denied R3; check "R3: Logg table without a Kvote column: deny" $? r3 r3-nobev "## Logg | Dato | Hva | Kvote | |---|---|---| | 1 | a | 1% |" denied R3; check "R3: Logg table without a Bevegelse column: deny" $? r3 r3-hdronly "$HDR" allowed; check "R3 control: header only, no rows yet (first session) -> allow" $? r3 r3-twolog "$HDR | 1 | a | ja | 1% | ## Logg | Dato | Hva | Bevegelse | Kvote | |---|---|---|---|" denied R3; check "R3: two Logg sections (ambiguous): deny" $? r3 r3-bullet "$HDR | 1 | a | ja | 1% | - 2 | b | nei | 30% (a row written as a bullet)" denied R3; check "R3: a list item inside Logg (a row the gate cannot read): deny" $? r3 r3-after "$HDR | 1 | a | ja | 1% | ## Next section - a bullet that belongs to another section | x | y |" allowed; check "R3 control: the Logg section ends at the next heading -> allow" $? SECT="$(printf '\302\247')" r3 r3-para "## $SECT Logg | dato | hva | bevegelse | kvoteandel | |:---|---|---|---:| | 1 | a | nei | 2% |" allowed; check "R3 control: '##
Logg', lower-case headers, Kvoteandel, aligned separator -> allow" $? r3 r3-endrlogg "## Endringslogg | Dato | Hva | Bevegelse | Kvote | |---|---|---|---| | 1 | a | ja | 1% |" denied R3; check "R3: '## Endringslogg' is not the Logg section: deny" $? # --- 4. R6: concurrency cap and night starts ------------------------------- mkplan r6 "$GATE_OK" "$LOG_OK" AG="$T/ag0" run_gate --repo "$T/r6" allowed; check "R6 control: 0 live sessions, cap 1 -> allow" $? grep -qx "agents --json --cwd $T/r6" "$FAKE_LOG" check "R6: live sessions are read with 'claude agents --json --cwd '" $? ln -s "$T/r6" "$T/r6-link" AG="$T/ag0" run_gate --repo "$T/r6-link" grep -qx "agents --json --cwd $T/r6" "$FAKE_LOG" check "R6: a symlinked --repo is resolved to its physical path before the query" $? AG="$T/ag1" run_gate --repo "$T/r6" denied R6; check "R6: 1 live session, no R1 proof -> cap 1 -> deny" $? printf '%s' "$ERR" | grep -q '1' check "R6: the denial states the live count" $? mkplan r6-r1 "$GATE_R1" "$LOG_OK" AG="$T/ag1" run_gate --repo "$T/r6-r1" denied R6; check "R6: R1 proof but no first measurement -> cap still 1 -> deny" $? mkplan r6-r1m "$GATE_R1M" "$LOG_OK" AG="$T/ag1" run_gate --repo "$T/r6-r1m" allowed; check "R6 control: R1 proof AND first measurement, 1 live -> cap 2 -> allow" $? AG="$T/ag2" run_gate --repo "$T/r6-r1m" denied R6; check "R6: 2 live (one-line JSON, interactive counts too), cap 2 -> deny" $? AG="$T/ag1" run_gate --repo "$T/r6" --kind read allowed; check "R6 control: --kind read is not capped -> allow with 1 live" $? [ ! -s "$FAKE_LOG" ] check "R6: --kind read does not query live sessions at all" $? AG="$T/agjunk" run_gate --repo "$T/r6" denied R6; check "R6: claude agents prints non-JSON: deny (fail-closed)" $? AG="$T/agempty" run_gate --repo "$T/r6" denied R6; check "R6: claude agents prints nothing: deny (fail-closed)" $? AG="$T/ag0" AGX=1 run_gate --repo "$T/r6" denied R6; check "R6: claude agents exits 1: deny (fail-closed)" $? NOCL="$T/noclaude"; mkdir -p "$NOCL" env PATH="$NOCL:/usr/bin:/bin" /bin/bash -c 'command -v claude' >/dev/null 2>&1; [ $? -ne 0 ] check "ground truth: claude is not on the reduced PATH" $? GPATH="$NOCL:/usr/bin:/bin" run_gate --repo "$T/r6" denied R6; check "R6: claude not on PATH: deny (fail-closed)" $? GPATH="$NOCL:/usr/bin:/bin" run_gate --repo "$T/r6" --kind read allowed; check "R6 control: --kind read needs no claude binary -> allow" $? NOW=23:00 run_gate --repo "$T/r6" denied R6; check "R6 night: 23:00 without R1 proof: deny" $? printf '%s' "$ERR" | grep -q 'r1-proeve' check "R6 night: the denial names r1-proeve" $? NOW=06:59 run_gate --repo "$T/r6" denied R6; check "R6 night: 06:59 without R1 proof: deny" $? NOW=07:00 run_gate --repo "$T/r6" allowed; check "R6 night control: 07:00 is day -> allow" $? NOW=22:59 run_gate --repo "$T/r6" allowed; check "R6 night control: 22:59 is day -> allow" $? NOW=12:00 run_gate --repo "$T/r6" --night denied R6; check "R6 night: --night at noon without R1 proof: deny" $? NOW=02:00 run_gate --repo "$T/r6" --kind read denied R6; check "R6 night: a read session at 02:00 without R1 proof: deny" $? NOW=02:00 run_gate --repo "$T/r6-r1" allowed; check "R6 night control: R1 proof present -> night start allowed" $? NOW=12:00 run_gate --repo "$T/r6-r1" --night allowed; check "R6 night control: --night with R1 proof -> allow" $? NOW=noon run_gate --repo "$T/r6" denied SESSION_GATE_NOW; check "clock override 'noon' is refused by name, never ignored" $? NOW=25:00 run_gate --repo "$T/r6" denied SESSION_GATE_NOW; check "clock override 25:00 is refused by name" $? NOW= run_gate --repo "$T/r6" denied SESSION_GATE_NOW; check "clock override set but empty is refused by name" $? NOW_UNSET=1 run_gate --repo "$T/r6-r1m" allowed; check "KNOWN-POSITIVE: real clock (override unset), R1 proof -> allow at any hour" $? # --- 5. Rule order: the first failing rule is the one reported ------------ mkplan order "" "$HDR | 1 | a | nei | 50% |" AG="$T/ag2" NOW=03:00 run_gate --repo "$T/order" denied R4; check "all three rules fail: R4 is reported first (one line)" $? echo "session-gate-selftest: $PASS passed, $FAIL failed (of $((PASS+FAIL)) checks)" [ "$FAIL" -eq 0 ] || exit 1 exit 0