#!/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 ~60-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 -- sets HOOK_EXIT, HOOK_STDERR run_hook() { HOOK_STDERR="$(node "$HOOK" <"$1" 2>&1 1>/dev/null)" HOOK_EXIT=$? } # payload -- 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(60); 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 60 lines allows" $? P="$(payload ' const content = "x\n".repeat(61); 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: 61 lines denies (exit 2)" $? printf '%s' "$HOOK_STDERR" | grep -q "61"; check "Write: denial message names the projected count" $? printf '%s' "$HOOK_STDERR" | grep -q "60"; 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(55)); ' "$FIXTURE/STATE.md" # 55 lines, replace one "x\n" occurrence with 6 "y\n" lines: net +5 -> 60, 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 60 lines allows" $? # same fixture, net +6 -> 61, 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 61 lines denies" $? printf '%s' "$HOOK_STDERR" | grep -q "61"; 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(50) + "b\n".repeat(5)); ' "$FIXTURE2/STATE.md" # 55 lines total. replace_all doubles each of the 50 "a\n" occurrences # (a\n -> a\na\n): net +50 -> 105 lines. A hook that only replaced the FIRST # occurrence would project 56 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 60 lines is the # NORMAL case a trim session starts from (measured on the real tree, # 2026-08-14: 23 of the machine's STATE.md files were over 60 lines, one at # 1405). Denying every write that doesn't land at <=60 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(156)); ' "$FIXTURE3/STATE.md" # Write: 156 -> 100 lines. Still over 60, 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(100) } })); ")" run_hook "$P" [ "$HOOK_EXIT" -eq 0 ]; check "Write: shrinking an oversized file allows, even if still over the limit" $? # Write: 156 -> 156 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(156) } })); ")" run_hook "$P" [ "$HOOK_EXIT" -eq 0 ]; check "Write: same-size rewrite of an oversized file allows" $? # Write: 156 -> 200 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(200) } })); ")" 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 61 lines: deny (the ratchet # must not read "no current file" as "anything goes" -- current defaults to 0). P="$(payload ' const content = "x\n".repeat(61); 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 156 lines; old_string is # the first 60 "x\n" occurrences (a contiguous substring), new_string is 4 of # them -> projects to 100 lines: still over 60, but smaller than 156. A # pre-ratchet hook denies this (100 > 60); 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(156)); ' "$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(60), 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" $? echo "" echo "state-line-guard-selftest: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] || exit 1 exit 0