allowTurn() appends BEFORE the turn runs, so during granted turn N the
ledger holds N records. The hook denied at `used >= budget`, which blocked
every tool call of the FINAL granted turn: the primitive granted B turns
and the harness permitted B-1. Worse, an exhausted run therefore always
terminated through an exit-2 tool denial instead of the graceful "cap
exhausted" exit at commands/trekresearch.md - and the prose says in as
many words that exit 2 is not exit 1, so the model was pushed out through
the one exit it is told NOT to treat as a cap.
The review recommended denying at `used > budget`. Taken alone that fixes
the count and breaks the hook: once the O_EXCL claim (previous commit)
makes a breached ledger impossible, `granted > budget` can no longer fire,
and the case this hook exists for - the loop consults the gate, is denied,
and issues the tool call anyway - would be allowed. A deny branch that
cannot be reached is a dead security claim, which is the same thing S82
removed two of rather than leave standing.
So the denial itself became a record. allowTurn() appends a tombstone
{runId, exhausted: true} when it denies for budget, and the hook denies on
the tombstone. Both properties now hold at once:
granted == budget, no tombstone -> turn B is in flight -> ALLOW
tombstone present -> the gate already said no -> DENY
granted > budget -> breached, any cause -> DENY
A tombstone is not a turn: readLedger reports {granted, exhausted}
separately so it can never consume budget. allowTurn short-circuits on an
existing tombstone, so a hammered gate neither re-walks every slot nor
grows the ledger. The tombstone write is best effort on purpose - the
denial is already the correct answer, so a ledger that cannot take the
record must not turn a denial into a grant.
The parallel-boundary test now asserts GRANTED turns rather than raw
ledger lines, because the denied callers legitimately add tombstones.
Review finding 8eb53458ac3efec778094f9f03b09e1cc1077a09 (MINOR).
Operator decision: tombstone over the literal recommended_action.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
181 lines
8 KiB
JavaScript
181 lines
8 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 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`;
|
||
|
||
// 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` +
|
||
` 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` +
|
||
` Raise TREKRESEARCH_MAX_CONV_TURNS deliberately, or set VOYAGE_DISABLE_CAP_HOOK=1.`,
|
||
);
|
||
}
|
||
|
||
allow();
|