joulehook: the joule budget survives the process, so it can be exceeded #310
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/joulehook-budget-survives-the-process"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The defect
SPEC §3.3 is normative:
The debit lived in the
LocalToolGatethatgate()constructs, andgate()runs once per process — this binary is a PreToolUse command the host spawns per action. So the spend total died with the process on every single call, and the next action started again at the fullJOULEHOOK_BUDGET_UJ.There was no sequence of actions that could exhaust the budget, so the deny branch could never be taken.
conformance/v1/receipt.jsonrecords the symptom:budget_remaining_uj: 1000000000, the untouched ceiling.And the deny branch that was reachable reported the wrong thing — one message, "is not in the granted capability set", covered every denial. So a budget denial would have been recorded as a capability denial, if one could ever have happened.
The hook already wrote the evidence and never read it: sealed receipts are appended to
JOULEHOOK_RECEIPT_LOG, hex per line.The fix
LocalToolGate::resumereplays a sealed stream into the gate's spend ledger viajcp_receipt::replay_spend, so only verified allow-receipts sealed by this gate's own steward contribute debits.Bridgegainssteward(),steward_pubkey()andrestore()to make that possible.gate()replays before deciding, and the allow reason now records the spend and how many receipts it replayed.The grants are deterministic — fixed issuer key,
nonce: [0; 16],expiry: 0— which is exactly why a later process rebuilds byte-identical grant ids for the debits to land on, and exactly why the budget refilled without them.Tests
the_budget_is_a_session_ceiling_when_the_stream_is_keptruns three separaterun()calls — each a fresh gate, which is what a per-action process is — persisting between them asmaindoes:Previously the third call allowed, and so would the three-hundredth.
Plus
without_a_stream_the_budget_is_not_enforced_across_calls(four calls, all allow, each disclosing it) andan_unreadable_receipt_stream_denies.Falsification
gate()replays into a throwaway gate, not the deciding one (the original defect)resumeapplies no debitsan_unreadable_receipt_stream_deniesFAILEDK1, K2 and K4 each hit a different assertion of the ceiling test — the replay reaching the deciding gate, the debits actually landing, and the denial naming its cause. All ten pre-existing tests, including #290's fail-closed pair, passed throughout.
Verification
jcp-bridgeis a path dependency ofjouledesk-rsandjoulehook-rsas well asjcp-rs, so all three were built:check --workspace --all-targetstest --workspacejcp/jcp-rsjoulehook/joulehook-rsjouledesk/jouledesk-rsNo dependency changed, so no
Cargo.lockin any workspace is affected.🤖 Generated with Claude Code
SPEC §3.3 is normative: "If the action carries an estimable cost, the grant's joule budget MUST be ≥ the estimate; over budget is `deny`. On `allow`, the spent amount is debited." The debit lived in the `LocalToolGate` that `gate()` constructs, and `gate()` runs once per process — this binary is a PreToolUse command the host spawns per action. So the spend total died with the process on every single call and the next action started again at the full `JOULEHOOK_BUDGET_UJ`. **There was no sequence of actions that could exhaust the budget**, so the deny branch could never be taken, and `conformance/v1/receipt.json` records the symptom: `budget_remaining_uj: 1000000000`, the untouched ceiling. Worse, the deny branch that *was* reachable reported the wrong thing: a single message, "is not in the granted capability set", covered every denial — so a budget denial would have been recorded as a capability denial if one could ever have happened. The hook already wrote the evidence and never read it: sealed receipts are appended to `JOULEHOOK_RECEIPT_LOG`, hex per line. - `LocalToolGate::resume` replays a sealed stream into the gate's spend ledger via `jcp_receipt::replay_spend`, so only verified allow-receipts of this gate's own steward contribute debits. `Bridge` gains `steward()`, `steward_pubkey()` and `restore()` to make that possible. The grants are deterministic (fixed issuer key, `nonce: [0; 16]`, `expiry: 0`), which is exactly why a later process rebuilds byte-identical grant ids for the debits to land on — and exactly why the budget refilled without them. - `gate()` replays the stream before deciding, and the allow reason now records the spend and how many receipts it replayed. - A budget denial says it is a budget denial. - **No stream configured** ⇒ the ceiling covers one call only. That is now disclosed in the allow reason rather than asserted: §3.3 states the budget MUST hold, and per-call it cannot. The crate's env doc previously carried the disclosure ("Cross-call budget accounting needs a persistent gate") while the SPEC's MUST did not; the doc is updated to point at the stream that now provides it. - **A stream that does not decode denies.** The prior spend is then unknown, the budget cannot be asserted, and allowing anyway is the refill the replay exists to prevent — fail closed, as SPEC §8 has this binary do for an unparseable payload and an unrecognised shape. A missing file is simply the first call. The headline test runs three separate `run()` calls — each a fresh gate, which is what a per-action process is — persisting between them as `main` does: 2 mJ, 2 mJ, 2 mJ against a 5 mJ ceiling gives allow, allow, **deny**, with the denial naming the budget. Previously that third call allowed, and so would the three-hundredth. Co-Authored-By: Claude Opus 5 <[email protected]>