fix(core): enforce max_calls across dispatches via a per-grant ledger (#264/AB-001) - #309
fix(core): enforce max_calls across dispatches via a per-grant ledger (#264/AB-001)#309hartsock wants to merge 1 commit into
Conversation
…#264/AB-001) WHAT Introduce a first-class `Grant` (authority bound to an unforgeable `GrantId`) and a registry-owned persistent budget ledger. `Registry::mint_grant(caveats)` mints a grant; `dispatch`/`dispatch_with_strength_floor` now take `&Grant` and charge a `max_calls` balance keyed by the grant's id, seeded create-on-first-use and decremented under a `Mutex`. WHY AB-001 (adversarial audit): each `dispatch` built a *fresh* gate seeded from the grant's `max_calls`, so an `AtMost(n)` bound was reset every call and silently unbounded across a session — the budget axis of the leash was unenforceable at the only boundary that spans calls. The ledger fixes this: the bound is now enforced across every dispatch a grant drives. Design (per owner decision "A"): budget is keyed by grant *identity*, not by serialized caveats, so two grants minted from equal caveats hold independent budgets. The `GrantId` constructor is private (core is deliberately rng-less; the private constructor is the unforgeability guarantee). Charge-first then refund-on-`Denied`: a policy denial at the gate OR inside a tool (an out-of-scope argument) spends nothing; a tool that *ran and failed* stays charged. Frontends: - MCP mints ONE session grant in `McpServer::new`, so a session `max_calls` bounds the whole session (the fix, made real through the MCP boundary). - Python `invoke` is a stateless one-shot: it mints an ephemeral grant per call, preserving the documented per-call `max_calls` semantics. Regression tests (fail before, pass after): - budget persists across dispatches on one grant (the AB-001 proof); - two grants from equal caveats have independent budgets; - a `Denied` dispatch refunds (both gate-level and in-tool argument denials); - 64 threads racing one grant never overspend (mutex check-and-decrement). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 817dbb3. Configure here.
| let result = tool.invoke(args, &cx).await; | ||
| if matches!(result, Err(ToolError::Denied { .. })) { | ||
| self.refund_grant(grant); | ||
| } |
There was a problem hiding this comment.
In-band denials consume session budget
High Severity
The dispatch methods incorrectly charge the max_calls budget for policy denials. The refund logic only triggers for ToolError::Denied from invoke, but some tools (like shell) signal denials by returning a successful Ok result with a denied: true flag. This causes calls that should be free to consume budget, potentially exhausting session grants prematurely.
Reviewed by Cursor Bugbot for commit 817dbb3. Configure here.
| // handle object; this per-call grant is the correct semantics for a | ||
| // stateless function. | ||
| let registry = shared_registry(); | ||
| let grant = registry.mint_grant(granted); |
There was a problem hiding this comment.
Python invoke grows grant ledger
Medium Severity
Each Python invoke mints a new grant on the process-global shared_registry() and dispatches once. charge_grant create-on-first-use inserts a GrantId row into the registry’s ledger map, and nothing ever removes it. Long-running embedders accumulate one HashMap entry per call for the process lifetime.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 817dbb3. Configure here.


Summary
AB-001 (adversarial audit): a
max_callsbound was silently unbounded across a session. EachRegistry::dispatchbuilt a freshGateseeded from the grant'smax_calls, soAtMost(n)reset every call — the budget axis of the leash was unenforceable at the one boundary that spans calls.This introduces a first-class
Grant(authority bound to an unforgeableGrantId) and a registry-owned persistent budget ledger:Registry::mint_grant(caveats) -> Grant— mint once; reuse across calls for a cross-dispatch budget.dispatch/dispatch_with_strength_floornow take&Grantand charge amax_callsbalance keyed by the grant's id, seeded create-on-first-use, decremented under aMutex.Design (owner decision "A")
GrantIdconstructor is private. Core is deliberately rng-less; the private constructor is the unforgeability guarantee (a counter gives every mint a distinct id).Denied. A policy denial — at the gate or inside a tool refusing an out-of-scope argument — spends nothing. A tool that ran and failed (Failed/timeout) stays charged.Frontends
McpServer::new, so a sessionmax_callsbounds the whole session — the fix made real through the MCP boundary.invokeis a stateless one-shot: it mints an ephemeral grant per call, preserving the documented per-callmax_callssemantics. (A session-scoped Python budget would need a persistent handle object — future work.)Test plan
New regression tests (fail before, pass after):
dispatch_budget_persists_across_dispatches— the AB-001 proof:AtMost(2)denies the 3rd dispatch on one grant.two_grants_equal_caveats_have_independent_budgets— identity-keyed, not value-keyed.denied_dispatch_does_not_charge_budget— refund covers both gate-level and in-tool argument denials.concurrent_dispatches_on_one_grant_never_overspend— 64 threads race one grant; exactly the budget is admitted (mutex check-and-decrement; no overspend, no lost decrement).Full gate green locally:
cargo test(core 138 + 4 new, mcp 26, tool-shell, umbrella doctest), Python wheelpytest(18),cargo fmt --check,cargo clippy -D warnings(all crates), and the pre-push Lean formal-proof gate (157 security theorems within[propext, Quot.sound]).Fixes #264
Note
High Risk
Changes the central dispatch API and capability budget enforcement at the registry choke point; incorrect ledger or refund logic could over- or under-enforce
max_callsacross hosts (MCP, Python, embedders).Overview
Fixes AB-001:
max_callsno longer resets on everyRegistry::dispatchbecause each call used to mint a freshGateseeded from the grant.Grant/GrantIdare added so dispatch takes&Grantinstead of bare&Caveats. Hosts callmint_grantonce and reuse the grant for a session-wide budget; budgets are keyed by unforgeable grant id (equal caveats ⇒ independent ledgers).The registry now owns a mutex-protected ledger that charges before authorize/invoke and refunds on gate or in-tool
Denied(failed runs stay charged). Per-dispatch gates use unlimited budget; counting lives only on the ledger.MCP mints one session grant in
McpServer::new. Pythoninvokestill mints an ephemeral grant per call for one-shot semantics. Call sites and tests are updated accordingly.Reviewed by Cursor Bugbot for commit 817dbb3. Configure here.