@agent-custody/receipts 0.5.3 → 0.5.4

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
@@ -225,6 +225,7 @@ If a vendor tells you their receipts prove more than the first five rows, ask th
225
225
  src/config.ts gateway and SDK config schemas, path resolution
226
226
  src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
227
227
  src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
228
+ src/witness.ts the witness: countersigns the log's checkpoints from another operator's machine, or refuses with an alarm
228
229
  src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
229
230
  src/checkpoints.ts signed heads published on a schedule, to files and to Postgres
230
231
  src/log-store.ts the log server's backends: the file, and Postgres with tenants, hashed tokens, one writer per tenant, rate limits
@@ -280,6 +281,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
280
281
  - 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.
281
282
  - 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).
282
283
 
284
+ - The witness: a second signer on a machine the log's operator does not control countersigns each checkpoint after proving it extends the last one it signed, refuses a rewritten or forked history with an alarm, and publishes its key; `audit --witness-url` requires it. Phase 6 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
283
285
  - The signer, keys, and checkpoints: the key in its own process (`signer`, `--signer-url`), the key document at `/.well-known/agent-custody-log.json` fetched and pinned by `verify --log-url` and `audit --log-url`, and signed checkpoints per log published to a directory and a table for a verifier who was not watching. Phase 3 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
284
286
  - The log over Postgres: `log --db-env`, leaves as hashes in one table keyed by tenant, one writer per tenant by advisory lock, tenants and hashed tokens in tables managed by `log-admin`, rate limits and a body cap, retries in the sink, and `import` for an existing file log. Phase 2 of [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
285
287
  - A log for someone else: `hashOnly` sends leaf hashes so the log never holds a receipt; the reference server runs several tenant logs at `/t/<tenant>/` with their own tokens and ids; tree heads name their log and the verifier checks it with `--log-id`. Phase 1 of the hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
@@ -289,7 +291,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
289
291
 
290
292
  **Next, in the order it pays off**
291
293
 
292
- 1. The hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6): running at log.agent-custody.dev with keys published and checkpoints on a second host, taking its first tenants. What remains is the witness that countersigns checkpoints.
294
+ 1. Run the witness for log.agent-custody.dev on a machine and under an account that is not ours, and require it in the welcome sheet. The code is done; what it needs is a second operator. [Issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
293
295
  2. Post-quantum signatures: ML-DSA beside Ed25519 in the same DSSE envelope, hybrid by default when a PQ key is present, in every signed artefact and in the browser verifier. [Issue #11](https://github.com/ch4r10t33r/agent-custody/issues/11).
294
296
  3. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
295
297
  4. Delegation chains for sub-agents.
package/dist/cli.js CHANGED
@@ -10,10 +10,12 @@ import { postgresResolver, serveLog } from "./log-sink.js";
10
10
  import { importLogFile, PostgresTenancy } from "./log-store.js";
11
11
  import { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
12
12
  import { connectSigner, fetchLogKeys, localSigner, serveSigner } from "./signer.js";
13
+ import { fetchWitnessKeys, Witness } from "./witness.js";
13
14
  import { CheckpointPublisher, fileResolver } from "./log-sink.js";
14
15
  import { createRequire } from "node:module";
15
16
  import { pruneLog } from "./retention.js";
16
17
  import { serveSidecar } from "./sidecar.js";
18
+ import { TREEHEAD_TYPE } from "./receipt.js";
17
19
  import { MerkleLog } from "./log.js";
18
20
  import { createSdkIssuer } from "./sdk/index.js";
19
21
  import { handleHookEvent } from "./sdk/claude.js";
@@ -46,13 +48,18 @@ const USAGE = `agent-custody <command>
46
48
  log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
47
49
  the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
48
50
  the welcome sheet; the public URLs fill the sheet in
51
+ witness --key <witness.key> --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]... [--every <seconds>] [--once]
52
+ a second signer, run by someone who is not the log's operator: fetches the log's latest
53
+ checkpoint per watched log, proves it extends the last one it signed, and countersigns it
54
+ into <dir>; refuses and writes an alarm otherwise. Serve <dir> from a host of your own.
49
55
  signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
50
56
  the one process that holds the log's key: POST /sign, GET /keys
51
57
  log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
52
58
  log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
53
59
  log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
54
- audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--json]
55
- with --log-url the log's published keys are fetched and pinned by keyid
60
+ audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) [--issuer-key <pub>] [--log-key <pub>] [--log-id <id>] [--witness-key <pub> | --witness-url <url>] [--json]
61
+ with --log-url the log's published keys are fetched and pinned by keyid; with a witness key or
62
+ URL the newer head must also carry the witness's countersignature
56
63
  checks that the newer receipt's log extends the older one's: nothing between them was rewritten
57
64
  `;
58
65
  /** A retired public key for the key document, from a .pub file; still listed so heads it signed keep verifying. */
@@ -157,6 +164,26 @@ async function main(argv) {
157
164
  await running.close();
158
165
  return 0;
159
166
  }
167
+ case "witness": {
168
+ const { values } = parseArgs({ args: rest, options: { key: { type: "string" }, "log-url": { type: "string" }, "checkpoints-url": { type: "string" }, out: { type: "string" }, tenant: { type: "string", multiple: true }, every: { type: "string", default: "300" }, once: { type: "boolean", default: false } } });
169
+ if (!values.key || !values["log-url"] || !values["checkpoints-url"] || !values.out)
170
+ throw new Error("witness needs --key, --log-url, --checkpoints-url, and --out");
171
+ const w = new Witness({ logUrl: values["log-url"], checkpointsUrl: values["checkpoints-url"], tenants: values.tenant?.length ? values.tenant : ["default"], key: loadPrivateKey(values.key), outDir: values.out });
172
+ const everyMs = Number(values.every) * 1000;
173
+ if (!(everyMs > 0))
174
+ throw new Error("--every must be a positive number of seconds");
175
+ if (values.once) {
176
+ const outcomes = await w.runOnce();
177
+ for (const o of outcomes)
178
+ console.log(`${o.tenant.padEnd(20)} ${o.outcome}${"treeSize" in o ? ` at ${o.treeSize}` : ""}${"reason" in o ? `: ${o.reason}` : ""}`);
179
+ return outcomes.some((o) => o.outcome === "refused") ? 1 : 0;
180
+ }
181
+ console.error(`agent-custody witness: keyid=${w.keyid} watching ${values["log-url"]} via ${values["checkpoints-url"]} every ${values.every}s, writing to ${values.out}`);
182
+ w.start(everyMs);
183
+ await new Promise((resolve) => process.once("SIGINT", resolve));
184
+ w.stop();
185
+ return 0;
186
+ }
160
187
  case "signer": {
161
188
  const { values } = parseArgs({ args: rest, options: { key: { type: "string" }, port: { type: "string", default: "8790" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "retired-key": { type: "string", multiple: true } } });
162
189
  if (!values.key)
@@ -339,19 +366,31 @@ async function main(argv) {
339
366
  "issuer-key": { type: "string", multiple: true },
340
367
  "log-key": { type: "string", multiple: true },
341
368
  "log-id": { type: "string" },
369
+ "witness-key": { type: "string", multiple: true },
370
+ "witness-url": { type: "string" },
342
371
  json: { type: "boolean", default: false },
343
372
  },
344
373
  });
345
374
  const keyFiles = [...(values["issuer-key"] ?? []), ...(values["log-key"] ?? [])];
346
375
  if (!values.older || !values.newer)
347
376
  throw new Error("audit needs --older and --newer");
377
+ const witnessKeys = [...(values["witness-key"] ?? []).map(loadPublicKey), ...(values["witness-url"] ? await fetchWitnessKeys(values["witness-url"]) : [])];
348
378
  if (!values.log === !values["log-url"])
349
379
  throw new Error("audit needs exactly one of --log or --log-url");
350
380
  const auditKeys = [...keyFiles.map(loadPublicKey), ...(values["log-url"] ? (await fetchLogKeys(values["log-url"])).keys : [])];
351
381
  if (auditKeys.length === 0)
352
382
  throw new Error("audit needs a key: --issuer-key, --log-key, or a --log-url that publishes its keys");
353
- const older = JSON.parse(readFileSync(values.older, "utf8")).treeHead;
354
- const newer = JSON.parse(readFileSync(values.newer, "utf8")).treeHead;
383
+ // --older and --newer take a receipt bundle, or a checkpoint file from the log's or the witness's host
384
+ const headOf = (file) => {
385
+ const j = JSON.parse(readFileSync(file, "utf8"));
386
+ if (j.treeHead)
387
+ return j.treeHead;
388
+ if (j.envelope && j.envelope.payloadType === TREEHEAD_TYPE)
389
+ return j.envelope;
390
+ throw new Error(`${file} is neither a receipt bundle nor a checkpoint`);
391
+ };
392
+ const older = headOf(values.older);
393
+ const newer = headOf(values.newer);
355
394
  const sizeOf = (env) => JSON.parse(Buffer.from(env.payload, "base64").toString()).treeSize;
356
395
  const [m, n] = [sizeOf(older), sizeOf(newer)];
357
396
  let proof;
@@ -363,7 +402,7 @@ async function main(argv) {
363
402
  throw new Error(`log refused the consistency query: ${res.status}`);
364
403
  proof = (await res.json()).hashes;
365
404
  }
366
- const result = auditExtends(older, newer, proof, auditKeys, values["log-id"]);
405
+ const result = auditExtends(older, newer, proof, auditKeys, values["log-id"], witnessKeys.length ? { witnessKeys } : {});
367
406
  if (values.json)
368
407
  console.log(JSON.stringify(result, null, 2));
369
408
  else {
package/dist/crypto.d.ts CHANGED
@@ -32,6 +32,10 @@ export interface Envelope {
32
32
  }[];
33
33
  }
34
34
  export declare function dsseSign(payloadType: string, payloadObj: unknown, kp: KeyPair): Envelope;
35
+ /** Adds a signature over the same payload: a countersignature, the DSSE way. The envelope keeps every earlier signature. */
36
+ export declare function dsseCountersign(env: Envelope, kp: KeyPair): Envelope;
37
+ /** Every trusted key whose signature on the envelope verifies, by keyid. Empty when none does. */
38
+ export declare function dsseVerifiers(env: Envelope, trusted: PublicKeyRef[]): string[];
35
39
  export type DsseVerifyResult = {
36
40
  ok: true;
37
41
  payload: unknown;
package/dist/crypto.js CHANGED
@@ -70,6 +70,26 @@ export function dsseSign(payloadType, payloadObj, kp) {
70
70
  signatures: [{ keyid: kp.keyid, sig: sig.toString("base64") }],
71
71
  };
72
72
  }
73
+ /** Adds a signature over the same payload: a countersignature, the DSSE way. The envelope keeps every earlier signature. */
74
+ export function dsseCountersign(env, kp) {
75
+ const payload = Buffer.from(env.payload, "base64");
76
+ const sig = sign(null, pae(env.payloadType, payload), kp.privateKey);
77
+ return { ...env, signatures: [...env.signatures.filter((s) => s.keyid !== kp.keyid), { keyid: kp.keyid, sig: sig.toString("base64") }] };
78
+ }
79
+ /** Every trusted key whose signature on the envelope verifies, by keyid. Empty when none does. */
80
+ export function dsseVerifiers(env, trusted) {
81
+ if (!env || typeof env.payload !== "string" || !Array.isArray(env.signatures))
82
+ return [];
83
+ const payload = Buffer.from(env.payload, "base64");
84
+ const data = pae(env.payloadType, payload);
85
+ const out = [];
86
+ for (const s of env.signatures) {
87
+ const key = trusted.find((t) => t.keyid === s.keyid);
88
+ if (key && verify(null, data, key.publicKey, Buffer.from(s.sig, "base64")))
89
+ out.push(s.keyid);
90
+ }
91
+ return out;
92
+ }
73
93
  /** Verifies the envelope against any of the given trusted keys, matched by keyid. */
74
94
  export function dsseVerify(env, trusted) {
75
95
  if (!env || typeof env.payload !== "string" || !Array.isArray(env.signatures) || env.signatures.length === 0) {
package/dist/index.d.ts CHANGED
@@ -8,6 +8,9 @@ export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler }
8
8
  export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
9
9
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
10
10
  export { adminRoutes, welcomeSheet } from "./log-admin.ts";
11
+ export { fetchWitnessKeys, Witness } from "./witness.ts";
12
+ export type { WitnessOptions, WitnessOutcome, WitnessedCheckpoint } from "./witness.ts";
13
+ export type { AuditOptions } from "./verify.ts";
11
14
  export type { AdminOptions } from "./log-admin.ts";
12
15
  export type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
13
16
  export type { AppendResult, LogBackend, PostgresLike, PostgresLogOptions, RateLimitOptions, Tenant, TokenRecord } from "./log-store.ts";
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter }
6
6
  export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
7
7
  export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
8
8
  export { adminRoutes, welcomeSheet } from "./log-admin.js";
9
+ export { fetchWitnessKeys, Witness } from "./witness.js";
9
10
  export * from "./config.js";
10
11
  export * from "./crypto.js";
11
12
  export * from "./delegation.js";
package/dist/verify.d.ts CHANGED
@@ -37,6 +37,10 @@ export interface AuditResult {
37
37
  * Does the newer tree head extend the older one? Both must be signed by a trusted log or issuer key, and the proof
38
38
  * must be the log's consistency proof between the two sizes. A pass means nothing in the older log was rewritten.
39
39
  */
40
- export declare function auditExtends(older: Envelope, newer: Envelope, proof: string[], keys: PublicKeyRef[], logId?: string): AuditResult;
40
+ export interface AuditOptions {
41
+ /** with these, the newer head must also carry a signature by one of them: a witness that is not the log's operator */
42
+ witnessKeys?: PublicKeyRef[];
43
+ }
44
+ export declare function auditExtends(older: Envelope, newer: Envelope, proof: string[], keys: PublicKeyRef[], logId?: string, opts?: AuditOptions): AuditResult;
41
45
  /** Human-readable report: checks, then every field with its provenance so the reader knows what was proven vs. claimed. */
42
46
  export declare function formatReport(r: VerifyResult): string;
package/dist/verify.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Independent verification of a receipt bundle. Needs only public keys, and optionally a copy of the log.
2
- import { canonicalize, digestOf, dsseVerify } from "./crypto.js";
2
+ import { canonicalize, digestOf, dsseVerifiers, dsseVerify } from "./crypto.js";
3
3
  import { delegationValidAt, verifyDelegation } from "./delegation.js";
4
4
  import { leafHash, MerkleLog, verifyConsistency, verifyInclusion } from "./log.js";
5
5
  import { checkProvider, checkUpstream, contentDigest, isProviderAttestation } from "./upstream.js";
@@ -111,11 +111,7 @@ export function verifyBundle(bundle, opts) {
111
111
  }
112
112
  return done(st);
113
113
  }
114
- /**
115
- * Does the newer tree head extend the older one? Both must be signed by a trusted log or issuer key, and the proof
116
- * must be the log's consistency proof between the two sizes. A pass means nothing in the older log was rewritten.
117
- */
118
- export function auditExtends(older, newer, proof, keys, logId) {
114
+ export function auditExtends(older, newer, proof, keys, logId, opts = {}) {
119
115
  const checks = [];
120
116
  const add = (name, ok, detail) => {
121
117
  checks.push(detail === undefined ? { name, ok } : { name, ok, detail });
@@ -138,6 +134,10 @@ export function auditExtends(older, newer, proof, keys, logId) {
138
134
  return { ok: false, checks, older: a, newer: b };
139
135
  const consistent = verifyConsistency(a.treeSize, a.rootHash, b.treeSize, b.rootHash, proof);
140
136
  add("newer log extends older log", consistent, consistent ? `${proof.length} proof hashes` : "history was rewritten, or the proof is for other tree heads");
137
+ if (opts.witnessKeys && opts.witnessKeys.length > 0) {
138
+ const by = dsseVerifiers(newer, opts.witnessKeys);
139
+ add("newer tree head countersigned by a witness", by.length > 0, by.length ? `witness ${short(by[0])}` : "no witness signature on the newer head");
140
+ }
141
141
  return { ok: checks.every((c) => c.ok), checks, older: a, newer: b };
142
142
  }
143
143
  const ISSUER_NOTE = {
@@ -0,0 +1,65 @@
1
+ import { type Envelope, type KeyPair, type PublicKeyRef } from "./crypto.ts";
2
+ export interface WitnessOptions {
3
+ /** the log's API base, e.g. https://log.example.com/; the key document and consistency proofs come from here */
4
+ logUrl: string;
5
+ /** where the log publishes checkpoints, e.g. https://checkpoints.example.com/ */
6
+ checkpointsUrl: string;
7
+ /** which logs to watch: "default" for the root paths, else tenant names */
8
+ tenants: string[];
9
+ key: KeyPair;
10
+ /** where countersigned checkpoints, alarms, the witness's own key document, and its state go */
11
+ outDir: string;
12
+ fetch?: typeof fetch;
13
+ warn?: (message: string) => void;
14
+ }
15
+ export interface WitnessedCheckpoint {
16
+ tenant: string;
17
+ logId: string | undefined;
18
+ treeSize: number;
19
+ rootHash: string;
20
+ /** the log's checkpoint envelope with the witness's signature added */
21
+ envelope: Envelope;
22
+ witness: {
23
+ keyid: string;
24
+ at: string;
25
+ };
26
+ }
27
+ export type WitnessOutcome = {
28
+ tenant: string;
29
+ outcome: "countersigned";
30
+ treeSize: number;
31
+ } | {
32
+ tenant: string;
33
+ outcome: "unchanged";
34
+ treeSize: number;
35
+ } | {
36
+ tenant: string;
37
+ outcome: "refused";
38
+ reason: string;
39
+ } | {
40
+ tenant: string;
41
+ outcome: "unavailable";
42
+ reason: string;
43
+ };
44
+ export declare class Witness {
45
+ private readonly o;
46
+ private readonly f;
47
+ private readonly warn;
48
+ private timer;
49
+ constructor(opts: WitnessOptions);
50
+ get keyid(): string;
51
+ private folder;
52
+ private state;
53
+ private logKeys;
54
+ private latest;
55
+ private proof;
56
+ /** One pass over every watched log. Never throws; every outcome is returned and the bad ones are also on disk. */
57
+ runOnce(): Promise<WitnessOutcome[]>;
58
+ private witnessOne;
59
+ /** A refusal is written where the countersignatures would have gone, so whoever reads the witness's host sees it. */
60
+ private refuse;
61
+ start(everyMs?: number): void;
62
+ stop(): void;
63
+ }
64
+ /** For verifiers: the witness's published keys, fetched from its host and pinned by keyid. */
65
+ export declare function fetchWitnessKeys(witnessUrl: string, f?: typeof fetch): Promise<PublicKeyRef[]>;
@@ -0,0 +1,155 @@
1
+ // The witness: a second signer, run by someone who is not the log's operator, that watches a log's published
2
+ // checkpoints and countersigns each one only after proving to itself that it extends the last one it signed. A
3
+ // verifier who requires the witness's signature on a head is protected against the log showing different histories
4
+ // to different people, and against the log's operator rewriting history, because the witness kept the earlier
5
+ // head and refuses, loudly, when the new one does not extend it. It is the phase of the hosted log that makes the
6
+ // log hold against us. It publishes what it signs as files, to be served from a host of its own.
7
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { dsseCountersign, dsseVerify, publicKeyFromPem } from "./crypto.js";
10
+ import { verifyConsistency } from "./log.js";
11
+ import { TREEHEAD_TYPE } from "./receipt.js";
12
+ const safe = (s) => s.replace(/[^A-Za-z0-9_.-]/g, "_");
13
+ export class Witness {
14
+ o;
15
+ f;
16
+ warn;
17
+ timer = null;
18
+ constructor(opts) {
19
+ this.o = opts;
20
+ this.f = opts.fetch ?? fetch;
21
+ this.warn = opts.warn ?? ((m) => console.error(m));
22
+ mkdirSync(join(opts.outDir, ".well-known"), { recursive: true });
23
+ // The witness's own key document, for verifiers to pin the way they pin the log's.
24
+ const doc = { keys: [{ keyid: opts.key.keyid, alg: "ed25519", publicKeyPem: opts.key.publicKey.export({ type: "spki", format: "pem" }), validFrom: new Date().toISOString() }] };
25
+ writeFileSync(join(opts.outDir, ".well-known", "agent-custody-witness.json"), JSON.stringify(doc, null, 2));
26
+ }
27
+ get keyid() {
28
+ return this.o.key.keyid;
29
+ }
30
+ folder(tenant) {
31
+ const d = join(this.o.outDir, safe(tenant));
32
+ mkdirSync(d, { recursive: true });
33
+ return d;
34
+ }
35
+ state(tenant) {
36
+ try {
37
+ return JSON.parse(readFileSync(join(this.folder(tenant), "state.json"), "utf8"));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ async logKeys() {
44
+ const res = await this.f(new URL("/.well-known/agent-custody-log.json", this.o.logUrl));
45
+ if (!res.ok)
46
+ throw new Error(`log key document: ${res.status}`);
47
+ const doc = (await res.json());
48
+ return doc.keys.map((k) => publicKeyFromPem(k.publicKeyPem));
49
+ }
50
+ async latest(tenant) {
51
+ const base = this.o.checkpointsUrl.endsWith("/") ? this.o.checkpointsUrl : `${this.o.checkpointsUrl}/`;
52
+ const res = await this.f(new URL(`${safe(tenant)}/latest.json`, base));
53
+ if (res.status === 404)
54
+ return null;
55
+ if (!res.ok)
56
+ throw new Error(`checkpoint for ${tenant}: ${res.status}`);
57
+ return (await res.json());
58
+ }
59
+ async proof(tenant, oldSize, newSize) {
60
+ const base = this.o.logUrl.endsWith("/") ? this.o.logUrl : `${this.o.logUrl}/`;
61
+ const path = tenant === "default" ? `consistency?old=${oldSize}&new=${newSize}` : `t/${tenant}/consistency?old=${oldSize}&new=${newSize}`;
62
+ const res = await this.f(new URL(path, base));
63
+ if (!res.ok)
64
+ throw new Error(`consistency proof for ${tenant}: ${res.status}`);
65
+ return (await res.json()).hashes;
66
+ }
67
+ /** One pass over every watched log. Never throws; every outcome is returned and the bad ones are also on disk. */
68
+ async runOnce() {
69
+ const out = [];
70
+ let keys;
71
+ try {
72
+ keys = await this.logKeys();
73
+ }
74
+ catch (e) {
75
+ const reason = e instanceof Error ? e.message : String(e);
76
+ this.warn(`agent-custody witness: ${reason}`);
77
+ return this.o.tenants.map((tenant) => ({ tenant, outcome: "unavailable", reason }));
78
+ }
79
+ for (const tenant of this.o.tenants) {
80
+ try {
81
+ out.push(await this.witnessOne(tenant, keys));
82
+ }
83
+ catch (e) {
84
+ const reason = e instanceof Error ? e.message : String(e);
85
+ this.warn(`agent-custody witness: ${tenant}: ${reason}`);
86
+ out.push({ tenant, outcome: "unavailable", reason });
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ async witnessOne(tenant, keys) {
92
+ const cp = await this.latest(tenant);
93
+ if (!cp)
94
+ return { tenant, outcome: "unavailable", reason: "no checkpoint published yet" };
95
+ const v = dsseVerify(cp.envelope, keys);
96
+ if (!v.ok || cp.envelope.payloadType !== TREEHEAD_TYPE)
97
+ return this.refuse(tenant, `checkpoint does not verify against the log's published keys: ${v.ok ? "not a tree head" : v.error}`, cp.envelope);
98
+ const head = v.payload;
99
+ const prev = this.state(tenant);
100
+ if (prev) {
101
+ if (prev.logId !== head.log)
102
+ return this.refuse(tenant, `checkpoint names log ${head.log ?? "none"}, the last one signed named ${prev.logId ?? "none"}`, cp.envelope);
103
+ if (head.treeSize < prev.treeSize)
104
+ return this.refuse(tenant, `checkpoint at size ${head.treeSize} is smaller than the last one signed at ${prev.treeSize}`, cp.envelope);
105
+ if (head.treeSize === prev.treeSize) {
106
+ if (head.rootHash !== prev.rootHash)
107
+ return this.refuse(tenant, `a different root at the same size ${head.treeSize}: the log shows two histories`, cp.envelope);
108
+ return { tenant, outcome: "unchanged", treeSize: head.treeSize };
109
+ }
110
+ const proof = await this.proof(tenant, prev.treeSize, head.treeSize);
111
+ if (!verifyConsistency(prev.treeSize, prev.rootHash, head.treeSize, head.rootHash, proof))
112
+ return this.refuse(tenant, `the log at ${head.treeSize} does not extend the head signed at ${prev.treeSize}: history was rewritten`, cp.envelope);
113
+ }
114
+ const envelope = dsseCountersign(cp.envelope, this.o.key);
115
+ const at = new Date().toISOString();
116
+ const record = { tenant, logId: head.log, treeSize: head.treeSize, rootHash: head.rootHash, envelope, witness: { keyid: this.o.key.keyid, at } };
117
+ const dir = this.folder(tenant);
118
+ const text = JSON.stringify(record, null, 2);
119
+ writeFileSync(join(dir, `${head.treeSize}.json`), text);
120
+ writeFileSync(join(dir, "latest.json"), text);
121
+ writeFileSync(join(dir, "state.json"), JSON.stringify({ treeSize: head.treeSize, rootHash: head.rootHash, logId: head.log }));
122
+ return { tenant, outcome: "countersigned", treeSize: head.treeSize };
123
+ }
124
+ /** A refusal is written where the countersignatures would have gone, so whoever reads the witness's host sees it. */
125
+ refuse(tenant, reason, envelope) {
126
+ const dir = this.folder(tenant);
127
+ const at = new Date().toISOString();
128
+ writeFileSync(join(dir, `ALARM-${at.replace(/[:.]/g, "-")}.json`), JSON.stringify({ tenant, at, reason, checkpoint: envelope }, null, 2));
129
+ writeFileSync(join(dir, "ALARM.json"), JSON.stringify({ tenant, at, reason }, null, 2));
130
+ this.warn(`agent-custody witness: REFUSED ${tenant}: ${reason}`);
131
+ return { tenant, outcome: "refused", reason };
132
+ }
133
+ start(everyMs = 300_000) {
134
+ if (this.timer)
135
+ return;
136
+ this.timer = setInterval(() => void this.runOnce(), everyMs);
137
+ this.timer.unref?.();
138
+ void this.runOnce();
139
+ }
140
+ stop() {
141
+ if (this.timer)
142
+ clearInterval(this.timer);
143
+ this.timer = null;
144
+ }
145
+ }
146
+ /** For verifiers: the witness's published keys, fetched from its host and pinned by keyid. */
147
+ export async function fetchWitnessKeys(witnessUrl, f = fetch) {
148
+ const res = await f(new URL("/.well-known/agent-custody-witness.json", witnessUrl));
149
+ if (!res.ok)
150
+ throw new Error(`witness ${witnessUrl} serves no key document: ${res.status}`);
151
+ const doc = (await res.json());
152
+ if (!Array.isArray(doc.keys) || doc.keys.length === 0)
153
+ throw new Error(`witness ${witnessUrl} lists no keys`);
154
+ return doc.keys.map((k) => publicKeyFromPem(k.publicKeyPem));
155
+ }
package/docs/usage.md CHANGED
@@ -105,7 +105,7 @@ An upstream need not be an MCP server. A plain HTTP API is described as tools:
105
105
  "log": { "url": "https://log.example.com/", "tokenEnv": "AGENT_CUSTODY_LOG_TOKEN" }
106
106
  ```
107
107
 
108
- 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. Add `"hashOnly": true` for any log run by someone else: the gateway then sends only the leaf hash, sha256 of the receipt envelope with the RFC 6962 prefix, so the log commits to the receipt without ever holding it, and the receipts with their arguments and results stay in `receiptsDir`. The verifier does not change; it hashes the envelope itself. A log that serves several tenants is reached at `<url>/t/<tenant>/`, and each of its tree heads names its log, which a verifier checks with `--log-id`. 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 ordinary call the upstream action has already happened by then, and the error says so; a receipt that was never logged must not be handed out. For a tool named in `precommit` the order is reversed, below, and the action never happens. 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 [--log-id <id>] [--tenants tenants.json]`. It serves `POST /append` with `{leaf}` or `{leafHash}` (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. `--log-id` writes that id into every tree head. `--tenants` names a JSON file, `{ "acme": { "file": "acme.jsonl", "tokenEnv": "ACME_TOKEN", "logId": "acme-eu" } }`, and each tenant is its own log at `/t/acme/…` with its own token and id; the default log stays at the root paths. With `--db-env DATABASE_URL` the server keeps its logs in Postgres instead of files, and needs the `pg` package beside it: leaves as hashes in one table keyed by tenant, one writer per tenant enforced with an advisory lock so a second instance is safe, tenants and their tokens in tables of their own with tokens stored only as hashes, and rate limits per token (50 appends a second, burst 100, a 64 KB body cap; a refused append answers 429 with `retry-after`, and the gateway's sink retries a few times). Tenants are managed with `log-admin --db-env DATABASE_URL`: `tenant add <id> [--log-id <id>]`, `token add <tenant> --label <text>` (the token is printed once), `token revoke <tenant> <hash-prefix>`, `tenant disable <id>`, and `import --file log.jsonl [--tenant default]` to bring an existing file log in as hashes. The root paths serve the tenant `default`, created on first start with `--log-id`, and `--token-env` still works for it. The key that signs tree heads can live in its own process: `agent-custody signer --key keys/log.key --port 8790 --token-env SIGNER_TOKEN` holds it and answers `POST /sign` with the shared secret and `GET /keys` to anyone; the log server then runs with `--signer-url http://signer:8790/ --signer-token-env SIGNER_TOKEN` instead of `--key`, and the process that faces the internet never holds the key. Either way the log serves its keys at `/.well-known/agent-custody-log.json`, current key first and retired keys (`--retired-key old.pub`) after it, so verifiers fetch and pin them with `verify --log-url` and `audit --log-url` rather than receiving a key file from the operator. With `--checkpoint-dir <dir>` the server publishes a signed checkpoint, every `--checkpoint-every` seconds (default 300), for each log whose tree has grown, as `<dir>/<tenant>/<treeSize>.json` and `latest.json`, and with a database also as rows; `GET /checkpoints?since=<size>` and `GET /t/<tenant>/checkpoints` list them. Serve the directory read-only from a second host, so the record of what the log signed does not depend on the log's API being up; a verifier who kept an earlier head audits against a later checkpoint with `audit --older <bundle> --newer <checkpoint> --log-url <url>`. With `--admin-token-env ADMIN_TOKEN` (Postgres only) the server also serves the operator's page at `/admin` and its API under `/admin/`: list and create tenants, mint a token that is shown once beside the tenant's welcome sheet, revoke tokens, disable tenants. Everything under `/admin`, the page included, needs the admin token: the browser asks for it (any user name, the token as the password) and an API client sends it as a bearer; a handful of wrong attempts from one address are throttled for a minute. Nothing is stored by the page. Behind a reverse proxy, start the server with `--trust-proxy` so those per-address limits key on `X-Forwarded-For` instead of on the proxy's own address, and only there, since the header is otherwise the client's to forge. `--public-url` and `--checkpoints-url` fill the sheet in. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers.
108
+ 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. Add `"hashOnly": true` for any log run by someone else: the gateway then sends only the leaf hash, sha256 of the receipt envelope with the RFC 6962 prefix, so the log commits to the receipt without ever holding it, and the receipts with their arguments and results stay in `receiptsDir`. The verifier does not change; it hashes the envelope itself. A log that serves several tenants is reached at `<url>/t/<tenant>/`, and each of its tree heads names its log, which a verifier checks with `--log-id`. 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 ordinary call the upstream action has already happened by then, and the error says so; a receipt that was never logged must not be handed out. For a tool named in `precommit` the order is reversed, below, and the action never happens. 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 [--log-id <id>] [--tenants tenants.json]`. It serves `POST /append` with `{leaf}` or `{leafHash}` (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. `--log-id` writes that id into every tree head. `--tenants` names a JSON file, `{ "acme": { "file": "acme.jsonl", "tokenEnv": "ACME_TOKEN", "logId": "acme-eu" } }`, and each tenant is its own log at `/t/acme/…` with its own token and id; the default log stays at the root paths. With `--db-env DATABASE_URL` the server keeps its logs in Postgres instead of files, and needs the `pg` package beside it: leaves as hashes in one table keyed by tenant, one writer per tenant enforced with an advisory lock so a second instance is safe, tenants and their tokens in tables of their own with tokens stored only as hashes, and rate limits per token (50 appends a second, burst 100, a 64 KB body cap; a refused append answers 429 with `retry-after`, and the gateway's sink retries a few times). Tenants are managed with `log-admin --db-env DATABASE_URL`: `tenant add <id> [--log-id <id>]`, `token add <tenant> --label <text>` (the token is printed once), `token revoke <tenant> <hash-prefix>`, `tenant disable <id>`, and `import --file log.jsonl [--tenant default]` to bring an existing file log in as hashes. The root paths serve the tenant `default`, created on first start with `--log-id`, and `--token-env` still works for it. The key that signs tree heads can live in its own process: `agent-custody signer --key keys/log.key --port 8790 --token-env SIGNER_TOKEN` holds it and answers `POST /sign` with the shared secret and `GET /keys` to anyone; the log server then runs with `--signer-url http://signer:8790/ --signer-token-env SIGNER_TOKEN` instead of `--key`, and the process that faces the internet never holds the key. Either way the log serves its keys at `/.well-known/agent-custody-log.json`, current key first and retired keys (`--retired-key old.pub`) after it, so verifiers fetch and pin them with `verify --log-url` and `audit --log-url` rather than receiving a key file from the operator. With `--checkpoint-dir <dir>` the server publishes a signed checkpoint, every `--checkpoint-every` seconds (default 300), for each log whose tree has grown, as `<dir>/<tenant>/<treeSize>.json` and `latest.json`, and with a database also as rows; `GET /checkpoints?since=<size>` and `GET /t/<tenant>/checkpoints` list them. Serve the directory read-only from a second host, so the record of what the log signed does not depend on the log's API being up; a verifier who kept an earlier head audits against a later checkpoint with `audit --older <bundle> --newer <checkpoint> --log-url <url>`. With `--admin-token-env ADMIN_TOKEN` (Postgres only) the server also serves the operator's page at `/admin` and its API under `/admin/`: list and create tenants, mint a token that is shown once beside the tenant's welcome sheet, revoke tokens, disable tenants. Everything under `/admin`, the page included, needs the admin token: the browser asks for it (any user name, the token as the password) and an API client sends it as a bearer; a handful of wrong attempts from one address are throttled for a minute. Nothing is stored by the page. Behind a reverse proxy, start the server with `--trust-proxy` so those per-address limits key on `X-Forwarded-For` instead of on the proxy's own address, and only there, since the header is otherwise the client's to forge. `--public-url` and `--checkpoints-url` fill the sheet in. The witness closes the last gap: `agent-custody witness --key witness.key --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]...` runs on a machine the log's operator does not control, fetches each watched log's latest checkpoint, verifies it against the log's published keys, proves with the log's consistency proof that it extends the last head the witness signed, and countersigns it into `<dir>/<tenant>/<size>.json` and `latest.json`; a checkpoint that does not extend, or a second history at the same size, gets `ALARM.json` instead. Its key document is `<dir>/.well-known/agent-custody-witness.json`. Serve `<dir>` from the witness's own host; verifiers add `--witness-url` (or `--witness-key`) to `audit`, and the newer head must then carry the witness's signature. [deploy/](../../deploy/README.md) runs the server, the signer, Postgres, and the checkpoints host as containers, and [deploy/witness/](../../deploy/witness/) the witness.
109
109
 
110
110
  `otel`, optional in both the gateway and SDK configs, sends every receipt to the collector you already run as one span over OTLP/HTTP, after the receipt is issued: `"otel": { "url": "http://localhost:4318", "headersEnv": { "x-api-key": "OTEL_KEY" }, "serviceName": "support-agents" }`. The span's trace id is the receipt id, its attributes carry the tool, agent, principal, execution status, policy decision, and log position, and its status is an error only when the upstream failed or errored, since a denial is the policy working. Export is best effort: a collector that is down or refuses costs a line on stderr, never a receipt. Tutorial 18 shows it against a stand-in collector.
111
111
 
@@ -163,7 +163,7 @@ An inclusion proof says a receipt was in the log at one moment. It does not say
163
163
  node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log log.jsonl --issuer-key keys/gateway.pub
164
164
  node src/cli.ts audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url https://log.example.com/t/acme/ --log-id acme
165
165
 
166
- `--log-url` also fetches the log's published keys from `/.well-known/agent-custody-log.json` and pins them by keyid, so no key file changes hands; `--log-key` still works for a key you were handed. The same flag on `verify` does the same for a receipt.
166
+ A witness that countersigns the log's checkpoints from a machine the operator does not control is required with `--witness-url https://witness.example.org/` or `--witness-key witness.pub`: the check `newer tree head countersigned by a witness` then has to pass, and `--newer` may be a checkpoint file from the witness's host. `--log-url` also fetches the log's published keys from `/.well-known/agent-custody-log.json` and pins them by keyid, so no key file changes hands; `--log-key` still works for a key you were handed. The same flag on `verify` does the same for a receipt.
167
167
  ```
168
168
 
169
169
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
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": {