@agent-custody/receipts 0.5.9 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -228,6 +228,7 @@ src/config.ts gateway and SDK config schemas, path resolution
228
228
  src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
229
229
  src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
230
230
  src/log-check.ts the outside monitor: verifies the head, checkpoints, and witness of a running log
231
+ src/portal.ts the tenant portal: register, first key, usage against plan, keys, Stripe billing, export, on the log's Postgres
231
232
  src/log-export.ts a tenant's export of their own log, self-checked, as a log file the verifier reads
232
233
  src/witness.ts the witness: countersigns the log's checkpoints from another operator's machine, or refuses with an alarm
233
234
  src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
@@ -238,7 +239,8 @@ src/policy.ts Cedar evaluation wrapper, fail-closed
238
239
  src/delegation.ts signed delegation grants
239
240
  src/receipt.ts receipt and authorization statement types and provenance labels
240
241
  src/issue.ts sign, log, and write a receipt, or commit an authorization first; shared by both producers
241
- src/gateway.ts the MCP proxy: scope check, facts, policy, forward, receipt
242
+ src/gateway.ts the MCP proxy: a host (key, policy, upstreams, log) and a session per grant; scope check, facts, policy, forward, receipt
243
+ src/gateway-http.ts the gateway over Streamable HTTP: one process, a session per connection, each under the grant it presents
242
244
  src/sdk/index.ts the interceptor: policy decision, record, wrap(tool fn)
243
245
  src/sdk/claude.ts Claude Code command hook and Claude Agent SDK in-process hooks
244
246
  src/sdk/openai-agents.ts, vercel-ai.ts, langchain.ts framework adapters, tested against the real packages
@@ -286,6 +288,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
286
288
  - Remote log: the issuer can append to a log run by someone else over HTTP, whose key then signs the tree heads, so a verifier learns the receipt was in a log the operator could not rewrite. Includes the reference log server, bearer-token auth, and a root endpoint for auditors.
287
289
  - Framework adapters, each tested against the real package with a scripted model and no network: OpenAI Agents SDK (`wrapTools` enforces, `observeRunner` records from lifecycle events), Vercel AI SDK (`wrapTools` over a real `generateText` loop), LangChain (`ReceiptCallbackHandler` records, `issuer.wrap` enforces).
288
290
 
291
+ - Plans and the tenant portal: every tenant is on a plan (free, ten thousand appends a month; team, a million; enterprise, no allowance) enforced at append with a clear 429; the portal at the operator's `PORTAL_HOST` lets a team register, get its tenant and first key, watch usage against the plan, mint and revoke keys, buy the team plan through Stripe, and copy the export command, with every action in the audit trail.
289
292
  - An audit trail of administrative actions: every tenant created or disabled and every token minted or revoked is recorded with who did it, from the admin page or the command line, shown on the page and carried in the tenant's export.
290
293
  - A tenant's export: `log-export` takes, with the tenant's own token, every leaf hash, the signed head, the published keys, the checkpoints, and their usage, checks that they add up, and writes a log copy the verifier reads offline; the evidence never depends on the operator staying in business.
291
294
  - Monitoring and metering: `log-check`, the outside probe that verifies the head, the checkpoints, and the witness and exits 1 on trouble, run every ten minutes by the `monitor` workflow; `GET /health`; and usage per tenant per month on the admin page and as CSV.
@@ -294,6 +297,8 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
294
297
  - The log over Postgres: `log --db-env`, leaves as hashes in one table keyed by tenant, one writer per tenant by advisory lock, tenants and hashed tokens in tables managed by `log-admin`, rate limits and a body cap, retries in the sink, and `import` for an existing file log. Phase 2 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
295
298
  - A log for someone else: `hashOnly` sends leaf hashes so the log never holds a receipt; the reference server runs several tenant logs at `/t/<tenant>/` with their own tokens and ids; tree heads name their log and the verifier checks it with `--log-id`. Phase 1 of the hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
296
299
  - OpenTelemetry export: with `otel` in either config, every receipt is also one span at the collector the team already runs, trace id equal to the receipt id, attributes for tool, agent, principal, status, decision, and log position; after the receipt, best effort, never on the evidence path.
300
+ - Delegation chains for sub-agents: a grant that names the agent's key lets it delegate a narrower grant, with the parent embedded, up to three deep; the verifier and the gateway walk the chain to the principal, refusing any link that escalates scope, widens the window, changes the principal, or is signed by the wrong key; the receipt names the sub-agent and the principal and carries the chain. Pinned by the `gateway-chain-*` vectors and mirrored in the browser verifier.
301
+ - One gateway for many agents: `gateway --http` serves the gateway over Streamable HTTP, one session per connection under the grant that connection presents, sessions sharing the upstreams and the policy and nothing else; a platform team runs one gateway in front of the tools instead of one process per agent.
297
302
  - Splunk export: with `splunk` in either config, every receipt is also one event at the HTTP Event Collector, with the receipt id, tool, agent, principal, status, decision, and log position as searchable fields and the token from the environment; the same best-effort rule.
298
303
  - The REST connector: a plain HTTP API described as tools in the gateway config, credentials from the environment, so an agent's direct API calls become receipted, policy-checked tool calls through the gateway.
299
304
  - Pre-commit authorization for consequential tools: named in `precommit`, a call is signed and logged before it is forwarded, withheld if the log will not take it, and its receipt carries the committed authorization with proof that it precedes the execution.
@@ -302,9 +307,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
302
307
 
