@agent-custody/receipts 0.5.6 → 0.5.7
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 +2 -0
- package/dist/cli.js +16 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/log-admin.js +5 -0
- package/dist/log-export.d.ts +28 -0
- package/dist/log-export.js +84 -0
- package/dist/log-sink.d.ts +11 -0
- package/dist/log-sink.js +30 -2
- package/dist/log-store.d.ts +3 -0
- package/dist/log-store.js +7 -0
- package/dist/log.d.ts +2 -0
- package/dist/log.js +4 -0
- package/dist/signer.js +3 -3
- package/dist/witness.js +1 -1
- package/docs/usage.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -227,6 +227,7 @@ src/config.ts gateway and SDK config schemas, path resolution
|
|
|
227
227
|
src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
|
|
228
228
|
src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
|
|
229
229
|
src/log-check.ts the outside monitor: verifies the head, checkpoints, and witness of a running log
|
|
230
|
+
src/log-export.ts a tenant's export of their own log, self-checked, as a log file the verifier reads
|
|
230
231
|
src/witness.ts the witness: countersigns the log's checkpoints from another operator's machine, or refuses with an alarm
|
|
231
232
|
src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
|
|
232
233
|
src/checkpoints.ts signed heads published on a schedule, to files and to Postgres
|
|
@@ -284,6 +285,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
|
|
|
284
285
|
- 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.
|
|
285
286
|
- 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).
|
|
286
287
|
|
|
288
|
+
- A tenant's export: `log-export` takes, with the tenant's own token, every leaf hash, the signed head, the published keys, the checkpoints, and their usage, checks that they add up, and writes a log copy the verifier reads offline; the evidence never depends on the operator staying in business.
|
|
287
289
|
- 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.
|
|
288
290
|
- 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).
|
|
289
291
|
- 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).
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoi
|
|
|
12
12
|
import { connectSigner, fetchLogKeys, localSigner, serveSigner } from "./signer.js";
|
|
13
13
|
import { fetchWitnessKeys, Witness } from "./witness.js";
|
|
14
14
|
import { checkLog, formatLogCheck } from "./log-check.js";
|
|
15
|
+
import { exportLog, formatExport } from "./log-export.js";
|
|
15
16
|
import { CheckpointPublisher, fileResolver } from "./log-sink.js";
|
|
16
17
|
import { createRequire } from "node:module";
|
|
17
18
|
import { pruneLog } from "./retention.js";
|
|
@@ -50,6 +51,9 @@ const USAGE = `agent-custody <command>
|
|
|
50
51
|
the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
|
|
51
52
|
the welcome sheet; the public URLs fill the sheet in
|
|
52
53
|
log-check --log-url <url> [--checkpoints-url <url>] [--witness-url <url>] [--tenant <name>]... [--max-lag <seconds>] [--json]
|
|
54
|
+
log-export --log-url <url> [--tenant <name>] --token-env NAME --out <dir> [--month YYYY-MM]... [--json]
|
|
55
|
+
a tenant's own log, with their token: every leaf hash as a log file the verifier
|
|
56
|
+
reads, the signed head, the published keys, the checkpoints, and their usage
|
|
53
57
|
the outside monitor: verifies the head against the published keys, that checkpoints keep up
|
|
54
58
|
with the head and the head extends them, and that the witness countersigns and raises no
|
|
55
59
|
alarm; exits 1 on any failure. Run it from cron or a scheduled workflow elsewhere.
|
|
@@ -177,6 +181,17 @@ async function main(argv) {
|
|
|
177
181
|
console.log(values.json ? JSON.stringify(r, null, 2) : formatLogCheck(r));
|
|
178
182
|
return r.ok ? 0 : 1;
|
|
179
183
|
}
|
|
184
|
+
case "log-export": {
|
|
185
|
+
const { values } = parseArgs({ args: rest, options: { "log-url": { type: "string" }, tenant: { type: "string" }, "token-env": { type: "string" }, out: { type: "string" }, month: { type: "string", multiple: true }, json: { type: "boolean", default: false } } });
|
|
186
|
+
if (!values["log-url"] || !values["token-env"] || !values.out)
|
|
187
|
+
throw new Error("log-export needs --log-url, --token-env, and --out");
|
|
188
|
+
const token = process.env[values["token-env"]];
|
|
189
|
+
if (!token)
|
|
190
|
+
throw new Error(`environment variable ${values["token-env"]} is not set`);
|
|
191
|
+
const r = await exportLog({ logUrl: values["log-url"], ...(values.tenant ? { tenant: values.tenant } : {}), token, outDir: values.out, ...(values.month?.length ? { months: values.month } : {}) });
|
|
192
|
+
console.log(values.json ? JSON.stringify(r, null, 2) : formatExport(r));
|
|
193
|
+
return r.problems.length ? 1 : 0;
|
|
194
|
+
}
|
|
180
195
|
case "witness": {
|
|
181
196
|
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
197
|
if (!values.key || !values["log-url"] || !values["checkpoints-url"] || !values.out)
|
|
@@ -410,7 +425,7 @@ async function main(argv) {
|
|
|
410
425
|
if (values.log)
|
|
411
426
|
proof = new MerkleLog(values.log).consistencyProof(Math.min(m, n), Math.max(m, n));
|
|
412
427
|
else {
|
|
413
|
-
const res = await fetch(new URL(`consistency?old=${Math.min(m, n)}&new=${Math.max(m, n)}`, values["log-url"].endsWith("/") ? values["log-url"] : `${values["log-url"]}/`));
|
|
428
|
+
const res = await fetch(new URL(`consistency?old=${Math.min(m, n)}&new=${Math.max(m, n)}`, values["log-url"].endsWith("/") ? values["log-url"] : `${values["log-url"]}/`), { signal: AbortSignal.timeout(10_000) });
|
|
414
429
|
if (!res.ok)
|
|
415
430
|
throw new Error(`log refused the consistency query: ${res.status}`);
|
|
416
431
|
proof = (await res.json()).hashes;
|
package/dist/config.d.ts
CHANGED
|
@@ -175,6 +175,7 @@ export declare const GatewayConfigSchema: z.ZodObject<{
|
|
|
175
175
|
url: z.ZodString;
|
|
176
176
|
tokenEnv: z.ZodOptional<z.ZodString>;
|
|
177
177
|
hashOnly: z.ZodOptional<z.ZodBoolean>;
|
|
178
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
178
179
|
}, z.core.$strip>>;
|
|
179
180
|
otel: z.ZodOptional<z.ZodObject<{
|
|
180
181
|
url: z.ZodString;
|
|
@@ -207,6 +208,7 @@ export declare const SdkConfigSchema: z.ZodObject<{
|
|
|
207
208
|
url: z.ZodString;
|
|
208
209
|
tokenEnv: z.ZodOptional<z.ZodString>;
|
|
209
210
|
hashOnly: z.ZodOptional<z.ZodBoolean>;
|
|
211
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
210
212
|
}, z.core.$strip>>;
|
|
211
213
|
otel: z.ZodOptional<z.ZodObject<{
|
|
212
214
|
url: z.ZodString;
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
/** Where receipts are logged: a local file, or a log reached over HTTP whose bearer token comes from an environment variable. */
|
|
5
|
-
const LogSchema = z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional(), /** send leaf hashes only; the log never holds the receipt. Use it for any log run by someone else */ hashOnly: z.boolean().optional() });
|
|
5
|
+
const LogSchema = z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional(), /** send leaf hashes only; the log never holds the receipt. Use it for any log run by someone else */ hashOnly: z.boolean().optional(), /** milliseconds one append may take before the log counts as unreachable; default 10000 */ timeoutMs: z.number().int().positive().optional() });
|
|
6
6
|
const oneLog = { message: "exactly one of logFile or log is required" };
|
|
7
7
|
/** Optional OpenTelemetry export: every receipt also becomes a span at this OTLP/HTTP collector, after it is issued. Never on the evidence path. */
|
|
8
8
|
const OtelSchema = z.object({ url: z.string().url(), headersEnv: z.record(z.string(), z.string().min(1)).optional(), serviceName: z.string().min(1).optional() });
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export type { GatewayOptions } from "./gateway.ts";
|
|
|
4
4
|
export { buildRequest, restUpstream } from "./rest.ts";
|
|
5
5
|
export { openExporter, otlpExporter, spanFor } from "./otel.ts";
|
|
6
6
|
export { hecEvent, splunkExporter } from "./splunk.ts";
|
|
7
|
+
export { exportLog, formatExport } from "./log-export.ts";
|
|
8
|
+
export type { ExportOptions, ExportResult } from "./log-export.ts";
|
|
7
9
|
export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.ts";
|
|
8
10
|
export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
|
|
9
11
|
export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ export { AUTHORIZATION_PREDICATE_TYPE, buildAuthorizationStatement } from "./rec
|
|
|
3
3
|
export { buildRequest, restUpstream } from "./rest.js";
|
|
4
4
|
export { openExporter, otlpExporter, spanFor } from "./otel.js";
|
|
5
5
|
export { hecEvent, splunkExporter } from "./splunk.js";
|
|
6
|
+
export { exportLog, formatExport } from "./log-export.js";
|
|
6
7
|
export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.js";
|
|
7
8
|
export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.js";
|
|
8
9
|
export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
|
package/dist/log-admin.js
CHANGED
|
@@ -35,6 +35,11 @@ export function welcomeSheet(o) {
|
|
|
35
35
|
` npx agent-custody audit --older receipts/<earlier>.json --newer receipts/<later>.json --log-url ${url} --log-id ${o.logId}`,
|
|
36
36
|
"--log-url fetches this log's published keys and pins them; --log-id makes sure the tree heads are this log's.",
|
|
37
37
|
"",
|
|
38
|
+
"Your log is yours to take, any time, with your token:",
|
|
39
|
+
` npx agent-custody log-export --log-url ${url} --tenant ${o.tenant} --token-env AGENT_CUSTODY_LOG_TOKEN --out custody-export/`,
|
|
40
|
+
"It fetches every leaf hash, the signed head, the keys, the checkpoints, and your usage, checks they add up, and writes",
|
|
41
|
+
"a log copy that verify --log and audit --log read with no server.",
|
|
42
|
+
"",
|
|
38
43
|
"What this log does not do: hold receipt contents, forge a receipt (your gateway key signs those), or, today,",
|
|
39
44
|
"countersign with a second independent witness. The proof table: https://agent-custody.dev/receipts/#what-a-receipt-proves-and-what-it-does-not",
|
|
40
45
|
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface ExportOptions {
|
|
2
|
+
logUrl: string;
|
|
3
|
+
/** the tenant whose log to export; omitted, the log at the root paths */
|
|
4
|
+
tenant?: string | undefined;
|
|
5
|
+
token: string;
|
|
6
|
+
outDir: string;
|
|
7
|
+
/** months of usage to include, YYYY-MM; default the current and the previous month */
|
|
8
|
+
months?: string[] | undefined;
|
|
9
|
+
fetch?: typeof fetch;
|
|
10
|
+
}
|
|
11
|
+
export interface ExportResult {
|
|
12
|
+
outDir: string;
|
|
13
|
+
logId: string | null;
|
|
14
|
+
treeSize: number;
|
|
15
|
+
rootHash: string;
|
|
16
|
+
keyid: string;
|
|
17
|
+
checkpoints: number;
|
|
18
|
+
usage: {
|
|
19
|
+
month: string;
|
|
20
|
+
appends: number;
|
|
21
|
+
totalLeaves: number;
|
|
22
|
+
liveTokens: number;
|
|
23
|
+
}[];
|
|
24
|
+
/** what did not add up; an export with problems is still written, and says so */
|
|
25
|
+
problems: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare function exportLog(o: ExportOptions): Promise<ExportResult>;
|
|
28
|
+
export declare function formatExport(r: ExportResult): string;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// A tenant's export of their own log: every leaf hash, the signed head, the published keys, the signed checkpoints,
|
|
2
|
+
// and their metering, fetched with their own token and written as files they can keep. The leaves go into a log
|
|
3
|
+
// file in the format the verifier already reads, so `verify --log` and `audit --log` work against the export with
|
|
4
|
+
// no server at all. The export checks itself before it is written: the head must verify against the published keys
|
|
5
|
+
// and its root must be the root of the leaves fetched, and the same for every checkpoint. A tenant who leaves takes
|
|
6
|
+
// this with them; a tenant who stays runs it on a schedule so the evidence never depends on one operator.
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { dsseVerify } from "./crypto.js";
|
|
10
|
+
import { rootOf } from "./log.js";
|
|
11
|
+
import { TREEHEAD_TYPE } from "./receipt.js";
|
|
12
|
+
import { fetchLogKeys } from "./signer.js";
|
|
13
|
+
const monthOf = (d) => d.toISOString().slice(0, 7);
|
|
14
|
+
const previousMonth = (d) => monthOf(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, 1)));
|
|
15
|
+
export async function exportLog(o) {
|
|
16
|
+
const f = o.fetch ?? fetch;
|
|
17
|
+
const base = o.logUrl.endsWith("/") ? o.logUrl : `${o.logUrl}/`;
|
|
18
|
+
const path = (op) => new URL(o.tenant ? `t/${o.tenant}/${op}` : op, base);
|
|
19
|
+
const headers = { authorization: `Bearer ${o.token}` };
|
|
20
|
+
const get = async (url, auth) => {
|
|
21
|
+
const res = await f(url, { signal: AbortSignal.timeout(30_000), ...(auth ? { headers } : {}) });
|
|
22
|
+
if (res.status === 401)
|
|
23
|
+
throw new Error(`the log refused the token for ${url.pathname}`);
|
|
24
|
+
if (!res.ok)
|
|
25
|
+
throw new Error(`${res.status} from ${url.pathname}`);
|
|
26
|
+
return res.json();
|
|
27
|
+
};
|
|
28
|
+
const problems = [];
|
|
29
|
+
const { doc, keys } = await fetchLogKeys(base, f);
|
|
30
|
+
const { treeHead } = (await get(path("head"), false));
|
|
31
|
+
const v = dsseVerify(treeHead, keys);
|
|
32
|
+
if (!v.ok || treeHead.payloadType !== TREEHEAD_TYPE)
|
|
33
|
+
throw new Error(`the head does not verify against the published keys: ${v.ok ? "not a tree head" : v.error}`);
|
|
34
|
+
const head = v.payload;
|
|
35
|
+
// every leaf, in pages, as of the head's size; leaves appended meanwhile are the next export's
|
|
36
|
+
const leaves = [];
|
|
37
|
+
while (leaves.length < head.treeSize) {
|
|
38
|
+
const page = (await get(new URL(`?since=${leaves.length}&limit=10000`, path("leaves")), true));
|
|
39
|
+
if (page.leaves.length === 0)
|
|
40
|
+
throw new Error(`the log returned no leaves past ${leaves.length} of ${head.treeSize}`);
|
|
41
|
+
leaves.push(...page.leaves.slice(0, head.treeSize - leaves.length));
|
|
42
|
+
}
|
|
43
|
+
const hashes = leaves.map((h) => Buffer.from(h, "hex"));
|
|
44
|
+
const root = rootOf(hashes);
|
|
45
|
+
if (root !== head.rootHash)
|
|
46
|
+
problems.push(`the root of the ${leaves.length} leaves fetched (${root.slice(0, 12)}) is not the head's (${head.rootHash.slice(0, 12)})`);
|
|
47
|
+
const cps = (await get(path("checkpoints"), false));
|
|
48
|
+
for (const c of cps.checkpoints) {
|
|
49
|
+
const cv = dsseVerify(c.treeHead, keys);
|
|
50
|
+
if (!cv.ok)
|
|
51
|
+
problems.push(`checkpoint at ${c.treeSize} does not verify: ${cv.error}`);
|
|
52
|
+
else if (c.treeSize <= head.treeSize && rootOf(hashes, c.treeSize) !== c.rootHash)
|
|
53
|
+
problems.push(`checkpoint at ${c.treeSize} has root ${c.rootHash.slice(0, 12)}, the leaves give ${rootOf(hashes, c.treeSize).slice(0, 12)}`);
|
|
54
|
+
}
|
|
55
|
+
const usage = [];
|
|
56
|
+
for (const month of o.months ?? [monthOf(new Date()), previousMonth(new Date())]) {
|
|
57
|
+
try {
|
|
58
|
+
usage.push((await get(new URL(`?month=${month}`, path("usage")), true)));
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
problems.push(`usage for ${month}: ${e instanceof Error ? e.message : String(e)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
mkdirSync(o.outDir, { recursive: true });
|
|
65
|
+
writeFileSync(join(o.outDir, "log.jsonl"), leaves.map((h) => JSON.stringify({ hash: h })).join("\n") + (leaves.length ? "\n" : ""));
|
|
66
|
+
writeFileSync(join(o.outDir, "head.json"), JSON.stringify({ treeHead, ...head }, null, 2));
|
|
67
|
+
writeFileSync(join(o.outDir, "keys.json"), JSON.stringify(doc, null, 2));
|
|
68
|
+
writeFileSync(join(o.outDir, "checkpoints.json"), JSON.stringify(cps.checkpoints, null, 2));
|
|
69
|
+
writeFileSync(join(o.outDir, "usage.json"), JSON.stringify(usage, null, 2));
|
|
70
|
+
const result = { outDir: o.outDir, logId: head.log ?? null, treeSize: head.treeSize, rootHash: head.rootHash, keyid: v.keyid, checkpoints: cps.checkpoints.length, usage, problems };
|
|
71
|
+
writeFileSync(join(o.outDir, "export.json"), JSON.stringify({ exportedAt: new Date().toISOString(), logUrl: base, tenant: o.tenant ?? null, ...result }, null, 2));
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
export function formatExport(r) {
|
|
75
|
+
const lines = [
|
|
76
|
+
`exported ${r.treeSize} leaf hash(es) of log ${r.logId ?? "(unnamed)"} to ${r.outDir}`,
|
|
77
|
+
`head root ${r.rootHash.slice(0, 16)}, signed by ${r.keyid.slice(0, 12)}, ${r.checkpoints} checkpoint(s)`,
|
|
78
|
+
...r.usage.map((u) => `usage ${u.month}: ${u.appends} append(s), ${u.totalLeaves} leaves in total, ${u.liveTokens} live token(s)`),
|
|
79
|
+
"",
|
|
80
|
+
"log.jsonl is a log copy the verifier reads: agent-custody verify <receipt> --log <outDir>/log.jsonl --issuer-key ...",
|
|
81
|
+
...(r.problems.length ? ["", ...r.problems.map((p) => `PROBLEM: ${p}`), "", "RESULT: EXPORT DOES NOT ADD UP"] : ["", "RESULT: EXPORT VERIFIED"]),
|
|
82
|
+
];
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
package/dist/log-sink.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface HttpLogOptions {
|
|
|
25
25
|
hashOnly?: boolean;
|
|
26
26
|
/** attempts on 429 and 5xx; default 3 */
|
|
27
27
|
retries?: number;
|
|
28
|
+
/** how long one append may take before it counts as unreachable; default 10000. A log that accepts and never answers must not hold a call forever. */
|
|
29
|
+
timeoutMs?: number;
|
|
28
30
|
fetch?: typeof fetch;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
@@ -38,6 +40,7 @@ export interface LogConfig {
|
|
|
38
40
|
url: string;
|
|
39
41
|
tokenEnv?: string | undefined;
|
|
40
42
|
hashOnly?: boolean | undefined;
|
|
43
|
+
timeoutMs?: number | undefined;
|
|
41
44
|
} | undefined;
|
|
42
45
|
}
|
|
43
46
|
/** The sink a config asks for: a remote log when `log` is set, otherwise the local file. */
|
|
@@ -75,10 +78,18 @@ export interface LogServerOptions {
|
|
|
75
78
|
/** The address a limit is keyed by: the socket's, or the proxy's forwarded one when the proxy is trusted. */
|
|
76
79
|
export declare function clientAddress(req: IncomingMessage, trustProxy?: boolean): string;
|
|
77
80
|
/** One log as the handler sees it, whatever stands behind it. */
|
|
81
|
+
export interface TenantUsage {
|
|
82
|
+
month: string;
|
|
83
|
+
appends: number;
|
|
84
|
+
totalLeaves: number;
|
|
85
|
+
liveTokens: number;
|
|
86
|
+
}
|
|
78
87
|
export interface ResolvedLog {
|
|
79
88
|
backend: LogBackend;
|
|
80
89
|
logId: string | undefined;
|
|
81
90
|
authorize(token: string | null): Promise<boolean>;
|
|
91
|
+
/** this log's own metering for a month, where the store keeps it */
|
|
92
|
+
usage?(month: string): Promise<TenantUsage>;
|
|
82
93
|
}
|
|
83
94
|
/** Turns the tenant in a path, or null for the root paths, into a log. */
|
|
84
95
|
export interface LogResolver {
|
package/dist/log-sink.js
CHANGED
|
@@ -44,6 +44,7 @@ export function httpLog(url, opts = {}) {
|
|
|
44
44
|
const f = opts.fetch ?? fetch;
|
|
45
45
|
const base = url.endsWith("/") ? url : `${url}/`;
|
|
46
46
|
const attempts = opts.retries ?? 3;
|
|
47
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
47
48
|
return {
|
|
48
49
|
kind: "http",
|
|
49
50
|
where: url,
|
|
@@ -56,6 +57,7 @@ export function httpLog(url, opts = {}) {
|
|
|
56
57
|
method: "POST",
|
|
57
58
|
headers: { "content-type": "application/json", ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) },
|
|
58
59
|
body: JSON.stringify(opts.hashOnly ? { leafHash: leafHash(leaf).toString("hex") } : { leaf }),
|
|
60
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
59
61
|
});
|
|
60
62
|
}
|
|
61
63
|
catch (e) {
|
|
@@ -88,7 +90,7 @@ export function openLog(cfg, key) {
|
|
|
88
90
|
const token = cfg.log.tokenEnv ? process.env[cfg.log.tokenEnv] : undefined;
|
|
89
91
|
if (cfg.log.tokenEnv && !token)
|
|
90
92
|
throw new Error(`log token: environment variable ${cfg.log.tokenEnv} is not set`);
|
|
91
|
-
return httpLog(cfg.log.url, { ...(token === undefined ? {} : { token }), ...(cfg.log.hashOnly ? { hashOnly: true } : {}) });
|
|
93
|
+
return httpLog(cfg.log.url, { ...(token === undefined ? {} : { token }), ...(cfg.log.hashOnly ? { hashOnly: true } : {}), ...(cfg.log.timeoutMs ? { timeoutMs: cfg.log.timeoutMs } : {}) });
|
|
92
94
|
}
|
|
93
95
|
if (!cfg.logFile)
|
|
94
96
|
throw new Error("config needs logFile or log.url");
|
|
@@ -147,6 +149,11 @@ export function postgresResolver(tenancy, opts = {}) {
|
|
|
147
149
|
backend,
|
|
148
150
|
logId: t.logId,
|
|
149
151
|
authorize: async (tok) => (tenant === null && (opts.staticTokens?.length ?? 0) > 0 && tokenMatches(opts.staticTokens, tok)) || (await tenancy.authorize(id, tok)),
|
|
152
|
+
usage: async (month) => {
|
|
153
|
+
const u = await tenancy.usage(month);
|
|
154
|
+
const row = u.tenants.find((t) => t.id === id);
|
|
155
|
+
return { month, appends: row?.appends ?? 0, totalLeaves: row?.totalLeaves ?? 0, liveTokens: row?.liveTokens ?? 0 };
|
|
156
|
+
},
|
|
150
157
|
};
|
|
151
158
|
},
|
|
152
159
|
async tenants() {
|
|
@@ -259,7 +266,7 @@ export function logHandler(source, keyOrSigner, opts = {}) {
|
|
|
259
266
|
}
|
|
260
267
|
}
|
|
261
268
|
// /t/<tenant>/<op> reaches that tenant's log; anything else is the default log.
|
|
262
|
-
const m = /^\/t\/([A-Za-z0-9_.-]+)\/(append|root|consistency|head|checkpoints)$/.exec(url.pathname);
|
|
269
|
+
const m = /^\/t\/([A-Za-z0-9_.-]+)\/(append|root|consistency|head|checkpoints|leaves|usage)$/.exec(url.pathname);
|
|
263
270
|
let which;
|
|
264
271
|
try {
|
|
265
272
|
which = await resolver.resolve(m ? m[1] : null);
|
|
@@ -301,6 +308,27 @@ export function logHandler(source, keyOrSigner, opts = {}) {
|
|
|
301
308
|
return json(200, await appendSigned(log, signer, { leaf: parsed.leaf }, logId));
|
|
302
309
|
}
|
|
303
310
|
const current = await log.size();
|
|
311
|
+
// A tenant's own data, with their token: every leaf hash, in pages, and their metering. The export command
|
|
312
|
+
// pages through these and rebuilds a log file the verifier reads directly.
|
|
313
|
+
if (req.method === "GET" && (url.pathname.endsWith("/leaves") || url.pathname.endsWith("/usage"))) {
|
|
314
|
+
if (!(await which.authorize(bearer(req))))
|
|
315
|
+
return json(401, { error: "unauthorized" });
|
|
316
|
+
if (url.pathname.endsWith("/usage")) {
|
|
317
|
+
if (!which.usage)
|
|
318
|
+
return json(404, { error: "this log keeps no usage" });
|
|
319
|
+
const month = url.searchParams.get("month") ?? new Date().toISOString().slice(0, 7);
|
|
320
|
+
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month))
|
|
321
|
+
return json(400, { error: "month must be YYYY-MM" });
|
|
322
|
+
return json(200, await which.usage(month), { "cache-control": "no-store" });
|
|
323
|
+
}
|
|
324
|
+
const since = url.searchParams.has("since") ? Number(url.searchParams.get("since")) : 0;
|
|
325
|
+
const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 10_000;
|
|
326
|
+
if (!Number.isInteger(since) || since < 0 || since > current)
|
|
327
|
+
return json(400, { error: `since must be an integer in 0..${current}` });
|
|
328
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10_000)
|
|
329
|
+
return json(400, { error: "limit must be an integer in 1..10000" });
|
|
330
|
+
return json(200, { since, size: current, leaves: await log.leafHashes(since, since + limit) }, { "cache-control": "no-store" });
|
|
331
|
+
}
|
|
304
332
|
if (req.method === "GET" && url.pathname.endsWith("/root")) {
|
|
305
333
|
const size = url.searchParams.has("size") ? Number(url.searchParams.get("size")) : current;
|
|
306
334
|
if (!Number.isInteger(size) || size < 0 || size > current)
|
package/dist/log-store.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface LogBackend {
|
|
|
9
9
|
appendHash(leafHashHex: string): Promise<AppendResult>;
|
|
10
10
|
root(size?: number): Promise<string>;
|
|
11
11
|
consistencyProof(oldSize: number, newSize?: number): Promise<string[]>;
|
|
12
|
+
/** leaf hashes from (inclusive) to (exclusive), hex */
|
|
13
|
+
leafHashes(from: number, to: number): Promise<string[]>;
|
|
12
14
|
}
|
|
13
15
|
/** The JSONL file log behind the asynchronous interface. */
|
|
14
16
|
export declare function fileBackend(file: string): LogBackend;
|
|
@@ -51,6 +53,7 @@ export declare class PostgresLog implements LogBackend {
|
|
|
51
53
|
private commit;
|
|
52
54
|
append(leaf: string): Promise<AppendResult>;
|
|
53
55
|
appendHash(leafHashHex: string): Promise<AppendResult>;
|
|
56
|
+
leafHashes(from: number, to: number): Promise<string[]>;
|
|
54
57
|
root(size?: number): Promise<string>;
|
|
55
58
|
consistencyProof(oldSize: number, newSize?: number): Promise<string[]>;
|
|
56
59
|
}
|
package/dist/log-store.js
CHANGED
|
@@ -22,6 +22,9 @@ export function fileBackend(file) {
|
|
|
22
22
|
async root(size) {
|
|
23
23
|
return log.root(size);
|
|
24
24
|
},
|
|
25
|
+
async leafHashes(from, to) {
|
|
26
|
+
return log.leafHashes(from, to);
|
|
27
|
+
},
|
|
25
28
|
async consistencyProof(oldSize, newSize) {
|
|
26
29
|
return log.consistencyProof(oldSize, newSize);
|
|
27
30
|
},
|
|
@@ -146,6 +149,10 @@ export class PostgresLog {
|
|
|
146
149
|
throw new Error("leafHash must be 64 lowercase hex characters");
|
|
147
150
|
return this.commit(Buffer.from(leafHashHex, "hex"));
|
|
148
151
|
}
|
|
152
|
+
async leafHashes(from, to) {
|
|
153
|
+
const n = await this.size();
|
|
154
|
+
return this.hashes.slice(Math.max(0, from), Math.min(to, n)).map((h) => h.toString("hex"));
|
|
155
|
+
}
|
|
149
156
|
async root(size) {
|
|
150
157
|
await this.sync();
|
|
151
158
|
const n = size ?? this.hashes.length;
|
package/dist/log.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export declare class MerkleLog {
|
|
|
31
31
|
private readonly file;
|
|
32
32
|
constructor(file: string);
|
|
33
33
|
get size(): number;
|
|
34
|
+
/** Leaf hashes `from` (inclusive) to `to` (exclusive), as hex: what an export carries and what a copy is rebuilt from. */
|
|
35
|
+
leafHashes(from: number, to?: number): string[];
|
|
34
36
|
/** Appends a leaf (an opaque string, typically a canonical JSON envelope). Returns its proof against the new root. */
|
|
35
37
|
append(leaf: string): InclusionProof & {
|
|
36
38
|
rootHash: string;
|
package/dist/log.js
CHANGED
|
@@ -177,6 +177,10 @@ export class MerkleLog {
|
|
|
177
177
|
get size() {
|
|
178
178
|
return this.hashes.length;
|
|
179
179
|
}
|
|
180
|
+
/** Leaf hashes `from` (inclusive) to `to` (exclusive), as hex: what an export carries and what a copy is rebuilt from. */
|
|
181
|
+
leafHashes(from, to = this.hashes.length) {
|
|
182
|
+
return this.hashes.slice(Math.max(0, from), Math.min(to, this.hashes.length)).map((h) => h.toString("hex"));
|
|
183
|
+
}
|
|
180
184
|
/** Appends a leaf (an opaque string, typically a canonical JSON envelope). Returns its proof against the new root. */
|
|
181
185
|
append(leaf) {
|
|
182
186
|
appendFileSync(this.file, JSON.stringify(leaf) + "\n");
|
package/dist/signer.js
CHANGED
|
@@ -30,7 +30,7 @@ export async function connectSigner(url, opts = {}) {
|
|
|
30
30
|
const f = opts.fetch ?? fetch;
|
|
31
31
|
const base = url.endsWith("/") ? url : `${url}/`;
|
|
32
32
|
const headers = { "content-type": "application/json", ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) };
|
|
33
|
-
const res = await f(new URL("keys", base), { headers });
|
|
33
|
+
const res = await f(new URL("keys", base), { headers, signal: AbortSignal.timeout(10_000) });
|
|
34
34
|
if (!res.ok)
|
|
35
35
|
throw new Error(`signer ${url} refused the key request: ${res.status}`);
|
|
36
36
|
const doc = (await res.json());
|
|
@@ -49,7 +49,7 @@ export async function connectSigner(url, opts = {}) {
|
|
|
49
49
|
return env;
|
|
50
50
|
},
|
|
51
51
|
async keys() {
|
|
52
|
-
const k = await f(new URL("keys", base), { headers });
|
|
52
|
+
const k = await f(new URL("keys", base), { headers, signal: AbortSignal.timeout(10_000) });
|
|
53
53
|
if (!k.ok)
|
|
54
54
|
throw new Error(`signer ${url} refused the key request: ${k.status}`);
|
|
55
55
|
return (await k.json());
|
|
@@ -120,7 +120,7 @@ export function serveSigner(kp, opts) {
|
|
|
120
120
|
}
|
|
121
121
|
/** For verifiers: the keys a log publishes, fetched from its origin and returned as key references pinned by keyid. */
|
|
122
122
|
export async function fetchLogKeys(logUrl, f = fetch) {
|
|
123
|
-
const res = await f(new URL("/.well-known/agent-custody-log.json", logUrl));
|
|
123
|
+
const res = await f(new URL("/.well-known/agent-custody-log.json", logUrl), { signal: AbortSignal.timeout(10_000) });
|
|
124
124
|
if (!res.ok)
|
|
125
125
|
throw new Error(`log ${logUrl} serves no key document: ${res.status}`);
|
|
126
126
|
const doc = (await res.json());
|
package/dist/witness.js
CHANGED
|
@@ -145,7 +145,7 @@ export class Witness {
|
|
|
145
145
|
}
|
|
146
146
|
/** For verifiers: the witness's published keys, fetched from its host and pinned by keyid. */
|
|
147
147
|
export async function fetchWitnessKeys(witnessUrl, f = fetch) {
|
|
148
|
-
const res = await f(new URL("/.well-known/agent-custody-witness.json", witnessUrl));
|
|
148
|
+
const res = await f(new URL("/.well-known/agent-custody-witness.json", witnessUrl), { signal: AbortSignal.timeout(10_000) });
|
|
149
149
|
if (!res.ok)
|
|
150
150
|
throw new Error(`witness ${witnessUrl} serves no key document: ${res.status}`);
|
|
151
151
|
const doc = (await res.json());
|
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. 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.
|
|
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`. An append that gets no answer within `timeoutMs` (default 10000) counts as unreachable and is retried like a server error, so a log that accepts connections and never answers cannot hold a call forever. 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. A tenant needs none of that to leave with their evidence: `agent-custody log-export --log-url <url> --tenant <name> --token-env AGENT_CUSTODY_LOG_TOKEN --out <dir>` fetches, with their own token, every leaf hash (`GET /t/<name>/leaves?since=&limit=`, pages of up to ten thousand), the signed head, the published keys, the signed checkpoints, and their own usage (`GET /t/<name>/usage?month=`), checks that the head and every checkpoint verify against the keys and that the leaves fetched hash to their roots, and writes `log.jsonl` in the format `verify --log` and `audit --log` read, so the export verifies receipts with no server at all. It exits 1 and says what did not add up if anything does not. Both routes answer only to that tenant's token. [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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-custody/receipts",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
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": {
|