#!/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" $? echo "" echo "state-line-guard-selftest: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] || exit 1 exit 0