@agent-custody/receipts 0.1.1 → 0.1.3

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
@@ -9,7 +9,7 @@ Two producers, one receipt format, one verifier.
9
9
 
10
10
  Anyone holding the public keys can verify a receipt offline. The agent is not trusted. The layer around it is, and the receipt says exactly how far that trust extends, starting with who issued it.
11
11
 
12
- - [Tutorials](docs/tutorials.md): fourteen runnable examples, one per aspect of the code, all executed by the test suite
12
+ - [Tutorials](docs/tutorials.md): fifteen runnable examples, one per aspect of the code, all executed by the test suite
13
13
  - [Usage guide](docs/usage.md): gateway setup, wiring into Claude Desktop, Claude Code, or your own agent loop
14
14
  - [The interceptor SDK](docs/sdk.md): Claude Code hooks, the Claude Agent SDK, adapters for the OpenAI Agents SDK, Vercel AI SDK and LangChain, and wrapping tool functions in anything else
15
15
  - [Writing policies](docs/policies.md): how a tool call becomes a Cedar request, with tested examples
@@ -103,6 +103,9 @@ Every receipt names its issuer, and the verifier prints what that issuer kind is
103
103
  | Vercel AI SDK | SDK | `wrapTools` | | a real `generateText` loop over the SDK's mock model |
104
104
  | LangChain / LangGraph (JS) | SDK | `tool(issuer.wrap(fn))` | `ReceiptCallbackHandler` | real `StructuredTool` invocations |
105
105
  | anything else | SDK | `issuer.wrap(name, fn)` | `issuer.record` | plain functions |
106
+ | Python: LangChain, OpenAI Agents SDK, Claude Agent SDK | sidecar + [Python package](../python/README.md) | `wrap_tools`, `claude_hook` PreToolUse deny, `client.wrap` | `ReceiptCallbackHandler` | the real Python packages, receipts checked by this verifier |
107
+ | Go, Java, Rust, any language with HTTP | sidecar | decide then record | record | [examples/languages](examples/languages), each run against a live sidecar |
108
+ | any MCP host in any language: Claude Agent SDK Python, OpenAI Agents Python | gateway | yes | | the gateway is an MCP server; [usage.md](docs/usage.md#python-hosts) |
106
109
 
107
110
  The framework packages are optional peer dependencies. Each adapter imports only from its own package.
108
111
 
@@ -167,7 +170,7 @@ Every field carries a provenance label. This is the design decision that matters
167
170
  bun install # from the repository root, once for the workspace
168
171
  cd packages/receipts
169
172
  node scripts/demo.ts # gateway: keys, grant, policy, four tool calls, verification, a tampering attempt; then the SDK wrapping the same tool
170
- node examples/01-keys-and-signing.ts # first of fourteen step-by-step examples, see docs/tutorials.md
173
+ node examples/01-keys-and-signing.ts # first of fifteen step-by-step examples, see docs/tutorials.md
171
174
  bun run test # this package; `bun run test` at the root runs every package
172
175
  ```
173
176
 
@@ -227,12 +230,14 @@ src/gateway.ts the MCP proxy: scope check, facts, policy, forward, receipt
227
230
  src/sdk/index.ts the interceptor: policy decision, record, wrap(tool fn)
228
231
  src/sdk/claude.ts Claude Code command hook and Claude Agent SDK in-process hooks
229
232
  src/sdk/openai-agents.ts, vercel-ai.ts, langchain.ts framework adapters, tested against the real packages
233
+ src/sidecar.ts the SDK issuer behind a local HTTP API, for agents in other languages
234
+ vectors/ conformance vectors: receipts, keys, logs, proofs, and expected verdicts; `bun run vectors` regenerates them
230
235
  src/verify.ts offline verification, the human-readable report, and the audit that a later log extends an earlier one
231
- src/cli.ts keygen, grant, gateway, hook, log, verify, audit
236
+ src/cli.ts keygen, grant, gateway, hook, serve, log, verify, audit
232
237
  src/index.ts the package's public surface; adapters are exported on ./sdk/<framework> subpaths
233
238
  tsconfig.build.json emits dist/ (JavaScript plus declarations) for consumers; the repo itself runs the .ts directly
234
239
  scripts/ fake Stripe upstream, fixture builders for gateway and SDK, demo
235
- examples/ fourteen runnable tutorials, one per aspect; each is run by the test suite
240
+ examples/ fifteen runnable tutorials, plus examples/languages/: Python, Go, Java, and Rust clients of the sidecar, run by the test suite, one per aspect; each is run by the test suite
236
241
  test/ unit tests per module, end-to-end gateway test, SDK and hook tests,
237
242
  adapter tests against the real packages, and a test that runs every policy in docs/policies.md
238
243
  docs/ tutorials, usage (gateway), sdk, policies, verification
@@ -249,6 +254,9 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
249
254
  - SDK core: policy decision, record, and a generic `wrap(tool, fn)` for any framework whose tools are functions.
250
255
  - Claude Code command hook for PreToolUse, PostToolUse, and PostToolUseFailure, with blocking on deny.
251
256
  - Claude Agent SDK in-process hooks over the same handler.
257
+ - The forwarded call carries the receipt id and the attested agent and principal in `_meta`, so a stateful upstream can cite the receipt; the memory server in `@agent-custody/state` runs this way.
258
+ - Conformance vectors, generated by the test suite and published with the spec, and a browser verifier on agent-custody.dev that passes all of them.
259
+ - Sidecar: the SDK issuer behind a local HTTP API (`serve`), with a Python package on PyPI-ready footing and Go, Java, and Rust clients, so agents in any language get the same receipts from one signing implementation.
252
260
  - Consistency proofs between tree heads (RFC 9162), served by the log and checked by the `audit` command, so an auditor holding an old tree head can prove nothing before it was rewritten.
253
261
  - 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.
254
262
  - 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).
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { generateKeyPair, loadPrivateKey, loadPublicKey, writeKeyPair } from "./
6
6
  import { createDelegation } from "./delegation.js";
7
7
  import { createGateway, serveStdio } from "./gateway.js";
8
8
  import { serveLog } from "./log-sink.js";
9
+ import { serveSidecar } from "./sidecar.js";
9
10
  import { MerkleLog } from "./log.js";
10
11
  import { createSdkIssuer } from "./sdk/index.js";
11
12
  import { handleHookEvent } from "./sdk/claude.js";
@@ -16,6 +17,7 @@ const USAGE = `agent-custody <command>
16
17
  grant --key <principal.key> --principal <id> --agent <id> --scopes <a,b> [--ttl-hours 24] --out <file>
17
18
  gateway --config <gateway.json>
18
19
  hook [--config <sdk.json>] Claude Code hook command; reads the event on stdin (or AGENT_CUSTODY_CONFIG)
20
+ serve --config <sdk.json> [--port 8788] [--host 127.0.0.1] the SDK as a local HTTP API for agents in other languages
19
21
  log --file <log.jsonl> --key <log.key> [--port 8787] [--host 127.0.0.1] [--token-env <NAME>] reference log server
20
22
  verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--log <log.jsonl>] [--json]
21
23
  audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--json]
@@ -80,6 +82,17 @@ async function main(argv) {
80
82
  console.log(JSON.stringify(out));
81
83
  return 0;
82
84
  }
85
+ case "serve": {
86
+ const { values } = parseArgs({ args: rest, options: { config: { type: "string" }, port: { type: "string", default: "8788" }, host: { type: "string", default: "127.0.0.1" } } });
87
+ if (!values.config)
88
+ throw new Error("serve needs --config");
89
+ const issuer = createSdkIssuer(loadSdkConfig(values.config));
90
+ const running = await serveSidecar(issuer, { port: Number(values.port), host: values.host });
91
+ console.error(`agent-custody serve: ${running.url} agent=${issuer.agentId} keyid=${issuer.keyid} log=${issuer.log.kind}:${issuer.log.where}`);
92
+ await new Promise((resolve) => process.once("SIGINT", resolve));
93
+ await running.close();
94
+ return 0;
95
+ }
83
96
  case "log": {
84
97
  const { values } = parseArgs({
85
98
  args: rest,
package/dist/crypto.d.ts CHANGED
@@ -21,6 +21,8 @@ export declare function writeKeyPair(kp: KeyPair, dir: string, name: string): {
21
21
  };
22
22
  export declare function loadPrivateKey(path: string): KeyPair;
23
23
  export declare function loadPublicKey(path: string): PublicKeyRef;
24
+ /** A public key from its SPKI PEM text, as found in a .pub file or a conformance vector. */
25
+ export declare function publicKeyFromPem(pem: string): PublicKeyRef;
24
26
  export interface Envelope {
25
27
  payloadType: string;
26
28
  payload: string;
package/dist/crypto.js CHANGED
@@ -50,7 +50,11 @@ export function loadPrivateKey(path) {
50
50
  return { privateKey, publicKey, keyid: keyidOf(publicKey) };
51
51
  }
52
52
  export function loadPublicKey(path) {
53
- const publicKey = createPublicKey(readFileSync(path));
53
+ return publicKeyFromPem(readFileSync(path, "utf8"));
54
+ }
55
+ /** A public key from its SPKI PEM text, as found in a .pub file or a conformance vector. */
56
+ export function publicKeyFromPem(pem) {
57
+ const publicKey = createPublicKey(pem);
54
58
  return { publicKey, keyid: keyidOf(publicKey) };
55
59
  }
56
60
  function pae(payloadType, payload) {
package/dist/gateway.d.ts CHANGED
@@ -4,6 +4,9 @@ import { type Delegation } from "./delegation.ts";
4
4
  export declare const GATEWAY_VERSION = "0.1.0";
5
5
  export declare const RECEIPT_META_KEY = "agent-custody/receipt";
6
6
  export declare const MODEL_META_KEY = "agent-custody/model";
7
+ /** Set by the gateway on the call it forwards upstream: the receipt id, and the agent and principal from the attested grant. */
8
+ export declare const AGENT_META_KEY = "agent-custody/agent";
9
+ export declare const PRINCIPAL_META_KEY = "agent-custody/principal";
7
10
  export interface CallParams {
8
11
  name: string;
9
12
  arguments?: Record<string, unknown>;
package/dist/gateway.js CHANGED
@@ -15,6 +15,9 @@ import { evaluate, policyDigest } from "./policy.js";
15
15
  export const GATEWAY_VERSION = "0.1.0";
16
16
  export const RECEIPT_META_KEY = "agent-custody/receipt";
17
17
  export const MODEL_META_KEY = "agent-custody/model";
18
+ /** Set by the gateway on the call it forwards upstream: the receipt id, and the agent and principal from the attested grant. */
19
+ export const AGENT_META_KEY = "agent-custody/agent";
20
+ export const PRINCIPAL_META_KEY = "agent-custody/principal";
18
21
  function resolveFactArgs(template, args) {
19
22
  const out = {};
20
23
  for (const [k, v] of Object.entries(template)) {
@@ -58,7 +61,7 @@ export async function createGateway(cfg) {
58
61
  const issuer = createIssuer(gatewayKey, cfg.receiptsDir, openLog(cfg, gatewayKey));
59
62
  const upstream = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
60
63
  await upstream.connect(new StdioClientTransport({ command: cfg.upstream.command, args: cfg.upstream.args, env: cfg.upstream.env, stderr: "inherit" }));
61
- const callUpstream = async (name, args) => (await upstream.callTool({ name, arguments: args }));
64
+ const callUpstream = async (name, args, meta) => (await upstream.callTool({ name, arguments: args, ...(meta ? { _meta: meta } : {}) }));
62
65
  async function gatherFacts(tool, args) {
63
66
  const facts = {};
64
67
  for (const f of cfg.facts.filter((f) => f.forTools.includes(tool))) {
@@ -98,7 +101,9 @@ export async function createGateway(cfg) {
98
101
  }
99
102
  if (policy.decision === "allow") {
100
103
  try {
101
- const result = await callUpstream(tool, args);
104
+ // The upstream learns which receipt this call is, and who the grant says is calling. An upstream that keeps
105
+ // state, such as the memory server, cites the receipt as the source of what it stores.
106
+ const result = await callUpstream(tool, args, { [RECEIPT_META_KEY]: receiptId, [AGENT_META_KEY]: delegation.agent, [PRINCIPAL_META_KEY]: delegation.principal });
102
107
  execution = { status: result.isError ? "failed" : "executed", result, resultDigest: digestOf(result), provenance: "observed" };
103
108
  }
104
109
  catch (e) {
package/dist/index.d.ts CHANGED
@@ -9,3 +9,4 @@ export * from "./policy.ts";
9
9
  export * from "./receipt.ts";
10
10
  export * from "./verify.ts";
11
11
  export * from "./sdk/index.ts";
12
+ export * from "./sidecar.ts";
package/dist/index.js CHANGED
@@ -10,3 +10,4 @@ export * from "./policy.js";
10
10
  export * from "./receipt.js";
11
11
  export * from "./verify.js";
12
12
  export * from "./sdk/index.js";
13
+ export * from "./sidecar.js";
@@ -0,0 +1,12 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import type { SdkIssuer } from "./sdk/index.ts";
3
+ export declare function sidecarHandler(issuer: SdkIssuer): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
4
+ export interface RunningSidecar {
5
+ url: string;
6
+ close(): Promise<void>;
7
+ }
8
+ /** Starts the sidecar. Port 0 picks a free port. Host defaults to loopback on purpose. */
9
+ export declare function serveSidecar(issuer: SdkIssuer, opts: {
10
+ port: number;
11
+ host?: string;
12
+ }): Promise<RunningSidecar>;
@@ -0,0 +1,93 @@
1
+ // The sidecar: the SDK issuer behind a local HTTP API, so agents written in any language can decide and record.
2
+ // Same config file, same receipts, same key. Everything it records is claimed, exactly as with the in-process SDK:
3
+ // the sidecar trusts what the agent's process tells it. Bind it to localhost; it is a per-host companion, not a service.
4
+ // GET /health -> { agentId, keyid, log: { kind, where } }
5
+ // POST /decide ToolEvent -> PolicyDecision | null
6
+ // POST /record { event, outcome, policy? } -> ReceiptBundle, or 4xx/5xx with { error }
7
+ import { createServer } from "node:http";
8
+ const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
9
+ function parseEvent(v) {
10
+ if (!isRecord(v) || typeof v.tool !== "string" || v.tool.length === 0)
11
+ throw new Error("event needs a non-empty string tool");
12
+ const args = isRecord(v.args) ? v.args : v.args === undefined ? {} : { input: v.args };
13
+ const ev = { tool: v.tool, args };
14
+ if (typeof v.model === "string")
15
+ ev.model = v.model;
16
+ if (isRecord(v.session))
17
+ ev.session = { id: typeof v.session.id === "string" ? v.session.id : null, toolUseId: typeof v.session.toolUseId === "string" ? v.session.toolUseId : null };
18
+ return ev;
19
+ }
20
+ function parseOutcome(v) {
21
+ if (!isRecord(v) || typeof v.status !== "string")
22
+ throw new Error("outcome needs a status");
23
+ switch (v.status) {
24
+ case "executed":
25
+ case "failed":
26
+ return { status: v.status, result: v.result ?? null };
27
+ case "denied":
28
+ return { status: "denied", reason: typeof v.reason === "string" ? v.reason : "denied" };
29
+ case "error":
30
+ return { status: "error", error: typeof v.error === "string" ? v.error : "error" };
31
+ default:
32
+ throw new Error(`unknown outcome status ${v.status}`);
33
+ }
34
+ }
35
+ function parsePolicy(v) {
36
+ if (v === undefined || v === null)
37
+ return null;
38
+ if (!isRecord(v) || (v.decision !== "allow" && v.decision !== "deny") || !Array.isArray(v.reasons) || !Array.isArray(v.errors) || typeof v.policyDigest !== "string")
39
+ throw new Error("policy must be a PolicyDecision from /decide");
40
+ return { decision: v.decision, reasons: v.reasons.map(String), errors: v.errors.map(String), policyDigest: v.policyDigest };
41
+ }
42
+ export function sidecarHandler(issuer) {
43
+ return async (req, res) => {
44
+ const json = (status, body) => {
45
+ res.writeHead(status, { "content-type": "application/json" });
46
+ res.end(JSON.stringify(body));
47
+ };
48
+ const url = new URL(req.url ?? "/", "http://localhost");
49
+ try {
50
+ if (req.method === "GET" && url.pathname === "/health")
51
+ return json(200, { agentId: issuer.agentId, keyid: issuer.keyid, log: { kind: issuer.log.kind, where: issuer.log.where } });
52
+ if (req.method !== "POST")
53
+ return json(404, { error: "not found" });
54
+ let raw = "";
55
+ for await (const chunk of req)
56
+ raw += chunk;
57
+ let body;
58
+ try {
59
+ body = JSON.parse(raw);
60
+ }
61
+ catch {
62
+ return json(400, { error: "body must be JSON" });
63
+ }
64
+ if (url.pathname === "/decide")
65
+ return json(200, issuer.decide(parseEvent(body)));
66
+ if (url.pathname === "/record") {
67
+ if (!isRecord(body))
68
+ return json(400, { error: "body must be {event, outcome, policy?}" });
69
+ const bundle = await issuer.record(parseEvent(body.event), parseOutcome(body.outcome), parsePolicy(body.policy));
70
+ return json(200, bundle);
71
+ }
72
+ return json(404, { error: "not found" });
73
+ }
74
+ catch (e) {
75
+ const msg = e instanceof Error ? e.message : String(e);
76
+ return json(/needs|must|unknown outcome/.test(msg) ? 400 : 502, { error: msg });
77
+ }
78
+ };
79
+ }
80
+ /** Starts the sidecar. Port 0 picks a free port. Host defaults to loopback on purpose. */
81
+ export function serveSidecar(issuer, opts) {
82
+ const host = opts.host ?? "127.0.0.1";
83
+ const handler = sidecarHandler(issuer);
84
+ const server = createServer((req, res) => {
85
+ void handler(req, res);
86
+ });
87
+ return new Promise((resolve) => {
88
+ server.listen(opts.port, host, () => {
89
+ const { port } = server.address();
90
+ resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => server.close(() => r())) });
91
+ });
92
+ });
93
+ }
package/docs/sdk.md CHANGED
@@ -177,3 +177,26 @@ The three framework packages are optional peer dependencies. Each adapter import
177
177
  ## What an SDK receipt is worth
178
178
 
179
179
  A verified SDK receipt establishes that a process holding the application key reported this call, at this time, with these arguments and this result, and that the record has not changed since. It does not establish that the process reported every call, that the arguments are what the tool really received, or that anyone outside the process checked anything. The verifier prints exactly that sentence under `ISSUER`. Keep it in the dashboard too.
180
+
181
+ ## Other languages: the sidecar
182
+
183
+ The interceptor above is TypeScript. Agents in any other language get the same receipts through the sidecar: the SDK issuer behind a local HTTP API, started from the same config file.
184
+
185
+ ```bash
186
+ agent-custody serve --config sdk.json # 127.0.0.1:8788 by default; --port and --host to change
187
+ ```
188
+
189
+ | | |
190
+ | --- | --- |
191
+ | `GET /health` | `{ agentId, keyid, log: { kind, where } }` |
192
+ | `POST /decide` with a `ToolEvent` `{ tool, args, model?, session? }` | the `PolicyDecision`, or `null` when no policy is configured |
193
+ | `POST /record` with `{ event, outcome, policy? }` | the `ReceiptBundle`; `outcome` is `{ status: "executed" \| "failed", result }`, `{ status: "denied", reason }`, or `{ status: "error", error }` |
194
+
195
+ The client's loop is decide, run the tool, record. A malformed body gets a 400 and nothing is written; a log that refuses the leaf gets a 502 and nothing is written. Bind the sidecar to loopback: it is a per-host companion holding the signing key, not a shared service, and everything it records is `claimed` exactly as with the in-process SDK, because it trusts what the client reports.
196
+
197
+ **Python** has a real package, [packages/python](../../python/README.md): `pip install agent-custody`, a standard-library client with `decide`, `record`, and `wrap`, and adapters for LangChain callbacks, OpenAI Agents function tools, and Claude Agent SDK hooks, each tested against the real package with receipts checked by this verifier.
198
+
199
+ **Go, Java, Rust, and Python without the package** each have a complete client in [examples/languages](../examples/languages): one file, standard library where the language has an HTTP client, decide then record. The test suite runs every one of them against a live sidecar. Any language with an HTTP client is the same forty lines.
200
+
201
+ The gateway needs none of this. It is an MCP server, so a Python or Go agent host that speaks MCP puts it in front of its tools with a config change; [usage.md](usage.md) shows the Python hosts.
202
+
package/docs/tutorials.md CHANGED
@@ -24,6 +24,7 @@ Suggested reading order is the numbering. Output lands in `examples-out/`, which
24
24
  | 12 | inside a receipt | [12-read-a-receipt.ts](../examples/12-read-a-receipt.ts) | the bundle's three parts, the in-toto statement, every predicate field with its provenance, the tree head | `src/receipt.ts` |
25
25
  | 13 | a log run by someone else | [13-remote-log.ts](../examples/13-remote-log.ts) | the reference log server on a free port, an SDK config that logs to it, a tree head signed by the log's key, verification failing without that key and passing with it, the root endpoint, a refused token | `src/log-sink.ts` |
26
26
  | 14 | proving history was not rewritten | [14-audit-history.ts](../examples/14-audit-history.ts) | three receipts and a kept tree head, a consistency proof that passes, the operator rewriting one leaf and appending a fourth call, the audit failing while the fourth receipt still verifies alone | `src/log.ts`, `src/verify.ts` |
27
+ | 15 | agents in other languages | [15-sidecar.ts](../examples/15-sidecar.ts) | the sidecar on a free port, a client written as a Python or Go program would write it: decide, run, record; a denial recorded without running the tool; both receipts verified | `src/sidecar.ts` |
27
28
 
28
29
  ## How policies are defined, in one paragraph
29
30
 
package/docs/usage.md CHANGED
@@ -124,6 +124,31 @@ Claude sees only the tools inside the grant's scopes. Every call it makes produc
124
124
  claude mcp add stripe -- node /abs/path/agent-custody/packages/receipts/src/cli.ts gateway --config /abs/path/gateway.json
125
125
  ```
126
126
 
127
+ ### Python hosts
128
+
129
+ The gateway is language-neutral: any host that can launch a stdio MCP server can use it. Claude Agent SDK for Python:
130
+
131
+ ```python
132
+ from claude_agent_sdk import ClaudeAgentOptions, query
133
+
134
+ options = ClaudeAgentOptions(mcp_servers={"stripe": {"command": "node", "args": ["/abs/path/agent-custody/packages/receipts/src/cli.ts", "gateway", "--config", "/abs/path/gateway.json"]}})
135
+ async for message in query(prompt="Refund customer cust_123 by 50 dollars", options=options):
136
+ ...
137
+ ```
138
+
139
+ OpenAI Agents SDK for Python:
140
+
141
+ ```python
142
+ from agents import Agent, Runner
143
+ from agents.mcp import MCPServerStdio
144
+
145
+ async with MCPServerStdio(params={"command": "node", "args": ["/abs/path/agent-custody/packages/receipts/src/cli.ts", "gateway", "--config", "/abs/path/gateway.json"]}) as stripe:
146
+ agent = Agent(name="support", instructions="...", mcp_servers=[stripe])
147
+ result = await Runner.run(agent, "Refund customer cust_123 by 50 dollars")
148
+ ```
149
+
150
+ With the npm package installed globally, `"command": "agent-custody", "args": ["gateway", "--config", ...]` replaces the node invocation. Every tool the agent sees comes through the gateway; denied calls never reach Stripe and still produce a receipt. For receipts from tools that are plain Python functions rather than MCP servers, use the sidecar and the Python package, in [sdk.md](sdk.md).
151
+
127
152
  ### Your own agent loop (TypeScript)
128
153
 
129
154
  This is what [scripts/demo.ts](../scripts/demo.ts) does.
@@ -165,6 +190,10 @@ Any other MCP client works the same way: Python's `mcp` package, LangGraph's MCP
165
190
 
166
191
  The receipt id is the file name under `receiptsDir`.
167
192
 
193
+ ## What the upstream gets
194
+
195
+ The call the gateway forwards carries three `_meta` keys the agent cannot set: `agent-custody/receipt`, the id of the receipt being issued for this call; `agent-custody/agent` and `agent-custody/principal`, from the signed delegation grant. An upstream that keeps state can cite the receipt as the source of what it stores and record the attested caller rather than a claimed one. The memory server in `@agent-custody/state` does exactly that. Fact lookups do not carry them; only the forwarded call does.
196
+
168
197
  ## Operational notes
169
198
 
170
199
  - **Money is integer minor units.** Cedar has no floating point. A float in `args` that a policy touches is an evaluation error, which is a deny.
@@ -98,6 +98,10 @@ A gateway receipt does **not** support:
98
98
  - that the model named in `model` produced the call. No hosted provider signs model identity.
99
99
  - that the gateway operator is honest. The operator holds the gateway key. Against a dishonest operator you need a log copy taken out of their control, or a signer they do not control. See the threat model table in the README.
100
100
 
101
+ ## In the browser
102
+
103
+ [agent-custody.dev/verify](https://agent-custody.dev/verify) runs the same checks in the page over WebCrypto, nothing uploaded. It is a second implementation of this verifier, and it passes every published [conformance vector](https://agent-custody.dev/receipt/vectors).
104
+
101
105
  ## Programmatic verification
102
106
 
103
107
  ```ts
@@ -157,3 +161,7 @@ Programmatically, `auditExtends(older.treeHead, newer.treeHead, proof, keys)` re
157
161
 
158
162
  The remote log also serves `GET /head`, its current tree head signed with the log's key, so an auditor can record heads on a schedule and later audit any two of them without holding a receipt for each.
159
163
 
164
+ ## Conformance vectors
165
+
166
+ `vectors/` in the package holds fixed receipts, keys, logs, proofs, and the verdicts this verifier produces for them, generated by `bun run vectors` and checked by the test suite on every run. A verifier written elsewhere proves it agrees by reproducing every verdict. They are published at [agent-custody.dev/receipt/vectors](https://agent-custody.dev/receipt/vectors).
167
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Chain of custody for AI agents: signed, independently verifiable receipts for tool calls. MCP gateway + Cedar policy + Merkle transparency log",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,13 +43,15 @@
43
43
  },
44
44
  "files": [
45
45
  "dist",
46
- "docs"
46
+ "docs",
47
+ "vectors"
47
48
  ],
48
49
  "scripts": {
49
50
  "build": "tsc -p tsconfig.build.json",
50
51
  "typecheck": "tsc --noEmit",
51
52
  "test": "vitest run",
52
- "demo": "tsx scripts/demo.ts"
53
+ "demo": "tsx scripts/demo.ts",
54
+ "vectors": "tsx scripts/vectors.ts"
53
55
  },
54
56
  "engines": {
55
57
  "node": ">=22"
@@ -0,0 +1,147 @@
1
+ {
2
+ "version": "0.2",
3
+ "note": "auditExtends(older, newer, proof, keys): both tree heads must verify against a listed key; the proof is the log's consistency proof between their sizes.",
4
+ "cases": [
5
+ {
6
+ "name": "remote-log-extends",
7
+ "description": "The tree head from the first remote receipt and the log's later head, with the proof the log served.",
8
+ "older": {
9
+ "payloadType": "application/vnd.agent-custody.treehead+json",
10
+ "payload": "eyJyb290SGFzaCI6Ijg3MzM5M2E4ZTQ4ZmFkNzVlMmRkMzY1MzRjNmRjNzUzMTc5YmM0NmEzMTAxNDZjZTI2OGUxNTc2ODZiOGMwZTQiLCJ0aW1lc3RhbXAiOiIyMDI2LTA5LTA3VDA2OjUyOjUxLjExMFoiLCJ0cmVlU2l6ZSI6MX0=",
11
+ "signatures": [
12
+ {
13
+ "keyid": "e1ed29255a4bd710006fe9ad333514e71c705608b769029088a818f57bcf080a",
14
+ "sig": "vydFxb4Z753KUPOXLEFiYnE0nntaoU9xXtBe7IMW8eucYeef+XpGu+4+bXpo/zYZATcx4h+7bMmDd3frqizkBg=="
15
+ }
16
+ ]
17
+ },
18
+ "newer": {
19
+ "payloadType": "application/vnd.agent-custody.treehead+json",
20
+ "payload": "eyJyb290SGFzaCI6ImNlYmViNDAzYjNlZWYzYzgwNWVjYzg5OTI2NTM0ZGM0NjkxY2I4OWJlN2I0ZjFjNjE3Njg5MDFkMzIxNDkzYmEiLCJ0aW1lc3RhbXAiOiIyMDI2LTA5LTA3VDA2OjUyOjUxLjExNFoiLCJ0cmVlU2l6ZSI6Mn0=",
21
+ "signatures": [
22
+ {
23
+ "keyid": "e1ed29255a4bd710006fe9ad333514e71c705608b769029088a818f57bcf080a",
24
+ "sig": "iyUBqiJcZd/RaEDXWhhxl34ekqy6tQ9qgsMQjAXlvuHS75aqPLeh0NRtUQB89LS2UMJYihmcSUdg/Z82uORqCQ=="
25
+ }
26
+ ]
27
+ },
28
+ "proof": [
29
+ "4869289b5853ed3431c235a1f4e97d1d4205723e622dc71bd836018c62572723"
30
+ ],
31
+ "keys": [
32
+ "log"
33
+ ],
34
+ "expected": {
35
+ "ok": true,
36
+ "failing": []
37
+ }
38
+ },
39
+ {
40
+ "name": "remote-log-wrong-order",
41
+ "description": "The same heads the wrong way round.",
42
+ "older": {
43
+ "payloadType": "application/vnd.agent-custody.treehead+json",
44
+ "payload": "eyJyb290SGFzaCI6ImNlYmViNDAzYjNlZWYzYzgwNWVjYzg5OTI2NTM0ZGM0NjkxY2I4OWJlN2I0ZjFjNjE3Njg5MDFkMzIxNDkzYmEiLCJ0aW1lc3RhbXAiOiIyMDI2LTA5LTA3VDA2OjUyOjUxLjExNFoiLCJ0cmVlU2l6ZSI6Mn0=",
45
+ "signatures": [
46
+ {
47
+ "keyid": "e1ed29255a4bd710006fe9ad333514e71c705608b769029088a818f57bcf080a",
48
+ "sig": "iyUBqiJcZd/RaEDXWhhxl34ekqy6tQ9qgsMQjAXlvuHS75aqPLeh0NRtUQB89LS2UMJYihmcSUdg/Z82uORqCQ=="
49
+ }
50
+ ]
51
+ },
52
+ "newer": {
53
+ "payloadType": "application/vnd.agent-custody.treehead+json",
54
+ "payload": "eyJyb290SGFzaCI6Ijg3MzM5M2E4ZTQ4ZmFkNzVlMmRkMzY1MzRjNmRjNzUzMTc5YmM0NmEzMTAxNDZjZTI2OGUxNTc2ODZiOGMwZTQiLCJ0aW1lc3RhbXAiOiIyMDI2LTA5LTA3VDA2OjUyOjUxLjExMFoiLCJ0cmVlU2l6ZSI6MX0=",
55
+ "signatures": [
56
+ {
57
+ "keyid": "e1ed29255a4bd710006fe9ad333514e71c705608b769029088a818f57bcf080a",
58
+ "sig": "vydFxb4Z753KUPOXLEFiYnE0nntaoU9xXtBe7IMW8eucYeef+XpGu+4+bXpo/zYZATcx4h+7bMmDd3frqizkBg=="
59
+ }
60
+ ]
61
+ },
62
+ "proof": [
63
+ "4869289b5853ed3431c235a1f4e97d1d4205723e622dc71bd836018c62572723"
64
+ ],
65
+ "keys": [
66
+ "log"
67
+ ],
68
+ "expected": {
69
+ "ok": false,
70
+ "failing": [
71
+ "older is not larger than newer"
72
+ ]
73
+ }
74
+ },
75
+ {
76
+ "name": "remote-log-wrong-key",
77
+ "description": "Tree heads checked against the app key, which did not sign them.",
78
+ "older": {
79
+ "payloadType": "application/vnd.agent-custody.treehead+json",
80
+ "payload": "eyJyb290SGFzaCI6Ijg3MzM5M2E4ZTQ4ZmFkNzVlMmRkMzY1MzRjNmRjNzUzMTc5YmM0NmEzMTAxNDZjZTI2OGUxNTc2ODZiOGMwZTQiLCJ0aW1lc3RhbXAiOiIyMDI2LTA5LTA3VDA2OjUyOjUxLjExMFoiLCJ0cmVlU2l6ZSI6MX0=",
81
+ "signatures": [
82
+ {
83
+ "keyid": "e1ed29255a4bd710006fe9ad333514e71c705608b769029088a818f57bcf080a",
84
+ "sig": "vydFxb4Z753KUPOXLEFiYnE0nntaoU9xXtBe7IMW8eucYeef+XpGu+4+bXpo/zYZATcx4h+7bMmDd3frqizkBg=="
85
+ }
86
+ ]
87
+ },
88
+ "newer": {
89
+ "payloadType": "application/vnd.agent-custody.treehead+json",
90
+ "payload": "eyJyb290SGFzaCI6ImNlYmViNDAzYjNlZWYzYzgwNWVjYzg5OTI2NTM0ZGM0NjkxY2I4OWJlN2I0ZjFjNjE3Njg5MDFkMzIxNDkzYmEiLCJ0aW1lc3RhbXAiOiIyMDI2LTA5LTA3VDA2OjUyOjUxLjExNFoiLCJ0cmVlU2l6ZSI6Mn0=",
91
+ "signatures": [
92
+ {
93
+ "keyid": "e1ed29255a4bd710006fe9ad333514e71c705608b769029088a818f57bcf080a",
94
+ "sig": "iyUBqiJcZd/RaEDXWhhxl34ekqy6tQ9qgsMQjAXlvuHS75aqPLeh0NRtUQB89LS2UMJYihmcSUdg/Z82uORqCQ=="
95
+ }
96
+ ]
97
+ },
98
+ "proof": [
99
+ "4869289b5853ed3431c235a1f4e97d1d4205723e622dc71bd836018c62572723"
100
+ ],
101
+ "keys": [
102
+ "app"
103
+ ],
104
+ "expected": {
105
+ "ok": false,
106
+ "failing": [
107
+ "older tree head signature",
108
+ "newer tree head signature"
109
+ ]
110
+ }
111
+ },
112
+ {
113
+ "name": "remote-log-bad-proof",
114
+ "description": "A proof with a hash removed.",
115
+ "older": {
116
+ "payloadType": "application/vnd.agent-custody.treehead+json",
117
+ "payload": "eyJyb290SGFzaCI6Ijg3MzM5M2E4ZTQ4ZmFkNzVlMmRkMzY1MzRjNmRjNzUzMTc5YmM0NmEzMTAxNDZjZTI2OGUxNTc2ODZiOGMwZTQiLCJ0aW1lc3RhbXAiOiIyMDI2LTA5LTA3VDA2OjUyOjUxLjExMFoiLCJ0cmVlU2l6ZSI6MX0=",
118
+ "signatures": [
119
+ {
120
+ "keyid": "e1ed29255a4bd710006fe9ad333514e71c705608b769029088a818f57bcf080a",
121
+ "sig": "vydFxb4Z753KUPOXLEFiYnE0nntaoU9xXtBe7IMW8eucYeef+XpGu+4+bXpo/zYZATcx4h+7bMmDd3frqizkBg=="
122
+ }
123
+ ]
124
+ },
125
+ "newer": {
126
+ "payloadType": "application/vnd.agent-custody.treehead+json",
127
+ "payload": "eyJyb290SGFzaCI6ImNlYmViNDAzYjNlZWYzYzgwNWVjYzg5OTI2NTM0ZGM0NjkxY2I4OWJlN2I0ZjFjNjE3Njg5MDFkMzIxNDkzYmEiLCJ0aW1lc3RhbXAiOiIyMDI2LTA5LTA3VDA2OjUyOjUxLjExNFoiLCJ0cmVlU2l6ZSI6Mn0=",
128
+ "signatures": [
129
+ {
130
+ "keyid": "e1ed29255a4bd710006fe9ad333514e71c705608b769029088a818f57bcf080a",
131
+ "sig": "iyUBqiJcZd/RaEDXWhhxl34ekqy6tQ9qgsMQjAXlvuHS75aqPLeh0NRtUQB89LS2UMJYihmcSUdg/Z82uORqCQ=="
132
+ }
133
+ ]
134
+ },
135
+ "proof": [],
136
+ "keys": [
137
+ "log"
138
+ ],
139
+ "expected": {
140
+ "ok": false,
141
+ "failing": [
142
+ "newer log extends older log"
143
+ ]
144
+ }
145
+ }
146
+ ]
147
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "version": "0.2",
3
+ "note": "canonical JSON: keys sorted, no whitespace, undefined dropped. digest = hex sha256 of the canonical string. keyid = hex sha256 of the SPKI DER public key. DSSE PAE = 'DSSEv1 ' + len(type) + ' ' + type + ' ' + len(payload) + ' ' + payload, signed with Ed25519.",
4
+ "values": [
5
+ {
6
+ "value": {
7
+ "b": 1,
8
+ "a": [
9
+ 3,
10
+ {
11
+ "z": null,
12
+ "y": "ÿ"
13
+ }
14
+ ]
15
+ },
16
+ "canonical": "{\"a\":[3,{\"y\":\"ÿ\",\"z\":null}],\"b\":1}",
17
+ "digest": "894fec1144463457d74d1eb8599cf2e04a42723765ec1d1aabb77d658d860f2b"
18
+ },
19
+ {
20
+ "value": "plain",
21
+ "canonical": "\"plain\"",
22
+ "digest": "945603a8f587786b463c3f94fce115c0fae88fac2728cc96ddf5981cf7f61741"
23
+ },
24
+ {
25
+ "value": 42,
26
+ "canonical": "42",
27
+ "digest": "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"
28
+ },
29
+ {
30
+ "value": [
31
+ 1,
32
+ 2,
33
+ 3
34
+ ],
35
+ "canonical": "[1,2,3]",
36
+ "digest": "a615eeaee21de5179de080de8c3052c8da901138406ba71c38c032845f7d54f4"
37
+ },
38
+ {
39
+ "value": {
40
+ "nested": {
41
+ "deep": {
42
+ "deeper": true
43
+ }
44
+ }
45
+ },
46
+ "canonical": "{\"nested\":{\"deep\":{\"deeper\":true}}}",
47
+ "digest": "d64d85fa17b84e33888c36e22c9543527721099faee1e070943096aa3c9ba4c2"
48
+ },
49
+ {
50
+ "value": {},
51
+ "canonical": "{}",
52
+ "digest": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"
53
+ }
54
+ ],
55
+ "keyid": {
56
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAEb81f6KaNmH1pNR7KbeIUo9DW65Y0Qsfdxqbtnhs7cg=\n-----END PUBLIC KEY-----\n",
57
+ "keyid": "0ecde3bf114156b895ceb4ea63daa345271e8ee979f47bc69cbf0266bb93f6f5"
58
+ },
59
+ "dsse": {
60
+ "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAEb81f6KaNmH1pNR7KbeIUo9DW65Y0Qsfdxqbtnhs7cg=\n-----END PUBLIC KEY-----\n",
61
+ "envelope": {
62
+ "payloadType": "application/vnd.example+json",
63
+ "payload": "eyJoZWxsbyI6IndvcmxkIn0=",
64
+ "signatures": [
65
+ {
66
+ "keyid": "0ecde3bf114156b895ceb4ea63daa345271e8ee979f47bc69cbf0266bb93f6f5",
67
+ "sig": "wkH8ufuyC3BJldxmhFUCImAOo38KyQO/uQMITvTjgikPeE9GIv6OwYEFtQnjcBNzY0+MRTn++dGeMwvVKzIBDw=="
68
+ }
69
+ ]
70
+ },
71
+ "payloadJson": "{\"hello\":\"world\"}",
72
+ "paeHex": "445353457631203238206170706c69636174696f6e2f766e642e6578616d706c652b6a736f6e203137207b2268656c6c6f223a22776f726c64227d",
73
+ "paeSha256": "8a321059588971b8abbbfeb3a5c7f629a14fc46d2ecf12895a79483dde78532e",
74
+ "expected": true
75
+ }
76
+ }