Skip to content

fix(core): enforce max_calls across dispatches via a per-grant ledger (#264/AB-001) - #309

Open
hartsock wants to merge 1 commit into
mainfrom
fix/ab-001-grant-budget-ledger
Open

fix(core): enforce max_calls across dispatches via a per-grant ledger (#264/AB-001)#309
hartsock wants to merge 1 commit into
mainfrom
fix/ab-001-grant-budget-ledger

Conversation

@hartsock

@hartsock hartsock commented Jul 28, 2026

Copy link
Copy Markdown
Member

Summary

AB-001 (adversarial audit): a max_calls bound was silently unbounded across a session. Each Registry::dispatch built a fresh Gate seeded from the grant's max_calls, so AtMost(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 unforgeable GrantId) 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_floor now take &Grant and charge a max_calls balance keyed by the grant's id, seeded create-on-first-use, decremented under a Mutex.

Design (owner decision "A")

  • Budget keyed by grant identity, not caveat value — two grants minted from equal caveats hold independent budgets.
  • GrantId constructor is private. Core is deliberately rng-less; the private constructor is the unforgeability guarantee (a counter gives every mint a distinct id).
  • Charge-first, refund-on-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

  • 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. (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 wheel pytest (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_calls across hosts (MCP, Python, embedders).

Overview
Fixes AB-001: max_calls no longer resets on every Registry::dispatch because each call used to mint a fresh Gate seeded from the grant.

Grant / GrantId are added so dispatch takes &Grant instead of bare &Caveats. Hosts call mint_grant once 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. Python invoke still 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.

…#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>
@hartsock hartsock added the risk:high High-risk change label Jul 28, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ 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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 817dbb3. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:high High-risk change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AB-001: max_calls is a per-dispatch counter, not a cross-dispatch budget

1 participant