An unreadable ledger returned 0 from countTurns in BOTH the primitive and the hook, so a run whose ledger existed but could not be read (EISDIR, EACCES, EIO) was handed the full budget again on every call - unbounded. research-loop-cap.mjs argues against exactly that three lines above the code that did it, and its missing-DIRECTORY case already failed closed. The unreadable-FILE case now agrees with it. Only ENOENT still counts as zero turns spent: that is the legitimate first-turn state, and the reason this cannot just throw on any read failure. The hook no longer carries its own countTurns. It imports the primitive's exported readLedger(), the same way it already resolves the data root through resolveDataRoot() - a reader and a writer with private copies of the counting rule is how a hook ends up enforcing a different bound than the gate it backs. In scope + cannot count now exits 2 with a message that says counting failed, not that the budget is spent. Fail-closed stays scoped to the loop: a test pins that an unreadable ledger in an OUT-of-scope session still exits 0, because a PreToolUse hook that over-blocks bricks every session on the box. Also dropped the existsSync pre-check before the read - readFileSync's own ENOENT carries the same information without a second syscall that can disagree with the read that follows it. Review finding 5e1c6230f48ead38fa77cd8f4b06bfdc2b5b7bbf (MINOR). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
152 lines
6.6 KiB
JavaScript
152 lines
6.6 KiB
JavaScript
#!/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
|
||
// <data root>/trekresearch-loop-scope/<session_id>.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": "<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 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';
|
||
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 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 used;
|
||
try {
|
||
used = readLedger(ledgerPath, marker.runId).granted;
|
||
} 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.`,
|
||
);
|
||
}
|
||
|
||
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();
|