@agent-custody/receipts 0.5.4 → 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 +2 -0
- package/dist/cli.js +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/log-admin.d.ts +2 -0
- package/dist/log-admin.js +25 -1
- package/dist/log-check.d.ts +25 -0
- package/dist/log-check.js +118 -0
- package/dist/log-sink.js +11 -0
- package/dist/log-store.d.ts +15 -0
- package/dist/log-store.js +19 -0
- package/docs/usage.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -225,6 +225,7 @@ If a vendor tells you their receipts prove more than the first five rows, ask th
|
|
|
225
225
|
src/config.ts gateway and SDK config schemas, path resolution
|
|
226
226
|
src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
|
|
227
227
|
src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
|
|
228
|
+
src/log-check.ts the outside monitor: verifies the head, checkpoints, and witness of a running log
|
|
228
229
|
src/witness.ts the witness: countersigns the log's checkpoints from another operator's machine, or refuses with an alarm
|
|
229
230
|
src/signer.ts the signer: the log's key in its own process, the key document verifiers fetch
|
|
230
231
|
src/checkpoints.ts signed heads published on a schedule, to files and to Postgres
|
|
@@ -281,6 +282,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
|
|
|
281
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.
|
|
282
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).
|
|
283
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.
|
|
284
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).
|
|
285
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).
|
|
286
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).
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ 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
13
|
import { fetchWitnessKeys, Witness } from "./witness.js";
|
|
14
|
+
import { checkLog, formatLogCheck } from "./log-check.js";
|
|
14
15
|
import { CheckpointPublisher, fileResolver } from "./log-sink.js";
|
|
15
16
|
import { createRequire } from "node:module";
|
|
16
17
|
import { pruneLog } from "./retention.js";
|
|
@@ -48,6 +49,10 @@ const USAGE = `agent-custody <command>
|
|
|
48
49
|
log ... --db-env NAME --admin-token-env NAME [--public-url <https://log.example.com/>] [--checkpoints-url <https://checkpoints.example.com/>]
|
|
49
50
|
the operator's admin page at /admin and its API, behind the admin token: tenants, tokens shown once,
|
|
50
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.
|
|
51
56
|
witness --key <witness.key> --log-url <url> --checkpoints-url <url> --out <dir> [--tenant <name>]... [--every <seconds>] [--once]
|
|
52
57
|
a second signer, run by someone who is not the log's operator: fetches the log's latest
|
|
53
58
|
checkpoint per watched log, proves it extends the last one it signed, and countersigns it
|
|
@@ -164,6 +169,14 @@ async function main(argv) {
|
|
|
164
169
|
await running.close();
|
|
165
170
|
return 0;
|
|
166
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
|
+
}
|
|
167
180
|
case "witness": {
|
|
168
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 } } });
|
|
169
182
|
if (!values.key || !values["log-url"] || !values["checkpoints-url"] || !values.out)
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signe
|
|
|
9
9
|
export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
|
|
10
10
|
export { adminRoutes, welcomeSheet } from "./log-admin.ts";
|
|
11
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";
|
|
12
14
|
export type { WitnessOptions, WitnessOutcome, WitnessedCheckpoint } from "./witness.ts";
|
|
13
15
|
export type { AuditOptions } from "./verify.ts";
|
|
14
16
|
export type { AdminOptions } from "./log-admin.ts";
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler }
|
|
|
7
7
|
export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.js";
|
|
8
8
|
export { adminRoutes, welcomeSheet } from "./log-admin.js";
|
|
9
9
|
export { fetchWitnessKeys, Witness } from "./witness.js";
|
|
10
|
+
export { checkLog, formatLogCheck } from "./log-check.js";
|
|
10
11
|
export * from "./config.js";
|
|
11
12
|
export * from "./crypto.js";
|
|
12
13
|
export * from "./delegation.js";
|
package/dist/log-admin.d.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/log-store.d.ts
CHANGED
|
@@ -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/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. [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`. 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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-custody/receipts",
|
|
3
|
-
"version": "0.5.
|
|
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": {
|