303
308
  1. Run the witness for log.agent-custody.dev on a machine and under an account that is not ours, and require it in the welcome sheet. The code is done; what it needs is a second operator. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
304
309
  2. Post-quantum signatures: ML-DSA beside Ed25519 in the same DSSE envelope, hybrid by default when a PQ key is present, in every signed artefact and in the browser verifier. [Issue #11](https://github.com/ch4r10t33r/agent-custody/issues/11).
305
- 3. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
306
- 4. Delegation chains for sub-agents.
307
- 5. Receiver-attested receipts for agent-to-agent calls.
308
- 6. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
310
+ 3. Receiver-attested receipts for agent-to-agent calls.
311
+ 4. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
309
312
 
310
313
  A Python SDK follows the same shape once the TypeScript adapters have settled.
package/dist/cli.js CHANGED
@@ -5,14 +5,16 @@ import { readFileSync, writeFileSync } from "node:fs";
5
5
  import { dirname, resolve } from "node:path";
6
6
  import { loadConfig, loadSdkConfig } from "./config.js";
7
7
  import { generateKeyPair, loadPrivateKey, loadPublicKey, writeKeyPair } from "./crypto.js";
8
- import { createDelegation } from "./delegation.js";
9
- import { createGateway, serveStdio } from "./gateway.js";
8
+ import { createDelegation, decodeDelegation, delegateFrom } from "./delegation.js";
9
+ import { createGateway, createGatewayHost, serveStdio } from "./gateway.js";
10
10
  import { postgresResolver, serveLog } from "./log-sink.js";
11
11
  import { importLogFile, PostgresTenancy } from "./log-store.js";
12
12
  import { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
13
13
  import { connectSigner, fetchLogKeys, localSigner, serveSigner } from "./signer.js";
14
14
  import { fetchWitnessKeys, Witness } from "./witness.js";
15
15
  import { checkLog, formatLogCheck } from "./log-check.js";
16
+ import { serveHttp } from "./gateway-http.js";
17
+ import { servePortal } from "./portal.js";
16
18
  import { exportLog, formatExport } from "./log-export.js";
17
19
  import { CheckpointPublisher, fileResolver } from "./log-sink.js";
18
20
  import { createRequire } from "node:module";
@@ -33,8 +35,13 @@ function secretFrom(envName) {
33
35
  const USAGE = `agent-custody <command>
34
36
 
35
37
  keygen --dir <dir> --name <name>
36
- grant --key <principal.key> --principal <id> --agent <id> --scopes <a,b> [--ttl-hours 24] --out <file>
37
- gateway --config <gateway.json>
38
+ grant --key <principal.key> --principal <id> --agent <id> --scopes <a,b> [--ttl-hours 24] [--agent-key <agent.pub>] --out <file>
39
+ --agent-key names the agent's own key in the grant, so the agent may delegate
40
+ delegate --key <agent.key> --parent <grant.json> --agent <sub-agent> --scopes <a,b> [--ttl-hours N] [--agent-key <sub.pub>] --out <file>
41
+ a narrower grant for a sub-agent, signed by the agent the parent names; the parent travels inside
42
+ gateway --config <gateway.json> [--http [--port 8790] [--host 127.0.0.1] [--idle-minutes 30]]
43
+ stdio: one gateway for the grant the config names. --http: one shared gateway, MCP over
44
+ Streamable HTTP at /mcp, each connection presenting its own grant as Authorization: Bearer
38
45
  hook [--config <sdk.json>] Claude Code hook command; reads the event on stdin (or AGENT_CUSTODY_CONFIG)
39
46
  serve --config <sdk.json> [--port 8788] [--host 127.0.0.1] the SDK as a local HTTP API for agents in other languages
40
47
  prune --log <log.jsonl> --before <ISO instant> [--receipts <dir>]
@@ -64,9 +71,12 @@ const USAGE = `agent-custody <command>
64
71
  into <dir>; refuses and writes an alarm otherwise. Serve <dir> from a host of your own.
65
72
  signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
66
73
  the one process that holds the log's key: POST /sign, GET /keys
67
- log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
74
+ log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id> | tenant plan <id> <free|team|enterprise>
68
75
  log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
69
- log-admin --db-env NAME audit [--tenant <id>] who did what to tenants and tokens, newest first
76
+ log-admin --db-env NAME audit [--tenant <id>]
77
+ portal --db-env NAME --secret-env NAME --public-url <log url> [--checkpoints-url <url>] [--portal-url <url>] [--port 8792] [--host 127.0.0.1]
78
+ [--stripe-key-env NAME --stripe-webhook-env NAME --stripe-price-team <price id>] [--trust-proxy]
79
+ the tenant portal: register, first key, usage against plan, keys, billing, export who did what to tenants and tokens, newest first
70
80
  log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
71
81
  audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--witness-key <pub> | --witness-url <url>] [--json]
72
82
  with --log-url the log's published keys are fetched and pinned by keyid; with a witness key or
@@ -126,6 +136,7 @@ async function main(argv) {
126
136
  agent: { type: "string" },
127
137
  scopes: { type: "string" },
128
138
  "ttl-hours": { type: "string", default: "24" },
139
+ "agent-key": { type: "string" },
129
140
  out: { type: "string" },
130
141
  },
131
142
  });
