fix(cap): move the enforcement boundary to a denial tombstone, not the turn count
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
This commit is contained in:
parent
156539204a
commit
32e20fc0dc
6 changed files with 180 additions and 33 deletions
|
|
@ -128,9 +128,9 @@ const ledgerPath = resolveLedgerPath(env);
|
|||
// list under settings.json:16's maxDimensions ceiling.
|
||||
const budget = resolveMaxConvTurns(env) * MAX_TOTAL_DIMENSIONS;
|
||||
|
||||
let used;
|
||||
let ledger;
|
||||
try {
|
||||
used = readLedger(ledgerPath, marker.runId).granted;
|
||||
ledger = readLedger(ledgerPath, marker.runId);
|
||||
} catch (e) {
|
||||
deny(
|
||||
` Run ${marker.runId} is in scope, but its turn ledger could not be read:\n` +
|
||||
|
|
@ -140,13 +140,42 @@ try {
|
|||
);
|
||||
}
|
||||
|
||||
if (used >= budget) {
|
||||
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} has spent ${used}/${budget} loop turns.\n` +
|
||||
` Tool: ${input?.tool_name ?? 'unknown'}${input?.agent_type ? ` (agent: ${input.agent_type})` : ''}\n` +
|
||||
` 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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue