repo-mailbox/scripts/state-line-guard-selftest.sh
Kjell Tore Guttormsen d8fdeaa991 fix(infra): a failed measurement must never render as a reassuring value
Tier 3, all five the same defect class (Verifiseringsloven ansikt 4): a
broken or uninstrumented query returning a positive-looking null, consumed
as a fact about the world.

F5  coord-count.sh: a mailbox root that does not exist was byte-identical
    to one where nobody has pending mail - zero lines, exit 0, silent
    stderr. Now exit 3 + a named stderr line; an existing-but-empty root
    stays a silent, clean 0. 3 rather than 2 because 2 already means "you
    called me wrong" and this means "the world you named is not there".
F14 coord-count.sh: the header promised exit 0 unconditionally while
    --exclude with no value already exited 2. Contract restated as
    0/2/3 and pinned as a check on the help TEXT.
F6  board.sh: `git status | wc -l` yields 0 lines whether the tree is
    clean or git refused to answer, so a failure printed DRT=0. Now "?",
    and BOTH awk consumers handle it - --plan's free-capacity test
    compares the field as a string against "0" (a "?" coerces to 0 in
    arithmetic and would certify an unmeasured tree as free), and the SUM
    roll-up names what it could not add.
F10 board.sh: a scan root that does not exist was skipped in silence and
    the empty scan exited 0. Bad roots are now named on stderr; exit 3
    only when NO root was scanned. A mix still exits 0 and prints the
    board. Replaces an assertion that encoded this defect as a pass.
F13 pre-state-line-guard.mjs: MAX_LINES is overridable via
    CLAUDE_STATE_MAX_LINES so the boundary is testable without hardcoding
    120 twice. An unusable value denies by name rather than falling back
    to the default - a limit that silently did not take effect is the
    same defect one layer up.

Every design choice mutation-tested; every negative check carries a
known-positive control. Section 11's first cut was vacuously green (wrong
basename + unexported fixture path) - recorded in CLAUDE.md rather than
quietly fixed, and the section now asserts its own ground truth.

Denominator measured, not estimated: coord-inbox.sh:57 and
coord-order-inbox.sh:60/64 carry the same `|| exit 0` shape and are
deliberately left alone (injection path, prose output, must never fail a
SessionStart) - stated in CLAUDE.md as a bounded gap.

Suites: coord 230->242, board 281->300, guard 40->54, route 69, orders
110, npm 11/11. Verified under system bash 3.2, not just Homebrew 5.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:15:05 +02:00

679 lines
26 KiB
Bash
Executable file

