fix(cap): fail closed when the ledger cannot be READ, in both modules
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
This commit is contained in:
parent
def6c05384
commit
d2b6a696bd
6 changed files with 161 additions and 32 deletions
|
|
@ -36,6 +36,12 @@
|
|||
// 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
|
||||
|
|
@ -48,7 +54,7 @@ import { join, dirname } from 'node:path';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const { resolveLedgerPath, resolveDataRoot, resolveMaxConvTurns, isStormEnabled, MAX_TOTAL_DIMENSIONS } =
|
||||
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';
|
||||
|
|
@ -111,27 +117,28 @@ if (!Number.isFinite(startedAt) || Date.now() - startedAt > ttlMs) {
|
|||
|
||||
// --- In scope from here on. ---
|
||||
|
||||
// 6. The ledger is the only source of truth for turns spent.
|
||||
// 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);
|
||||
|
||||
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);
|
||||
|
||||
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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue