@agent-custody/receipts 0.1.0 → 0.1.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
@@ -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): twelve runnable examples, one per aspect of the code, all executed by the test suite
12
+ - [Tutorials](docs/tutorials.md): fourteen 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
@@ -21,13 +21,7 @@ Anyone holding the public keys can verify a receipt offline. The agent is not tr
21
21
  npm install @agent-custody/receipts # or bun add, pnpm add
22
22
  ```
23
23
 
24
- Not on npm yet: the `@agent-custody` scope is still to be claimed. Until then, build from a clone and install the tarball:
25
-
26
- ```bash
27
- bun install && bun run build # at the repository root
28
- cd packages/receipts && bun pm pack # writes agent-custody-receipts-0.1.0.tgz here
29
- npm install /path/to/agent-custody/packages/receipts/agent-custody-receipts-0.1.0.tgz # in your project
30
- ```
24
+ Published on npm as [`@agent-custody/receipts`](https://www.npmjs.com/package/@agent-custody/receipts): compiled JavaScript with type declarations, Node 22 or later, Apache-2.0.
31
25
 
32
26
  Record receipts from inside your own agent, no gateway needed. Generate a signing key, point a config at it, wrap the functions the agent calls:
33
27
 
@@ -62,7 +56,7 @@ flowchart LR
62
56
  G["agent-custody gateway<br/>scope check → fact lookups → Cedar policy"]
63
57
  U["Upstream MCP server<br/>Stripe, database, GitHub, ..."]
64
58
  R[("receipt bundles<br/>receipts/*.json")]
65
- L[("Merkle log<br/>log.jsonl")]
59
+ L[("Merkle log<br/>local file, or a remote log<br/>run by someone else")]
66
60
  V["Verifier<br/>auditor, counterparty, CI job"]
67
61
  O["Observability<br/>OTel, LangSmith, Arize"]
68
62
 
@@ -173,7 +167,7 @@ Every field carries a provenance label. This is the design decision that matters
173
167
  bun install # from the repository root, once for the workspace
174
168
  cd packages/receipts
175
169
  node scripts/demo.ts # gateway: keys, grant, policy, four tool calls, verification, a tampering attempt; then the SDK wrapping the same tool
176
- node examples/01-keys-and-signing.ts # first of twelve step-by-step examples, see docs/tutorials.md
170
+ node examples/01-keys-and-signing.ts # first of fourteen step-by-step examples, see docs/tutorials.md
177
171
  bun run test # this package; `bun run test` at the root runs every package
178
172
  ```
179
173
 
@@ -223,7 +217,8 @@ If a vendor tells you their receipts prove more than the first five rows, ask th
223
217
  ```
224
218
  src/config.ts gateway and SDK config schemas, path resolution
225
219
  src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
226
- src/log.ts Merkle log: append, root, inclusion proof, verify, JSONL persistence
220
+ src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
221
+ src/log-sink.ts where leaves go: the local file, or a remote log over HTTP; plus the reference log server
227
222
  src/policy.ts Cedar evaluation wrapper, fail-closed
228
223
  src/delegation.ts signed delegation grants
229
224
  src/receipt.ts receipt statement types and provenance labels
@@ -232,12 +227,12 @@ src/gateway.ts the MCP proxy: scope check, facts, policy, forward, receipt
232
227
  src/sdk/index.ts the interceptor: policy decision, record, wrap(tool fn)
233
228
  src/sdk/claude.ts Claude Code command hook and Claude Agent SDK in-process hooks
234
229
  src/sdk/openai-agents.ts, vercel-ai.ts, langchain.ts framework adapters, tested against the real packages
235
- src/verify.ts offline verification and the human-readable report
236
- src/cli.ts keygen, grant, gateway, hook, verify
230
+ 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
237
232
  src/index.ts the package's public surface; adapters are exported on ./sdk/<framework> subpaths
238
233
  tsconfig.build.json emits dist/ (JavaScript plus declarations) for consumers; the repo itself runs the .ts directly
239
234
  scripts/ fake Stripe upstream, fixture builders for gateway and SDK, demo
240
- examples/ twelve runnable tutorials, one per aspect; each is run by the test suite
235
+ examples/ fourteen runnable tutorials, one per aspect; each is run by the test suite
241
236
  test/ unit tests per module, end-to-end gateway test, SDK and hook tests,
242
237
  adapter tests against the real packages, and a test that runs every policy in docs/policies.md
243
238
  docs/ tutorials, usage (gateway), sdk, policies, verification
@@ -254,16 +249,17 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
254
249
  - SDK core: policy decision, record, and a generic `wrap(tool, fn)` for any framework whose tools are functions.
255
250
  - Claude Code command hook for PreToolUse, PostToolUse, and PostToolUseFailure, with blocking on deny.
256
251
  - Claude Agent SDK in-process hooks over the same handler.
252
+ - 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
+ - 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.
257
254
  - 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).
258
255
 
259
256
  **Next, in the order it pays off**
260
257
 
261
258
  1. OpenTelemetry export: emit each receipt as a span with the receipt id and issuer kind as attributes, so existing collectors and dashboards carry them without a new pipeline.
262
259
  2. Embed upstream signed responses (Stripe webhook signatures, GitHub delivery signatures) so gateway execution can move from `observed` to `attested`.
263
- 3. Consistency proofs between tree heads, so an auditor can check that a later log extends an earlier copy.
264
- 4. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
265
- 5. Delegation chains for sub-agents.
266
- 6. Receiver-attested receipts for agent-to-agent calls.
267
- 7. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
260
+ 3. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
261
+ 4. Delegation chains for sub-agents.
262
+ 5. Receiver-attested receipts for agent-to-agent calls.
263
+ 6. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
268
264
 
269
265
  A Python SDK follows the same shape once the TypeScript adapters have settled.
package/dist/cli.js CHANGED
@@ -5,16 +5,21 @@ import { loadConfig, loadSdkConfig } from "./config.js";
5
5
  import { generateKeyPair, loadPrivateKey, loadPublicKey, writeKeyPair } from "./crypto.js";
6
6
  import { createDelegation } from "./delegation.js";
7
7
  import { createGateway, serveStdio } from "./gateway.js";
8
+ import { serveLog } from "./log-sink.js";
9
+ import { MerkleLog } from "./log.js";
8
10
  import { createSdkIssuer } from "./sdk/index.js";
9
11
  import { handleHookEvent } from "./sdk/claude.js";
10
- import { formatReport, verifyBundle } from "./verify.js";
12
+ import { auditExtends, formatReport, verifyBundle } from "./verify.js";
11
13
  const USAGE = `agent-custody <command>
12
14
 
13
15
  keygen --dir <dir> --name <name>
14
16
  grant --key <principal.key> --principal <id> --agent <id> --scopes <a,b> [--ttl-hours 24] --out <file>
15
17
  gateway --config <gateway.json>
16
18
  hook [--config <sdk.json>] Claude Code hook command; reads the event on stdin (or AGENT_CUSTODY_CONFIG)
17
- verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log <log.jsonl>] [--json]
19
+ log --file <log.jsonl> --key <log.key> [--port 8787] [--host 127.0.0.1] [--token-env <NAME>] reference log server
20
+ verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--log <log.jsonl>] [--json]
21
+ audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--json]
22
+ checks that the newer receipt's log extends the older one's: nothing between them was rewritten
18
23
  `;
19
24
  async function main(argv) {
20
25
  const [cmd, ...rest] = argv;
@@ -71,10 +76,27 @@ async function main(argv) {
71
76
  if (!configPath)
72
77
  throw new Error("hook needs --config or AGENT_CUSTODY_CONFIG");
73
78
  const input = JSON.parse(readFileSync(0, "utf8"));
74
- const out = handleHookEvent(createSdkIssuer(loadSdkConfig(configPath)), input);
79
+ const out = await handleHookEvent(createSdkIssuer(loadSdkConfig(configPath)), input);
75
80
  console.log(JSON.stringify(out));
76
81
  return 0;
77
82
  }
83
+ case "log": {
84
+ const { values } = parseArgs({
85
+ args: rest,
86
+ options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" } },
87
+ });
88
+ if (!values.file || !values.key)
89
+ throw new Error("log needs --file and --key");
90
+ const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
91
+ if (values["token-env"] && !token)
92
+ throw new Error(`log: environment variable ${values["token-env"]} is not set`);
93
+ const key = loadPrivateKey(values.key);
94
+ const running = await serveLog(values.file, key, { port: Number(values.port), host: values.host, ...(token ? { tokens: [token] } : {}) });
95
+ console.error(`agent-custody log: ${running.url} keyid=${key.keyid} file=${values.file} ${token ? "bearer token required" : "open, anyone may append"}`);
96
+ await new Promise((resolve) => process.once("SIGINT", resolve));
97
+ await running.close();
98
+ return 0;
99
+ }
78
100
  case "verify": {
79
101
  const { values, positionals } = parseArgs({
80
102
  args: rest,
@@ -83,6 +105,7 @@ async function main(argv) {
83
105
  "issuer-key": { type: "string", multiple: true },
84
106
  "gateway-key": { type: "string", multiple: true },
85
107
  "principal-key": { type: "string", multiple: true },
108
+ "log-key": { type: "string", multiple: true },
86
109
  log: { type: "string" },
87
110
  json: { type: "boolean", default: false },
88
111
  },
@@ -95,11 +118,53 @@ async function main(argv) {
95
118
  const result = verifyBundle(bundle, {
96
119
  issuerKeys: issuerKeyFiles.map(loadPublicKey),
97
120
  principalKeys: (values["principal-key"] ?? []).map(loadPublicKey),
121
+ ...(values["log-key"] ? { logKeys: values["log-key"].map(loadPublicKey) } : {}),
98
122
  ...(values.log ? { logFile: values.log } : {}),
99
123
  });
100
124
  console.log(values.json ? JSON.stringify(result, null, 2) : formatReport(result));
101
125
  return result.ok ? 0 : 1;
102
126
  }
127
+ case "audit": {
128
+ const { values } = parseArgs({
129
+ args: rest,
130
+ options: {
131
+ older: { type: "string" },
132
+ newer: { type: "string" },
133
+ log: { type: "string" },
134
+ "log-url": { type: "string" },
135
+ "issuer-key": { type: "string", multiple: true },
136
+ "log-key": { type: "string", multiple: true },
137
+ json: { type: "boolean", default: false },
138
+ },
139
+ });
140
+ const keyFiles = [...(values["issuer-key"] ?? []), ...(values["log-key"] ?? [])];
141
+ if (!values.older || !values.newer || keyFiles.length === 0)
142
+ throw new Error("audit needs --older, --newer, and at least one --issuer-key or --log-key");
143
+ if (!values.log === !values["log-url"])
144
+ throw new Error("audit needs exactly one of --log or --log-url");
145
+ const older = JSON.parse(readFileSync(values.older, "utf8")).treeHead;
146
+ const newer = JSON.parse(readFileSync(values.newer, "utf8")).treeHead;
147
+ const sizeOf = (env) => JSON.parse(Buffer.from(env.payload, "base64").toString()).treeSize;
148
+ const [m, n] = [sizeOf(older), sizeOf(newer)];
149
+ let proof;
150
+ if (values.log)
151
+ proof = new MerkleLog(values.log).consistencyProof(Math.min(m, n), Math.max(m, n));
152
+ else {
153
+ const res = await fetch(new URL(`consistency?old=${Math.min(m, n)}&new=${Math.max(m, n)}`, values["log-url"].endsWith("/") ? values["log-url"] : `${values["log-url"]}/`));
154
+ if (!res.ok)
155
+ throw new Error(`log refused the consistency query: ${res.status}`);
156
+ proof = (await res.json()).hashes;
157
+ }
158
+ const result = auditExtends(older, newer, proof, keyFiles.map(loadPublicKey));
159
+ if (values.json)
160
+ console.log(JSON.stringify(result, null, 2));
161
+ else {
162
+ for (const c of result.checks)
163
+ console.log(`${c.ok ? "PASS" : "FAIL"} ${c.name}${c.detail ? ` (${c.detail})` : ""}`);
164
+ console.log(`\nRESULT: ${result.ok ? "NEWER LOG EXTENDS OLDER LOG" : "NOT CONSISTENT"}`);
165
+ }
166
+ return result.ok ? 0 : 1;
167
+ }
103
168
  default:
104
169
  console.error(USAGE);
105
170
  return cmd === undefined || cmd === "--help" || cmd === "-h" ? 0 : 2;
package/dist/config.d.ts CHANGED
@@ -24,7 +24,11 @@ export declare const GatewayConfigSchema: z.ZodObject<{
24
24
  forTools: z.ZodArray<z.ZodString>;
25
25
  }, z.core.$strip>>>;
26
26
  receiptsDir: z.ZodString;
27
- logFile: z.ZodString;
27
+ logFile: z.ZodOptional<z.ZodString>;
28
+ log: z.ZodOptional<z.ZodObject<{
29
+ url: z.ZodString;
30
+ tokenEnv: z.ZodOptional<z.ZodString>;
31
+ }, z.core.$strip>>;
28
32
  }, z.core.$strip>;
29
33
  export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
30
34
  export type FactConfig = z.infer<typeof FactSchema>;
@@ -38,7 +42,11 @@ export declare const SdkConfigSchema: z.ZodObject<{
38
42
  }, z.core.$strip>;
39
43
  policyFile: z.ZodOptional<z.ZodString>;
40
44
  receiptsDir: z.ZodString;
41
- logFile: z.ZodString;
45
+ logFile: z.ZodOptional<z.ZodString>;
46
+ log: z.ZodOptional<z.ZodObject<{
47
+ url: z.ZodString;
48
+ tokenEnv: z.ZodOptional<z.ZodString>;
49
+ }, z.core.$strip>>;
42
50
  framework: z.ZodOptional<z.ZodString>;
43
51
  }, z.core.$strip>;
44
52
  export type SdkConfig = z.infer<typeof SdkConfigSchema>;
package/dist/config.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
+ /** Where receipts are logged: a local file, or a log reached over HTTP whose bearer token comes from an environment variable. */
5
+ const LogSchema = z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional() });
6
+ const oneLog = { message: "exactly one of logFile or log is required" };
7
+ const hasOneLog = (c) => (c.logFile ? 1 : 0) + (c.log ? 1 : 0) === 1;
4
8
  const FactSchema = z.object({
5
9
  /** key under context.facts */
6
10
  name: z.string().min(1),
@@ -23,8 +27,9 @@ export const GatewayConfigSchema = z.object({
23
27
  policyFile: z.string(),
24
28
  facts: z.array(FactSchema).default([]),
25
29
  receiptsDir: z.string(),
26
- logFile: z.string(),
27
- });
30
+ logFile: z.string().optional(),
31
+ log: LogSchema.optional(),
32
+ }).refine(hasOneLog, oneLog);
28
33
  /** Loads a config file and resolves every path relative to the file's directory. */
29
34
  export function loadConfig(path) {
30
35
  const cfg = GatewayConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
@@ -37,7 +42,7 @@ export function loadConfig(path) {
37
42
  trustedPrincipalKeys: cfg.trustedPrincipalKeys.map(r),
38
43
  policyFile: r(cfg.policyFile),
39
44
  receiptsDir: r(cfg.receiptsDir),
40
- logFile: r(cfg.logFile),
45
+ ...(cfg.logFile ? { logFile: r(cfg.logFile) } : {}),
41
46
  };
42
47
  }
43
48
  export const SdkConfigSchema = z.object({
@@ -48,10 +53,11 @@ export const SdkConfigSchema = z.object({
48
53
  /** optional Cedar policy; when present, wrapped tools and PreToolUse hooks can deny */
49
54
  policyFile: z.string().optional(),
50
55
  receiptsDir: z.string(),
51
- logFile: z.string(),
56
+ logFile: z.string().optional(),
57
+ log: LogSchema.optional(),
52
58
  /** free-text label of the host framework, e.g. "claude-code", "openai-agents" */
53
59
  framework: z.string().optional(),
54
- });
60
+ }).refine(hasOneLog, oneLog);
55
61
  export function loadSdkConfig(path) {
56
62
  const cfg = SdkConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
57
63
  const base = dirname(resolve(path));
@@ -61,6 +67,6 @@ export function loadSdkConfig(path) {
61
67
  identity: { keyFile: r(cfg.identity.keyFile) },
62
68
  ...(cfg.policyFile ? { policyFile: r(cfg.policyFile) } : {}),
63
69
  receiptsDir: r(cfg.receiptsDir),
64
- logFile: r(cfg.logFile),
70
+ ...(cfg.logFile ? { logFile: r(cfg.logFile) } : {}),
65
71
  };
66
72
  }
package/dist/gateway.js CHANGED
@@ -10,6 +10,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
10
10
  import { digestOf, loadPrivateKey, loadPublicKey } from "./crypto.js";
11
11
  import { delegationValidAt, verifyDelegation } from "./delegation.js";
12
12
  import { createIssuer } from "./issue.js";
13
+ import { openLog } from "./log-sink.js";
13
14
  import { evaluate, policyDigest } from "./policy.js";
14
15
  export const GATEWAY_VERSION = "0.1.0";
15
16
  export const RECEIPT_META_KEY = "agent-custody/receipt";
@@ -54,7 +55,7 @@ export async function createGateway(cfg) {
54
55
  const principalKeyid = grant.keyid;
55
56
  const policyText = readFileSync(cfg.policyFile, "utf8");
56
57
  const pDigest = policyDigest(policyText);
57
- const issuer = createIssuer(gatewayKey, cfg.receiptsDir, cfg.logFile);
58
+ const issuer = createIssuer(gatewayKey, cfg.receiptsDir, openLog(cfg, gatewayKey));
58
59
  const upstream = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
59
60
  await upstream.connect(new StdioClientTransport({ command: cfg.upstream.command, args: cfg.upstream.args, env: cfg.upstream.env, stderr: "inherit" }));
60
61
  const callUpstream = async (name, args) => (await upstream.callTool({ name, arguments: args }));
@@ -107,7 +108,7 @@ export async function createGateway(cfg) {
107
108
  else {
108
109
  execution = { status: "denied", reason: [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched", provenance: "observed" };
109
110
  }
110
- issuer.issue({
111
+ await issuer.issue({
111
112
  receiptId,
112
113
  timestamp,
113
114
  issuer: { kind: "gateway", keyid: issuer.keyid, version: GATEWAY_VERSION },
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./delegation.ts";
4
4
  export * from "./gateway.ts";
5
5
  export * from "./issue.ts";
6
6
  export * from "./log.ts";
7
+ export * from "./log-sink.ts";
7
8
  export * from "./policy.ts";
8
9
  export * from "./receipt.ts";
9
10
  export * from "./verify.ts";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export * from "./delegation.js";
5
5
  export * from "./gateway.js";
6
6
  export * from "./issue.js";
7
7
  export * from "./log.js";
8
+ export * from "./log-sink.js";
8
9
  export * from "./policy.js";
9
10
  export * from "./receipt.js";
10
11
  export * from "./verify.js";
package/dist/issue.d.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import { type KeyPair } from "./crypto.ts";
2
+ import { type LogSink } from "./log-sink.ts";
2
3
  import { type ReceiptBundle, type ReceiptPredicate } from "./receipt.ts";
3
4
  export interface Issuer {
4
5
  keyid: string;
5
- issue(predicate: ReceiptPredicate): ReceiptBundle;
6
+ log: LogSink;
7
+ /** Signs the statement, appends it to the log, writes the bundle. Rejects if the log refuses the leaf; no bundle is written then. */
8
+ issue(predicate: ReceiptPredicate): Promise<ReceiptBundle>;
6
9
  }
7
- export declare function createIssuer(key: KeyPair, receiptsDir: string, logFile: string): Issuer;
10
+ /** `log` is a sink, or a file path for the local log with tree heads signed by the issuer's key. */
11
+ export declare function createIssuer(key: KeyPair, receiptsDir: string, log: string | LogSink): Issuer;
package/dist/issue.js CHANGED
@@ -2,18 +2,19 @@
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { canonicalize, dsseSign } from "./crypto.js";
5
- import { MerkleLog } from "./log.js";
6
- import { buildStatement, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
7
- export function createIssuer(key, receiptsDir, logFile) {
8
- const log = new MerkleLog(logFile);
5
+ import { fileLog } from "./log-sink.js";
6
+ import { buildStatement, RECEIPT_TYPE } from "./receipt.js";
7
+ /** `log` is a sink, or a file path for the local log with tree heads signed by the issuer's key. */
8
+ export function createIssuer(key, receiptsDir, log) {
9
+ const sink = typeof log === "string" ? fileLog(log, key) : log;
9
10
  mkdirSync(receiptsDir, { recursive: true });
10
11
  return {
11
12
  keyid: key.keyid,
12
- issue(predicate) {
13
+ log: sink,
14
+ async issue(predicate) {
13
15
  const envelope = dsseSign(RECEIPT_TYPE, buildStatement(predicate), key);
14
- const entry = log.append(canonicalize(envelope));
15
- const treeHead = dsseSign(TREEHEAD_TYPE, { treeSize: entry.treeSize, rootHash: entry.rootHash, timestamp: new Date().toISOString() }, key);
16
- const bundle = { envelope, treeHead, inclusion: { leafIndex: entry.leafIndex, treeSize: entry.treeSize, hashes: entry.hashes } };
16
+ const entry = await sink.append(canonicalize(envelope));
17
+ const bundle = { envelope, treeHead: entry.treeHead, inclusion: entry.inclusion };
17
18
  writeFileSync(join(receiptsDir, `${predicate.receiptId}.json`), JSON.stringify(bundle, null, 2));
18
19
  return bundle;
19
20
  },
@@ -0,0 +1,53 @@
1
+ import { type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { type Envelope, type KeyPair } from "./crypto.ts";
3
+ import { type InclusionProof } from "./log.ts";
4
+ export interface LogAppend {
5
+ inclusion: InclusionProof;
6
+ /** signed TreeHead; the signature's keyid says who runs the log */
7
+ treeHead: Envelope;
8
+ }
9
+ export interface LogSink {
10
+ readonly kind: "file" | "http";
11
+ /** the file path or the URL, for reports */
12
+ readonly where: string;
13
+ append(leaf: string): Promise<LogAppend>;
14
+ }
15
+ /** A local JSONL Merkle log. Tree heads are signed with the given key, normally the issuer's own. */
16
+ export declare function fileLog(file: string, key: KeyPair): LogSink;
17
+ export interface HttpLogOptions {
18
+ /** sent as a bearer token; the log decides what it is worth */
19
+ token?: string;
20
+ fetch?: typeof fetch;
21
+ }
22
+ /** A log reached over HTTP: POST <url>/append with {leaf}, expecting a LogAppend back. */
23
+ export declare function httpLog(url: string, opts?: HttpLogOptions): LogSink;
24
+ export interface LogConfig {
25
+ logFile?: string | undefined;
26
+ log?: {
27
+ url: string;
28
+ tokenEnv?: string | undefined;
29
+ } | undefined;
30
+ }
31
+ /** The sink a config asks for: a remote log when `log` is set, otherwise the local file. */
32
+ export declare function openLog(cfg: LogConfig, key: KeyPair): LogSink;
33
+ export interface LogServerOptions {
34
+ /** bearer tokens accepted on append; when empty, anyone may append */
35
+ tokens?: string[];
36
+ }
37
+ /**
38
+ * The reference log server as a node:http request handler.
39
+ * POST /append {leaf} -> LogAppend, tree head signed with the log's key
40
+ * GET /root?size=N -> {treeSize, rootHash}, for auditors checking a tree head against the log
41
+ * GET /consistency?old=M&new=N -> {oldSize, newSize, hashes}, proof that the log at N extends the log at M
42
+ * GET /head -> {treeHead}, the current tree head signed with the log's key
43
+ */
44
+ export declare function logHandler(file: string, key: KeyPair, opts?: LogServerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
45
+ export interface RunningLog {
46
+ url: string;
47
+ close(): Promise<void>;
48
+ }
49
+ /** Starts the reference log server. Port 0 picks a free port. */
50
+ export declare function serveLog(file: string, key: KeyPair, opts: LogServerOptions & {
51
+ port: number;
52
+ host?: string;
53
+ }): Promise<RunningLog>;
@@ -0,0 +1,136 @@
1
+ // Where log leaves go.
2
+ // The file sink is the local Merkle log, with tree heads signed by the issuer's own key: tamper-evident, but the
3
+ // operator holds the file. The HTTP sink hands each leaf to a log run by someone else, who signs the tree head with
4
+ // their key. A verifier who trusts that key learns the receipt was in a log the operator cannot rewrite.
5
+ // logHandler and serveLog are the other side: a reference log server over node:http, the same code a hosted log runs.
6
+ import { timingSafeEqual } from "node:crypto";
7
+ import { createServer } from "node:http";
8
+ import { dsseSign } from "./crypto.js";
9
+ import { MerkleLog } from "./log.js";
10
+ import { TREEHEAD_TYPE } from "./receipt.js";
11
+ function appendSigned(log, key, leaf) {
12
+ const e = log.append(leaf);
13
+ const head = { treeSize: e.treeSize, rootHash: e.rootHash, timestamp: new Date().toISOString() };
14
+ return { inclusion: { leafIndex: e.leafIndex, treeSize: e.treeSize, hashes: e.hashes }, treeHead: dsseSign(TREEHEAD_TYPE, head, key) };
15
+ }
16
+ /** A local JSONL Merkle log. Tree heads are signed with the given key, normally the issuer's own. */
17
+ export function fileLog(file, key) {
18
+ const log = new MerkleLog(file);
19
+ return {
20
+ kind: "file",
21
+ where: file,
22
+ async append(leaf) {
23
+ return appendSigned(log, key, leaf);
24
+ },
25
+ };
26
+ }
27
+ /** A log reached over HTTP: POST <url>/append with {leaf}, expecting a LogAppend back. */
28
+ export function httpLog(url, opts = {}) {
29
+ const f = opts.fetch ?? fetch;
30
+ const base = url.endsWith("/") ? url : `${url}/`;
31
+ return {
32
+ kind: "http",
33
+ where: url,
34
+ async append(leaf) {
35
+ const res = await f(new URL("append", base), {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json", ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) },
38
+ body: JSON.stringify({ leaf }),
39
+ });
40
+ if (!res.ok)
41
+ throw new Error(`log ${url} refused the append: ${res.status} ${(await res.text()).slice(0, 200)}`);
42
+ const body = (await res.json());
43
+ if (!body.inclusion || !body.treeHead)
44
+ throw new Error(`log ${url} returned a malformed append result`);
45
+ return body;
46
+ },
47
+ };
48
+ }
49
+ /** The sink a config asks for: a remote log when `log` is set, otherwise the local file. */
50
+ export function openLog(cfg, key) {
51
+ if (cfg.log) {
52
+ const token = cfg.log.tokenEnv ? process.env[cfg.log.tokenEnv] : undefined;
53
+ if (cfg.log.tokenEnv && !token)
54
+ throw new Error(`log token: environment variable ${cfg.log.tokenEnv} is not set`);
55
+ return httpLog(cfg.log.url, token === undefined ? {} : { token });
56
+ }
57
+ if (!cfg.logFile)
58
+ throw new Error("config needs logFile or log.url");
59
+ return fileLog(cfg.logFile, key);
60
+ }
61
+ /**
62
+ * The reference log server as a node:http request handler.
63
+ * POST /append {leaf} -> LogAppend, tree head signed with the log's key
64
+ * GET /root?size=N -> {treeSize, rootHash}, for auditors checking a tree head against the log
65
+ * GET /consistency?old=M&new=N -> {oldSize, newSize, hashes}, proof that the log at N extends the log at M
66
+ * GET /head -> {treeHead}, the current tree head signed with the log's key
67
+ */
68
+ export function logHandler(file, key, opts = {}) {
69
+ const log = new MerkleLog(file);
70
+ const tokens = opts.tokens ?? [];
71
+ const authorized = (req) => {
72
+ if (tokens.length === 0)
73
+ return true;
74
+ const h = req.headers.authorization ?? "";
75
+ const given = Buffer.from(h.startsWith("Bearer ") ? h.slice(7) : "");
76
+ return tokens.some((t) => {
77
+ const want = Buffer.from(t);
78
+ return want.length === given.length && timingSafeEqual(want, given);
79
+ });
80
+ };
81
+ return async (req, res) => {
82
+ const json = (status, body) => {
83
+ res.writeHead(status, { "content-type": "application/json" });
84
+ res.end(JSON.stringify(body));
85
+ };
86
+ const url = new URL(req.url ?? "/", "http://localhost");
87
+ if (req.method === "POST" && url.pathname.endsWith("/append")) {
88
+ if (!authorized(req))
89
+ return json(401, { error: "unauthorized" });
90
+ let body = "";
91
+ for await (const chunk of req)
92
+ body += chunk;
93
+ let leaf;
94
+ try {
95
+ leaf = JSON.parse(body).leaf;
96
+ }
97
+ catch {
98
+ return json(400, { error: "body must be JSON {leaf}" });
99
+ }
100
+ if (typeof leaf !== "string" || leaf.length === 0)
101
+ return json(400, { error: "leaf must be a non-empty string" });
102
+ return json(200, appendSigned(log, key, leaf));
103
+ }
104
+ if (req.method === "GET" && url.pathname.endsWith("/root")) {
105
+ const size = url.searchParams.has("size") ? Number(url.searchParams.get("size")) : log.size;
106
+ if (!Number.isInteger(size) || size < 0 || size > log.size)
107
+ return json(400, { error: `size must be an integer in 0..${log.size}` });
108
+ return json(200, { treeSize: size, rootHash: log.root(size) });
109
+ }
110
+ if (req.method === "GET" && url.pathname.endsWith("/consistency")) {
111
+ const oldSize = Number(url.searchParams.get("old"));
112
+ const newSize = url.searchParams.has("new") ? Number(url.searchParams.get("new")) : log.size;
113
+ if (![oldSize, newSize].every(Number.isInteger) || oldSize < 0 || oldSize > newSize || newSize > log.size)
114
+ return json(400, { error: `old and new must be integers with 0 <= old <= new <= ${log.size}` });
115
+ return json(200, { oldSize, newSize, hashes: log.consistencyProof(oldSize, newSize) });
116
+ }
117
+ if (req.method === "GET" && url.pathname.endsWith("/head")) {
118
+ const head = { treeSize: log.size, rootHash: log.root(), timestamp: new Date().toISOString() };
119
+ return json(200, { treeHead: dsseSign(TREEHEAD_TYPE, head, key) });
120
+ }
121
+ return json(404, { error: "not found" });
122
+ };
123
+ }
124
+ /** Starts the reference log server. Port 0 picks a free port. */
125
+ export function serveLog(file, key, opts) {
126
+ const host = opts.host ?? "127.0.0.1";
127
+ const server = createServer((req, res) => {
128
+ void logHandler(file, key, opts)(req, res);
129
+ });
130
+ return new Promise((resolve) => {
131
+ server.listen(opts.port, host, () => {
132
+ const { port } = server.address();
133
+ resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => server.close(() => r())) });
134
+ });
135
+ });
136
+ }
package/dist/log.d.ts CHANGED
@@ -4,6 +4,10 @@ export interface InclusionProof {
4
4
  hashes: string[];
5
5
  }
6
6
  export declare function leafHash(data: string): Buffer;
7
+ /** Proof that the tree of size newSize extends the tree of size oldSize. Empty when oldSize is 0 or equal to newSize. */
8
+ export declare function consistencyProof(leafHashes: Buffer[], oldSize: number, newSize?: number): string[];
9
+ /** RFC 9162 section 2.1.4.2. Pure: needs only the two sizes, the two roots, and the proof. */
10
+ export declare function verifyConsistency(oldSize: number, oldRootHex: string, newSize: number, newRootHex: string, proofHex: string[]): boolean;
7
11
  export declare function rootOf(leafHashes: Buffer[], size?: number): string;
8
12
  export declare function inclusionProof(leafHashes: Buffer[], leafIndex: number, treeSize?: number): InclusionProof;
9
13
  /** RFC 9162 section 2.1.3.2 verification. Pure function: needs only the leaf hash, proof and claimed root. */
@@ -18,6 +22,8 @@ export declare class MerkleLog {
18
22
  rootHash: string;
19
23
  };
20
24
  root(size?: number): string;
25
+ /** Proof that this log at newSize extends its own earlier state at oldSize. */
26
+ consistencyProof(oldSize: number, newSize?: number): string[];
21
27
  /** Reads a log file and returns the root at the given size, for auditors holding a copy of the log. */
22
28
  static rootFromFile(file: string, size: number): string;
23
29
  }
package/dist/log.js CHANGED
@@ -38,6 +38,64 @@ function path(m, leaves, lo, hi) {
38
38
  ? [...path(m, leaves, lo, lo + k), mth(leaves, lo + k, hi)]
39
39
  : [...path(m - k, leaves, lo + k, hi), mth(leaves, lo, lo + k)];
40
40
  }
41
+ /** RFC 9162 section 2.1.4.1: SUBPROOF(m, D[n], b). */
42
+ function subproof(m, leaves, lo, hi, b) {
43
+ const n = hi - lo;
44
+ if (m === n)
45
+ return b ? [] : [mth(leaves, lo, hi)];
46
+ const k = split(n);
47
+ return m <= k
48
+ ? [...subproof(m, leaves, lo, lo + k, b), mth(leaves, lo + k, hi)]
49
+ : [...subproof(m - k, leaves, lo + k, hi, false), mth(leaves, lo, lo + k)];
50
+ }
51
+ /** Proof that the tree of size newSize extends the tree of size oldSize. Empty when oldSize is 0 or equal to newSize. */
52
+ export function consistencyProof(leafHashes, oldSize, newSize = leafHashes.length) {
53
+ if (oldSize < 0 || oldSize > newSize || newSize > leafHashes.length)
54
+ throw new Error("sizes out of range");
55
+ if (oldSize === 0 || oldSize === newSize)
56
+ return [];
57
+ return subproof(oldSize, leafHashes, 0, newSize, true).map((b) => b.toString("hex"));
58
+ }
59
+ /** RFC 9162 section 2.1.4.2. Pure: needs only the two sizes, the two roots, and the proof. */
60
+ export function verifyConsistency(oldSize, oldRootHex, newSize, newRootHex, proofHex) {
61
+ if (oldSize < 0 || oldSize > newSize)
62
+ return false;
63
+ if (oldSize === newSize)
64
+ return proofHex.length === 0 && oldRootHex === newRootHex;
65
+ if (oldSize === 0)
66
+ return proofHex.length === 0;
67
+ if (proofHex.length === 0)
68
+ return false;
69
+ const proof = proofHex.map((x) => Buffer.from(x, "hex"));
70
+ if ((oldSize & (oldSize - 1)) === 0)
71
+ proof.unshift(Buffer.from(oldRootHex, "hex"));
72
+ let fn = oldSize - 1;
73
+ let sn = newSize - 1;
74
+ while (fn % 2 === 1) {
75
+ fn = Math.floor(fn / 2);
76
+ sn = Math.floor(sn / 2);
77
+ }
78
+ let fr = proof[0];
79
+ let sr = proof[0];
80
+ for (const c of proof.slice(1)) {
81
+ if (sn === 0)
82
+ return false;
83
+ if (fn % 2 === 1 || fn === sn) {
84
+ fr = nodeHash(c, fr);
85
+ sr = nodeHash(c, sr);
86
+ while (fn % 2 === 0 && fn !== 0) {
87
+ fn = Math.floor(fn / 2);
88
+ sn = Math.floor(sn / 2);
89
+ }
90
+ }
91
+ else {
92
+ sr = nodeHash(sr, c);
93
+ }
94
+ fn = Math.floor(fn / 2);
95
+ sn = Math.floor(sn / 2);
96
+ }
97
+ return sn === 0 && fr.toString("hex") === oldRootHex && sr.toString("hex") === newRootHex;
98
+ }
41
99
  export function rootOf(leafHashes, size = leafHashes.length) {
42
100
  return mth(leafHashes, 0, size).toString("hex");
43
101
  }
@@ -102,6 +160,10 @@ export class MerkleLog {
102
160
  root(size = this.size) {
103
161
  return rootOf(this.hashes, size);
104
162
  }
163
+ /** Proof that this log at newSize extends its own earlier state at oldSize. */
164
+ consistencyProof(oldSize, newSize = this.size) {
165
+ return consistencyProof(this.hashes, oldSize, newSize);
166
+ }
105
167
  /** Reads a log file and returns the root at the given size, for auditors holding a copy of the log. */
106
168
  static rootFromFile(file, size) {
107
169
  return new MerkleLog(file).root(size);
@@ -22,7 +22,7 @@ export interface HookOutput {
22
22
  * so the host's normal permission flow still applies. This adapter never auto-approves.
23
23
  * PostToolUse / PostToolUseFailure: issue the receipt for the completed call.
24
24
  */
25
- export declare function handleHookEvent(issuer: SdkIssuer, input: HookInput): HookOutput;
25
+ export declare function handleHookEvent(issuer: SdkIssuer, input: HookInput): Promise<HookOutput>;
26
26
  /**
27
27
  * Hooks for the Claude Agent SDK's query({ hooks }) option. Register all three events.
28
28
  * Typed loosely on purpose so this file does not depend on the SDK package.
@@ -11,14 +11,14 @@ function toEvent(input) {
11
11
  * so the host's normal permission flow still applies. This adapter never auto-approves.
12
12
  * PostToolUse / PostToolUseFailure: issue the receipt for the completed call.
13
13
  */
14
- export function handleHookEvent(issuer, input) {
14
+ export async function handleHookEvent(issuer, input) {
15
15
  const ev = toEvent(input);
16
16
  switch (input.hook_event_name) {
17
17
  case "PreToolUse": {
18
18
  const policy = issuer.decide(ev);
19
19
  if (policy && policy.decision === "deny") {
20
20
  const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
21
- const bundle = issuer.record(ev, { status: "denied", reason }, policy);
21
+ const bundle = await issuer.record(ev, { status: "denied", reason }, policy);
22
22
  return {
23
23
  continue: true,
24
24
  hookSpecificOutput: {
@@ -31,10 +31,10 @@ export function handleHookEvent(issuer, input) {
31
31
  return {};
32
32
  }
33
33
  case "PostToolUse":
34
- issuer.record(ev, { status: "executed", result: input.tool_response ?? null }, issuer.decide(ev));
34
+ await issuer.record(ev, { status: "executed", result: input.tool_response ?? null }, issuer.decide(ev));
35
35
  return {};
36
36
  case "PostToolUseFailure":
37
- issuer.record(ev, { status: "failed", result: input.error ?? input.tool_response ?? null }, issuer.decide(ev));
37
+ await issuer.record(ev, { status: "failed", result: input.error ?? input.tool_response ?? null }, issuer.decide(ev));
38
38
  return {};
39
39
  default:
40
40
  return {};
@@ -1,4 +1,5 @@
1
1
  import type { SdkConfig } from "../config.ts";
2
+ import { type LogSink } from "../log-sink.ts";
2
3
  import { type PolicyDecision } from "../policy.ts";
3
4
  import type { ReceiptBundle } from "../receipt.ts";
4
5
  export declare const SDK_VERSION = "0.1.0";
@@ -24,10 +25,12 @@ export type Outcome = {
24
25
  export interface SdkIssuer {
25
26
  agentId: string;
26
27
  keyid: string;
28
+ /** where the leaves go: the local file or the remote log */
29
+ log: LogSink;
27
30
  /** Evaluates the configured policy for a call. Returns null when no policy is configured. */
28
31
  decide(ev: ToolEvent): PolicyDecision | null;
29
- /** Issues one receipt for a completed, failed, denied, or errored call. */
30
- record(ev: ToolEvent, outcome: Outcome, policy?: PolicyDecision | null): ReceiptBundle;
32
+ /** Issues one receipt for a completed, failed, denied, or errored call. Rejects if the log refuses it. */
33
+ record(ev: ToolEvent, outcome: Outcome, policy?: PolicyDecision | null): Promise<ReceiptBundle>;
31
34
  /** Wraps a tool function: decide, run, record. Throws PolicyDeniedError on deny, after issuing the denial receipt. */
32
35
  wrap<A extends Record<string, unknown>, R>(tool: string, fn: (args: A) => R | Promise<R>, meta?: Omit<ToolEvent, "tool" | "args">): (args: A) => Promise<R>;
33
36
  }
package/dist/sdk/index.js CHANGED
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { digestOf, loadPrivateKey } from "../crypto.js";
6
6
  import { createIssuer } from "../issue.js";
7
+ import { openLog } from "../log-sink.js";
7
8
  import { evaluate } from "../policy.js";
8
9
  export const SDK_VERSION = "0.1.0";
9
10
  export class PolicyDeniedError extends Error {
@@ -20,7 +21,7 @@ export class PolicyDeniedError extends Error {
20
21
  }
21
22
  export function createSdkIssuer(cfg) {
22
23
  const key = loadPrivateKey(cfg.identity.keyFile);
23
- const issuer = createIssuer(key, cfg.receiptsDir, cfg.logFile);
24
+ const issuer = createIssuer(key, cfg.receiptsDir, openLog(cfg, key));
24
25
  const policyText = cfg.policyFile ? readFileSync(cfg.policyFile, "utf8") : null;
25
26
  const decide = (ev) => policyText === null ? null : evaluate(policyText, { agentId: cfg.agentId, tool: ev.tool, context: { args: ev.args, facts: {} } });
26
27
  function record(ev, outcome, policy = null) {
@@ -47,6 +48,7 @@ export function createSdkIssuer(cfg) {
47
48
  return {
48
49
  agentId: cfg.agentId,
49
50
  keyid: issuer.keyid,
51
+ log: issuer.log,
50
52
  decide,
51
53
  record,
52
54
  wrap(tool, fn, meta = {}) {
@@ -55,16 +57,16 @@ export function createSdkIssuer(cfg) {
55
57
  const policy = decide(ev);
56
58
  if (policy && policy.decision === "deny") {
57
59
  const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
58
- const bundle = record(ev, { status: "denied", reason }, policy);
60
+ const bundle = await record(ev, { status: "denied", reason }, policy);
59
61
  throw new PolicyDeniedError(tool, reason, receiptIdOf(bundle));
60
62
  }
61
63
  try {
62
64
  const result = await fn(args);
63
- record(ev, { status: "executed", result }, policy);
65
+ await record(ev, { status: "executed", result }, policy);
64
66
  return result;
65
67
  }
66
68
  catch (e) {
67
- record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
69
+ await record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
68
70
  throw e;
69
71
  }
70
72
  };
@@ -8,8 +8,8 @@ export declare class ReceiptCallbackHandler extends BaseCallbackHandler {
8
8
  handleToolStart(tool: {
9
9
  name?: string;
10
10
  }, input: string, runId: string, _parentRunId?: string, _tags?: string[], _metadata?: Record<string, unknown>, runName?: string, toolCallId?: string): void;
11
- handleToolEnd(output: unknown, runId: string): void;
12
- handleToolError(err: Error, runId: string): void;
11
+ handleToolEnd(output: unknown, runId: string): Promise<void>;
12
+ handleToolError(err: Error, runId: string): Promise<void>;
13
13
  }
14
14
  /** Convenience: `tool.invoke(args, receiptCallbacks(issuer))`, or spread into any RunnableConfig. */
15
15
  export declare function receiptCallbacks(issuer: SdkIssuer): {
@@ -40,19 +40,19 @@ export class ReceiptCallbackHandler extends BaseCallbackHandler {
40
40
  const name = runName ?? tool.name ?? "unknown";
41
41
  this.pending.set(runId, { tool: name, args: parseArgs(input), session: { id: null, toolUseId: toolCallId ?? null } });
42
42
  }
43
- handleToolEnd(output, runId) {
43
+ async handleToolEnd(output, runId) {
44
44
  const ev = this.pending.get(runId);
45
45
  if (!ev)
46
46
  return;
47
47
  this.pending.delete(runId);
48
- this.issuer.record(ev, { status: "executed", result: unwrapOutput(output) }, null);
48
+ await this.issuer.record(ev, { status: "executed", result: unwrapOutput(output) }, null);
49
49
  }
50
- handleToolError(err, runId) {
50
+ async handleToolError(err, runId) {
51
51
  const ev = this.pending.get(runId);
52
52
  if (!ev)
53
53
  return;
54
54
  this.pending.delete(runId);
55
- this.issuer.record(ev, { status: "error", error: err.message }, null);
55
+ await this.issuer.record(ev, { status: "error", error: err.message }, null);
56
56
  }
57
57
  }
58
58
  /** Convenience: `tool.invoke(args, receiptCallbacks(issuer))`, or spread into any RunnableConfig. */
@@ -33,16 +33,16 @@ export function wrapTools(issuer, tools) {
33
33
  const policy = issuer.decide(ev);
34
34
  if (policy && policy.decision === "deny") {
35
35
  const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
36
- const bundle = issuer.record(ev, { status: "denied", reason }, policy);
36
+ const bundle = await issuer.record(ev, { status: "denied", reason }, policy);
37
37
  return `Denied by policy: ${reason} (receipt ${receiptIdOf(bundle)})`;
38
38
  }
39
39
  try {
40
40
  const result = await t.invoke(ctx, input, details);
41
- issuer.record(ev, { status: "executed", result: parseResult(result) }, policy);
41
+ await issuer.record(ev, { status: "executed", result: parseResult(result) }, policy);
42
42
  return result;
43
43
  }
44
44
  catch (e) {
45
- issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
45
+ await issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
46
46
  throw e;
47
47
  }
48
48
  };
@@ -61,6 +61,7 @@ export function observeRunner(issuer, runner) {
61
61
  const callId = details?.toolCall?.callId ?? "";
62
62
  const ev = pending.get(callId) ?? { tool: tool.name, args: {}, session: { id: null, toolUseId: callId || null } };
63
63
  pending.delete(callId);
64
- issuer.record(ev, { status: "executed", result: parseResult(result) }, null);
64
+ // The runner does not await listeners. A log that refuses the leaf is reported on stderr; nothing else can see it here.
65
+ issuer.record(ev, { status: "executed", result: parseResult(result) }, null).catch((e) => console.error(`agent-custody: receipt not issued: ${e instanceof Error ? e.message : e}`));
65
66
  });
66
67
  }
@@ -16,16 +16,16 @@ export function wrapTools(issuer, tools) {
16
16
  const policy = issuer.decide(ev);
17
17
  if (policy && policy.decision === "deny") {
18
18
  const reason = [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched";
19
- const bundle = issuer.record(ev, { status: "denied", reason }, policy);
19
+ const bundle = await issuer.record(ev, { status: "denied", reason }, policy);
20
20
  throw new PolicyDeniedError(name, reason, receiptIdOf(bundle));
21
21
  }
22
22
  try {
23
23
  const result = await original(input, options);
24
- issuer.record(ev, { status: "executed", result }, policy);
24
+ await issuer.record(ev, { status: "executed", result }, policy);
25
25
  return result;
26
26
  }
27
27
  catch (e) {
28
- issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
28
+ await issuer.record(ev, { status: "error", error: e instanceof Error ? e.message : String(e) }, policy);
29
29
  throw e;
30
30
  }
31
31
  };
package/dist/verify.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type PublicKeyRef } from "./crypto.ts";
2
- import { type ReceiptBundle, type ReceiptStatement } from "./receipt.ts";
1
+ import { type Envelope, type PublicKeyRef } from "./crypto.ts";
2
+ import { type ReceiptBundle, type ReceiptStatement, type TreeHead } from "./receipt.ts";
3
3
  export interface Check {
4
4
  name: string;
5
5
  ok: boolean;
@@ -9,6 +9,8 @@ export interface VerifyOptions {
9
9
  /** keys trusted to have issued receipts: gateway keys, SDK application keys */
10
10
  issuerKeys: PublicKeyRef[];
11
11
  principalKeys: PublicKeyRef[];
12
+ /** keys of logs run by someone other than the issuer; tree heads are checked against these and the issuer keys */
13
+ logKeys?: PublicKeyRef[];
12
14
  /** If given, the root is recomputed from this log file at the receipt's tree size and compared. */
13
15
  logFile?: string;
14
16
  }
@@ -18,5 +20,16 @@ export interface VerifyResult {
18
20
  statement: ReceiptStatement | null;
19
21
  }
20
22
  export declare function verifyBundle(bundle: ReceiptBundle, opts: VerifyOptions): VerifyResult;
23
+ export interface AuditResult {
24
+ ok: boolean;
25
+ checks: Check[];
26
+ older: TreeHead | null;
27
+ newer: TreeHead | null;
28
+ }
29
+ /**
30
+ * Does the newer tree head extend the older one? Both must be signed by a trusted log or issuer key, and the proof
31
+ * must be the log's consistency proof between the two sizes. A pass means nothing in the older log was rewritten.
32
+ */
33
+ export declare function auditExtends(older: Envelope, newer: Envelope, proof: string[], keys: PublicKeyRef[]): AuditResult;
21
34
  /** Human-readable report: checks, then every field with its provenance so the reader knows what was proven vs. claimed. */
22
35
  export declare function formatReport(r: VerifyResult): string;
package/dist/verify.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Independent verification of a receipt bundle. Needs only public keys, and optionally a copy of the log.
2
2
  import { canonicalize, digestOf, dsseVerify } from "./crypto.js";
3
3
  import { delegationValidAt, verifyDelegation } from "./delegation.js";
4
- import { leafHash, MerkleLog, verifyInclusion } from "./log.js";
4
+ import { leafHash, MerkleLog, verifyConsistency, verifyInclusion } from "./log.js";
5
5
  import { RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
6
6
  const short = (s) => s.slice(0, 12);
7
7
  export function verifyBundle(bundle, opts) {
@@ -49,8 +49,10 @@ export function verifyBundle(bundle, opts) {
49
49
  add("policy decision consistent with execution", consistent, `${p.policy.decision} -> ${p.execution.status}`);
50
50
  add("no policy errors on an allow", !(p.policy.decision === "allow" && p.policy.errors.length > 0));
51
51
  }
52
- const th = dsseVerify(bundle.treeHead, opts.issuerKeys);
53
- add("tree head signature", th.ok && bundle.treeHead.payloadType === TREEHEAD_TYPE, th.ok ? undefined : th.error);
52
+ const logKeys = opts.logKeys ?? [];
53
+ const th = dsseVerify(bundle.treeHead, [...logKeys, ...opts.issuerKeys]);
54
+ const byLog = th.ok && logKeys.some((k) => k.keyid === th.keyid);
55
+ add("tree head signature", th.ok && bundle.treeHead.payloadType === TREEHEAD_TYPE, th.ok ? `${byLog ? "log key" : "issuer key"} ${short(th.keyid)}` : th.error);
54
56
  if (th.ok) {
55
57
  const head = th.payload;
56
58
  add("tree head matches inclusion proof size", head.treeSize === bundle.inclusion.treeSize);
@@ -70,6 +72,31 @@ export function verifyBundle(bundle, opts) {
70
72
  }
71
73
  return done(st);
72
74
  }
75
+ /**
76
+ * Does the newer tree head extend the older one? Both must be signed by a trusted log or issuer key, and the proof
77
+ * must be the log's consistency proof between the two sizes. A pass means nothing in the older log was rewritten.
78
+ */
79
+ export function auditExtends(older, newer, proof, keys) {
80
+ const checks = [];
81
+ const add = (name, ok, detail) => {
82
+ checks.push(detail === undefined ? { name, ok } : { name, ok, detail });
83
+ return ok;
84
+ };
85
+ const decode = (label, env) => {
86
+ const v = dsseVerify(env, keys);
87
+ add(`${label} tree head signature`, v.ok && env.payloadType === TREEHEAD_TYPE, v.ok ? `keyid ${short(v.keyid)}` : v.error);
88
+ return v.ok ? v.payload : null;
89
+ };
90
+ const a = decode("older", older);
91
+ const b = decode("newer", newer);
92
+ if (!a || !b)
93
+ return { ok: false, checks, older: a, newer: b };
94
+ if (!add("older is not larger than newer", a.treeSize <= b.treeSize, `${a.treeSize} -> ${b.treeSize}`))
95
+ return { ok: false, checks, older: a, newer: b };
96
+ const consistent = verifyConsistency(a.treeSize, a.rootHash, b.treeSize, b.rootHash, proof);
97
+ add("newer log extends older log", consistent, consistent ? `${proof.length} proof hashes` : "history was rewritten, or the proof is for other tree heads");
98
+ return { ok: checks.every((c) => c.ok), checks, older: a, newer: b };
99
+ }
73
100
  const ISSUER_NOTE = {
74
101
  gateway: "enforced outside the agent's process; the agent could neither skip nor forge this receipt",
75
102
  sdk: "self-reported by the agent's own process; tamper-evident after issue, but nothing here was enforced outside the agent",
package/docs/sdk.md CHANGED
@@ -28,7 +28,7 @@ Use the SDK for reach. Use the gateway for anything that moves money, touches pr
28
28
  }
29
29
  ```
30
30
 
31
- `policyFile` and `principalId` are optional. Without a policy the SDK records and never denies. Paths resolve relative to the config file. Generate the key with `node src/cli.ts keygen --dir keys --name app`.
31
+ `policyFile` and `principalId` are optional. Without a policy the SDK records and never denies. Paths resolve relative to the config file. Instead of `logFile`, `"log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }` sends every leaf to a log run by someone else, whose key then signs the tree heads; see [usage.md](usage.md) for what that changes and [verification.md](verification.md) for what it proves. Generate the key with `node src/cli.ts keygen --dir keys --name app`.
32
32
 
33
33
  Policies see `context.args` and an empty `context.facts`. A policy that reads `context.facts` or `context.grant` errors, which is a deny. That is intended: an SDK policy cannot pretend it checked something outside the agent's process.
34
34
 
@@ -154,9 +154,11 @@ For finer control use the two primitives `wrap` is built from:
154
154
 
155
155
  ```ts
156
156
  const decision = issuer.decide({ tool, args }); // PolicyDecision | null
157
- const bundle = issuer.record({ tool, args, model, session }, { status: "executed", result }, decision);
157
+ const bundle = await issuer.record({ tool, args, model, session }, { status: "executed", result }, decision);
158
158
  ```
159
159
 
160
+ `record` returns a promise because the log may be remote. It rejects, and writes no bundle, if the log refuses the leaf. `handleHookEvent` is asynchronous for the same reason. The record-only adapters that cannot await, such as `observeRunner`, report a refused leaf on stderr.
161
+
160
162
  ## Which adapter enforces
161
163
 
162
164
  | framework | enforce + record | record only |
package/docs/tutorials.md CHANGED
@@ -22,6 +22,8 @@ Suggested reading order is the numbering. Output lands in `examples-out/`, which
22
22
  | 10 | Vercel AI SDK | [10-vercel-ai.ts](../examples/10-vercel-ai.ts) | a real generateText loop over the SDK's mock model, a denial as a tool-error part | `src/sdk/vercel-ai.ts` |
23
23
  | 11 | LangChain | [11-langchain.ts](../examples/11-langchain.ts) | the callback handler, tool_call ids, enforcement by wrapping the function | `src/sdk/langchain.ts` |
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
+ | 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
+ | 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` |
25
27
 
26
28
  ## How policies are defined, in one paragraph
27
29
 
package/docs/usage.md CHANGED
@@ -79,6 +79,14 @@ when {
79
79
 
80
80
  `upstream` is spawned by the gateway exactly as an MCP host would spawn it. `env` is passed through, which is where upstream credentials go. The agent never sees them.
81
81
 
82
+ `logFile` is the local Merkle log, with tree heads signed by the gateway's own key. To log to a server the operator does not control, replace it with `log`:
83
+
84
+ ```json
85
+ "log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }
86
+ ```
87
+
88
+ Exactly one of the two. The bearer token comes from the named environment variable, never from the file, and a missing variable fails at startup. With a remote log the tree head in each receipt is signed by the log's key, and a verifier must be given that key with `--log-key`. If the log refuses a leaf, the receipt is not issued and the call returns an error to the agent; for an executed call the upstream action has already happened by then, which is the honest outcome, since a receipt that was never logged must not be handed out. The reference log server is `node src/cli.ts log --file log.jsonl --key keys/log.key --port 8787 --token-env AGENT_CUSTODY_LOG_TOKEN`. It serves `POST /append` (token required when one is configured), `GET /root?size=N`, `GET /consistency?old=M&new=N`, and `GET /head`; [verification.md](verification.md) says what each proves.
89
+
82
90
  `facts` tells the gateway which upstream tool to call before evaluating policy for a given tool. `$args.<key>` copies a value from the intercepted call. The result appears in Cedar as `context.facts.<name>` and in the receipt with its own digest, labelled `observed`. If a fact lookup fails, the call is denied and the receipt says why.
83
91
 
84
92
  **5. Run the gateway.** It speaks MCP on stdin/stdout and logs to stderr only.
@@ -6,7 +6,9 @@ A verifier needs three things and no network access:
6
6
  2. the issuer's public key: the gateway key, or the application key for SDK receipts
7
7
  3. the principal's public key, for gateway receipts, which carry a signed delegation
8
8
 
9
- A fourth is optional: a copy of the issuer's log file, which lets the verifier confirm the receipt sits in a log whose root the verifier recomputed, not one the issuer merely asserted.
9
+ A fourth is optional: a copy of the log file, which lets the verifier confirm the receipt sits in a log whose root the verifier recomputed, not one the issuer merely asserted.
10
+
11
+ When the issuer logs to a remote log, the tree head is signed by the log's key rather than the issuer's. The verifier then needs that public key too, and the report says which key signed the tree head. That is the point of a remote log: a tree head signed by a party that is not the operator says the receipt was in a log the operator could not rewrite. A tree head signed by the issuer's own key says only that the issuer has not changed its story since.
10
12
 
11
13
  ## From the command line
12
14
 
@@ -14,6 +16,7 @@ A fourth is optional: a copy of the issuer's log file, which lets the verifier c
14
16
  node src/cli.ts verify receipts/<id>.json \
15
17
  --issuer-key keys/gateway.pub \
16
18
  --principal-key keys/principal.pub \
19
+ --log-key keys/log.pub \ # only for receipts logged to a remote log
17
20
  --log log.jsonl # optional
18
21
  ```
