#!/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 // /trekresearch-loop-scope/.json, where the data root // comes from research-loop-cap.mjs's resolveDataRoot() — the same function // the writer resolves through, because a writer and a reader that resolve // the root separately are a hook that enforces nothing while reporting that // it does: // { "runId": "", "startedAt": "" } // 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. // // Stated limit, because the guarantee above is about OTHER sessions and reads // as broader than it is: `claude --resume` keeps the same session_id, so a // resumed session is the same session by this key. If a run reached its cap // and then died before removing the marker, the resume inherits the remainder // of the TTL, for any WebSearch/WebFetch/Task — research or not. Three things // bound it rather than close it: only an EXHAUSTED run denies at all (a // part-spent crash leaves no tombstone and is allowed), the TTL is 2h rather // than a working day, and every denial prints the marker path to delete. A // liveness check would close it properly, but a PreToolUse hook has nothing // trustworthy to check liveness against — the marker's writer is a shell // snippet whose $$ is a subshell, not the session. // // 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 and over budget => exit 2. Fail CLOSED, mirroring // research-loop-cap.mjs's own stance: a budget control that cannot count // must not grant. (The former "CLAUDE_PLUGIN_DATA absent" deny is gone — // the root now always resolves, so that branch could no longer fire.) // - In scope and the ledger cannot be counted (EISDIR, EACCES, EIO — anything // but ENOENT) => exit 2, same reason. This branch used to ALLOW: the hook // carried a private countTurns() whose catch returned 0, so an unreadable // ledger read as "no turns spent". Counting now goes through the // primitive's exported readLedger(), so reader and writer cannot hold // different rules about what an unreadable ledger means. // // 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, resolveDataRoot, resolveMaxConvTurns, isStormEnabled, readLedger, MAX_TOTAL_DIMENSIONS } = await import(join(HERE, '..', '..', 'lib', 'util', 'research-loop-cap.mjs')); const SCOPE_DIRNAME = 'trekresearch-loop-scope'; // 2h — comfortably longer than any real research run (a 24-turn loop at a couple // of minutes a turn is under an hour), and short enough that debris does not own // the rest of the working day. The TTL is measured from marker.startedAt rather // than from last activity, and `claude --resume` keeps the same session_id, so // this window is what a resumed session can inherit from a run that died holding // the marker. It was 6h; nothing needed six. const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; 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 through the writer's own root resolution. // VOYAGE_CAP_SCOPE_DIR stays as a test/override seam; unset, this lands on // exactly the directory the Phase 5 snippet writes into. const scopeDir = env.VOYAGE_CAP_SCOPE_DIR || resolveDataRoot(env); 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, and it is counted // through the primitive's OWN readLedger(). This hook used to carry a // private copy of the counting rule whose read error returned 0 — so an // unreadable ledger read as "no turns spent" and ALLOWED, in the one branch // where this hook is supposed to fail closed. const ledgerPath = resolveLedgerPath(env); // 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; let ledger; try { ledger = readLedger(ledgerPath, marker.runId); } catch (e) { deny( ` Run ${marker.runId} is in scope, but its turn ledger could not be read:\n` + ` ${e.message}\n` + ` A budget control that cannot count must not grant. Fix or remove the\n` + ` ledger, or set VOYAGE_DISABLE_CAP_HOOK=1 to disable enforcement.`, ); } const toolLine = ` Tool: ${input?.tool_name ?? 'unknown'}${input?.agent_type ? ` (agent: ${input.agent_type})` : ''}\n`; // Every denial names the marker. If this run is over and the marker outlived it // — the loop's own cleanup covers its three exits, but a crash between the // exhaustion record and the removal runs no cleanup at all — deleting this file // is the remedy, and a resumed session (same session_id) would otherwise sit out // the remaining TTL for work that has nothing to do with research. const remedyLines = ` If this loop is not running, the marker is debris — delete it:\n` + ` ${markerPath}\n` + ` It also auto-resets ${Math.round(ttlMs / 3600000)}h after the run started (VOYAGE_CAP_SCOPE_TTL_MS).\n`; // 8. The boundary is the TOMBSTONE, not the count. // // allowTurn() appends before the turn runs, so during the final granted turn the // ledger already holds `budget` records. Denying at `granted >= budget` blocked // that turn's own tool calls — the primitive granted B turns and this hook // permitted B-1 — and it forced every exhausted run out through an exit-2 tool // denial rather than the graceful "cap exhausted" exit, the only exit the prose // at commands/trekresearch.md teaches the model to handle. // // Moving the boundary to `granted > budget` alone would have made this hook // unable to fire at all once the claim mechanism made a breached ledger // impossible — a deny branch that cannot be reached is a dead security claim, // not a backstop. So the primitive records its own denials, and the case this // hook exists for is the one it now catches: the gate said no and a tool call // arrived anyway. if (ledger.exhausted > 0) { deny( ` Run ${marker.runId} was already denied a turn by the budget gate\n` + ` (${ledger.granted}/${budget} loop turns spent), and this call came after it.\n` + toolLine + ` Remaining gaps belong in the brief as open questions, not in another turn.\n` + remedyLines + ` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`, ); } // 9. Backstop for a ledger that exceeded the bound however it managed to. if (ledger.granted > budget) { deny( ` Run ${marker.runId} shows ${ledger.granted} granted turns against a budget of ${budget}.\n` + toolLine + ` The ledger has been breached; the loop is over regardless of cause.\n` + remedyLines + ` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`, ); } allow();