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).
136 lines
5.2 KiB
JavaScript
136 lines
5.2 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
|
|
// ~120-line convention (global CLAUDE.md's Kontinuitets-system section;
|
|
// raised from ~60 by operator decision 2026-08-14).
|
|
//
|
|
// 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.
|
|
//
|
|
// currentLineCountOf() assumes file_path arrives ABSOLUTE - the Write and
|
|
// Edit tool contracts both require it, so a relative path never reaches this
|
|
// hook in practice. This matters because a read failure is swallowed as
|
|
// current=0: a relative path resolving against the wrong cwd would silently
|
|
// collapse the ratchet back into the flat gate it exists to avoid (Write) or
|
|
// fail open with no enforcement at all (Edit, via the outer readFileSync
|
|
// catch). Do not "harden" this away with input.cwd without re-reading why
|
|
// it was never needed.
|
|
//
|
|
// 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 at the 120-line threshold, 13 of the machine's
|
|
// STATE.md files were already over 120 lines, one at 1496. 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 = 120;
|
|
|
|
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)
|
|
// A string replacement here would let JS interpret $-sequences inside
|
|
// newStr ($&, $`, $', $$, $n) as special patterns instead of literal
|
|
// text - a function replacement is never pattern-substituted.
|
|
: 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);
|