org-ops dispatched a work order (20260814T144553Z) from an /insights sweep of 160 sessions: a real STATE.md drifted to 155-156 lines before anyone noticed, and one trim pass on it increased the line count instead of shrinking it. Prose alone doesn't enforce. org-ops proposed a PostToolUse hook. Checked against the official hooks docs first: PostToolUse fires after the tool has already written the file and cannot block it (confirmed "Can block? No"), only nag afterward. Built it as PreToolUse instead, the only event that can deny the call before the file lands. pre-state-line-guard.mjs denies (stderr + exit 2, matching llm-security's pre-write-pathguard.mjs) a Write or Edit on any STATE.md whose projected result exceeds 60 lines. Write projects from the call's own content; Edit projects from the current on-disk file with old_string replaced by new_string, honoring replace_all (every occurrence) vs the default (first occurrence only) the same way the real Edit tool does. Anything the hook can't project confidently (missing file, old_string not found) is left to the real tool. state-line-guard-selftest.sh: 16 checks, including a replace_all fixture that a first-occurrence-only projection would wrongly allow. Wired into hooks/hooks.json as PreToolUse on Write|Edit. Version 0.22.0 -> 0.23.0. Suite total: 191 + 152 + 69 + 16 = 428. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186kZGKddxfA9N84HqMLbb2
214 lines
6.3 KiB
Bash
Executable file
214 lines
6.3 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" $?
|
|
|
|
echo ""
|
|
echo "state-line-guard-selftest: $PASS passed, $FAIL failed"
|
|
[ "$FAIL" -eq 0 ] || exit 1
|
|
exit 0
|