@agent-custody/receipts 0.5.3 → 0.5.5

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,8 @@ 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/log-check.ts the outside monitor: verifies the head, checkpoints, and witness of a running log
229
+ src/witness.ts the witness: countersigns the log's checkpoints from another operator's machine, or refuses with an alarm
228
230
  src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
229
231
  src/checkpoints.ts signed heads published on a schedule, to files and to Postgres
230
232
  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 +282,8 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
280
282
  - 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
283
  - 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
284
 
285
+ - Monitoring and metering: `log-check`, the outside probe that verifies the head, the checkpoints, and the witness and exits 1 on trouble, run every ten minutes by the `monitor` workflow; `GET /health`; and usage per tenant per month on the admin page and as CSV.
286
+ - 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
287
  - 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
288
  - 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
289
  - 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 +293,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
289
293
 
290
294
  **Next, in the order it pays off**
291
295
 
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.
296
+ 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
297
  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
298
  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
299
  4. Delegation chains for sub-agents.
package/dist/cli.js CHANGED
@@ -10,10 +10,13 @@ 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";
14
+ import { checkLog, formatLogCheck } from "./log-check.js";
13
15
  import { CheckpointPublisher, fileResolver } from "./log-sink.js";
14
16
  import { createRequire } from "node:module";
15
17
  import { pruneLog } from "./retention.js";
16
18
  import { serveSidecar } from "./sidecar.js";
19
+ import { TREEHEAD_TYPE } from "./receipt.js";
17
20
  import { MerkleLog } from "./log.js";
18
21
  import { createSdkIssuer } from "./sdk/index.js";
19
22
  import { handleHookEvent } from "./sdk/claude.js";
@@ -46,13 +49,22 @@ const USAGE = `agent-custody <command>
46
49
  log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
47
50
  the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
48
51
  the welcome sheet; the public URLs fill the sheet in
52
+ log-check --log-url <url> [--checkpoints-url <url>] [--witness-url <url>] [--tenant <name>]... [--max-lag <seconds>] [--json]
53
+ the outside monitor: verifies the head against the published keys, that checkpoints keep up
54
+ with the head and the head extends them, and that the witness countersigns and raises no
55
+ alarm; exits 1 on any failure. Run it from cron or a scheduled workflow elsewhere.
56
+ witness --key <witness.key> --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]... [--every <seconds>] [--once]
57
+ a second signer, run by someone who is not the log's operator: fetches the log's latest
58
+ checkpoint per watched log, proves it extends the last one it signed, and countersigns it
59
+ into <dir>; refuses and writes an alarm otherwise. Serve <dir> from a host of your own.
49
60
  signer --key <log.key> --port 8790 [--host 127.0.0.1] [--token-env NAME] [--retired-key <pub>]...
50
61
  the one process that holds the log's key: POST /sign, GET /keys
51
62
  log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
52
63
  log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
53
64
  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
65
+ 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]
66
+ with --log-url the log's published keys are fetched and pinned by keyid; with a witness key or
67
+ URL the newer head must also carry the witness's countersignature
56
68
  checks that the newer receipt's log extends the older one's: nothing between them was rewritten
57
69
  `;
58
70
  /** A retired public key for the key document, from a .pub file; still listed so heads it signed keep verifying. */
@@ -157,6 +169,34 @@ async function main(argv) {
157
169
  await running.close();
158
170
  return 0;
159
171
  }