#!/bin/bash
# state-line-guard-selftest.sh - proves hooks/scripts/pre-state-line-guard.mjs
# actually PREVENTS a Write/Edit that would push a STATE.md past the
# documented ~120-line convention (global CLAUDE.md), and leaves everything
# else alone. ASCII only, bash 3.2 safe.
#
# PreToolUse, not PostToolUse: the org-ops work order (20260814T144553Z) asked
# for PostToolUse, but PostToolUse fires AFTER the tool already ran and cannot
# undo the write (confirmed against the official hooks docs, 2026-08-14).
# PreToolUse is the only event that can deny before the file lands. Blocking
# convention (stderr + exit 2) matches llm-security's pre-write-pathguard.mjs,
# the only other PreToolUse Write/Edit guard in this marketplace.
set -u
export LC_ALL=C
DIR="$(cd "$(dirname "$0")" && pwd)"
HOOK="$DIR/../hooks/scripts/pre-state-line-guard.mjs"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
PASS=0; FAIL=0
check() { if [ "$2" -eq 0 ]; then PASS=$((PASS+1)); echo " ok - $1"; else FAIL=$((FAIL+1)); echo " FAIL - $1"; fi; }
# run_hook <json-file> -- sets HOOK_EXIT, HOOK_STDERR
run_hook() {
HOOK_STDERR="$(node "$HOOK" <"$1" 2>&1 1>/dev/null)"
HOOK_EXIT=$?
}
# payload <node-script-writing-JSON-to-stdout> -- returns path to a tmp file
payload() {
f="$TMPDIR/payload_$$_$RANDOM.json"
node -e "$1" >"$f"
printf '%s' "$f"
}
echo "state-line-guard-selftest"
# --- 1. Write: line-count boundary ------------------------------------------
P="$(payload '
const content = "x\n".repeat(120);
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: "/tmp/wherever/STATE.md", content }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Write: exactly 120 lines allows" $?
P="$(payload '
const content = "x\n".repeat(121);
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: "/tmp/wherever/STATE.md", content }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Write: 121 lines denies (exit 2)" $?
printf '%s' "$HOOK_STDERR" | grep -q "121"; check "Write: denial message names the projected count" $?
printf '%s' "$HOOK_STDERR" | grep -q "120"; check "Write: denial message names the max" $?
# --- 2. Write: only STATE.md is guarded -------------------------------------
P="$(payload '
const content = "x\n".repeat(500);
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: "/tmp/wherever/NOTES.md", content }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Write: non-STATE.md file allows regardless of size" $?
P="$(payload '
const content = "x\n".repeat(500);
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: "/some/deep/plugin/subdir/STATE.md", content }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Write: STATE.md matched by basename at any depth" $?
# --- 3. Only Write/Edit are guarded ------------------------------------------
P="$(payload '
const content = "x\n".repeat(500);
process.stdout.write(JSON.stringify({
tool_name: "Read",
tool_input: { file_path: "/tmp/wherever/STATE.md", content }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Read: never guarded, regardless of content field" $?
# --- 4. Malformed / partial input never crashes the hook --------------------
P="$TMPDIR/malformed.json"
printf 'not json at all {' >"$P"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "malformed JSON on stdin fails open" $?
P="$(payload '
process.stdout.write(JSON.stringify({ tool_name: "Write", tool_input: {} }));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Write with no file_path fails open" $?
P="$(payload '
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: "/tmp/wherever/STATE.md" }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Write with no content field fails open" $?
# --- 5. Edit: projects the post-edit file, not the diff ---------------------
FIXTURE="$TMPDIR/a"
mkdir -p "$FIXTURE"
node -e '
const fs = require("fs");
fs.writeFileSync(process.argv[1], "x\n".repeat(115));
' "$FIXTURE/STATE.md"
# 115 lines, replace one "x\n" occurrence with 6 "y\n" lines: net +5 -> 120, allow
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Edit',
tool_input: {
file_path: '$FIXTURE/STATE.md',
old_string: 'x\\n',
new_string: 'y\\n'.repeat(6)
}
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: projected 120 lines allows" $?
# same fixture, net +6 -> 121, deny
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Edit',
tool_input: {
file_path: '$FIXTURE/STATE.md',
old_string: 'x\\n',
new_string: 'y\\n'.repeat(7)
}
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Edit: projected 121 lines denies" $?
printf '%s' "$HOOK_STDERR" | grep -q "121"; check "Edit: denial message names the projected count" $?
# --- 6. Edit: replace_all is honored, not just the first occurrence --------
FIXTURE2="$TMPDIR/b"
mkdir -p "$FIXTURE2"
node -e '
const fs = require("fs");
fs.writeFileSync(process.argv[1], "a\n".repeat(110) + "b\n".repeat(5));
' "$FIXTURE2/STATE.md"
# 115 lines total. replace_all doubles each of the 110 "a\n" occurrences
# (a\n -> a\na\n): net +110 -> 225 lines. A hook that only replaced the FIRST
# occurrence would project 116 lines and wrongly allow this.
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Edit',
tool_input: {
file_path: '$FIXTURE2/STATE.md',
old_string: 'a\\n',
new_string: 'a\\na\\n',
replace_all: true
}
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Edit: replace_all counts every occurrence, not just the first" $?
# --- 7. Edit: cases the hook must leave to the real tool --------------------
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Edit',
tool_input: {
file_path: '$FIXTURE/STATE.md',
old_string: 'this string is not in the fixture',
new_string: 'y\\n'.repeat(500)
}
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: old_string not found in file fails open" $?
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Edit',
tool_input: {
file_path: '$TMPDIR/does-not-exist/STATE.md',
old_string: 'x',
new_string: 'y\\n'.repeat(500)
}
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: nonexistent file fails open" $?
# --- 8. Ratchet: an already-oversized file must stay editable --------------
# The guard's job is "never let it grow past the limit", not "never let it be
# touched again once over the limit". A file already over 120 lines is the
# NORMAL case a trim session starts from (measured on the real tree,
# 2026-08-14, at the 120-line threshold: 13 of the machine's STATE.md files
# were over 120 lines, one at 1496). Denying every write that doesn't land at
# <=120 in a single shot would make every one of those files un-editable
# except by a perfect one-shot rewrite - exactly backwards for a hook meant to
# make trimming possible.
FIXTURE3="$TMPDIR/c"
mkdir -p "$FIXTURE3"
node -e '
const fs = require("fs");
fs.writeFileSync(process.argv[1], "x\n".repeat(216));
' "$FIXTURE3/STATE.md"
# Write: 216 -> 160 lines. Still over 120, but strictly smaller: allow.
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Write',
tool_input: { file_path: '$FIXTURE3/STATE.md', content: 'x\\n'.repeat(160) }
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Write: shrinking an oversized file allows, even if still over the limit" $?
# Write: 216 -> 216 lines (untouched size, e.g. only prose changed): allow.
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Write',
tool_input: { file_path: '$FIXTURE3/STATE.md', content: 'x\\n'.repeat(216) }
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Write: same-size rewrite of an oversized file allows" $?
# Write: 216 -> 260 lines. Still growing an already-oversized file: deny.
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Write',
tool_input: { file_path: '$FIXTURE3/STATE.md', content: 'x\\n'.repeat(260) }
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Write: growing an already-oversized file still denies" $?
# Write: brand-new STATE.md (no current file) at 121 lines: deny (the ratchet
# must not read "no current file" as "anything goes" -- current defaults to 0).
P="$(payload '
const content = "x\n".repeat(121);
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: "/tmp/brand-new-dir-xyz/STATE.md", content }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Write: creating a new oversized STATE.md still denies" $?
# Edit: same ratchet, via the Edit path. Fixture at 216 lines; old_string is
# 20 "x\n" occurrences (a contiguous substring), new_string is 4 of them ->
# projects to 200 lines: still over 120, but smaller than 216. A pre-ratchet
# hook denies this (200 > 120); the ratchet must allow it.
FIXTURE4="$TMPDIR/d"
mkdir -p "$FIXTURE4"
node -e '
const fs = require("fs");
fs.writeFileSync(process.argv[1], "x\n".repeat(216));
' "$FIXTURE4/STATE.md"
P="$(payload "
process.stdout.write(JSON.stringify({
tool_name: 'Edit',
tool_input: {
file_path: '$FIXTURE4/STATE.md',
old_string: 'x\\n'.repeat(20),
new_string: 'x\\n'.repeat(4)
}
}));
")"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: shrinking an oversized file allows, even if still over the limit" $?
# --- 9. Edit: new_string is treated LITERALLY, never as a String.replace ----
# special-pattern ($&, $`, $', $$, $n). Line 117 used to call
# current.replace(oldStr, newStr) with newStr as a STRING: JavaScript then
# interprets $-sequences inside the REPLACEMENT as special patterns even
# though the SEARCH side (oldStr) is a plain string, not a RegExp. A
# new_string documenting old backtick-substitution style ($`cmd`) is exactly
# the kind of prose a STATE.md's shell-conventions section writes routinely.
# Measured against the real bug (.claude/STATE.md, 2026-08-15): a 5-line
# addition on a 112-line file projected to 219 lines and was wrongly denied.
# Fix: current.replace(oldStr, () => newStr) - a function replacement is
# never pattern-substituted, so this covers every $-sequence, not just $`.
FIXTURE5="$TMPDIR/e"
mkdir -p "$FIXTURE5"
node -e '
const fs = require("fs");
const content = "p\n".repeat(100) + "TARGET\n" + "q\n".repeat(11);
fs.writeFileSync(process.argv[1], content);
' "$FIXTURE5/STATE.md"
# 112 lines total (100 + 1 + 11), matching the real repro's file size.
export STATE_GUARD_FIXTURE5="$FIXTURE5/STATE.md"
P="$(payload '
const path = process.env.STATE_GUARD_FIXTURE5;
const oldStr = "TARGET\n";
const newStr = "TARGET\n" +
"avoid old backtick-substitution style: $`cmd` (use $(cmd) instead)\n" +
"line2\n" + "line3\n" + "line4\n" + "line5\n";
process.stdout.write(JSON.stringify({
tool_name: "Edit",
tool_input: { file_path: path, old_string: oldStr, new_string: newStr }
}));
')"
run_hook "$P"
# Real net change is +5 lines (112 -> 117): under MAX_LINES, must allow. A
# dollar-pattern-vulnerable replace() balloons this past 120 and wrongly denies.
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: new_string containing \$\` is treated literally, not pattern-substituted (allows a real +5-line edit)" $?
unset STATE_GUARD_FIXTURE5
FIXTURE6="$TMPDIR/f"
mkdir -p "$FIXTURE6"
node -e '
const fs = require("fs");
const content = "p\n".repeat(100) + "TARGET\n" + "q\n".repeat(11);
fs.writeFileSync(process.argv[1], content);
' "$FIXTURE6/STATE.md"
export STATE_GUARD_FIXTURE6="$FIXTURE6/STATE.md"
P="$(payload '
const path = process.env.STATE_GUARD_FIXTURE6;
const oldStr = "TARGET\n";
const newStr = "TARGET line, matched text follows: $& -- end\n" +
"line2\n" + "line3\n" + "line4\n" + "line5\n";
process.stdout.write(JSON.stringify({
tool_name: "Edit",
tool_input: { file_path: path, old_string: oldStr, new_string: newStr }
}));
')"
run_hook "$P"
# Same class, different special sequence ($& = the whole matched substring):
# proves the fix is general (a function replacement), not a $`-specific patch.
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: new_string containing \$& is also treated literally (fix is general, not backtick-specific)" $?
unset STATE_GUARD_FIXTURE6
# --- 10. status=done must not be writable while commits are unpushed --------
# ORDRE 42 (operator, 2026-08-16). Measured that day: round 3 of AAA+ dispatched
# 15 sessions; two of them (human-friendly-style, graceful-handoff) had their
# push REFUSED by the UFW rate limit on port 22, reported that honestly in the
# coord inbox - and still wrote status=done. Result: board line said done, one
# commit unpushed, the published surface 404. `done` today means "the session
# finished", not "the work landed", and the difference is invisible to everyone
# reading the board. Worse, `done` removes a repo from the board plan, so
# `morning --say <repo>` could not reach them either: one defect hid the other.
#
# The guard is on the WRITE, not on session end, and that is a measured choice,
# not the cheap one (see the hook header for the full argument): Stop fires
# "once per turn", not once at session end, and SessionEnd cannot block at all
# ("Shows stderr to user only") - both quoted from the official hooks docs,
# 2026-08-16.
#
# The KNOWN-POSITIVE control is mandatory here: a guard that denies everything
# passes every negative test and is worthless. Both outcomes are pinned below.
NOHOOKS="$TMPDIR/nohooks"
mkdir -p "$NOHOOKS"
# g <git args> -- git with the operator's global config neutralised. The real
# machine sets core.hooksPath globally (measured 2026-08-16), so a fixture repo
# would otherwise run the operator's own git hooks.
g() {
git -c user.name=selftest -c user.email=selftest@example.invalid \
-c commit.gpgsign=false -c core.hooksPath="$NOHOOKS" "$@"
}
# mkrepo <name> <branch> -- work repo at $TMPDIR/<name> with a bare remote at
# $TMPDIR/<name>.git, one commit pushed, upstream tracking configured.
mkrepo() {
g init -q --bare "$TMPDIR/$1.git"
g init -q "$TMPDIR/$1"
g -C "$TMPDIR/$1" symbolic-ref HEAD "refs/heads/$2"
printf 'seed\n' >"$TMPDIR/$1/f.txt"
g -C "$TMPDIR/$1" add -A
g -C "$TMPDIR/$1" commit -qm seed
g -C "$TMPDIR/$1" remote add origin "$TMPDIR/$1.git"
g -C "$TMPDIR/$1" push -q -u origin "$2"
}
# addcommit <name> <subject> -- one more local commit, deliberately not pushed.
addcommit() {
printf '%s\n' "$2" >>"$TMPDIR/$1/f.txt"
g -C "$TMPDIR/$1" add -A
g -C "$TMPDIR/$1" commit -qm "$2"
}
# state_text <status> -- a minimal, convention-shaped STATE.md.
state_text() {
printf '# STATE\n\n## NESTE - START HER\n<!-- board: status=%s; blocked-on=-; next-cost=Sonnet 5/high -->\nnext step goes here\n' "$1"
}
# write_payload -- Write payload from $SG_PATH / $SG_CONTENT
write_payload() {
payload '
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: process.env.SG_PATH, content: process.env.SG_CONTENT + "\n" }
}));
'
}
# 10.1 the measured defect: done + unpushed commit -> deny
mkrepo repo-unpushed main
addcommit repo-unpushed "feat: work that never left this checkout"
export SG_PATH="$TMPDIR/repo-unpushed/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Write: status=done with an unpushed commit denies (exit 2)" $?
printf '%s' "$HOOK_STDERR" | grep -q "1 commit"; check "denial message names how many commits are unpushed" $?
printf '%s' "$HOOK_STDERR" | grep -q "never left this checkout"; check "denial message shows the unpushed commit, not just a count" $?
printf '%s' "$HOOK_STDERR" | grep -q "git push"; check "denial message says what to do (push)" $?
printf '%s' "$HOOK_STDERR" | grep -q "status=blocked"; check "denial message names the honest alternative (status=blocked)" $?
# 10.2 KNOWN-POSITIVE CONTROL: done + everything pushed -> allow.
# Without this check, a guard that denies unconditionally passes 10.1 and every
# other negative case in this section while being worthless.
mkrepo repo-clean main
export SG_PATH="$TMPDIR/repo-clean/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "KNOWN-POSITIVE: status=done with everything pushed allows" $?
# 10.3 the guard judges the claim, not the repo: an honest status is always
# writable, which is the escape hatch that keeps the deny non-wedging.
export SG_PATH="$TMPDIR/repo-unpushed/STATE.md"
SG_CONTENT="$(state_text in-progress)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "status=in-progress with unpushed commits allows" $?
SG_CONTENT="$(state_text blocked)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "status=blocked with unpushed commits allows" $?
# 10.4 a repo with no remote at all: "pushed" has no meaning there. Measured on
# the real tree 2026-08-16: 8 of 44 repos carrying a STATE.md have no upstream,
# and one of them (ghcp) is status=done. Denying there would make STATE.md
# unwritable in repos that can never satisfy the check.
g init -q "$TMPDIR/repo-noremote"
g -C "$TMPDIR/repo-noremote" symbolic-ref HEAD refs/heads/main
printf 'seed\n' >"$TMPDIR/repo-noremote/f.txt"
g -C "$TMPDIR/repo-noremote" add -A
g -C "$TMPDIR/repo-noremote" commit -qm seed
export SG_PATH="$TMPDIR/repo-noremote/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "repo with no upstream allows status=done" $?
# 10.5 detached HEAD: no branch, so no upstream to compare against.
mkrepo repo-detached main
addcommit repo-detached "unpushed on a detached head"
g -C "$TMPDIR/repo-detached" checkout -q --detach HEAD
export SG_PATH="$TMPDIR/repo-detached/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "detached HEAD allows status=done (nothing to compare against)" $?
# 10.6 upstream configured but the remote-tracking ref is gone (never pushed a
# first time, or the ref was pruned). The two are indistinguishable from here,
# and a confident "nothing has ever landed" would be the wrong-and-loud kind of
# error, so this fails OPEN - a documented hole, not an oversight.
mkrepo repo-noref main
addcommit repo-noref "unpushed with no remote-tracking ref"
g -C "$TMPDIR/repo-noref" update-ref -d refs/remotes/origin/main
export SG_PATH="$TMPDIR/repo-noref/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "missing remote-tracking ref fails open" $?
# 10.7 the comparison is against the branch's OWN upstream, never a hardcoded
# origin/main..main. Measured 2026-08-16: three repos on the real tree sit on
# a branch named master.
mkrepo repo-master master
addcommit repo-master "unpushed on master"
export SG_PATH="$TMPDIR/repo-master/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "non-main branch: unpushed commits still deny (upstream, not origin/main)" $?
# 10.8 a STATE.md outside any git repo
mkdir -p "$TMPDIR/plain-dir"
export SG_PATH="$TMPDIR/plain-dir/STATE.md"
SG_CONTENT="$(state_text done)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "STATE.md outside any git repo allows" $?
# 10.9 only the board line counts, never prose. A STATE.md describing THIS very
# defect contains the literal string status=done in its prose - this file's own
# repo wrote exactly that the evening the guard was built. The selector is
# board.sh's own anchor (^<!-- board:), so both read the same line or neither
# does.
export SG_PATH="$TMPDIR/repo-unpushed/STATE.md"
SG_CONTENT="$(printf '# STATE\n\n## NESTE - START HER\n<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/high -->\nThe guard denies status=done while commits are unpushed.\n')"
export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "prose containing status=done is not the board line" $?
# 10.10 exact vocabulary token. board.sh's own prefix/case defect (F3+F4, queued
# separately) reads done2 as done today; this guard does not, and pinning that
# keeps the guard aligned with the vocabulary rather than with the defect. Once
# F3+F4 lands, done2 is MALFORMED there too and green nowhere.
SG_CONTENT="$(state_text done2)"; export SG_CONTENT
P="$(write_payload)"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "status=done2 is not the token done (exact match, not prefix)" $?
# 10.11 the Edit path shares the same projection as Write
printf '# STATE\n\n## NESTE - START HER\n<!-- board: status=in-progress; blocked-on=-; next-cost=Sonnet 5/high -->\nnext step goes here\n' >"$TMPDIR/repo-unpushed/STATE.md"
export SG_PATH="$TMPDIR/repo-unpushed/STATE.md"
P="$(payload '
process.stdout.write(JSON.stringify({
tool_name: "Edit",
tool_input: {
file_path: process.env.SG_PATH,
old_string: "status=in-progress",
new_string: "status=done"
}
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]; check "Edit: introducing status=done with unpushed commits denies" $?
# 10.12 and the correction is always writable in one edit - the deny can never
# wedge a session that cannot push.
printf '# STATE\n\n## NESTE - START HER\n<!-- board: status=done; blocked-on=-; next-cost=Sonnet 5/high -->\nnext step goes here\n' >"$TMPDIR/repo-unpushed/STATE.md"
P="$(payload '
process.stdout.write(JSON.stringify({
tool_name: "Edit",
tool_input: {
file_path: process.env.SG_PATH,
old_string: "status=done",
new_string: "status=blocked"
}
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]; check "Edit: correcting done -> blocked is allowed with unpushed commits" $?
unset SG_PATH SG_CONTENT
# --- 11. F13: the line limit is overridable, and an unusable override is loud
# The limit was a bare `const MAX_LINES = 120`, so a test of the BOUNDARY had
# no choice but to hardcode 120 in every fixture - which means the tests and
# the code encoded the same number twice, and section 1's boundary fixtures
# would have to be rewritten by hand the next time the operator moves it (they
# already were once, 60 -> 120). CLAUDE_STATE_MAX_LINES makes the boundary
# testable at a cheap value AND gives the operator the same knob
# CLAUDE_COORD_DIR gives them over the mailbox root.
#
# The override is not a bypass claim: this guard has always been escapable by
# writing the file some other way (Bash, an editor), exactly as the sibling
# pathguard is. What it must never do is silently NOT take effect.
# Control first, at the DEFAULT: with no override set, the shipped limit still
# governs. This is what proves the denials below come from the override rather
# than from the guard having become stricter for everyone.
unset CLAUDE_STATE_MAX_LINES
P="$(payload '
const content = "x\n".repeat(6);
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: "/tmp/f13/STATE.md", content }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]
check "F13: control - a 6-line new STATE.md is allowed at the default limit" $?
# The override takes effect, in BOTH directions. One assertion alone would not
# do: a broken parse that clamped everything to 0 would deny the 6-line file
# and look like a working override.
CLAUDE_STATE_MAX_LINES=5
export CLAUDE_STATE_MAX_LINES
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]
check "F13: an override of 5 denies a 6-line new STATE.md" $?
printf '%s' "$HOOK_STDERR" | grep -q 'max 5'
check "F13: the denial message quotes the OVERRIDDEN limit, not the default" $?
P5="$(payload '
const content = "x\n".repeat(5);
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: "/tmp/f13/STATE.md", content }
}));
')"
run_hook "$P5"
[ "$HOOK_EXIT" -eq 0 ]
check "F13: exactly-at-the-override is still allowed (boundary, not off by one)" $?
# The ratchet is a property of the guard, not of the constant, so it must
# survive the override: an already-oversized file can still be edited toward
# compliance. Same rule section 8 pins at the default.
# The file must be named exactly STATE.md and the variable must be exported
# BEFORE node reads it. Both were wrong in this section's first cut, and the
# check went GREEN anyway - the basename gate let the write through without
# measuring a thing, and the fixture file was never created. A vacuous pass
# is the very defect this order is closing, so the fixture asserts its own
# ground truth before the check that depends on it.
mkdir -p "$TMPDIR/f13-ratchet"
export SG_BIG="$TMPDIR/f13-ratchet/STATE.md"
node -e 'require("fs").writeFileSync(process.env.SG_BIG, "y\n".repeat(40))'
[ "$(wc -l < "$SG_BIG" | tr -d ' ')" = "40" ]
check "F13: fixture ground truth - the oversized STATE.md really is 40 lines" $?
P="$(payload '
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: process.env.SG_BIG, content: "y\n".repeat(20) }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 0 ]
check "F13: the ratchet survives the override (40 -> 20, still over 5, allowed)" $?
# The inverse, so the check above cannot pass by the guard simply never firing
# on this path: GROWING the same oversized file is still denied at 5.
P="$(payload '
process.stdout.write(JSON.stringify({
tool_name: "Write",
tool_input: { file_path: process.env.SG_BIG, content: "y\n".repeat(60) }
}));
')"
run_hook "$P"
[ "$HOOK_EXIT" -eq 2 ]
check "F13: growing that same oversized file is still denied under the override" $?
# An override that cannot be used is DENIED, never silently ignored. A silent
# fallback to 120 is the exact defect class this whole order is closing: the
# caller would believe a limit was in force that never was, and a selftest
# would go green having measured the default while claiming to measure 5.
# Failing here is recoverable in one action (unset the variable) and the
# message says which one.
for bad in "0" "-3" "abc" "" "12.5" "1e3"; do
CLAUDE_STATE_MAX_LINES="$bad"
export CLAUDE_STATE_MAX_LINES
run_hook "$P5"
[ "$HOOK_EXIT" -eq 2 ] && printf '%s' "$HOOK_STDERR" | grep -q 'CLAUDE_STATE_MAX_LINES'
check "F13: an unusable override ('$bad') is refused by name, not ignored" $?
done
# ...and an UNSET variable is not an unusable one. Without this the check above
# would pass against a guard that refused every write on the planet.
unset CLAUDE_STATE_MAX_LINES
run_hook "$P5"
[ "$HOOK_EXIT" -eq 0 ]
check "F13: control - an UNSET override is the normal case, not a refusal" $?
unset SG_BIG
echo ""
echo "state-line-guard-selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0