19
22
 
@@ -106,6 +109,7 @@ const bundle = JSON.parse(readFileSync("receipts/<id>.json", "utf8"));
106
109
  const result = verifyBundle(bundle, {
107
110
  issuerKeys: [loadPublicKey("keys/gateway.pub")], // gateway keys and SDK application keys
108
111
  principalKeys: [loadPublicKey("keys/principal.pub")],
112
+ logKeys: [loadPublicKey("keys/log.pub")], // only for receipts logged to a remote log
109
113
  logFile: "log.jsonl", // optional
110
114
  });
111
115
 
@@ -128,7 +132,7 @@ The formats are standard on purpose, so a verifier in another language needs no
128
132
 
129
133
  ## Auditing a log copy
130
134
 
131
- Take copies of `log.jsonl` on a schedule and keep them where the operator cannot write. Then for any receipt:
135
+ Take copies of `log.jsonl` on a schedule and keep them where the operator cannot write. A remote log serves `GET /root?size=N` so an auditor can compare a tree head with the log's own root without a copy; example 13 does this. Then for any receipt:
132
136
 
133
137
  ```bash
134
138
  node src/cli.ts verify receipts/<id>.json --issuer-key ... --principal-key ... --log /audit/copies/log-2026-09-04.jsonl
@@ -137,3 +141,19 @@ node src/cli.ts verify receipts/<id>.json --issuer-key ... --principal-key ... -
137
141
  The last check recomputes the root at the receipt's tree size from your copy. If the operator later deletes, reorders, or edits a line before that position, the recomputed root changes and the check fails.
138
142
 
139
143
  Today a copy must be at least as long as the receipt's tree size. Consistency proofs between two tree heads, which would let you check that a newer log extends an older copy without holding the whole file, are on the roadmap.
144
+
145
+ ## Proving history was not rewritten
146
+
147
+ An inclusion proof says a receipt was in the log at one moment. It does not say the log still contains, unchanged, everything it contained earlier. That is what a consistency proof is for: given two tree heads, it proves the larger tree extends the smaller one, so nothing before the older head was rewritten. Keep the tree head from any receipt; it is the older head in every later audit.
148
+
149
+ ```bash
150
+ node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log log.jsonl --issuer-key keys/gateway.pub
151
+ node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url https://log.example.com/ --log-key keys/log.pub
152
+ ```
153
+
154
+ Both tree heads must be signed by a trusted key. With `--log` the proof is computed from a copy of the log; with `--log-url` it is fetched from the log's `GET /consistency?old=M&new=N`. Exit code 0 means the newer log extends the older one. A failure means either history was rewritten between the two heads or the proof belongs to other tree heads; example 14 shows a rewritten log failing this way while every individual receipt still verifies.
155
+
156
+ Programmatically, `auditExtends(older.treeHead, newer.treeHead, proof, keys)` returns the same checks. The proof algorithm is RFC 9162 section 2.1.4, so any log implementing it can answer, and any verifier implementing it can check.
157
+
158
+ 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
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": {