@aroha-sdk/delegation
v0.2.0
Published
Turnkey multi-hop delegation for Aroha agent networks — verified mandate chains, auto-attenuating ctx.delegate(), depth enforcement, and stitched receipts on top of @aroha-sdk/run.
Maintainers
Readme
@aroha-sdk/delegation
Turnkey multi-hop delegation for agent networks. Build webs of agents — A delegates to B, which delegates to C and D — where every hop carries signed, verifiable, shrinking authority, and a receipt tree comes back to whoever started it.
The cryptographic primitives live in @aroha-sdk/credentials; the single-agent server lives in @aroha-sdk/run. This package is the composition layer that turns "multi-hop agent web" from a custom verify/attenuate/call pipeline in every node into a few lines per node.
A (root issuer) ──► B (orchestrator) ──► C (searcher)
└─► D (summariser)A node in the web
import { serveDelegated, staticResolver } from "@aroha-sdk/delegation";
serveDelegated("orchestrator", {
identity: { did: "did:aroha:acme:orchestrator", privateKey: myKey },
trustAnchors: { "did:aroha:human:alice": alicePublicKeyB64 },
resolvePublicKey: registryResolver(), // or staticResolver({...}) for closed networks
}, async (ctx) => {
ctx.assertCapability("research"); // throws unless the verified mandate allows it
// Delegate onward — authority is attenuated automatically:
// subset scope, depth − 1, blocked list carried forward, same correlationId
const search = await ctx.delegate(searcherDidHash, ctx.message,
{ allowed: ["web-search"] });
const summary = await ctx.delegate(summariserDidHash, search.message,
{ allowed: ["summarise"] });
return summary.message;
}).start(8000);Every request to this agent must carry a valid mandate chain or it is rejected with 400 before your handler runs. Fail closed, always.
Starting a chain (the root issuer)
import { issueDelegation, callDelegated } from "@aroha-sdk/delegation";
const { envelope } = await issueDelegation(
{ did: "did:aroha:human:alice", privateKey: aliceKey },
orchestratorDid,
{
allowed: ["research", "web-search", "summarise"],
constraints: { maxDelegationDepth: 1 }, // B may delegate once; C/D may not
ttlMs: 60_000,
},
);
const res = await callDelegated(orchestratorEndpoint, "history of agent protocols", envelope);
console.log(res.message);
console.log(res.receipts[0]); // B's receipt, with C's and D's nested in .childrenWhat the chain guarantees
Each hop appends one signed mandate to the envelope riding context.aroha. verifyMandateChain() — run automatically by every serveDelegated node — checks the whole path, root → leaf:
| Check | Attack it stops |
|---|---|
| Every link's Ed25519 signature | Forged or tampered mandates |
| Root key pinned to trustAnchors | An attacker minting their own "root" |
| grantor(i) === grantee(i−1) + parentMandateId linkage | Splicing a mandate from another chain |
| allowed(i) ⊆ allowed(i−1) | Scope widening mid-chain |
| blocked list must survive every hop | Laundering a ban through a sub-agent |
| Child expiry ≤ parent expiry | Zombie authority outliving its grant |
| maxDelegationDepth strictly decrements | Runaway agent-spawns-agent recursion |
Depth is enforced here, at the chain level — maxDelegationDepth: 0 means the mandate is a dead end, and ctx.delegate() refuses with DELEGATION_DEPTH_EXCEEDED before a child token is even signed.
Receipts come back as a tree
Every node builds a TaskReceipt automatically (actions, violations, timings) and returns it as a response artifact. ctx.delegate() collects downstream receipts and nests them, so the root issuer receives the whole execution tree under one correlationId:
orchestrator (complete)
├─ searcher (complete) — actions: web-search
└─ summariser (complete) — actions: summariseRunnable example
A full A → B → (C, D) web on localhost — including a blocked over-delegation, a rejected forged root, and an out-of-grant refusal — ships in the repo at examples/delegation-web/demo.mjs.
Mandate verification without @aroha-sdk/run
serveDelegated above wraps @aroha-sdk/run's serve() end to end — the
right choice when you want its streaming/approval/receipt machinery too. If
you're hosting with a raw ArohaServer from @aroha-sdk/core instead and
just need mandate-chain verification, mount it directly as middleware — the
same composability pattern @aroha-sdk/credentials'
createRbacMiddleware() already uses:
import { ArohaServer } from "@aroha-sdk/core";
import { createDelegationMiddleware, getVerifiedMandate } from "@aroha-sdk/delegation";
const server = new ArohaServer({
agentDID: "did:aroha:acme:orchestrator",
didDocument,
port: 8000,
resolvePublicKey,
middleware: [
createDelegationMiddleware({
did: "did:aroha:acme:orchestrator",
trustAnchors: { "did:aroha:human:alice": alicePublicKeyB64 },
}),
],
onMessage: async (envelope, respond) => {
const verified = getVerifiedMandate(envelope); // undefined only if require:false and none was sent
// ...
},
});No @aroha-sdk/run dependency in this path. You lose serveDelegated's
automatic receipt stitching and ctx.delegate() convenience — this only
answers "is there a valid mandate chain granting the caller access," the
same boundary RBAC draws around permission checks.
Wire format
No new protocol: the envelope rides the existing RunRequest.context under the aroha key, so any /v1/run-speaking agent can participate.
{ "message": "…", "context": { "aroha": {
"v": 1,
"chain": ["<rootToken>", "<childToken>", …],
"rootKeyB64": "<root issuer's Ed25519 public key>",
"correlationId": "…"
}}}License
MIT © Aroha Labs
