repo-mailbox/hooks/scripts/pre-state-line-guard.mjs
Kjell Tore Guttormsen c1dabf109d fix(hooks): state-line-guard ratchets against current size, not a flat gate
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
2026-08-14 17:10:04 +02:00

122 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
// Hook: pre-state-line-guard.mjs
// Event: PreToolUse (Write|Edit)
// Purpose: block a Write/Edit that would push a STATE.md past the documented
// ~60-line convention (global CLAUDE.md's Kontinuitets-system section).
//
// PreToolUse, not PostToolUse: org-ops' work order (20260814T144553Z) asked
// for a PostToolUse hook, but PostToolUse fires AFTER the tool already ran
// and cannot undo the write (confirmed against the official hooks docs,
// 2026-08-14: "Can block? No" for PostToolUse). PreToolUse is the only event
// that can deny before the file lands. The prose limit existed already and
// still drifted silently to 155-156 lines in a real STATE.md before anyone
// noticed via /insights - a hook is the mechanical backstop prose can't be.
//
// Blocking convention (stderr + exit 2) matches llm-security's
// pre-write-pathguard.mjs, the only other PreToolUse Write/Edit guard in
// this marketplace.
//
// Protocol:
// - Read JSON from stdin: { tool_name, tool_input }
// - Only Write/Edit targeting a file named exactly STATE.md (any
// directory) are checked; everything else fails open immediately.
// - Write: the projected content is tool_input.content.
// - Edit: the projected content is the CURRENT on-disk file with
// old_string replaced by new_string (every occurrence if
// tool_input.replace_all is true, otherwise the first only) - the same
// transform the real Edit tool applies. Anything this hook cannot
// project confidently (file missing, old_string not found, fields of
// the wrong type) is left to the real tool, which will give a clearer
// error than a guess here would.
// - RATCHET: denies only when the projected line count is BOTH over
// MAX_LINES and larger than the file's CURRENT line count (0 for a file
// that doesn't exist yet). A file already over the limit is the normal
// starting point for a trim, not an edge case - measured on the real
// tree 2026-08-14, 23 of the machine's STATE.md files were already over
// 60 lines, one at 1405. Comparing only against MAX_LINES (no ratchet)
// would deny every incremental trim of those files that doesn't land at
// <=60 in one shot - the opposite of what a guard meant to make trimming
// possible should do. The ratchet still blocks what the guard exists to
// block: a compliant file growing past the limit, or a brand-new file
// being created oversized.
// - Block: stderr + exit 2
// - Allow: exit 0, no output
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
const MAX_LINES = 60;
function allow() {
process.exit(0);
}
function countLines(text) {
const matches = text.match(/\n/g);
return matches ? matches.length : 0;
}
function currentLineCountOf(path) {
try {
return countLines(readFileSync(path, 'utf-8'));
} catch {
return 0;
}
}
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
allow();
}
const toolName = input?.tool_name;
const toolInput = input?.tool_input ?? {};
const filePath = toolInput.file_path;
if (
(toolName !== 'Write' && toolName !== 'Edit') ||
typeof filePath !== 'string' ||
basename(filePath) !== 'STATE.md'
) {
allow();
}
let projected;
let currentLines;
if (toolName === 'Write') {
if (typeof toolInput.content !== 'string') allow();
projected = toolInput.content;
currentLines = currentLineCountOf(filePath);
} else {
let current;
try {
current = readFileSync(filePath, 'utf-8');
} catch {
allow();
}
const oldStr = toolInput.old_string;
const newStr = toolInput.new_string;
if (typeof oldStr !== 'string' || typeof newStr !== 'string' || !current.includes(oldStr)) {
allow();
}
projected = toolInput.replace_all
? current.split(oldStr).join(newStr)
: current.replace(oldStr, newStr);
currentLines = countLines(current);
}
const lines = countLines(projected);
if (lines > MAX_LINES && lines > currentLines) {
process.stderr.write(
`\n[repo-mailbox] STATE LINE GUARD: ${toolName} blocked\n` +
` File: ${filePath}\n` +
` Projected: ${lines} lines (current: ${currentLines}, max ${MAX_LINES} per the STATE.md convention)\n\n` +
`This would grow STATE.md further past the limit. Trim it instead -- ` +
`any write that reduces the line count is allowed, even if still over ${MAX_LINES}.\n`
);
process.exit(2);
}
process.exit(0);