172
+ case "log-check": {
173
+ const { values } = parseArgs({ args: rest, options: { "log-url": { type: "string" }, "checkpoints-url": { type: "string" }, "witness-url": { type: "string" }, tenant: { type: "string", multiple: true }, "max-lag": { type: "string", default: "900" }, json: { type: "boolean", default: false } } });
174
+ if (!values["log-url"])
175
+ throw new Error("log-check needs --log-url");
176
+ const r = await checkLog({ logUrl: values["log-url"], ...(values["checkpoints-url"] ? { checkpointsUrl: values["checkpoints-url"] } : {}), ...(values["witness-url"] ? { witnessUrl: values["witness-url"] } : {}), tenants: values.tenant?.length ? values.tenant : ["default"], maxLagMs: Number(values["max-lag"]) * 1000 });
177
+ console.log(values.json ? JSON.stringify(r, null, 2) : formatLogCheck(r));
178
+ return r.ok ? 0 : 1;
179
+ }
180
+ case "witness": {
181
+ 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 } } });
182
+ if (!values.key || !values["log-url"] || !values["checkpoints-url"] || !values.out)
183
+ throw new Error("witness needs --key, --log-url, --checkpoints-url, and --out");
184
+ 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 });
185
+ const everyMs = Number(values.every) * 1000;
186
+ if (!(everyMs > 0))
187
+ throw new Error("--every must be a positive number of seconds");
188
+ if (values.once) {
189
+ const outcomes = await w.runOnce();
190
+ for (const o of outcomes)
191
+ console.log(`${o.tenant.padEnd(20)} ${o.outcome}${"treeSize" in o ? ` at ${o.treeSize}` : ""}${"reason" in o ? `: ${o.reason}` : ""}`);
192
+ return outcomes.some((o) => o.outcome === "refused") ? 1 : 0;
193
+ }
194
+ console.error(`agent-custody witness: keyid=${w.keyid} watching ${values["log-url"]} via ${values["checkpoints-url"]} every ${values.every}s, writing to ${values.out}`);
195
+ w.start(everyMs);
196
+ await new Promise((resolve) => process.once("SIGINT", resolve));
197
+ w.stop();
198
+ return 0;
199
+ }
160
200
  case "signer": {
161
201
  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
202
  if (!values.key)
@@ -339,19 +379,31 @@ async function main(argv) {
339
379
  "issuer-key": { type: "string", multiple: true },
340
380
  "log-key": { type: "string", multiple: true },
341
381
  "log-id": { type: "string" },
382
+ "witness-key": { type: "string", multiple: true },
383
+ "witness-url": { type: "string" },
342
384
  json: { type: "boolean", default: false },
343
385
  },
344
386
  });
345
387
  const keyFiles = [...(values["issuer-key"] ?? []), ...(values["log-key"] ?? [])];
346
388
  if (!values.older || !values.newer)
347
389
  throw new Error("audit needs --older and --newer");
390
+ const witnessKeys = [...(values["witness-key"] ?? []).map(loadPublicKey), ...(values["witness-url"] ? await fetchWitnessKeys(values["witness-url"]) : [])];
348
391
  if (!values.log === !values["log-url"])
349
392
  throw new Error("audit needs exactly one of --log or --log-url");
350
393
  const auditKeys = [...keyFiles.map(loadPublicKey), ...(values["log-url"] ? (await fetchLogKeys(values["log-url"])).keys : [])];
351
394
  if (auditKeys.length === 0)
352
395
  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;
396
+ // --older and --newer take a receipt bundle, or a checkpoint file from the log's or the witness's host
397
+ const headOf = (file) => {
398
+ const j = JSON.parse(readFileSync(file, "utf8"));
399
+ if (j.treeHead)
400
+ return j.treeHead;
401
+ if (j.envelope && j.envelope.payloadType === TREEHEAD_TYPE)
402
+ return j.envelope;
403
+ throw new Error(`${file} is neither a receipt bundle nor a checkpoint`);
404
+ };
405
+ const older = headOf(values.older);
406
+ const newer = headOf(values.newer);
355
407
  const sizeOf = (env) => JSON.parse(Buffer.from(env.payload, "base64").toString()).treeSize;
356
408
  const [m, n] = [sizeOf(older), sizeOf(newer)];
357
409
  let proof;
@@ -363,7 +415,7 @@ async function main(argv) {
363
415
  throw new Error(`log refused the consistency query: ${res.status}`);
364
416
  proof = (await res.json()).hashes;
365
417
  }
366
- const result = auditExtends(older, newer, proof, auditKeys, values["log-id"]);
418
+ const result = auditExtends(older, newer, proof, auditKeys, values["log-id"], witnessKeys.length ? { witnessKeys } : {});
367
419
  if (values.json)
368
420
  console.log(JSON.stringify(result, null, 2));
369
421
  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,11 @@ 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 { checkLog, formatLogCheck } from "./log-check.ts";
13
+ export type { LogCheck, LogCheckOptions, LogCheckResult } from "./log-check.ts";
14
+ export type { WitnessOptions, WitnessOutcome, WitnessedCheckpoint } from "./witness.ts";
15
+ export type { AuditOptions } from "./verify.ts";
11
16
  export type { AdminOptions } from "./log-admin.ts";
