feat(hooks): enforce research loop cap at PreToolUse or document the gap

This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 20:21:42 +02:00
commit e9ff8ab023
6 changed files with 441 additions and 2 deletions

View file

@ -0,0 +1,149 @@
#!/usr/bin/env node
// Hook: pre-agent-cap.mjs
// Event: PreToolUse (WebSearch | WebFetch | Task)
// Purpose: Enforce the /trekresearch Phase 5 loop bound at the harness level,
// so the cap is a reader that fells rather than prose the model obeys.
//
// Why this exists: the Phase 5 budget gate (lib/util/research-loop-cap.mjs) is
// invoked BY the loop. A gate the caller chooses to consult is advice. The
// spike in docs/spike-pretooluse-subagent-reach.md (RESULT: FIRES) established
// that a plugin PreToolUse hook does observe tool calls made INSIDE sub-agents
// on CC 2.1.226, which is what makes a second, non-optional gate possible.
//
// Two limits carried over from that spike, neither of which changes the design:
// - Reach was measured on one CC version and regressed once before (#34692),
// so this hook is defence in depth, never the only gate. research-loop-cap
// must stay correct if this hook silently stops firing.
// - Whether a blocking (exit 2) decision from inside a sub-agent propagates
// usefully was NOT measured — the probe always exited 0 by design.
//
// Scope key — the property that makes this safe to wire globally:
// session_id + a scope marker file that only the Phase 5 loop writes, at
// ${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json:
// { "runId": "<run id>", "startedAt": "<ISO-8601>" }
// No marker for this session => out of scope => allow, unconditionally. An
// unrelated session must never be denied because some other run spent its
// budget; a PreToolUse hook that over-blocks breaks every session on the box.
//
// Fail-open vs fail-closed, deliberately split:
// - Out of scope (no marker, no session_id, unparsable stdin, stale marker,
// kill switch, STORM off) => exit 0. Fail OPEN.
// - In scope but the ledger cannot be read (CLAUDE_PLUGIN_DATA absent) =>
// exit 2. Fail CLOSED, mirroring research-loop-cap.mjs's own stance: a
// budget control that cannot count must not grant.
//
// Counting is read-only. The ledger is append-only and written solely by
// research-loop-cap.mjs's allowTurn(); if this hook appended, the cap would
// count its own enforcement.
//
// Kill switch: VOYAGE_DISABLE_CAP_HOOK=1 disables enforcement entirely.
import { readFileSync, existsSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const { resolveLedgerPath, resolveMaxConvTurns, isStormEnabled, MAX_TOTAL_DIMENSIONS } =
await import(join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs'));
const SCOPE_DIRNAME = 'trekresearch-loop-scope';
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000; // 6h — longer than any real research run
const env = process.env;
function allow() {
process.exit(0);
}
function deny(message) {
process.stderr.write(`[voyage] BLOCKED: trekresearch loop cap\n${message}\n`);
process.exit(2);
}
// 1. Kill switch.
if (env.VOYAGE_DISABLE_CAP_HOOK === '1') allow();
// 2. Default-off: no loop runs unless STORM is enabled, so nothing to enforce.
if (!isStormEnabled(env)) allow();
// 3. Parse stdin. Unparsable input is not evidence of a loop turn.
let input;
try {
input = JSON.parse(readFileSync(0, 'utf-8'));
} catch {
allow();
}
const sessionId = input?.session_id;
if (!sessionId || typeof sessionId !== 'string') allow();
// 4. Resolve the scope marker. VOYAGE_CAP_SCOPE_DIR exists so the marker
// location stays addressable when CLAUDE_PLUGIN_DATA is being tested as
// absent; in production both point at the same plugin data directory.
const scopeDir = env.VOYAGE_CAP_SCOPE_DIR || env.CLAUDE_PLUGIN_DATA;
if (!scopeDir) allow();
const markerPath = join(scopeDir, SCOPE_DIRNAME, `${sessionId}.json`);
if (!existsSync(markerPath)) allow();
let marker;
try {
marker = JSON.parse(readFileSync(markerPath, 'utf-8'));
} catch {
allow(); // A marker we cannot read cannot tell us which run we are in.
}
if (!marker?.runId) allow();
// 5. TTL / auto-reset. A marker left behind by a crashed run must not deny
// tool calls for the rest of the machine's life.
const ttlRaw = Number(env.VOYAGE_CAP_SCOPE_TTL_MS);
const ttlMs = Number.isFinite(ttlRaw) && ttlRaw > 0 ? ttlRaw : DEFAULT_TTL_MS;
const startedAt = Date.parse(marker.startedAt ?? '');
if (!Number.isFinite(startedAt) || Date.now() - startedAt > ttlMs) {
try { rmSync(markerPath, { force: true }); } catch { /* best effort */ }
allow();
}
// --- In scope from here on. ---
// 6. The ledger is the only source of truth for turns spent.
const ledgerPath = resolveLedgerPath(env);
if (!ledgerPath) {
deny(
` Session ${sessionId} is inside a Phase 5 research loop (run ${marker.runId}),\n` +
` but CLAUDE_PLUGIN_DATA is not set, so the turn ledger cannot be read.\n` +
` A budget control that cannot count does not grant. Set CLAUDE_PLUGIN_DATA,\n` +
` or set VOYAGE_DISABLE_CAP_HOOK=1 to disable this hook.`,
);
}
function countTurns(path, runId) {
if (!existsSync(path)) return 0;
let text;
try { text = readFileSync(path, 'utf-8'); } catch { return 0; }
let count = 0;
for (const line of text.split('\n')) {
if (!line) continue;
try {
if (JSON.parse(line).runId === runId) count++;
} catch { /* skip malformed lines */ }
}
return count;
}
// 7. Same bound the primitive uses: turns-per-dimension × the whole dimension
// list under settings.json:16's maxDimensions ceiling.
const budget = resolveMaxConvTurns(env) * MAX_TOTAL_DIMENSIONS;
const used = countTurns(ledgerPath, marker.runId);
if (used >= budget) {
deny(
` Run ${marker.runId} has spent ${used}/${budget} loop turns.\n` +
` Tool: ${input?.tool_name ?? 'unknown'}${input?.agent_type ? ` (agent: ${input.agent_type})` : ''}\n` +
` Remaining gaps belong in the brief as open questions, not in another turn.\n` +
` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`,
);
}
allow();