docs / guide

view as .md

Capabilities (delegation)#

A capability deed carries a bounded grant on top of the identity every deed already proves — not just "who is calling" but "what this specific caller is allowed to do, and until when." One principal delegates a narrowed slice of its own authority to another identity by signing a Delegation; the receiving identity attaches that link to its own deed and presents it. Your app verifies the whole chain in one call and gets back the caller's identity plus the grants that actually apply — then enforces each request against them with CapabilityGuard. If you haven't already, read Concepts for the deed mental model this builds on.

The runnable reference this guide describes lives at examples/mcp-server/ (capability-principal.mjs, capability-principal-zk.mjs, capability-worker.mjs, capability-chain.mjs (★ preview — see "Structure-hiding delegation" below), and the capability-gated routes in server.mjs) — every excerpt below is transcribed from those files.

The grant grammar#

{
  "res": "mcp://acme/tools/*",
  "act": ["call"],
  "cav": { "exp": 1786015180, "max_uses": 100, "nb": { "tool": "search|fetch" } }
}

A Grant names a resource pattern (res), the actions allowed on it (act), and caveats (cav) bounding it:

  • res is an exact string, or a prefix ending in one trailing * (mcp://acme/tools/* matches mcp://acme/tools/search, not mcp://acme/other).
  • act is the set of actions this grant covers on that resource ("call", "write", whatever your app's own vocabulary is).
  • cav.exp bounds when the grant stops being usable; cav.max_uses is carried on every grant your guard inspects but is not enforced by the guard itself — counting real uses needs durable state (a DB row, a Redis counter) that only your app can own; cav.nb (narrow-by) is a set of named predicates — {"tool": "search|fetch"} means the request's tool argument must be exactly search or fetch.

A delegated grant can only ever get narrower, never wider: a child link in a chain must name a resource pattern, an action set, and caveats that are each a subset of its parent's. A grant with no caveat at all is the widest possible caveat, so it may only appear at the root of a chain.

Delegating — delegate#

A principal signs a Delegation handing another identity a narrowed grant, valid until exp, tagged with the revocation cohort it was signed against (epoch_label/epoch — see Revocation below):

import { delegationBinding, parentRefHashRoot, delegate } from "@grantor/sdk";

const grant = {
  res: "mcp://acme/tools/*", act: ["call"],
  cav: { exp, max_uses: 100, nb: { tool: "search|fetch" } },
};
const grantsJson = JSON.stringify([grant]);
const parentRefHash = parentRefHashRoot(iss); // the first link in a chain
const bindingHex = delegationBinding(tenantId, iss, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash);
const signatureHex = await sign(bindingHex); // your own secp160k1/EIP-191 signer

const link = delegate(
  tenantId, iss, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash,
  undefined, // pubkey — omitted for a recoverable EIP-191/secp256k1 signer
  signatureHex,
);

A delegation chain's root must currently be a registered agent keyGrantorRegistry.registerAgentKey(tenantId, commitment), where commitment is keccak256("0x{lowercase 20-byte address}"), the delegator's own address string. This is the SAME on-chain mapping admin-sig deeds check, under a DIFFERENT preimage than agent-zk's CAIP-10 commitment — a key registered for one does not automatically root a chain for the other. There is no permissionless-wallet-root path yet; a chain rooted at an unregistered or revoked key fails verification (BadDelegation).

A chain can have more than one link — an agent that received a grant can delegate a narrower slice of it onward, the same way — but every link after the root must narrow the immediately preceding link's grants, and the last link's to must equal the presenting identity's own proven sub.

Anonymous delegation — delegateZk#

import { ZkAgent } from "@grantor/agent";
import { parentRefHashRoot } from "@grantor/sdk";

const agent = ZkAgent.fromAgentKeySignature(agentKeySignatureBytes);
const grantsJson = JSON.stringify([grant]);

// A delegation's `iss` is a Semaphore nullifier fixed by (identity, tenant)
// alone — computed before the proof, but only ever returned FROM one. Mint
// once (any parent hash — even a placeholder `zeroHash` — works to learn
// `iss`; that proof is discarded), then mint again with the correct parent
// hash now that `iss` is known.
const probe = JSON.parse(await agent.delegateZk(
  rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, zeroHash,
));
const parentRefHash = parentRefHashRoot(probe.iss);
const link = JSON.parse(await agent.delegateZk(
  rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash,
));

A member of tenant T can grant another identity a bounded slice of authority without revealing which member of the tenant it is. Instead of a recoverable EIP-191 signature, delegateZk authenticates the link with a real Semaphore membership proof over the SAME on-chain ZK tree ZkAgent.mintDeed proves against — the proof is the authentication, so a zk-kind Delegation carries no sig/pubkey at all. Its iss is a delegation nullifier, not an address or a commitment: a per-tenant pseudonym, deterministic for a given identity but unlinkable to the commitment the tenant admin registered.

Your app verifies a zk-rooted chain through the exact SAME entry point as a signed one — verifyWithCapabilityAt/verify_deed_with_capability — no separate code path, no separate route. The claims it gets back name the delegate's proven identity as usual; the only thing it ever learns about the anonymous delegator is that nullifier, printed here as the delegator's pseudonym, never a member identity:

delegator pseudonym (nullifier) iss=4629071830519134272348858930177220791871450050742923343585703717248052541528

Only the chain's root is checked against the registry — the same "root-only" gating a signed chain uses. An intermediate zk link's root is authenticated by its own nullifier and proof, not separately checked on-chain; recency is enforced once, where the chain's authority actually originates.

Three independent bounds, all live against the real registry:

  • exp bounds issuance, exactly as it does for a signed link — a link past its own exp stops verifying, no on-chain action needed.
  • bumpEpoch is the instant, bulk kill — identical mechanism to the signed leg's Revocation section: one transaction on the link's epoch_label invalidates it, and every other link in that cohort, immediately.
  • Revoking the member (GrantorRegistry.revokeZkAgent) invalidates every already-signed delegation rooted at it, immediately — it overwrites the on-chain root history for the WHOLE tenant tree, not just that one member, so the next root-recency check on an already-minted deed fails closed with a BadDelegation worded distinctly as a stale root ("not a currently-recent registry member"), independent of whatever epoch state that link was signed against.

Unlinkable across chains — delegateZk(..., salt)#

const grantsJson = JSON.stringify([grant]);

// Same two-call pattern as above, with one addition: a per-chain `salt` as
// the trailing argument to `delegateZk`.
const probe = JSON.parse(await agent.delegateZk(
  rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, zeroHash, salt,
));
const parentRefHash = parentRefHashRoot(probe.iss);
const link = JSON.parse(await agent.delegateZk(
  rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash, salt,
));

salt is an optional trailing string on delegateZk, in every language. It scopes the delegation nullifier further, so the SAME anonymous member delegating twice with two different salts produces two pseudonyms that share nothing — no field on either deed, or anywhere on the wire, ties them back to one delegator:

chain A (salt="chain-a"): delegator pseudonym iss=826493158991174290343971509743582095105037072152399465317093330796638885617
chain B (salt="chain-b"): delegator pseudonym iss=14936235482157490080433353871006983321048803913765619935150511037695437371846

Omitting salt (or passing undefined/null, depending on the language) reproduces the one stable, recognizable pseudonym this member already uses for the tenant — the same iss delegateZk returns with no salt today. Which one you want is a per-chain choice, not a global setting: mint a fresh salt for a chain you want unlinkable from the member's other chains, or leave salt off for a chain where staying recognizable — the same delegator every time — is the point.

Verification does not change: verifyWithCapabilityAt recomputes the nullifier scope from whatever salt the deed itself carries, so a salted and an unsalted chain from the same member both verify through the exact same call. A salt tampered after minting fails the same way a tampered grant does — the proof was built over the original value, so a verifier recomputing against a different one lands on the wrong nullifier scope and rejects with BadDelegation.

Presenting — mintCapabilityDeed#

The identity receiving a delegation attaches it to its own already-minted deed (a user-sig login, an agent-zk proof, whatever mode it authenticates with) and presents the result instead of the bare identity deed:

import { mintCapabilityDeed } from "@grantor/sdk";

const baseDeed = await authenticate(origin, signMessage, { chainReader, registry, rpcUrl });
const capabilityDeed = mintCapabilityDeed(baseDeed, grantsJson, JSON.stringify([link]));

grantsJson here is the presenter's own asserted grants — normally the same grant it received, sometimes a narrower slice of it. mintCapabilityDeed does not itself check that the assertion is a legal narrowing; that check is what verification does next, so a forged or widened assertion is caught at the one place that matters, not trusted at mint time.

Verifying — verifyWithCapabilityAt#

Your app verifies a capability deed with one call, alongside the ordinary identity check — it walks the chain root to leaf, recovers each link's signer cryptographically (never trusts the claimed iss), checks every narrowing step, and confirms the root is still an enrolled, non-revoked agent key:

const { claims, grants } = await verifier.verifyWithCapabilityAt(deedJson, challenge, now);

claims is the same identity verify_deed already gives you; grants is the chain's effective authority — [] for an identity-only deed with no delegation attached, so this call is a strict superset of the plain identity check and safe to use everywhere, not just on capability-gated routes.

Enforcing — CapabilityGuard#

grantorExpress ships a matching pair of middleware: protectCapability verifies the presented deed and attaches req.deed/req.grants; requireCapability checks one route's own resource/action against those grants, denying with CapabilityGuard.authorize's rules if nothing covers the request:

app.post(
  "/mcp/tools/:tool",
  g.protectCapability,
  g.requireCapability("mcp://acme/tools/*", "call", {
    argsFrom: (req) => ({ tool: req.params.tool }),
  }),
  (req, res) => res.json({ tool: req.params.tool, sub: req.deed.sub }),
);

A request to /mcp/tools/search or /mcp/tools/fetch passes — the grant's nb: {"tool": "search|fetch"} predicate matches. A request to /mcp/tools/write is refused: the deed itself is genuine (verification already succeeded), it just carries no grant that covers tool=writeCapabilityDenied, HTTP 403, deliberately distinct from every other code in this table, which means "this deed did not check out" and maps to 401/503 instead. See Errors for the full table.

Calling CapabilityGuard.authorize directly (outside Express, or from a non-HTTP call site) looks the same, against grants you already verified:

import { CapabilityGuard } from "@grantor/verify";

CapabilityGuard.authorize(grants, {
  res: "mcp://acme/tools/search", act: "call", args: { tool: "search" }, now,
});

Revocation#

Three independent mechanisms bound how much a delegation can do and for how long, at three different scopes:

  • Caveats bound the blast radius of one grant. res/act/nb narrow exactly what a delegated grant covers — a grant scoped to {tool: "search|fetch"} never covers a write call no matter how it is presented.
  • Each link's own exp bounds its window. A delegation link — and the leaf deed it is attached to — simply stops verifying once its own exp passes, no on-chain action needed.
  • bumpEpoch is the instant, bulk kill. Every Delegation is signed against a revocation cohort (epoch_label, defaulting to the tenant-wide cohort) at a specific epoch. The tenant admin calls GrantorRegistry.bumpEpoch(tenantId, label) to increment that counter — one transaction invalidates every outstanding link signed against the old value for that cohort, immediately, with no per-link bookkeeping:
RESULT: {"search":{"status":401,"body":{"error":"EpochRevoked", …}}, …}

Your verifier's own chain-read cache (if you run one) bounds how quickly a bumpEpoch is observed — a cached delegation_epoch read serves the old value until its TTL expires, the same tradeoff the tenant-active and root-recency caches already make. The revocation itself is instant on-chain; your app's cache TTL is the only thing standing between the transaction and your next verification seeing it.

Structure-hiding delegation (preview)#

⚠️ PREVIEW. This mode uses a single-contributor, deterministic trusted setup — not a multi-party ceremony. The toxic waste is not assumed discarded, and the circuit is pending external cryptographic review. Treat every zk-chain proof as unauthenticated for anything beyond a demo, and do not use this mode for production authority. Every other mode on this page (delegate, delegateZk) is unaffected.

Every mode above still reveals something about the chain's shape: a Delegation array on the wire, one link per hop, each carrying a real signature or proof. Structure-hiding delegation collapses an entire chain — however many hops, however it narrows — into ONE Groth16 proof and one opaque effective-grant commitment (g_eff). The deed that reaches your server carries no delegations array and no way to recover a hop count from the wire at all.

// Holder side (a script that plays every role in the chain — see
// examples/mcp-server/capability-chain.mjs): authorize N hops, fetch the
// root's real on-chain membership witness, and prove.
import { ChainHopAuthorizer, proveDelegationChain } from "@grantor/agent";

const auth = new ChainHopAuthorizer(tenantId, epoch);
const hop0 = auth.authorizeHop(member, agentA.publicHex(), grantJson);
const hop1 = auth.authorizeHop(agentA, agentB.publicHex(), grantJson);
const hop2 = auth.authorizeHop(agentB, worker.publicHex(), grantJson);

const deedJson = proveDelegationChain(
  `[${hop0},${hop1},${hop2}]`, siblingsHex, isLeft, worker,
  rootHex, challenge, tenantId, audience, deedExp, epoch,
);

// RP side: verify the proof for real, then OPEN and enforce g_eff.
const claims = await verifier.verifyDelegationChainAt(deedJson, challenge, now);
const gEff = arithGrantCommit(JSON.stringify(claimedGrantValues));
if (gEff !== claims.sub) throw new Error("does not open the proven commitment");
CapabilityGuard.authorize([openedGrant], { res, act, args, now });

verifyDelegationChainAt is a dedicated entry point — it never touches verify_deed/verify_deed_with_capability, and a non-zk-chain deed is refused there either way, the same isolation admin-sig gets for a different reason. It does real Groth16 verification against a committed verifying key, plus the exact same tenant/root/epoch chain gates every other mode uses — no new contract, no new gate method.

Opening g_eff is your job, not the verifier's#

The in-circuit narrowing checks each hop's caveat-predicate commitment (nb_commit) by equality, never by opening it — so a Groth16-verified zk-chain deed has proven what authority exists (an opaque commitment), not that this specific request is covered by it. verifyDelegationChainAt resolves sub to that commitment and stops there on purpose. Your app must:

  1. build the GrantValues ({res_id, act_mask, exp, max_uses, nb_commit}) it believes the chain committed to — from its own interning tables, never from anything an untrusted caller asserts (see the next section);
  2. recompute arithGrantCommit over those values and require it equal claims.sub — only a value that opens the real commitment is ever enforced;
  3. run the opened grant through CapabilityGuard.authorize exactly like a P0/P1 effective grant — same res/act/nb rules, same 403 CapabilityDenied on a miss.

crates/grantor-verify/src/capguard.rs's CapabilityGuard::authorize_zk_chain is the Rust-native version of this same two-step split; arithGrantCommit (@grantor/verify) is the wasm export that lets TypeScript perform the same Poseidon-hash check without reimplementing it.

The res-interning contract is yours to keep#

A circuit sees five field elements, not a resource glob or a JSON caveat map. Your app assigns the meaning — which res_id is which resource pattern, which bit of act_mask is which action, which nb_commit is which predicate set — the same way examples/mcp-server/server.mjs's zk-chain route keeps a small fixed table (res_id: 7"mcp://acme/tools/*", bit 0 ⇒ "call", one nb_commit id ⇒ {tool: "search|fetch"}) and refuses anything outside it. This module cannot validate that mapping for you — a mis-interning is an integration bug in your app, not a circuit bug.

The WireGrant precision ceiling#

exp/max_uses cross the wire as plain JSON numbers, not hex — so they must stay under 2^53 - 1 (Number.MAX_SAFE_INTEGER). Both are naturally small (a unix timestamp, a usage cap), so this is not a practical limit, but WireGrant's parser refuses a larger value outright rather than proving over one a JS caller's own runtime already silently corrupted.

Cost: real proving, every time#

Structure hiding costs a real Groth16 proof, not a signature check — ~18-22 seconds measured in Node (wasm), ~0.8 seconds native. This is server-side/agent-side work, not something you'd ask a browser tab to do on every click; the reference exchanges one proof for a short-lived session bearer (POST /auth/token/zk-chain) rather than re-proving per tool call, the same "deed is the login, bearer is the session" shape every other mode uses.

Run the proof#

just verify-ts agent-ts sdk-ts   # build the wasm packages the example imports (once)
just caps-e2e                     # delegate -> present -> enforce -> revoke, all live

just caps-e2e runs examples/mcp-server/caps-e2e.sh, which deploys GrantorRegistry, creates and funds a tenant, and starts the reference server. A worker learns its own stable sub; a principal registers itself as an on-chain agent key and delegates it "call tools search|fetch, up to 100 uses, expiring in about an hour"; the worker presents the resulting capability deed and calls all three routes — search and fetch succeed, write is refused with CapabilityDenied. The tenant admin then calls bumpEpoch on the delegation cohort, and the SAME already-signed delegation is presented again: every call now fails with EpochRevoked, proving the bulk revocation without touching a single link.

The same script then runs the anonymous leg: a member registers into the tenant's ZK tree, delegates the identical grant via delegateZk instead, and the worker presents the resulting deed to the same three routes with the same outcome — the transcript prints the delegator's pseudonym, never its commitment or address. The tenant admin then proves both zk revocation channels live, on that SAME already-signed deed: bumping its cohort's epoch (EpochRevoked), and separately revoking the member itself (BadDelegation, worded as a stale root) — two independent reasons the same deed stops verifying.

Finally, the SAME member delegates to two workers on two salted chains and a third with no salt at all: the two salted deeds print two different pseudonyms — proven to differ, both verifying and enforcing exactly like every other leg above — and the no-salt deed reproduces the one stable pseudonym the anonymous leg already printed earlier in the same run.

The same script then proves structure-hiding delegation (★ preview) live: a member registers into the SAME on-chain ZK tree, capability-chain.mjs authorizes a 3-hop chain (member → agent A → agent B → worker) entirely in its own process and produces ONE Groth16 proof, and the server verifies it, opens g_eff against its own interning table, and enforces the identical search/fetch allowed, write denied outcome through the SAME CapabilityGuard — proving is real, ~18-22 seconds measured, so this leg is the slowest in the script. Two revocation legs then each prove a FRESH deed (a zk-chain deed's challenge is bound into the proof, so an already-presented one cannot demonstrate a second failure): bumpEpoch on the chain's own cohort (EpochRevoked) and revokeZkAgent on the root member (StaleRoot).

See also#

  • examples/mcp-server/README.md — the full MCP reference, including the identity modes capability deeds build on top of.
  • ErrorsBadDelegation, EpochRevoked, CapabilityDenied.
  • Verify a deed — the plain identity check this extends.
  • Concepts — the deed mental model.

This page is also served as Markdown — agents should read that. The whole tree is indexed for machines in llms.txt.