repo-mailbox/scripts/state-line-guard-selftest.sh
Kjell Tore Guttormsen b5c860eb03 fix(state-line-guard): Edit path used String.replace, not a function
current.replace(oldStr, newStr) with newStr as a STRING lets JS treat
$-sequences inside it ($&, $`, $', $$, $n) as special replacement
patterns, even though oldStr (the search side) is a plain string. A
new_string documenting old backtick-substitution style ($`cmd`) - the
kind of prose a STATE.md shell-conventions section writes routinely -
triggers it. 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, covering every $-sequence at once. The
replace_all branch (split/join) was never affected.

Direction was always fail-closed (over-blocks, never under-blocks a
real oversize), but it made exactly the STATE.md files that document
shell conventions hard to edit via Edit.

state-line-guard-selftest.sh: 23/23 (+2, section 9: $` as the repro,
$& as a second sequence proving the fix is general).

Also updates CLAUDE.md's pinned selftest counts (197/178/69/21 were
already stale before this session's own additions; now 206/183/69/23).
2026-08-15 20:37:18 +02:00

360 lines
12 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
echo ""
echo "state-line-guard-selftest: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
exit 0