Advisor review caught this before the v0.23.0 tag landed: the guard compared the projected line count only against the fixed 60-line max, never against the file's current size, so trimming an already-oversized STATE.md (e.g. 156 -> 100 lines, still over 60 but smaller) was denied exactly like growing it would be. Verified against the real tree: 23 of the machine's STATE.md files are already over 60 lines today, one at 1405. Shipped as a flat gate, this hook would have made most of them un-editable except by a single write landing at <=60 in one shot -- backwards for a guard meant to make trimming possible. Fixed with a ratchet: deny only when the projection is over the max AND larger than the file's current line count (0 for a file that doesn't exist yet), for both Write and Edit. A compliant file still cannot grow past the limit and a new file still cannot be created oversized, but an oversized file can now be edited toward compliance one write at a time. state-line-guard-selftest.sh: 16 -> 21 checks (new section 8: shrink allows, same-size allows, grow-while-oversized still denies, new-oversized still denies). Suite total: 191 + 152 + 69 + 21 = 433. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186kZGKddxfA9N84HqMLbb2
295 lines
9.2 KiB
Bash
Executable file
295 lines
9.2 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 ~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 <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(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
|