12
17
  export type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
13
18
  export type { AppendResult, LogBackend, PostgresLike, PostgresLogOptions, RateLimitOptions, Tenant, TokenRecord } from "./log-store.ts";
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ 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";
10
+ export { checkLog, formatLogCheck } from "./log-check.js";
9
11
  export * from "./config.js";
10
12
  export * from "./crypto.js";
11
13
  export * from "./delegation.js";
@@ -31,5 +31,7 @@ export declare function welcomeSheet(o: {
31
31
  * GET /admin/tenants/:id/tokens [{ label, tokenHash, createdAt, revokedAt }]
32
32
  * POST /admin/tenants/:id/tokens { label } { token, tokenHash, welcome } token shown once
33
33
  * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
34
+ * GET /admin/usage?month=YYYY-MM { month, tenants: [{ id, logId, appends, totalLeaves, liveTokens, disabled }] }
35
+ * GET /admin/usage.csv?month=YYYY-MM the same as CSV, for an invoice
34
36
  */
35
37
  export declare function adminRoutes(opts: AdminOptions): (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<boolean>;
package/dist/log-admin.js CHANGED
@@ -50,6 +50,8 @@ export function welcomeSheet(o) {
50
50
  * GET /admin/tenants/:id/tokens [{ label, tokenHash, createdAt, revokedAt }]
51
51
  * POST /admin/tenants/:id/tokens { label } { token, tokenHash, welcome } token shown once
52
52
  * POST /admin/tenants/:id/tokens/:prefix/revoke { revoked }
53
+ * GET /admin/usage?month=YYYY-MM { month, tenants: [{ id, logId, appends, totalLeaves, liveTokens, disabled }] }
54
+ * GET /admin/usage.csv?month=YYYY-MM the same as CSV, for an invoice
53
55
  */
54
56
  export function adminRoutes(opts) {
55
57
  // Five wrong tokens from one address, then one more a minute: enough to stop guessing, not enough to lock out a typo.
@@ -97,7 +99,17 @@ export function adminRoutes(opts) {
97
99
  try {
98
100
  const t = opts.tenancy;
99
101
  const parts = url.pathname.split("/").filter(Boolean); // ["admin", ...]
100
- if (req.method === "GET" && parts.length === 2 && parts[1] === "info") {
102
+ const month = url.searchParams.get("month") ?? new Date().toISOString().slice(0, 7);
103
+ if (req.method === "GET" && parts.length === 2 && parts[1] === "usage") {
104
+ json(200, await t.usage(month));
105
+ }
106
+ else if (req.method === "GET" && parts.length === 2 && parts[1] === "usage.csv") {
107
+ const u = await t.usage(month);
108
+ const csv = ["month,tenant,log_id,appends,total_leaves,live_tokens,disabled", ...u.tenants.map((x) => [u.month, x.id, x.logId, x.appends, x.totalLeaves, x.liveTokens, x.disabled].join(","))].join("\n") + "\n";
109
+ res.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="agent-custody-usage-${u.month}.csv"`, "cache-control": "no-store" });
110
+ res.end(csv);
111
+ }
112
+ else if (req.method === "GET" && parts.length === 2 && parts[1] === "info") {
101
113
  json(200, { publicUrl: opts.publicUrl ?? null, checkpointsUrl: opts.checkpointsUrl ?? null, keyid: opts.keyid ?? null });
102
114
  }
103
115
  else if (req.method === "GET" && parts.length === 2 && parts[1] === "tenants") {
@@ -191,6 +203,9 @@ const ADMIN_PAGE = `<!doctype html>
191
203
  <button class="quiet" id="copyTok">Copy token</button> <button class="quiet" id="copySheet">Copy welcome sheet</button>
192
204
  <pre id="sheet"></pre>
193
205
  </div>
206
+ <h2>Usage</h2>
207
+ <div class="row"><label>month<input id="month" type="month"></label><button class="quiet" id="loadUsage">Show</button><a id="csv" class="quiet" href="#" style="align-self:center">Download CSV</a></div>
208
+ <table><thead><tr><th>tenant</th><th>log id</th><th>appends this month</th><th>leaves in total</th><th>live tokens</th></tr></thead><tbody id="usage"></tbody></table>
194
209
  <h2>Tokens of a tenant</h2>
195
210
  <div class="row"><label>tenant<input id="ltid" placeholder="acme" autocomplete="off"></label><button class="quiet" id="listTokens">List</button></div>
196
211
  <table><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="tokens"></tbody></table>
@@ -223,6 +238,7 @@ const ADMIN_PAGE = `<!doctype html>
223
238
  const info = await api("GET", "/admin/info");
224
239
  $("where").textContent = (info.publicUrl || location.origin) + " · keyid " + (info.keyid ? info.keyid.slice(0, 12) : "?") + (info.checkpointsUrl ? " · checkpoints at " + info.checkpointsUrl : "");
225
240
  await loadTenants();
241
+ await loadUsage();
226
242
  } catch (e) { say(e.message, "err"); }
227
243
  };
228
244
  $("addTenant").onclick = async () => { try { const t = await api("POST", "/admin/tenants", { id: $("tid").value.trim(), logId: $("lid").value.trim() }); say("tenant " + t.id + " created; reached at /t/" + t.id + "/", "ok"); $("ttid").value = t.id; await loadTenants(); } catch (e) { say(e.message, "err"); } };
@@ -237,6 +253,14 @@ const ADMIN_PAGE = `<!doctype html>
237
253
  $("copyTok").onclick = () => navigator.clipboard.writeText($("tokval").textContent).then(() => say("token copied", "ok"));
238
254
  $("copySheet").onclick = () => navigator.clipboard.writeText($("sheet").textContent).then(() => say("welcome sheet copied", "ok"));
239
255
  $("listTokens").onclick = () => loadTokens($("ltid").value.trim()).catch((e) => say(e.message, "err"));
256
+ const loadUsage = async () => {
257
+ const month = $("month").value || new Date().toISOString().slice(0, 7);
258
+ const u = await api("GET", "/admin/usage?month=" + encodeURIComponent(month));
259
+ $("csv").href = "/admin/usage.csv?month=" + encodeURIComponent(month);
260
+ $("usage").innerHTML = u.tenants.map((t) => "<tr><td><code>" + esc(t.id) + "</code>" + (t.disabled ? " <span class=muted>disabled</span>" : "") + "</td><td><code>" + esc(t.logId) + "</code></td><td>" + t.appends + "</td><td>" + t.totalLeaves + "</td><td>" + t.liveTokens + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>no tenants</td></tr>";
261
+ };
262
+ $("loadUsage").onclick = () => loadUsage().catch((e) => say(e.message, "err"));
263
+ $("month").value = new Date().toISOString().slice(0, 7);
240
264
  document.addEventListener("click", async (e) => {
241
265
  const b = e.target.closest("button"); if (!b) return;
242
266
  if (b.dataset.disable && confirm("Disable tenant " + b.dataset.disable + "? Its paths answer 404 within ten seconds.")) { try { await api("POST", "/admin/tenants/" + encodeURIComponent(b.dataset.disable) + "/disable"); await loadTenants(); say("disabled " + b.dataset.disable, "ok"); } catch (err) { say(err.message, "err"); } }
@@ -0,0 +1,25 @@
1
+ export interface LogCheckOptions {
2
+ logUrl: string;
3
+ checkpointsUrl?: string;
4
+ witnessUrl?: string;
5
+ /** which logs to probe: "default" for the root paths, else tenant names */
6
+ tenants?: string[];
7
+ /** how far a checkpoint may trail the head, in milliseconds, before that is a failure; default fifteen minutes */
8
+ maxLagMs?: number;
9
+ /** how old a checkpoint may be while the head has not moved; default a day, since an idle log is not a broken one */
10
+ maxIdleMs?: number;
11
+ fetch?: typeof fetch;
12
+ now?: () => number;
13
+ }
14
+ export interface LogCheck {
15
+ tenant: string | null;
16
+ name: string;
17
+ ok: boolean;
18
+ detail?: string;
19
+ }
20
+ export interface LogCheckResult {
21
+ ok: boolean;
22
+ checks: LogCheck[];
23
+ }
24
+ export declare function checkLog(o: LogCheckOptions): Promise<LogCheckResult>;
25
+ export declare function formatLogCheck(r: LogCheckResult): string;
@@ -0,0 +1,118 @@
1
+ // The probe: what an outside monitor runs against a hosted log every few minutes. It does not trust the log's
2
+ // answers; it verifies them the way an auditor would, with the log's published keys, and it fails loudly when the
3
+ // log is down, its head does not verify, its checkpoints have fallen behind its head, or its witness has stopped
4
+ // countersigning. Run it from cron on a machine that is not the log's, or from a scheduled workflow; the exit code
5
+ // is the alert.
6
+ import { dsseVerify, dsseVerifiers } from "./crypto.js";
7
+ import { verifyConsistency } from "./log.js";
8
+ import { TREEHEAD_TYPE } from "./receipt.js";
9
+ import { fetchLogKeys } from "./signer.js";
10
+ import { fetchWitnessKeys } from "./witness.js";
11
+ const short = (s) => s.slice(0, 12);
12
+ export async function checkLog(o) {
13
+ const f = o.fetch ?? fetch;
14
+ const now = o.now ?? Date.now;
15
+ const maxLag = o.maxLagMs ?? 15 * 60_000;
16
+ const maxIdle = o.maxIdleMs ?? 24 * 3_600_000;
17
+ const checks = [];
18
+ const add = (tenant, name, ok, detail) => {
19
+ checks.push(detail === undefined ? { tenant, name, ok } : { tenant, name, ok, detail });
20
+ return ok;
21
+ };
22
+ const base = o.logUrl.endsWith("/") ? o.logUrl : `${o.logUrl}/`;
23
+ const get = async (url) => {
24
+ const res = await f(url, { signal: AbortSignal.timeout(10_000) });
25
+ if (!res.ok)
26
+ throw new Error(`${res.status} from ${url.pathname}`);
27
+ return res.json();
28
+ };
29
+ let keys = [];
30
+ try {
31
+ keys = (await fetchLogKeys(base, f)).keys;
32
+ add(null, "key document served", true, `${keys.length} key(s), current ${short(keys[0].keyid)}`);
33
+ }
34
+ catch (e) {
35
+ add(null, "key document served", false, e instanceof Error ? e.message : String(e));
36
+ return { ok: false, checks };
37
+ }
38
+ let witnessKeys = [];
39
+ if (o.witnessUrl) {
40
+ try {
41
+ witnessKeys = await fetchWitnessKeys(o.witnessUrl, f);
42
+ add(null, "witness key document served", true, `witness ${short(witnessKeys[0].keyid)}`);
43
+ }
44
+ catch (e) {
45
+ add(null, "witness key document served", false, e instanceof Error ? e.message : String(e));
46
+ }
47
+ }
48
+ for (const tenant of o.tenants ?? ["default"]) {
49
+ const path = (op) => new URL(tenant === "default" ? op : `t/${tenant}/${op}`, base);
50
+ let head = null;
51
+ try {
52
+ const { treeHead } = (await get(path("head")));
53
+ const v = dsseVerify(treeHead, keys);
54
+ head = v.ok && treeHead.payloadType === TREEHEAD_TYPE ? v.payload : null;
55
+ add(tenant, "head verifies against the published keys", head !== null, head ? `size ${head.treeSize}, signed by ${short(v.ok ? v.keyid : "?")}` : v.ok ? "not a tree head" : v.error);
56
+ }
57
+ catch (e) {
58
+ add(tenant, "head verifies against the published keys", false, e instanceof Error ? e.message : String(e));
59
+ continue;
60
+ }
61
+ if (!head)
62
+ continue;
63
+ if (!o.checkpointsUrl)
64
+ continue;
65
+ const cpBase = o.checkpointsUrl.endsWith("/") ? o.checkpointsUrl : `${o.checkpointsUrl}/`;
66
+ let cp = null;
67
+ try {
68
+ const fetched = (await get(new URL(`${tenant}/latest.json`, cpBase)));
69
+ const v = dsseVerify(fetched.envelope, keys);
70
+ add(tenant, "latest checkpoint verifies", v.ok, v.ok ? `size ${fetched.treeSize} signed ${fetched.signedAt}` : v.error);
71
+ if (v.ok)
72
+ cp = fetched;
73
+ }
74
+ catch (e) {
75
+ add(tenant, "latest checkpoint verifies", false, e instanceof Error ? e.message : String(e));
76
+ }
77
+ if (!cp)
78
+ continue;
79
+ const age = now() - Date.parse(cp.signedAt);
80
+ if (cp.treeSize < head.treeSize) {
81
+ // the head moved on; the publisher must follow within maxLag of the head's own timestamp
82
+ const lag = now() - Date.parse(head.timestamp);
83
+ add(tenant, "checkpoint keeps up with the head", lag <= maxLag, `checkpoint at ${cp.treeSize}, head at ${head.treeSize}, head signed ${Math.round(lag / 1000)}s ago`);
84
+ }
85
+ else {
86
+ add(tenant, "checkpoint keeps up with the head", cp.treeSize === head.treeSize && age <= maxIdle, cp.treeSize > head.treeSize ? `checkpoint at ${cp.treeSize} is AHEAD of the head at ${head.treeSize}` : `at the head, checkpoint signed ${Math.round(age / 60_000)} min ago`);
87
+ }
88
+ if (cp.treeSize <= head.treeSize) {
89
+ try {
90
+ const proof = (await get(path(`consistency?old=${cp.treeSize}&new=${head.treeSize}`)));
91
+ add(tenant, "head extends the checkpoint", verifyConsistency(cp.treeSize, cp.rootHash, head.treeSize, head.rootHash, proof.hashes), `${cp.treeSize} -> ${head.treeSize}`);
92
+ }
93
+ catch (e) {
94
+ add(tenant, "head extends the checkpoint", false, e instanceof Error ? e.message : String(e));
95
+ }
96
+ }
97
+ if (o.witnessUrl && witnessKeys.length > 0) {
98
+ const wBase = o.witnessUrl.endsWith("/") ? o.witnessUrl : `${o.witnessUrl}/`;
99
+ try {
100
+ const w = (await get(new URL(`${tenant}/latest.json`, wBase)));
101
+ const by = dsseVerifiers(w.envelope, witnessKeys);
102
+ add(tenant, "witness has countersigned", by.length > 0, by.length ? `at size ${w.treeSize}` : "latest witnessed checkpoint carries no witness signature");
103
+ add(tenant, "witness keeps up with the checkpoints", w.treeSize >= cp.treeSize || now() - Date.parse(cp.signedAt) <= maxLag, `witness at ${w.treeSize}, checkpoint at ${cp.treeSize}`);
104
+ const alarm = await f(new URL(`${tenant}/ALARM.json`, wBase), { signal: AbortSignal.timeout(10_000) });
105
+ add(tenant, "witness has raised no alarm", alarm.status === 404, alarm.status === 404 ? undefined : `ALARM.json is present (${alarm.status})`);
106
+ }
107
+ catch (e) {
108
+ add(tenant, "witness has countersigned", false, e instanceof Error ? e.message : String(e));
109
+ }
110
+ }
111
+ }
112
+ return { ok: checks.every((c) => c.ok), checks };
113
+ }
114
+ export function formatLogCheck(r) {
115
+ const lines = r.checks.map((c) => `${c.ok ? "PASS" : "FAIL"} ${c.tenant ? `${c.tenant.padEnd(16)} ` : "".padEnd(17)}${c.name}${c.detail ? ` (${c.detail})` : ""}`);
116
+ lines.push("", r.ok ? "RESULT: LOG HEALTHY" : "RESULT: LOG NEEDS ATTENTION");
117
+ return lines.join("\n");
118
+ }
package/dist/log-sink.js CHANGED
@@ -237,6 +237,17 @@ export function logHandler(source, keyOrSigner, opts = {}) {
237
237
  const url = new URL(req.url ?? "/", "http://localhost");
238
238
  if (admin && (await admin(req, res, url)))
239
239
  return;
240
+ if (req.method === "GET" && url.pathname === "/health") {
241
+ // Liveness for a load balancer or a container: the signer answers and the default log answers. No secrets, no sizes.
242
+ try {
243
+ const doc = await signer.keys();
244
+ const root = await resolver.resolve(null);
245
+ return json(root ? 200 : 503, { ok: !!root, keyid: doc.keys[0]?.keyid ?? null, checkpoints: !!opts.checkpoints }, { "cache-control": "no-store" });
246
+ }
247
+ catch (e) {
248
+ return json(503, { ok: false, error: e instanceof Error ? e.message : String(e) });
249
+ }
250
+ }
240
251
  if (req.method === "GET" && url.pathname === "/.well-known/agent-custody-log.json") {
241
252
  try {
242
253
  const doc = await signer.keys();
@@ -95,6 +95,21 @@ export declare class PostgresTenancy {
95
95
  }>;
96
96
  /** Revokes the tokens of a tenant whose hash starts with the prefix; returns how many. */
97
97
  revokeToken(tenantId: string, hashPrefix: string): Promise<number>;
98
+ /**
99
+ * Appends per tenant for one month, YYYY-MM in UTC, plus each tenant's total leaves and live tokens: the numbers
100
+ * any pricing rests on. One query on the leaves table, grouped; tenants with no appends that month show zero.
101
+ */
102
+ usage(month: string): Promise<{
103
+ month: string;
104
+ tenants: {
105
+ id: string;
106
+ logId: string;
107
+ appends: number;
108
+ totalLeaves: number;
109
+ liveTokens: number;
110
+ disabled: boolean;
111
+ }[];
112
+ }>;
98
113
  listTokens(tenantId: string): Promise<TokenRecord[]>;
99
114
  }
100
115
  /**
package/dist/log-store.js CHANGED
@@ -263,6 +263,25 @@ export class PostgresTenancy {
263
263
  this.tokenCache.delete(`${tenantId}:${r.token_hash}`);
264
264
  return rows.length;
265
265
  }
266
+ /**
267
+ * Appends per tenant for one month, YYYY-MM in UTC, plus each tenant's total leaves and live tokens: the numbers
268
+ * any pricing rests on. One query on the leaves table, grouped; tenants with no appends that month show zero.
269
+ */
270
+ async usage(month) {
271
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
272
+ throw new Error("month must be YYYY-MM");
273
+ await this.init();
274
+ const start = `${month}-01T00:00:00Z`;
275
+ const [y, m] = month.split("-").map(Number);
276
+ const end = `${m === 12 ? y + 1 : y}-${String(m === 12 ? 1 : m + 1).padStart(2, "0")}-01T00:00:00Z`;
277
+ const p = this.prefix;
278
+ const rows = (await this.client.query(`SELECT t.id, t.log_id, t.disabled_at,
279
+ (SELECT COUNT(*) FROM ${p}leaves l WHERE l.tenant_id = t.id AND l.appended_at >= $1::timestamptz AND l.appended_at < $2::timestamptz) AS appends,
280
+ (SELECT COUNT(*) FROM ${p}leaves l WHERE l.tenant_id = t.id) AS total,
281
+ (SELECT COUNT(*) FROM ${p}tokens k WHERE k.tenant_id = t.id AND k.revoked_at IS NULL) AS live
282
+ FROM ${p}tenants t ORDER BY t.created_at`, [start, end])).rows;
283
+ return { month, tenants: rows.map((r) => ({ id: String(r.id), logId: String(r.log_id), appends: Number(r.appends), totalLeaves: Number(r.total), liveTokens: Number(r.live), disabled: !!r.disabled_at })) };
284
+ }
266
285
  async listTokens(tenantId) {
267
286
  await this.init();
268
287
  return (await this.client.query(`SELECT tenant_id, label, token_hash, created_at, revoked_at FROM ${this.prefix}tokens WHERE tenant_id = $1 ORDER BY created_at`, [tenantId])).rows.map((r) => ({ tenantId: String(r.tenant_id), label: String(r.label), tokenHash: String(r.token_hash), createdAt: new Date(r.created_at).toISOString(), revokedAt: r.revoked_at ? new Date(r.revoked_at).toISOString() : null }));
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. Two more things an operator needs. `agent-custody log-check --log-url <url> --checkpoints-url <url> [--witness-url <url>] [--tenant <name>]... [--max-lag <seconds>]` is the outside monitor: it verifies the head against the published keys, that the latest checkpoint verifies and keeps up with the head, that the head extends the checkpoint, and, with a witness, that the witness has countersigned, keeps up, and has raised no alarm; it exits 1 on any failure, so cron or a scheduled workflow on a machine that is not the log's turns it into an alert. `GET /health` on the server is the liveness check for a load balancer. And `GET /admin/usage?month=YYYY-MM`, on the admin page and as `/admin/usage.csv`, is the metering: appends per tenant for the month, leaves in total, live tokens, the numbers any invoice rests on. [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.5",
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": {