@@ -139,15 +150,41 @@ async function main(argv) {
139
150
  scopes: values.scopes.split(",").map((s) => s.trim()).filter(Boolean),
140
151
  issuedAt: new Date(now).toISOString(),
141
152
  expiresAt: new Date(now + Number(values["ttl-hours"]) * 3600_000).toISOString(),
153
+ ...(values["agent-key"] ? { agentKey: readFileSync(values["agent-key"], "utf8") } : {}),
142
154
  });
143
155
  writeFileSync(values.out, JSON.stringify(env, null, 2));
144
156
  console.log(`wrote ${values.out}`);
145
157
  return 0;
146
158
  }
159
+ case "delegate": {
160
+ const { values } = parseArgs({ args: rest, options: { key: { type: "string" }, parent: { type: "string" }, agent: { type: "string" }, scopes: { type: "string" }, "ttl-hours": { type: "string" }, "agent-key": { type: "string" }, out: { type: "string" } } });
161
+ if (!values.key || !values.parent || !values.agent || !values.scopes || !values.out)
162
+ throw new Error("delegate needs --key --parent --agent --scopes --out");
163
+ const parent = JSON.parse(readFileSync(values.parent, "utf8"));
164
+ const env = delegateFrom(parent, loadPrivateKey(values.key), {
165
+ agent: values.agent,
166
+ scopes: values.scopes.split(",").map((s) => s.trim()).filter(Boolean),
167
+ ...(values["ttl-hours"] ? { expiresAt: new Date(Date.now() + Number(values["ttl-hours"]) * 3600_000).toISOString() } : {}),
168
+ ...(values["agent-key"] ? { agentKey: readFileSync(values["agent-key"], "utf8") } : {}),
169
+ });
170
+ writeFileSync(values.out, JSON.stringify(env, null, 2));
171
+ const chain = decodeDelegation(env);
172
+ console.log(`delegated to ${chain.agent}: scopes [${chain.scopes.join(", ")}] until ${chain.expiresAt}, under ${decodeDelegation(parent).agent}'s grant from ${chain.principal}`);
173
+ return 0;
174
+ }
147
175
  case "gateway": {
148
- const { values } = parseArgs({ args: rest, options: { config: { type: "string" } } });
176
+ const { values } = parseArgs({ args: rest, options: { config: { type: "string" }, http: { type: "boolean", default: false }, port: { type: "string", default: "8790" }, host: { type: "string", default: "127.0.0.1" }, "idle-minutes": { type: "string", default: "30" } } });
149
177
  if (!values.config)
150
178
  throw new Error("gateway needs --config");
179
+ if (values.http) {
180
+ const host = await createGatewayHost(loadConfig(values.config));
181
+ const running = await serveHttp(host, { port: Number(values.port), host: values.host, idleMs: Number(values["idle-minutes"]) * 60_000 });
182
+ console.error(`agent-custody gateway: ${running.url} keyid=${host.keyid} one session per grant; GET /health`);
183
+ await new Promise((resolve) => process.once("SIGINT", resolve));
184
+ await running.close();
185
+ await host.close();
186
+ return 0;
187
+ }
151
188
  const gw = await createGateway(loadConfig(values.config));
152
189
  console.error(`agent-custody gateway: agent=${gw.agentId} principal=${gw.delegation.principal} scopes=[${gw.delegation.scopes.join(", ")}]`);
153
190
  await serveStdio(gw);
@@ -228,6 +265,38 @@ async function main(argv) {
228
265
  await running.close();
229
266
  return 0;
230
267
  }
268
+ case "portal": {
269
+ const { values } = parseArgs({ args: rest, options: { "db-env": { type: "string" }, "secret-env": { type: "string" }, "public-url": { type: "string" }, "checkpoints-url": { type: "string" }, "portal-url": { type: "string" }, port: { type: "string", default: "8792" }, host: { type: "string", default: "127.0.0.1" }, "stripe-key-env": { type: "string" }, "stripe-webhook-env": { type: "string" }, "stripe-price-team": { type: "string" }, "trust-proxy": { type: "boolean", default: false } } });
270
+ if (!values["db-env"] || !values["secret-env"] || !values["public-url"])
271
+ throw new Error("portal needs --db-env, --secret-env, and --public-url");
272
+ const secret = process.env[values["secret-env"]];
273
+ if (!secret || secret.length < 32)
274
+ throw new Error(`environment variable ${values["secret-env"]} must hold a secret of at least 32 characters`);
275
+ let stripe;
276
+ if (values["stripe-key-env"] || values["stripe-webhook-env"] || values["stripe-price-team"]) {
277
+ if (!values["stripe-key-env"] || !values["stripe-webhook-env"] || !values["stripe-price-team"])
278
+ throw new Error("billing needs all three of --stripe-key-env, --stripe-webhook-env, --stripe-price-team");
279
+ const secretKey = process.env[values["stripe-key-env"]];
280
+ const webhookSecret = process.env[values["stripe-webhook-env"]];
281
+ if (!secretKey || !webhookSecret)
282
+ throw new Error("the Stripe key and webhook secret variables must both be set");
283
+ stripe = { secretKey, webhookSecret, priceTeam: values["stripe-price-team"] };
284
+ }
285
+ const client = openPostgres(values["db-env"]);
286
+ const tenancy = new PostgresTenancy(client);
287
+ let keyid;
288
+ try {
289
+ keyid = (await fetchLogKeys(values["public-url"])).keys[0]?.keyid;
290
+ }
291
+ catch {
292
+ // the log may not be reachable from here at start; the sheet then omits the keyid
293
+ }
294
+ const running = await servePortal({ tenancy, client, secret, publicUrl: values["public-url"], ...(values["checkpoints-url"] ? { checkpointsUrl: values["checkpoints-url"] } : {}), ...(values["portal-url"] ? { portalUrl: values["portal-url"] } : {}), ...(keyid ? { keyid } : {}), ...(stripe ? { stripe } : {}), trustProxy: values["trust-proxy"] }, { port: Number(values.port), host: values.host });
295
+ console.error(`agent-custody portal: ${running.url} log=${values["public-url"]} billing=${stripe ? "stripe" : "off"}${values["trust-proxy"] ? " trust-proxy" : ""}`);
296
+ await new Promise((resolve) => process.once("SIGINT", resolve));
297
+ await running.close();
298
+ return 0;
299
+ }
231
300
  case "log-admin": {
232
301
  const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { "db-env": { type: "string" }, "log-id": { type: "string" }, label: { type: "string" }, file: { type: "string" }, tenant: { type: "string", default: "default" } } });
233
302
  if (!values["db-env"])
@@ -241,7 +310,11 @@ async function main(argv) {
241
310
  }
242
311
  else if (what === "tenant" && verb === "list") {
243
312
  for (const t of await tenancy.listTenants())
244
- console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
313
+ console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} plan=${t.plan.padEnd(10)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
314
+ }
315
+ else if (what === "tenant" && verb === "plan" && args[0] && args[1]) {
316
+ const t = await tenancy.setPlan(args[0], args[1], actor);
317
+ console.log(`tenant ${t.id} on plan ${t.plan}`);
245
318
  }
246
319
  else if (what === "tenant" && verb === "disable" && args[0]) {
247
320
  await tenancy.disableTenant(args[0], actor);
package/dist/config.d.ts CHANGED
@@ -158,7 +158,7 @@ export declare const GatewayConfigSchema: z.ZodObject<{
158
158
  }, z.core.$strip>]>, z.ZodObject<{
159
159
  name: z.ZodString;
160
160
  }, z.core.$strip>>>>;
161
- grantFile: z.ZodString;
161
+ grantFile: z.ZodOptional<z.ZodString>;
162
162
  trustedPrincipalKeys: z.ZodArray<z.ZodString>;
163
163
  policyFile: z.ZodString;
164
164
  facts: z.ZodDefault<z.ZodArray<z.ZodObject<{
package/dist/config.js CHANGED
@@ -53,7 +53,8 @@ export const GatewayConfigSchema = z.object({
53
53
  upstream: UpstreamSchema.optional(),
54
54
  /** several upstreams behind one gateway and one grant; each tool name must belong to exactly one of them */
55
55
  upstreams: z.array(UpstreamSchema.and(z.object({ name: z.string().min(1) }))).min(1).optional(),
56
- grantFile: z.string(),
56
+ /** the one grant a stdio gateway serves; over HTTP each connection presents its own, and this is not needed */
57
+ grantFile: z.string().optional(),
57
58
  trustedPrincipalKeys: z.array(z.string()).min(1),
58
59
  policyFile: z.string(),
59
60
  facts: z.array(FactSchema).default([]),
@@ -76,7 +77,7 @@ export function loadConfig(path) {
76
77
  return {
77
78
  ...cfg,
78
79
  identity: { keyFile: r(cfg.identity.keyFile) },
79
- grantFile: r(cfg.grantFile),
80
+ ...(cfg.grantFile ? { grantFile: r(cfg.grantFile) } : {}),
80
81
  trustedPrincipalKeys: cfg.trustedPrincipalKeys.map(r),
81
82
  policyFile: r(cfg.policyFile),
82
83
  receiptsDir: r(cfg.receiptsDir),
package/dist/crypto.d.ts CHANGED
@@ -22,6 +22,8 @@ export declare function writeKeyPair(kp: KeyPair, dir: string, name: string): {
22
22
  export declare function loadPrivateKey(path: string): KeyPair;
23
23
  export declare function loadPublicKey(path: string): PublicKeyRef;
24
24
  /** A public key from its SPKI PEM text, as found in a .pub file or a conformance vector. */
25
+ /** The SPKI PEM of a public key: what a grant carries as `agentKey`, and what `.pub` files hold. */
26
+ export declare function publicKeyToPem(publicKey: KeyObject): string;
25
27
  export declare function publicKeyFromPem(pem: string): PublicKeyRef;
26
28
  export interface Envelope {
27
29
  payloadType: string;
package/dist/crypto.js CHANGED
@@ -53,6 +53,10 @@ export function loadPublicKey(path) {
53
53
  return publicKeyFromPem(readFileSync(path, "utf8"));
54
54
  }
55
55
  /** A public key from its SPKI PEM text, as found in a .pub file or a conformance vector. */
56
+ /** The SPKI PEM of a public key: what a grant carries as `agentKey`, and what `.pub` files hold. */
57
+ export function publicKeyToPem(publicKey) {
58
+ return publicKey.export({ type: "spki", format: "pem" }).toString();
59
+ }
56
60
  export function publicKeyFromPem(pem) {
57
61
  const publicKey = createPublicKey(pem);
58
62
  return { publicKey, keyid: keyidOf(publicKey) };
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { type Envelope, type KeyPair, type PublicKeyRef } from "./crypto.ts";
3
3
  export declare const DELEGATION_TYPE = "application/vnd.agent-custody.delegation+json";
4
+ /** the longest chain a verifier walks: the principal's grant and up to three delegations below it */
5
+ export declare const MAX_DELEGATION_DEPTH = 4;
4
6
  export declare const DelegationSchema: z.ZodObject<{
5
7
  version: z.ZodLiteral<"0.1">;
6
8
  principal: z.ZodString;
@@ -8,17 +10,48 @@ export declare const DelegationSchema: z.ZodObject<{
8
10
  scopes: z.ZodArray<z.ZodString>;
9
11
  issuedAt: z.ZodISODateTime;
10
12
  expiresAt: z.ZodISODateTime;
13
+ agentKey: z.ZodOptional<z.ZodString>;
14
+ parent: z.ZodOptional<z.ZodObject<{
15
+ payloadType: z.ZodString;
16
+ payload: z.ZodString;
17
+ signatures: z.ZodArray<z.ZodObject<{
18
+ keyid: z.ZodString;
19
+ sig: z.ZodString;
20
+ }, z.core.$strip>>;
21
+ }, z.core.$strip>>;
11
22
  }, z.core.$strip>;
12
23
  export type Delegation = z.infer<typeof DelegationSchema>;
13
24
  export declare function createDelegation(principalKey: KeyPair, d: Delegation): Envelope;
25
+ export interface SubDelegation {
26
+ agent: string;
27
+ scopes: string[];
28
+ issuedAt?: string;
29
+ expiresAt?: string;
30
+ /** the sub-agent's own public key, so it may delegate further */
31
+ agentKey?: string;
32
+ }
33
+ /** The parent's payload as written, without verifying it; the verifier does that. */
34
+ export declare function decodeDelegation(env: Envelope): Delegation | null;
35
+ /**
36
+ * An agent delegates part of its grant to a sub-agent. `agentKey` is the delegating agent's key, the one its own grant
37
+ * names. The result embeds the parent; it is refused here, before signing, when it asks for more than the parent has.
38
+ */
39
+ export declare function delegateFrom(parent: Envelope, agentKey: KeyPair, sub: SubDelegation): Envelope;
14
40
  export type DelegationVerifyResult = {
15
41
  ok: true;
42
+ /** the grant the receipt was issued under: the leaf of the chain */
16
43
  delegation: Delegation;
44
+ /** the principal's key: the signer of the root grant */
17
45
  keyid: string;
46
+ /** root first, leaf last; one entry for a direct grant */
47
+ chain: Delegation[];
18
48
  } | {
19
49
  ok: false;
20
50
  error: string;
21
51
  };
22
- export declare function verifyDelegation(env: Envelope, trustedPrincipals: PublicKeyRef[]): DelegationVerifyResult;
52
+ /** Verifies a grant, walking its chain to a grant signed by one of the trusted principal keys. */
53
+ export declare function verifyDelegation(env: Envelope, trustedPrincipals: PublicKeyRef[], depth?: number): DelegationVerifyResult;
23
54
  /** True when `at` (ISO) lies inside the grant's validity window. */
24
55
  export declare function delegationValidAt(d: Delegation, at: string): boolean;
56
+ /** "principal → agent → sub-agent", for reports. */
57
+ export declare function describeChain(chain: Delegation[]): string;
@@ -1,7 +1,16 @@
1
1
  // A delegation grant: a principal signs a statement that an agent may use certain tools for a window of time.
2
+ //
3
+ // A grant may carry the agent's own public key (`agentKey`); an agent so named may delegate to a sub-agent by signing
4
+ // a narrower grant that embeds the grant it came from (`parent`). A verifier walks the chain to the principal: every
5
+ // link is signed by the key its parent names, every scope is one its parent holds, every window lies inside its
6
+ // parent's, and the principal is the same throughout. The receipt then names the sub-agent as the agent and the
7
+ // principal as the principal, exactly as with a direct grant, and the whole chain travels inside the receipt.
2
8
  import { z } from "zod";
3
- import { dsseSign, dsseVerify } from "./crypto.js";
9
+ import { dsseSign, dsseVerify, publicKeyFromPem } from "./crypto.js";
4
10
  export const DELEGATION_TYPE = "application/vnd.agent-custody.delegation+json";
11
+ /** the longest chain a verifier walks: the principal's grant and up to three delegations below it */
12
+ export const MAX_DELEGATION_DEPTH = 4;
13
+ const EnvelopeSchema = z.object({ payloadType: z.string(), payload: z.string(), signatures: z.array(z.object({ keyid: z.string(), sig: z.string() })).min(1) });
5
14
  export const DelegationSchema = z.object({
6
15
  version: z.literal("0.1"),
7
16
  principal: z.string().min(1),
@@ -9,23 +18,98 @@ export const DelegationSchema = z.object({
9
18
  scopes: z.array(z.string().min(1)).min(1),
10
19
  issuedAt: z.iso.datetime(),
11
20
  expiresAt: z.iso.datetime(),
21
+ /** the agent's own public key, SPKI PEM; with it the agent may delegate to a sub-agent */
22
+ agentKey: z.string().min(1).optional(),
23
+ /** the grant this one was delegated from; the chain ends at a grant signed by a trusted principal */
24
+ parent: EnvelopeSchema.optional(),
12
25
  });
13
26
  export function createDelegation(principalKey, d) {
14
27
  return dsseSign(DELEGATION_TYPE, DelegationSchema.parse(d), principalKey);
15
28
  }
16
- export function verifyDelegation(env, trustedPrincipals) {
29
+ /** The parent's payload as written, without verifying it; the verifier does that. */
30
+ export function decodeDelegation(env) {
31
+ try {
32
+ const parsed = DelegationSchema.safeParse(JSON.parse(Buffer.from(env.payload, "base64").toString("utf8")));
33
+ return parsed.success ? parsed.data : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /**
40
+ * An agent delegates part of its grant to a sub-agent. `agentKey` is the delegating agent's key, the one its own grant
41
+ * names. The result embeds the parent; it is refused here, before signing, when it asks for more than the parent has.
42
+ */
43
+ export function delegateFrom(parent, agentKey, sub) {
44
+ const p = decodeDelegation(parent);
45
+ if (!p)
46
+ throw new Error("the parent grant is not a delegation");
47
+ if (!p.agentKey)
48
+ throw new Error(`the parent grant names no agent key, so ${p.agent} cannot delegate`);
49
+ if (publicKeyFromPem(p.agentKey).keyid !== agentKey.keyid)
50
+ throw new Error(`this key is not the one the parent grant names for ${p.agent}`);
51
+ const extra = sub.scopes.filter((s) => !p.scopes.includes(s));
52
+ if (extra.length)
53
+ throw new Error(`a sub-agent cannot be given scopes its delegator lacks: ${extra.join(", ")}`);
54
+ const issuedAt = sub.issuedAt ?? new Date().toISOString();
55
+ const expiresAt = sub.expiresAt ?? p.expiresAt;
56
+ if (Date.parse(issuedAt) < Date.parse(p.issuedAt) || Date.parse(expiresAt) > Date.parse(p.expiresAt))
57
+ throw new Error("a sub-agent's window must lie inside its delegator's");
58
+ return createDelegation(agentKey, { version: "0.1", principal: p.principal, agent: sub.agent, scopes: sub.scopes, issuedAt, expiresAt, ...(sub.agentKey ? { agentKey: sub.agentKey } : {}), parent });
59
+ }
60
+ /** Verifies a grant, walking its chain to a grant signed by one of the trusted principal keys. */
61
+ export function verifyDelegation(env, trustedPrincipals, depth = 1) {
17
62
  if (env.payloadType !== DELEGATION_TYPE)
18
63
  return { ok: false, error: `unexpected payloadType ${env.payloadType}` };
19
- const r = dsseVerify(env, trustedPrincipals);
64
+ if (depth > MAX_DELEGATION_DEPTH)
65
+ return { ok: false, error: `delegation chain deeper than ${MAX_DELEGATION_DEPTH}` };
66
+ const unverified = decodeDelegation(env);
67
+ if (!unverified)
68
+ return { ok: false, error: "invalid delegation" };
69
+ if (!unverified.parent) {
70
+ const r = dsseVerify(env, trustedPrincipals);
71
+ if (!r.ok)
72
+ return r;
73
+ const parsed = DelegationSchema.safeParse(r.payload);
74
+ if (!parsed.success)
75
+ return { ok: false, error: `invalid delegation: ${parsed.error.message}` };
76
+ return { ok: true, delegation: parsed.data, keyid: r.keyid, chain: [parsed.data] };
77
+ }
78
+ const up = verifyDelegation(unverified.parent, trustedPrincipals, depth + 1);
79
+ if (!up.ok)
80
+ return { ok: false, error: `link ${depth}: ${up.error}` };
81
+ const parent = up.delegation;
82
+ if (!parent.agentKey)
83
+ return { ok: false, error: `link ${depth}: ${parent.agent} holds no agent key and cannot delegate` };
84
+ let parentKey;
85
+ try {
86
+ parentKey = publicKeyFromPem(parent.agentKey);
87
+ }
88
+ catch {
89
+ return { ok: false, error: `link ${depth}: the agent key named for ${parent.agent} is not a public key` };
90
+ }
91
+ const r = dsseVerify(env, [parentKey]);
20
92
  if (!r.ok)
21
- return r;
93
+ return { ok: false, error: `link ${depth}: not signed by ${parent.agent}'s key: ${r.error}` };
22
94
  const parsed = DelegationSchema.safeParse(r.payload);
23
95
  if (!parsed.success)
24
96
  return { ok: false, error: `invalid delegation: ${parsed.error.message}` };
25
- return { ok: true, delegation: parsed.data, keyid: r.keyid };
97
+ const d = parsed.data;
98
+ if (d.principal !== parent.principal)
99
+ return { ok: false, error: `link ${depth}: principal changed from ${parent.principal} to ${d.principal}` };
100
+ const extra = d.scopes.filter((s) => !parent.scopes.includes(s));
101
+ if (extra.length)
102
+ return { ok: false, error: `link ${depth}: ${d.agent} was given scopes ${parent.agent} does not hold: ${extra.join(", ")}` };
103
+ if (Date.parse(d.issuedAt) < Date.parse(parent.issuedAt) || Date.parse(d.expiresAt) > Date.parse(parent.expiresAt))
104
+ return { ok: false, error: `link ${depth}: ${d.agent}'s window is not inside ${parent.agent}'s` };
105
+ return { ok: true, delegation: d, keyid: up.keyid, chain: [...up.chain, d] };
26
106
  }
27
107
  /** True when `at` (ISO) lies inside the grant's validity window. */
28
108
  export function delegationValidAt(d, at) {
29
109
  const t = Date.parse(at);
30
110
  return t >= Date.parse(d.issuedAt) && t <= Date.parse(d.expiresAt);
31
111
  }
112
+ /** "principal → agent → sub-agent", for reports. */
113
+ export function describeChain(chain) {
114
+ return [chain[0].principal, ...chain.map((d) => d.agent)].join(" → ");
115
+ }
@@ -0,0 +1,30 @@
1
+ import { type IncomingMessage } from "node:http";
2
+ import type { Envelope } from "./crypto.ts";
3
+ import { type GatewayHost } from "./gateway.ts";
4
+ export declare const GRANT_HEADER = "x-agent-custody-grant";
5
+ /** The header value that presents a grant: the DSSE envelope as base64url JSON. Send it as `Authorization: Bearer <value>` or as `X-Agent-Custody-Grant`. */
6
+ export declare function grantHeader(envelope: Envelope): string;
7
+ export declare function parseGrantHeader(req: IncomingMessage): Envelope | null;
8
+ export interface HttpGatewayOptions {
9
+ port: number;
10
+ host?: string;
11
+ /** the MCP endpoint path; default /mcp */
12
+ path?: string;
13
+ /** a session with no request for this long is closed; default thirty minutes */
14
+ idleMs?: number;
15
+ /** where session events are reported; default stderr */
16
+ log?: (message: string) => void;
17
+ }
18
+ export interface RunningHttpGateway {
19
+ url: string;
20
+ /** live sessions by MCP session id */
21
+ sessions(): {
22
+ id: string;
23
+ agent: string;
24
+ principal: string;
25
+ since: string;
26
+ }[];
27
+ close(): Promise<void>;
28
+ }
29
+ /** Serves a gateway host as an MCP server over Streamable HTTP, a session per grant. */
30
+ export declare function serveHttp(host: GatewayHost, opts: HttpGatewayOptions): Promise<RunningHttpGateway>;
@@ -0,0 +1,139 @@
1
+ // The gateway over HTTP: one process, many agents, each connection under its own grant. A platform team runs one
2
+ // gateway in front of the tools; every agent connects with MCP over Streamable HTTP and presents the grant its
3
+ // principal signed, and the gateway opens a session for exactly that grant. Sessions share the upstreams, the policy,
4
+ // the key, and the log; each has its own consumed facts and its own receipts, and none can see another's tools.
5
+ // The grant is the credential: it is signed by a principal key the gateway trusts, so nothing else is needed to
6
+ // authenticate a connection, and a grant that is expired, revoked by time, or signed by a stranger gets 403 with the
7
+ // reason in the body. (Not 401: the MCP client transport treats 401 as an OAuth challenge and hides the body.)
8
+ // Bind to loopback or put TLS in front; the transport itself is plain HTTP.
9
+ import { randomUUID } from "node:crypto";
10
+ import { createServer } from "node:http";
11
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
12
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
13
+ import { CallToolRequestSchema, isInitializeRequest, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
14
+ import { GATEWAY_VERSION } from "./gateway.js";
15
+ export const GRANT_HEADER = "x-agent-custody-grant";
16
+ /** The header value that presents a grant: the DSSE envelope as base64url JSON. Send it as `Authorization: Bearer <value>` or as `X-Agent-Custody-Grant`. */
17
+ export function grantHeader(envelope) {
18
+ return Buffer.from(JSON.stringify(envelope)).toString("base64url");
19
+ }
20
+ export function parseGrantHeader(req) {
21
+ const explicit = req.headers[GRANT_HEADER];
22
+ const auth = req.headers.authorization ?? "";
23
+ const raw = typeof explicit === "string" && explicit ? explicit : auth.startsWith("Bearer ") ? auth.slice(7) : "";
24
+ if (!raw)
25
+ return null;
26
+ try {
27
+ const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
28
+ return parsed && typeof parsed === "object" && typeof parsed.payload === "string" && Array.isArray(parsed.signatures) ? parsed : null;
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /** Serves a gateway host as an MCP server over Streamable HTTP, a session per grant. */
35
+ export async function serveHttp(host, opts) {
36
+ const bind = opts.host ?? "127.0.0.1";
37
+ const path = opts.path ?? "/mcp";
38
+ const idleMs = opts.idleMs ?? 30 * 60_000;
39
+ const log = opts.log ?? ((m) => console.error(m));
40
+ const sessions = new Map();
41
+ const json = (res, status, body) => {
42
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
43
+ res.end(JSON.stringify(body));
44
+ };
45
+ const rpcError = (res, status, message) => json(res, status, { jsonrpc: "2.0", error: { code: -32000, message }, id: null });
46
+ const readBody = async (req) => {
47
+ let text = "";
48
+ for await (const chunk of req) {
49
+ text += chunk;
50
+ if (text.length > 4_194_304)
51
+ throw new Error("body larger than 4 MB");
52
+ }
53
+ return text ? JSON.parse(text) : undefined;
54
+ };
55
+ const closeSession = async (id, why) => {
56
+ const s = sessions.get(id);
57
+ if (!s)
58
+ return;
59
+ sessions.delete(id);
60
+ log(`agent-custody gateway: session ${id.slice(0, 8)} for ${s.gateway.agentId} closed (${why})`);
61
+ await s.gateway.close();
62
+ await s.transport.close().catch(() => { });
63
+ };
64
+ const openSession = async (req, res, body) => {
65
+ const envelope = parseGrantHeader(req);
66
+ if (!envelope)
67
+ return rpcError(res, 403, `a grant is required: send the delegation envelope as base64url in Authorization: Bearer or ${GRANT_HEADER}`);
68
+ let gateway;
69
+ try {
70
+ gateway = host.open(envelope);
71
+ }
72
+ catch (e) {
73
+ return rpcError(res, 403, e instanceof Error ? e.message : String(e));
74
+ }
75
+ const transport = new StreamableHTTPServerTransport({
76
+ sessionIdGenerator: () => randomUUID(),
77
+ onsessioninitialized: (id) => {
78
+ sessions.set(id, { gateway, transport, server, since: new Date().toISOString(), lastSeen: Date.now() });
79
+ log(`agent-custody gateway: session ${id.slice(0, 8)} opened for agent=${gateway.agentId} principal=${gateway.delegation.principal} scopes=[${gateway.delegation.scopes.join(", ")}]`);
80
+ },
81
+ onsessionclosed: (id) => void closeSession(id, "closed by the client"),
82
+ });
83
+ const server = new Server({ name: "agent-custody-gateway", version: GATEWAY_VERSION }, { capabilities: { tools: {} } });
84
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await gateway.listTools() }));
85
+ server.setRequestHandler(CallToolRequestSchema, async (r) => gateway.handleCall(r.params));
86
+ await server.connect(transport);
87
+ await transport.handleRequest(req, res, body);
88
+ };
89
+ const handler = async (req, res) => {
90
+ const url = new URL(req.url ?? "/", "http://localhost");
91
+ if (req.method === "GET" && url.pathname === "/health")
92
+ return json(res, 200, { ok: true, sessions: sessions.size, keyid: host.keyid });
93
+ if (url.pathname !== path)
94
+ return json(res, 404, { error: "not found" });
95
+ try {
96
+ const sid = req.headers["mcp-session-id"];
97
+ const existing = typeof sid === "string" ? sessions.get(sid) : undefined;
98
+ if (existing) {
99
+ existing.lastSeen = Date.now();
100
+ const body = req.method === "POST" ? await readBody(req) : undefined;
101
+ await existing.transport.handleRequest(req, res, body);
102
+ return;
103
+ }
104
+ if (typeof sid === "string")
105
+ return rpcError(res, 404, "unknown or expired session; initialize again with your grant");
106
+ if (req.method !== "POST")
107
+ return rpcError(res, 400, "initialize first: POST an initialize request with your grant");
108
+ const body = await readBody(req);
109
+ if (!isInitializeRequest(body))
110
+ return rpcError(res, 400, "the first request of a session must be initialize");
111
+ await openSession(req, res, body);
112
+ }
113
+ catch (e) {
114
+ if (!res.headersSent)
115
+ rpcError(res, 500, e instanceof Error ? e.message : String(e));
116
+ }
117
+ };
118
+ const server = createServer((req, res) => void handler(req, res));
119
+ const reaper = setInterval(() => {
120
+ const cutoff = Date.now() - idleMs;
121
+ for (const [id, s] of sessions)
122
+ if (s.lastSeen < cutoff)
123
+ void closeSession(id, "idle");
124
+ }, Math.min(idleMs, 60_000));
125
+ reaper.unref?.();
126
+ await new Promise((resolve) => server.listen(opts.port, bind, resolve));
127
+ const { port } = server.address();
128
+ return {
129
+ url: `http://${bind}:${port}${path}`,
130
+ sessions: () => [...sessions.entries()].map(([id, s]) => ({ id, agent: s.gateway.agentId, principal: s.gateway.delegation.principal, since: s.since })),
131
+ async close() {
132
+ clearInterval(reaper);
133
+ for (const id of [...sessions.keys()])
134
+ await closeSession(id, "server closing");
135
+ server.closeAllConnections?.();
136
+ await new Promise((resolve) => server.close(() => resolve()));
137
+ },
138
+ };
139
+ }
package/dist/gateway.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type CallToolResult, type Tool } from "@modelcontextprotocol/sdk/types.js";
2
2
  import type { GatewayConfig } from "./config.ts";
3
+ import { type Envelope } from "./crypto.ts";
3
4
  import { type Delegation } from "./delegation.ts";
4
5
  import { type LogSink } from "./log-sink.ts";
5
6
  import { type ReceiptExporter } from "./otel.ts";
@@ -31,6 +32,19 @@ export interface GatewayOptions {
31
32
  /** told about every receipt after it is written, in place of the exporter the config names */
32
33
  exporter?: ReceiptExporter;
33
34
  }
35
+ /**
36
+ * The shared part of a gateway: the key, the policy, the issuer, the log, the upstreams, and the fact lookups. One host
37
+ * serves many sessions, each opened with its own grant; over stdio there is exactly one, over HTTP one per connection.
38
+ */
39
+ export interface GatewayHost {
40
+ keyid: string;
41
+ /** a session for this grant: the grant is verified against the trusted principal keys and its validity window first */
42
+ open(grantEnvelope: Envelope): Gateway;
43
+ /** closes the upstreams; every session opened from this host is finished with */
44
+ close(): Promise<void>;
45
+ }
46
+ export declare function createGatewayHost(cfg: GatewayConfig, options?: GatewayOptions): Promise<GatewayHost>;
47
+ /** One gateway for the grant the config names: what `agent-custody gateway` serves over stdio. Closing it closes the host. */
34
48
  export declare function createGateway(cfg: GatewayConfig, options?: GatewayOptions): Promise<Gateway>;
35
49
  /** Exposes the gateway as an MCP server over stdio. Everything diagnostic must go to stderr. */
36
50
  export declare function serveStdio(gw: Gateway): Promise<void>;