@agent-custody/receipts 0.5.7 → 0.5.8
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 +11 -4
- package/dist/index.d.ts +1 -0
- package/dist/log-admin.js +30 -10
- package/dist/log-export.d.ts +2 -0
- package/dist/log-export.js +10 -2
- package/dist/log-sink.d.ts +3 -1
- package/dist/log-sink.js +11 -2
- package/dist/log-store.d.ts +20 -4
- package/dist/log-store.js +24 -5
- package/docs/threat-model.md +114 -0
- package/docs/usage.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ Anyone holding the public keys can verify a receipt offline. The agent is not tr
|
|
|
15
15
|
- [Writing policies](docs/policies.md): how a tool call becomes a Cedar request, with tested examples
|
|
16
16
|
- [Verifying a receipt](docs/verification.md): what each check means and what a verified receipt does and does not prove
|
|
17
17
|
- [What the evidence satisfies](docs/compliance.md): the receipts, packs, and certificates mapped to SOC 2, ISO 27001, the EU AI Act, and UK GDPR, with what none of them claims
|
|
18
|
+
- [Threat model](docs/threat-model.md): every party who could make a receipt false, the move, what stops it, and whether that is a property of the evidence or of the deployment; and what is not defended
|
|
18
19
|
|
|
19
20
|
## Getting started
|
|
20
21
|
|
|
@@ -285,6 +286,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
|
|
|
285
286
|
- 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.
|
|
286
287
|
- 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).
|
|
287
288
|
|
|
289
|
+
- An audit trail of administrative actions: every tenant created or disabled and every token minted or revoked is recorded with who did it, from the admin page or the command line, shown on the page and carried in the tenant's export.
|
|
288
290
|
- 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.
|
|
289
291
|
- 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.
|
|
290
292
|
- 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).
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { hostname, userInfo } from "node:os";
|
|
2
3
|
import { parseArgs } from "node:util";
|
|
3
4
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { dirname, resolve } from "node:path";
|
|
@@ -65,6 +66,7 @@ const USAGE = `agent-custody <command>
|
|
|
65
66
|
the one process that holds the log's key: POST /sign, GET /keys
|
|
66
67
|
log-admin --db-env NAME tenant add <id> [--log-id <id>] | tenant list | tenant disable <id>
|
|
67
68
|
log-admin --db-env NAME token add <tenant> --label <text> | token list <tenant> | token revoke <tenant> <hash-prefix>
|
|
69
|
+
log-admin --db-env NAME audit [--tenant <id>] who did what to tenants and tokens, newest first
|
|
68
70
|
log-admin --db-env NAME import --file <log.jsonl> [--tenant default] copies a file log into the database as hashes
|
|
69
71
|
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]
|
|
70
72
|
with --log-url the log's published keys are fetched and pinned by keyid; with a witness key or
|
|
@@ -232,8 +234,9 @@ async function main(argv) {
|
|
|
232
234
|
throw new Error("log-admin needs --db-env NAME");
|
|
233
235
|
const tenancy = new PostgresTenancy(openPostgres(values["db-env"]));
|
|
234
236
|
const [what, verb, ...args] = positionals;
|
|
237
|
+
const actor = `cli:${userInfo().username}@${hostname()}`;
|
|
235
238
|
if (what === "tenant" && verb === "add" && args[0]) {
|
|
236
|
-
const t = await tenancy.addTenant(args[0], values["log-id"] ?? args[0]);
|
|
239
|
+
const t = await tenancy.addTenant(args[0], values["log-id"] ?? args[0], actor);
|
|
237
240
|
console.log(`tenant ${t.id} log=${t.logId} reached at /t/${t.id}/`);
|
|
238
241
|
}
|
|
239
242
|
else if (what === "tenant" && verb === "list") {
|
|
@@ -241,13 +244,13 @@ async function main(argv) {
|
|
|
241
244
|
console.log(`${t.id.padEnd(24)} log=${t.logId.padEnd(28)} created ${t.createdAt}${t.disabledAt ? ` DISABLED ${t.disabledAt}` : ""}`);
|
|
242
245
|
}
|
|
243
246
|
else if (what === "tenant" && verb === "disable" && args[0]) {
|
|
244
|
-
await tenancy.disableTenant(args[0]);
|
|
247
|
+
await tenancy.disableTenant(args[0], actor);
|
|
245
248
|
console.log(`tenant ${args[0]} disabled`);
|
|
246
249
|
}
|
|
247
250
|
else if (what === "token" && verb === "add" && args[0]) {
|
|
248
251
|
if (!values.label)
|
|
249
252
|
throw new Error("token add needs --label");
|
|
250
|
-
const { token, tokenHash } = await tenancy.addToken(args[0], values.label);
|
|
253
|
+
const { token, tokenHash } = await tenancy.addToken(args[0], values.label, actor);
|
|
251
254
|
console.error(`token for ${args[0]} (${values.label}); shown once, stored as hash ${tokenHash.slice(0, 12)}…:`);
|
|
252
255
|
console.log(token);
|
|
253
256
|
}
|
|
@@ -256,7 +259,11 @@ async function main(argv) {
|
|
|
256
259
|
console.log(`${t.tokenHash.slice(0, 12)} ${t.label.padEnd(24)} created ${t.createdAt}${t.revokedAt ? ` REVOKED ${t.revokedAt}` : ""}`);
|
|
257
260
|
}
|
|
258
261
|
else if (what === "token" && verb === "revoke" && args[0] && args[1]) {
|
|
259
|
-
console.log(`revoked ${await tenancy.revokeToken(args[0], args[1])} token(s)`);
|
|
262
|
+
console.log(`revoked ${await tenancy.revokeToken(args[0], args[1], actor)} token(s)`);
|
|
263
|
+
}
|
|
264
|
+
else if (what === "audit") {
|
|
265
|
+
for (const e of await tenancy.audit({ ...(values.tenant !== "default" ? { tenant: values.tenant } : {}), limit: 100 }))
|
|
266
|
+
console.log(`${e.at} ${e.actor.padEnd(40)} ${e.action.padEnd(14)} ${(e.tenantId ?? "").padEnd(20)} ${Object.entries(e.detail).map(([k, v]) => `${k}=${v}`).join(" ")}`);
|
|
260
267
|
}
|
|
261
268
|
else if (what === "import") {
|
|
262
269
|
if (!values.file)
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { hecEvent, splunkExporter } from "./splunk.ts";
|
|
|
7
7
|
export { exportLog, formatExport } from "./log-export.ts";
|
|
8
8
|
export type { ExportOptions, ExportResult } from "./log-export.ts";
|
|
9
9
|
export { fileBackend, importLogFile, PostgresLog, PostgresTenancy, RateLimiter } from "./log-store.ts";
|
|
10
|
+
export type { AuditEntry } from "./log-store.ts";
|
|
10
11
|
export { connectSigner, fetchLogKeys, localSigner, serveSigner, signerHandler } from "./signer.ts";
|
|
11
12
|
export type { KeyDocument, RemoteSignerOptions, RetiredKey, RunningSigner, Signer, SignerServerOptions } from "./signer.ts";
|
|
12
13
|
export { bothCheckpoints, dirCheckpoints, postgresCheckpoints } from "./checkpoints.ts";
|
package/dist/log-admin.js
CHANGED
|
@@ -64,11 +64,11 @@ export function adminRoutes(opts) {
|
|
|
64
64
|
const presented = (req) => {
|
|
65
65
|
const h = req.headers.authorization ?? "";
|
|
66
66
|
if (h.startsWith("Bearer ") && h.length > 7)
|
|
67
|
-
return h.slice(7);
|
|
67
|
+
return { token: h.slice(7), user: null };
|
|
68
68
|
if (h.startsWith("Basic ") && h.length > 6) {
|
|
69
69
|
const pair = Buffer.from(h.slice(6), "base64").toString();
|
|
70
70
|
const at = pair.indexOf(":");
|
|
71
|
-
return at >= 0 ? pair.slice(at + 1) : pair;
|
|
71
|
+
return at >= 0 ? { token: pair.slice(at + 1), user: pair.slice(0, at) } : { token: pair, user: null };
|
|
72
72
|
}
|
|
73
73
|
return null;
|
|
74
74
|
};
|
|
@@ -81,7 +81,7 @@ export function adminRoutes(opts) {
|
|
|
81
81
|
};
|
|
82
82
|
const addr = clientAddress(req, opts.trustProxy);
|
|
83
83
|
const given = presented(req);
|
|
84
|
-
if (given === null || !same(given, opts.token)) {
|
|
84
|
+
if (given === null || !same(given.token, opts.token)) {
|
|
85
85
|
if (!failures.take(`admin:${addr}`))
|
|
86
86
|
return json(429, { error: "too many attempts; wait a minute" }, { "retry-after": "60" }), true;
|
|
87
87
|
// The challenge makes the browser ask; the same 401 tells an API client what is missing.
|
|
@@ -101,6 +101,10 @@ export function adminRoutes(opts) {
|
|
|
101
101
|
}
|
|
102
102
|
return text ? JSON.parse(text) : {};
|
|
103
103
|
};
|
|
104
|
+
// Who did it, for the audit trail: the user name the browser prompt asked for (any name, but it is recorded), or
|
|
105
|
+
// "bearer" for an API client, and the address either came from. There is one admin token; the name is what
|
|
106
|
+
// tells two operators apart.
|
|
107
|
+
const actor = `admin:${given.user?.replace(/[^\w.@-]/g, "").slice(0, 64) || "bearer"}@${addr}`;
|
|
104
108
|
try {
|
|
105
109
|
const t = opts.tenancy;
|
|
106
110
|
const parts = url.pathname.split("/").filter(Boolean); // ["admin", ...]
|
|
@@ -114,6 +118,13 @@ export function adminRoutes(opts) {
|
|
|
114
118
|
res.writeHead(200, { "content-type": "text/csv; charset=utf-8", "content-disposition": `attachment; filename="agent-custody-usage-${u.month}.csv"`, "cache-control": "no-store" });
|
|
115
119
|
res.end(csv);
|
|
116
120
|
}
|
|
121
|
+
else if (req.method === "GET" && parts.length === 2 && parts[1] === "audit") {
|
|
122
|
+
const tenant = url.searchParams.get("tenant");
|
|
123
|
+
const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 200;
|
|
124
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
125
|
+
return json(400, { error: "limit must be an integer in 1..1000" }), true;
|
|
126
|
+
json(200, { entries: await t.audit({ ...(tenant ? { tenant } : {}), limit }) });
|
|
127
|
+
}
|
|
117
128
|
else if (req.method === "GET" && parts.length === 2 && parts[1] === "info") {
|
|
118
129
|
json(200, { publicUrl: opts.publicUrl ?? null, checkpointsUrl: opts.checkpointsUrl ?? null, keyid: opts.keyid ?? null });
|
|
119
130
|
}
|
|
@@ -125,10 +136,10 @@ export function adminRoutes(opts) {
|
|
|
125
136
|
const b = await body();
|
|
126
137
|
if (typeof b.id !== "string" || !/^[A-Za-z0-9_.-]+$/.test(b.id))
|
|
127
138
|
return json(400, { error: "id must be a plain identifier" }), true;
|
|
128
|
-
json(200, await t.addTenant(b.id, typeof b.logId === "string" && b.logId ? b.logId : b.id));
|
|
139
|
+
json(200, await t.addTenant(b.id, typeof b.logId === "string" && b.logId ? b.logId : b.id, actor));
|
|
129
140
|
}
|
|
130
141
|
else if (req.method === "POST" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "disable") {
|
|
131
|
-
await t.disableTenant(parts[2]);
|
|
142
|
+
await t.disableTenant(parts[2], actor);
|
|
132
143
|
json(200, { disabled: parts[2] });
|
|
133
144
|
}
|
|
134
145
|
else if (req.method === "GET" && parts.length === 4 && parts[1] === "tenants" && parts[3] === "tokens") {
|
|
@@ -140,12 +151,12 @@ export function adminRoutes(opts) {
|
|
|
140
151
|
const tenant = await t.tenant(parts[2]);
|
|
141
152
|
if (!tenant)
|
|
142
153
|
return json(404, { error: "unknown tenant" }), true;
|
|
143
|
-
const minted = await t.addToken(tenant.id, label);
|
|
154
|
+
const minted = await t.addToken(tenant.id, label, actor);
|
|
144
155
|
const welcome = opts.publicUrl ? welcomeSheet({ tenant: tenant.id, logId: tenant.logId, publicUrl: opts.publicUrl, ...(opts.checkpointsUrl ? { checkpointsUrl: opts.checkpointsUrl } : {}), ...(opts.keyid ? { keyid: opts.keyid } : {}) }) : null;
|
|
145
156
|
json(200, { ...minted, welcome });
|
|
146
157
|
}
|
|
147
158
|
else if (req.method === "POST" && parts.length === 6 && parts[1] === "tenants" && parts[3] === "tokens" && parts[5] === "revoke") {
|
|
148
|
-
json(200, { revoked: await t.revokeToken(parts[2], parts[4]) });
|
|
159
|
+
json(200, { revoked: await t.revokeToken(parts[2], parts[4], actor) });
|
|
149
160
|
}
|
|
150
161
|
else {
|
|
151
162
|
json(404, { error: "not found" });
|
|
@@ -214,6 +225,9 @@ const ADMIN_PAGE = `<!doctype html>
|
|
|
214
225
|
<h2>Tokens of a tenant</h2>
|
|
215
226
|
<div class="row"><label>tenant<input id="ltid" placeholder="acme" autocomplete="off"></label><button class="quiet" id="listTokens">List</button></div>
|
|
216
227
|
<table><thead><tr><th>label</th><th>hash</th><th>created</th><th>state</th><th></th></tr></thead><tbody id="tokens"></tbody></table>
|
|
228
|
+
<h2>Activity</h2>
|
|
229
|
+
<p class="muted">Every tenant and token change on this log, newest first, with who made it: the name entered at the sign-in prompt, or the command line on the server. Tenants see their own rows in their export.</p>
|
|
230
|
+
<table><thead><tr><th>when</th><th>who</th><th>action</th><th>tenant</th><th>detail</th></tr></thead><tbody id="audit"></tbody></table>
|
|
217
231
|
<p class="muted" id="msg"></p>
|
|
218
232
|
</section>
|
|
219
233
|
</main>
|
|
@@ -244,15 +258,17 @@ const ADMIN_PAGE = `<!doctype html>
|
|
|
244
258
|
$("where").textContent = (info.publicUrl || location.origin) + " · keyid " + (info.keyid ? info.keyid.slice(0, 12) : "?") + (info.checkpointsUrl ? " · checkpoints at " + info.checkpointsUrl : "");
|
|
245
259
|
await loadTenants();
|
|
246
260
|
await loadUsage();
|
|
261
|
+
await loadAudit();
|
|
247
262
|
} catch (e) { say(e.message, "err"); }
|
|
248
263
|
};
|
|
249
|
-
$("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"); } };
|
|
264
|
+
$("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(); await loadAudit(); } catch (e) { say(e.message, "err"); } };
|
|
250
265
|
$("mint").onclick = async () => {
|
|
251
266
|
try {
|
|
252
267
|
const r = await api("POST", "/admin/tenants/" + encodeURIComponent($("ttid").value.trim()) + "/tokens", { label: $("label").value.trim() });
|
|
253
268
|
$("tokval").textContent = r.token; $("sheet").textContent = r.welcome || "(set --public-url on the server for the welcome sheet)"; $("minted").hidden = false;
|
|
254
269
|
say("token minted for " + $("ttid").value.trim() + "; stored as hash " + r.tokenHash.slice(0, 12), "ok");
|
|
255
270
|
await loadTenants();
|
|
271
|
+
await loadAudit();
|
|
256
272
|
} catch (e) { say(e.message, "err"); }
|
|
257
273
|
};
|
|
258
274
|
$("copyTok").onclick = () => navigator.clipboard.writeText($("tokval").textContent).then(() => say("token copied", "ok"));
|
|
@@ -264,12 +280,16 @@ const ADMIN_PAGE = `<!doctype html>
|
|
|
264
280
|
$("csv").href = "/admin/usage.csv?month=" + encodeURIComponent(month);
|
|
265
281
|
$("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>";
|
|
266
282
|
};
|
|
283
|
+
const loadAudit = async () => {
|
|
284
|
+
const a = await api("GET", "/admin/audit?limit=100");
|
|
285
|
+
$("audit").innerHTML = a.entries.map((e) => "<tr><td>" + esc(e.at.replace("T", " ").slice(0, 19)) + "</td><td><code>" + esc(e.actor) + "</code></td><td>" + esc(e.action) + "</td><td><code>" + esc(e.tenantId || "") + "</code></td><td class=muted>" + esc(Object.entries(e.detail).map(([k, v]) => k + "=" + v).join(" ")) + "</td></tr>").join("") || "<tr><td colspan=5 class=muted>nothing yet</td></tr>";
|
|
286
|
+
};
|
|
267
287
|
$("loadUsage").onclick = () => loadUsage().catch((e) => say(e.message, "err"));
|
|
268
288
|
$("month").value = new Date().toISOString().slice(0, 7);
|
|
269
289
|
document.addEventListener("click", async (e) => {
|
|
270
290
|
const b = e.target.closest("button"); if (!b) return;
|
|
271
|
-
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"); } }
|
|
272
|
-
if (b.dataset.revoke) { const [id, prefix] = b.dataset.revoke.split("|"); if (confirm("Revoke token " + prefix + " of " + id + "?")) { try { await api("POST", "/admin/tenants/" + encodeURIComponent(id) + "/tokens/" + prefix + "/revoke"); await loadTokens(id); await loadTenants(); say("revoked", "ok"); } catch (err) { say(err.message, "err"); } } }
|
|
291
|
+
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(); await loadAudit(); say("disabled " + b.dataset.disable, "ok"); } catch (err) { say(err.message, "err"); } }
|
|
292
|
+
if (b.dataset.revoke) { const [id, prefix] = b.dataset.revoke.split("|"); if (confirm("Revoke token " + prefix + " of " + id + "?")) { try { await api("POST", "/admin/tenants/" + encodeURIComponent(id) + "/tokens/" + prefix + "/revoke"); await loadTokens(id); await loadTenants(); await loadAudit(); say("revoked", "ok"); } catch (err) { say(err.message, "err"); } } }
|
|
273
293
|
});
|
|
274
294
|
enter();
|
|
275
295
|
})();
|
package/dist/log-export.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface ExportResult {
|
|
|
15
15
|
rootHash: string;
|
|
16
16
|
keyid: string;
|
|
17
17
|
checkpoints: number;
|
|
18
|
+
/** administrative actions on this tenant: tokens minted and revoked, the tenant created or disabled, by whom */
|
|
19
|
+
audit: number;
|
|
18
20
|
usage: {
|
|
19
21
|
month: string;
|
|
20
22
|
appends: number;
|
package/dist/log-export.js
CHANGED
|
@@ -61,20 +61,28 @@ export async function exportLog(o) {
|
|
|
61
61
|
problems.push(`usage for ${month}: ${e instanceof Error ? e.message : String(e)}`);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
let audit = [];
|
|
65
|
+
try {
|
|
66
|
+
audit = (await get(new URL("?limit=1000", path("audit")), true)).entries;
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
problems.push(`audit trail: ${e instanceof Error ? e.message : String(e)}`);
|
|
70
|
+
}
|
|
64
71
|
mkdirSync(o.outDir, { recursive: true });
|
|
65
72
|
writeFileSync(join(o.outDir, "log.jsonl"), leaves.map((h) => JSON.stringify({ hash: h })).join("\n") + (leaves.length ? "\n" : ""));
|
|
66
73
|
writeFileSync(join(o.outDir, "head.json"), JSON.stringify({ treeHead, ...head }, null, 2));
|
|
67
74
|
writeFileSync(join(o.outDir, "keys.json"), JSON.stringify(doc, null, 2));
|
|
68
75
|
writeFileSync(join(o.outDir, "checkpoints.json"), JSON.stringify(cps.checkpoints, null, 2));
|
|
69
76
|
writeFileSync(join(o.outDir, "usage.json"), JSON.stringify(usage, null, 2));
|
|
70
|
-
|
|
77
|
+
writeFileSync(join(o.outDir, "audit.json"), JSON.stringify(audit, null, 2));
|
|
78
|
+
const result = { outDir: o.outDir, logId: head.log ?? null, treeSize: head.treeSize, rootHash: head.rootHash, keyid: v.keyid, checkpoints: cps.checkpoints.length, audit: audit.length, usage, problems };
|
|
71
79
|
writeFileSync(join(o.outDir, "export.json"), JSON.stringify({ exportedAt: new Date().toISOString(), logUrl: base, tenant: o.tenant ?? null, ...result }, null, 2));
|
|
72
80
|
return result;
|
|
73
81
|
}
|
|
74
82
|
export function formatExport(r) {
|
|
75
83
|
const lines = [
|
|
76
84
|
`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)`,
|
|
85
|
+
`head root ${r.rootHash.slice(0, 16)}, signed by ${r.keyid.slice(0, 12)}, ${r.checkpoints} checkpoint(s), ${r.audit} administrative action(s) on this tenant`,
|
|
78
86
|
...r.usage.map((u) => `usage ${u.month}: ${u.appends} append(s), ${u.totalLeaves} leaves in total, ${u.liveTokens} live token(s)`),
|
|
79
87
|
"",
|
|
80
88
|
"log.jsonl is a log copy the verifier reads: agent-custody verify <receipt> --log <outDir>/log.jsonl --issuer-key ...",
|
package/dist/log-sink.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { type Envelope, type KeyPair } from "./crypto.ts";
|
|
3
3
|
import { type InclusionProof } from "./log.ts";
|
|
4
|
-
import { type LogBackend, type PostgresTenancy, type RateLimitOptions } from "./log-store.ts";
|
|
4
|
+
import { type AuditEntry, type LogBackend, type PostgresTenancy, type RateLimitOptions } from "./log-store.ts";
|
|
5
5
|
import { type Signer } from "./signer.ts";
|
|
6
6
|
import type { Checkpoint, CheckpointStore } from "./checkpoints.ts";
|
|
7
7
|
import { type AdminOptions } from "./log-admin.ts";
|
|
@@ -90,6 +90,8 @@ export interface ResolvedLog {
|
|
|
90
90
|
authorize(token: string | null): Promise<boolean>;
|
|
91
91
|
/** this log's own metering for a month, where the store keeps it */
|
|
92
92
|
usage?(month: string): Promise<TenantUsage>;
|
|
93
|
+
/** administrative actions on this log, newest first, where the store keeps them */
|
|
94
|
+
audit?(limit: number): Promise<AuditEntry[]>;
|
|
93
95
|
}
|
|
94
96
|
/** Turns the tenant in a path, or null for the root paths, into a log. */
|
|
95
97
|
export interface LogResolver {
|
package/dist/log-sink.js
CHANGED
|
@@ -154,6 +154,7 @@ export function postgresResolver(tenancy, opts = {}) {
|
|
|
154
154
|
const row = u.tenants.find((t) => t.id === id);
|
|
155
155
|
return { month, appends: row?.appends ?? 0, totalLeaves: row?.totalLeaves ?? 0, liveTokens: row?.liveTokens ?? 0 };
|
|
156
156
|
},
|
|
157
|
+
audit: (limit) => tenancy.audit({ tenant: id, limit }),
|
|
157
158
|
};
|
|
158
159
|
},
|
|
159
160
|
async tenants() {
|
|
@@ -266,7 +267,7 @@ export function logHandler(source, keyOrSigner, opts = {}) {
|
|
|
266
267
|
}
|
|
267
268
|
}
|
|
268
269
|
// /t/<tenant>/<op> reaches that tenant's log; anything else is the default log.
|
|
269
|
-
const m = /^\/t\/([A-Za-z0-9_.-]+)\/(append|root|consistency|head|checkpoints|leaves|usage)$/.exec(url.pathname);
|
|
270
|
+
const m = /^\/t\/([A-Za-z0-9_.-]+)\/(append|root|consistency|head|checkpoints|leaves|usage|audit)$/.exec(url.pathname);
|
|
270
271
|
let which;
|
|
271
272
|
try {
|
|
272
273
|
which = await resolver.resolve(m ? m[1] : null);
|
|
@@ -310,9 +311,17 @@ export function logHandler(source, keyOrSigner, opts = {}) {
|
|
|
310
311
|
const current = await log.size();
|
|
311
312
|
// A tenant's own data, with their token: every leaf hash, in pages, and their metering. The export command
|
|
312
313
|
// 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 (req.method === "GET" && (url.pathname.endsWith("/leaves") || url.pathname.endsWith("/usage") || url.pathname.endsWith("/audit"))) {
|
|
314
315
|
if (!(await which.authorize(bearer(req))))
|
|
315
316
|
return json(401, { error: "unauthorized" });
|
|
317
|
+
if (url.pathname.endsWith("/audit")) {
|
|
318
|
+
if (!which.audit)
|
|
319
|
+
return json(404, { error: "this log keeps no audit trail" });
|
|
320
|
+
const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 200;
|
|
321
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
322
|
+
return json(400, { error: "limit must be an integer in 1..1000" });
|
|
323
|
+
return json(200, { entries: await which.audit(limit) }, { "cache-control": "no-store" });
|
|
324
|
+
}
|
|
316
325
|
if (url.pathname.endsWith("/usage")) {
|
|
317
326
|
if (!which.usage)
|
|
318
327
|
return json(404, { error: "this log keeps no usage" });
|
package/dist/log-store.d.ts
CHANGED
|
@@ -71,6 +71,15 @@ export interface TokenRecord {
|
|
|
71
71
|
createdAt: string;
|
|
72
72
|
revokedAt: string | null;
|
|
73
73
|
}
|
|
74
|
+
/** One administrative action: who did what to which tenant, when. Written by every mutation and never deleted. */
|
|
75
|
+
export interface AuditEntry {
|
|
76
|
+
id: number;
|
|
77
|
+
at: string;
|
|
78
|
+
actor: string;
|
|
79
|
+
action: "tenant.add" | "tenant.disable" | "token.add" | "token.revoke";
|
|
80
|
+
tenantId: string | null;
|
|
81
|
+
detail: Record<string, unknown>;
|
|
82
|
+
}
|
|
74
83
|
/** Tenants and their tokens, in Postgres. Tokens are stored hashed; a lookup hashes what the caller presented. */
|
|
75
84
|
export declare class PostgresTenancy {
|
|
76
85
|
private readonly client;
|
|
@@ -81,6 +90,12 @@ export declare class PostgresTenancy {
|
|
|
81
90
|
private ready;
|
|
82
91
|
constructor(client: PostgresLike, opts?: PostgresLogOptions);
|
|
83
92
|
private init;
|
|
93
|
+
private record;
|
|
94
|
+
/** Administrative actions, newest first; for one tenant when given. What the admin page shows and a tenant's export carries. */
|
|
95
|
+
audit(opts?: {
|
|
96
|
+
tenant?: string;
|
|
97
|
+
limit?: number;
|
|
98
|
+
}): Promise<AuditEntry[]>;
|
|
84
99
|
private row;
|
|
85
100
|
/** The tenant, or null. Answers from a ten-second cache, so a disabled tenant is refused within that. */
|
|
86
101
|
tenant(id: string): Promise<Tenant | null>;
|
|
@@ -88,16 +103,17 @@ export declare class PostgresTenancy {
|
|
|
88
103
|
authorize(tenantId: string, token: string | null): Promise<boolean>;
|
|
89
104
|
/** The tenant's log, one instance per tenant per process. */
|
|
90
105
|
log(tenantId: string): Promise<PostgresLog>;
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
/** Creates a tenant, or renames its log id. `by` names who did it in the audit trail. */
|
|
107
|
+
addTenant(id: string, logId?: string, by?: string): Promise<Tenant>;
|
|
108
|
+
disableTenant(id: string, by?: string): Promise<void>;
|
|
93
109
|
listTenants(): Promise<Tenant[]>;
|
|
94
110
|
/** Mints a token for a tenant. The token is returned once and stored only as its hash. */
|
|
95
|
-
addToken(tenantId: string, label: string): Promise<{
|
|
111
|
+
addToken(tenantId: string, label: string, by?: string): Promise<{
|
|
96
112
|
token: string;
|
|
97
113
|
tokenHash: string;
|
|
98
114
|
}>;
|
|
99
115
|
/** Revokes the tokens of a tenant whose hash starts with the prefix; returns how many. */
|
|
100
|
-
revokeToken(tenantId: string, hashPrefix: string): Promise<number>;
|
|
116
|
+
revokeToken(tenantId: string, hashPrefix: string, by?: string): Promise<number>;
|
|
101
117
|
/**
|
|
102
118
|
* Appends per tenant for one month, YYYY-MM in UTC, plus each tenant's total leaves and live tokens: the numbers
|
|
103
119
|
* any pricing rests on. One query on the leaves table, grouped; tenants with no appends that month show zero.
|
package/dist/log-store.js
CHANGED
|
@@ -190,10 +190,23 @@ export class PostgresTenancy {
|
|
|
190
190
|
await PostgresLog.ensureSchema(this.client, p);
|
|
191
191
|
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}tenants (id TEXT PRIMARY KEY, log_id TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), disabled_at TIMESTAMPTZ)`);
|
|
192
192
|
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}tokens (token_hash TEXT PRIMARY KEY, tenant_id TEXT NOT NULL REFERENCES ${p}tenants(id), label TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ)`);
|
|
193
|
+
await this.client.query(`CREATE TABLE IF NOT EXISTS ${p}audit (id BIGSERIAL PRIMARY KEY, at TIMESTAMPTZ NOT NULL DEFAULT now(), actor TEXT NOT NULL, action TEXT NOT NULL, tenant_id TEXT, detail JSONB NOT NULL DEFAULT '{}')`);
|
|
193
194
|
})();
|
|
194
195
|
}
|
|
195
196
|
return this.ready;
|
|
196
197
|
}
|
|
198
|
+
async record(actor, action, tenantId, detail) {
|
|
199
|
+
await this.client.query(`INSERT INTO ${this.prefix}audit (actor, action, tenant_id, detail) VALUES ($1, $2, $3, $4)`, [actor ?? "unattributed", action, tenantId, JSON.stringify(detail)]);
|
|
200
|
+
}
|
|
201
|
+
/** Administrative actions, newest first; for one tenant when given. What the admin page shows and a tenant's export carries. */
|
|
202
|
+
async audit(opts = {}) {
|
|
203
|
+
await this.init();
|
|
204
|
+
const limit = Math.min(Math.max(1, opts.limit ?? 200), 1000);
|
|
205
|
+
const rows = (opts.tenant
|
|
206
|
+
? await this.client.query(`SELECT id, at, actor, action, tenant_id, detail FROM ${this.prefix}audit WHERE tenant_id = $1 ORDER BY id DESC LIMIT ${limit}`, [opts.tenant])
|
|
207
|
+
: await this.client.query(`SELECT id, at, actor, action, tenant_id, detail FROM ${this.prefix}audit ORDER BY id DESC LIMIT ${limit}`)).rows;
|
|
208
|
+
return rows.map((r) => ({ id: Number(r.id), at: new Date(r.at).toISOString(), actor: String(r.actor), action: r.action, tenantId: r.tenant_id === null || r.tenant_id === undefined ? null : String(r.tenant_id), detail: (typeof r.detail === "string" ? JSON.parse(r.detail) : r.detail) }));
|
|
209
|
+
}
|
|
197
210
|
row(r) {
|
|
198
211
|
return { id: String(r.id), logId: String(r.log_id), createdAt: new Date(r.created_at).toISOString(), disabledAt: r.disabled_at ? new Date(r.disabled_at).toISOString() : null };
|
|
199
212
|
}
|
|
@@ -233,42 +246,48 @@ export class PostgresTenancy {
|
|
|
233
246
|
}
|
|
234
247
|
return l;
|
|
235
248
|
}
|
|
236
|
-
|
|
249
|
+
/** Creates a tenant, or renames its log id. `by` names who did it in the audit trail. */
|
|
250
|
+
async addTenant(id, logId = id, by) {
|
|
237
251
|
if (!/^[A-Za-z0-9_.-]+$/.test(id))
|
|
238
252
|
throw new Error(`tenant id must be a plain identifier; got "${id}"`);
|
|
239
253
|
await this.init();
|
|
240
254
|
const rows = (await this.client.query(`INSERT INTO ${this.prefix}tenants (id, log_id) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET log_id = EXCLUDED.log_id RETURNING id, log_id, created_at, disabled_at`, [id, logId])).rows;
|
|
241
255
|
this.tenantCache.delete(id);
|
|
256
|
+
await this.record(by, "tenant.add", id, { logId });
|
|
242
257
|
return this.row(rows[0]);
|
|
243
258
|
}
|
|
244
|
-
async disableTenant(id) {
|
|
259
|
+
async disableTenant(id, by) {
|
|
245
260
|
await this.init();
|
|
246
261
|
await this.client.query(`UPDATE ${this.prefix}tenants SET disabled_at = now() WHERE id = $1 AND disabled_at IS NULL`, [id]);
|
|
247
262
|
this.tenantCache.delete(id);
|
|
263
|
+
await this.record(by, "tenant.disable", id, {});
|
|
248
264
|
}
|
|
249
265
|
async listTenants() {
|
|
250
266
|
await this.init();
|
|
251
267
|
return (await this.client.query(`SELECT id, log_id, created_at, disabled_at FROM ${this.prefix}tenants ORDER BY created_at`)).rows.map((r) => this.row(r));
|
|
252
268
|
}
|
|
253
269
|
/** Mints a token for a tenant. The token is returned once and stored only as its hash. */
|
|
254
|
-
async addToken(tenantId, label) {
|
|
270
|
+
async addToken(tenantId, label, by) {
|
|
255
271
|
await this.init();
|
|
256
272
|
if (!(await this.tenant(tenantId)))
|
|
257
273
|
throw new Error(`unknown tenant ${tenantId}`);
|
|
258
274
|
const token = randomBytes(32).toString("hex");
|
|
259
275
|
const tokenHash = sha256hex(token);
|
|
260
276
|
await this.client.query(`INSERT INTO ${this.prefix}tokens (token_hash, tenant_id, label) VALUES ($1, $2, $3)`, [tokenHash, tenantId, label]);
|
|
277
|
+
await this.record(by, "token.add", tenantId, { label, tokenHash: tokenHash.slice(0, 12) });
|
|
261
278
|
return { token, tokenHash };
|
|
262
279
|
}
|
|
263
280
|
/** Revokes the tokens of a tenant whose hash starts with the prefix; returns how many. */
|
|
264
|
-
async revokeToken(tenantId, hashPrefix) {
|
|
281
|
+
async revokeToken(tenantId, hashPrefix, by) {
|
|
265
282
|
await this.init();
|
|
266
283
|
if (hashPrefix.length < 8)
|
|
267
284
|
throw new Error("give at least eight characters of the token hash");
|
|
268
285
|
const rows = (await this.client.query(`UPDATE ${this.prefix}tokens SET revoked_at = now() WHERE tenant_id = $1 AND token_hash LIKE $2 AND revoked_at IS NULL RETURNING token_hash`, [tenantId, `${hashPrefix}%`])).rows;
|
|
269
286
|
for (const r of rows)
|
|
270
287
|
this.tokenCache.delete(`${tenantId}:${r.token_hash}`);
|
|
271
|
-
|
|
288
|
+
const revoked = rows.length;
|
|
289
|
+
await this.record(by, "token.revoke", tenantId, { hashPrefix: hashPrefix.slice(0, 12), revoked });
|
|
290
|
+
return revoked;
|
|
272
291
|
}
|
|
273
292
|
/**
|
|
274
293
|
* Appends per tenant for one month, YYYY-MM in UTC, plus each tenant's total leaves and live tokens: the numbers
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Threat model
|
|
2
|
+
|
|
3
|
+
What agent-custody is built to withstand, what it is not, and who has to be trusted in each setup. Written for the security reviewer who has to decide whether the evidence this produces would hold in a dispute, and for us, since every design decision in the packages traces to a row here. Read [verification.md](verification.md) for what each check proves and [compliance.md](compliance.md) for which control each artefact satisfies; this page is about attackers.
|
|
4
|
+
|
|
5
|
+
## The claim being defended
|
|
6
|
+
|
|
7
|
+
A receipt is a signed statement that a named agent, acting for a named principal under a specific grant and policy, asked for a specific tool call at a specific time, and that the call was allowed, denied, withheld, executed, or failed, with the arguments and the result digested. The remote log commits to the receipt's existence before or shortly after the action, in a tree whose heads are signed by a key the agent's operator does not hold. The claim under attack is therefore: **this receipt describes what actually happened, and it was not written, altered, or dropped after the fact.**
|
|
8
|
+
|
|
9
|
+
Everything below asks who could make that claim false, and what stops them.
|
|
10
|
+
|
|
11
|
+
## Parties
|
|
12
|
+
|
|
13
|
+
| party | holds | wants, if hostile |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| **the agent** (the model and its process) | its own arguments, whatever credentials are in its environment | to act outside its grant, to hide an action, to make a denied action look allowed |
|
|
16
|
+
| **the operator** (the team running the agent and the gateway) | the gateway key, the receipts directory, the local log, the policy | to rewrite history after an incident, to produce receipts for actions that did not happen, to drop receipts for ones that did |
|
|
17
|
+
| **the principal** (whoever the agent acts for) | their signing key | to disown a grant they signed, or to claim a grant they never gave |
|
|
18
|
+
| **the upstream** (the tool, API, or MCP server) | its own key if it signs results, its own records | to deny it served a result, or to claim it served a different one |
|
|
19
|
+
| **the log operator** (us, for the hosted log) | the log's signing key, every tenant's leaf hashes, token hashes | to rewrite a tenant's tree, to show different histories to different verifiers, to read receipt contents |
|
|
20
|
+
| **the witness** (a second operator) | its own key | to countersign a rewritten history, or to refuse a true one |
|
|
21
|
+
| **a network attacker** | the wire between any two of the above | to replay, drop, or alter appends and results |
|
|
22
|
+
| **a verifier** (auditor, counsel, regulator) | public keys and receipts they were handed | to be misled by any of the above |
|
|
23
|
+
|
|
24
|
+
## What the design assumes
|
|
25
|
+
|
|
26
|
+
- Ed25519 signatures and SHA-256 are sound. Post-quantum signatures are [issue #11](https://github.com/ch4r10t33r/agent-custody/issues/11), not present.
|
|
27
|
+
- Private keys stay private to their holder. Key custody is the holder's problem; the packages read keys from files or a signer process and never transmit them.
|
|
28
|
+
- The gateway runs where the agent cannot read its key or its receipts directory. A gateway in the agent's own container, with the key readable by the agent, is an SDK with extra steps and the receipts it produces are worth exactly that.
|
|
29
|
+
- Clocks are roughly right. Timestamps in receipts are the producer's; the log's checkpoint times and the witness's countersignature times are the only times a verifier can hold anyone to.
|
|
30
|
+
- A verifier obtains public keys from somewhere the attacker does not control: the log's key document over TLS, or a key file handed over out of band.
|
|
31
|
+
|
|
32
|
+
## Attacks and what stops them
|
|
33
|
+
|
|
34
|
+
Each row names the attacker, the move, the defence, and whether the defence is a property of the evidence (holds against anyone later) or a property of the deployment (holds only if the deployment is as described).
|
|
35
|
+
|
|
36
|
+
### By the agent
|
|
37
|
+
|
|
38
|
+
| attack | defence | kind |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| Call a tool the grant does not allow | The gateway evaluates the policy against the grant the principal signed; the agent never sees the policy or the key. A denied call never reaches the upstream and still produces a receipt. | deployment: only calls through the gateway are covered |
|
|
41
|
+
| Feed the policy lies in the arguments | `context.args` is `claimed`; policies that matter read `context.facts`, which the gateway fetched itself and marks `observed`. A policy that decides on args alone is the operator's choice and the receipt shows it. | evidence: provenance is in the receipt |
|
|
42
|
+
| Bypass the gateway with a credential in its own environment | Nothing in the packages prevents this. The gateway covers the tools behind it and no others. | not covered; the deployment must keep credentials out of the agent |
|
|
43
|
+
| Skip an in-process hook or SDK wrapper | Same: an SDK receipt is the agent's own report, and every field in it is `claimed`. | not covered; use the gateway for anything consequential |
|
|
44
|
+
| Write memory the fleet will trust | Writes not made through the gateway are `claimed` and quarantined until a gateway confirms them; reads leave them out unless asked for. | deployment |
|
|
45
|
+
| Exhaust the log so its own calls are withheld rather than recorded | A withheld call shows `allow` next to `withheld` and nothing happened upstream; there is no state in which an action occurs without a receipt when `precommit` names the tool. | evidence |
|
|
46
|
+
|
|
47
|
+
### By the operator
|
|
48
|
+
|
|
49
|
+
| attack | defence | kind |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| Rewrite a receipt after the fact | The receipt's envelope is signed by the gateway key, which the operator holds, so the signature alone does not stop this. What stops it is the log: the leaf hash of the original envelope is in a tree whose head the log signed, and a rewritten receipt has a different hash with no inclusion proof. | evidence, with a remote log; deployment, with a local log the operator can rewrite |
|
|
52
|
+
| Drop a receipt | The log's tree only grows, and consistency proofs between any two heads show nothing was removed. A receipt the operator never logged never existed as evidence, which is the point of `precommit` for consequential calls: the authorization leaf goes in before the action. | evidence for logged receipts; withheld calls for pre-committed tools |
|
|
53
|
+
| Forge a receipt for an action that never happened | Nothing in the log stops the operator logging a fabricated receipt; the log commits to existence, not truth. What limits it: the upstream's signature over the result when the upstream signs, provider attestations where the provider delivers them, and the principal's signature on the grant. A forged receipt for a signing upstream fails its `upstream` checks. | evidence, where the upstream signs; otherwise the operator's word |
|
|
54
|
+
| Replace the policy and claim a different one decided | The receipt carries the sha256 of the policy text; a verifier with the policy file can check it, and a policy change is a different digest in every later receipt. | evidence |
|
|
55
|
+
| Produce a second, cleaner history | With a remote log, both histories would need heads signed by the log's key at the same size with different roots; the checkpoints host and the witness make that detectable. With a local log, a copy taken earlier by someone else is the only defence. | evidence with a witnessed remote log |
|
|
56
|
+
| Erase what an agent believed | `forget` erases the value and leaves a digest keyed by a forget key held outside the ledger, so the erasure is provable without the value. A forget with `none` leaves nothing and the custody pack says so. | evidence |
|
|
57
|
+
|
|
58
|
+
### By the principal
|
|
59
|
+
|
|
60
|
+
| attack | defence | kind |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| Disown a grant | The grant is signed with the principal's key and embedded in every receipt issued under it; the verifier checks the signature against the principal's public key. | evidence |
|
|
63
|
+
| Claim a grant they never gave | Nobody else holds their key. A leaked principal key is the principal's problem and the reason grants carry expiry. | assumption |
|
|
64
|
+
|
|
65
|
+
### By the upstream
|
|
66
|
+
|
|
67
|
+
| attack | defence | kind |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| Deny it served a result | When the upstream signs `{ receiptId, tool, contentDigest }` the receipt carries its signature. When it does not sign, the receipt carries the gateway's digest of what the gateway saw, which is the operator's word against the upstream's. | evidence, where the upstream signs |
|
|
70
|
+
| Serve a different result to the gateway than it records | Same signature. Providers that deliver attestations (Stripe, GitHub webhooks) are checked against the provider's secret. | evidence, where available |
|
|
71
|
+
|
|
72
|
+
### By the log operator
|
|
73
|
+
|
|
74
|
+
| attack | defence | kind |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| Read receipt contents | With `hashOnly`, the log never receives them; only leaf hashes cross the wire. This is the default in the welcome sheet and the runbook, and it is the tenant's setting, not ours. | deployment on the tenant's side |
|
|
77
|
+
| Rewrite a tenant's tree | Every head is signed and published as a checkpoint on a second host; a rewrite means two signed heads at one size with different roots, or a later head that does not extend an earlier one. Anyone holding an earlier head detects it with `audit`; the monitor does it every ten minutes; the witness countersigns only heads that extend the last it signed. | evidence, given a witness or an earlier head held elsewhere |
|
|
78
|
+
| Show different histories to different verifiers | Same: checkpoints are public and the witness sees one history. Without a witness, two verifiers who compare heads detect it, and nobody else does. | evidence with a witness; otherwise detection needs comparison |
|
|
79
|
+
| Mint a token for a tenant and append noise | Appends are hashes with no content; the tenant's export and usage show leaves and live tokens they did not make, and the audit trail, which the tenant's export carries, records who minted what and from where. | deployment |
|
|
80
|
+
| Sign with a key not in the key document | Verifiers pin by keyid from the published document; a head signed by an unpublished key fails. Retired keys stay published so old heads keep verifying, and a rotation is announced with its date. | evidence |
|
|
81
|
+
| Disappear | The tenant's export carries every leaf hash, the signed head, the keys, and the checkpoints; `verify --log` and `audit --log` work against it with no server. | evidence |
|
|
82
|
+
|
|
83
|
+
### By the witness
|
|
84
|
+
|
|
85
|
+
| attack | defence | kind |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| Countersign a rewritten history | It cannot without also holding the log's key; a countersignature is over the log's own signed head. A witness that countersigns two heads at one size has published its own dishonesty. | evidence |
|
|
88
|
+
| Refuse a true history | An alarm with no consistency failure behind it is a false alarm; the log's consistency proof settles it in public. Verifiers who require a witness signature see a gap, which is the correct outcome for a disputed period. | evidence |
|
|
89
|
+
| Collude with the log operator | Two independent operators are the assumption. A witness on the log operator's machine proves nothing, and the deployment guide says so. | assumption |
|
|
90
|
+
|
|
91
|
+
### On the network
|
|
92
|
+
|
|
93
|
+
| attack | defence | kind |
|
|
94
|
+
| --- | --- | --- |
|
|
95
|
+
| Replay an append | Appends are idempotent in effect: the same leaf hash appended twice is two leaves, both true. Nothing an attacker replays creates a receipt the gateway did not sign. | evidence |
|
|
96
|
+
| Alter an append in flight | TLS between gateway and log; the leaf hash is over a signed envelope, so an altered hash simply fails inclusion for the real receipt. | evidence |
|
|
97
|
+
| Drop the log's answer | The gateway retries, then errors or withholds; no receipt is handed out without an inclusion proof. Timeouts bound the wait. | evidence |
|
|
98
|
+
| Steal a tenant token | Tokens are bearer secrets; a stolen one appends noise to that tenant's log until revoked. Rate limits bound the damage per second; the tenant's usage shows it. | deployment |
|
|
99
|
+
|
|
100
|
+
## What is not defended
|
|
101
|
+
|
|
102
|
+
Stated plainly, because a security review that finds these itself will not believe the rest.
|
|
103
|
+
|
|
104
|
+
- **Actions outside the gateway.** A credential the agent holds directly is used directly. The gateway is a choke point only for what goes through it.
|
|
105
|
+
- **Truth of SDK receipts.** An in-process receipt is the process's own report. It is history, not evidence against that process.
|
|
106
|
+
- **Truth of what the upstream did** when the upstream does not sign. The receipt proves what the gateway sent and what it got back, on the gateway's word.
|
|
107
|
+
- **The operator's key custody.** A stolen gateway key signs receipts the verifier cannot tell from real ones; the log still bounds when they were created.
|
|
108
|
+
- **Availability.** A log that is down withholds pre-committed calls. That is the designed behaviour and it is a denial of service on the agent. Run the log with the runbook's monitoring, and expect a tenant to ask for the SLA.
|
|
109
|
+
- **Post-quantum adversaries.** Issue #11.
|
|
110
|
+
- **A dishonest log operator with a dishonest witness.** Two colluding parties can present a consistent false history. The defence is choosing them independently.
|
|
111
|
+
|
|
112
|
+
## How the hosted log is run against this model
|
|
113
|
+
|
|
114
|
+
The deployment in [deploy/](../../deploy/README.md) and its [runbook](../../deploy/RUNBOOK.md): the signer alone holds the key and is reachable only inside the compose network; the log process facing the internet holds no key; tenant tokens are hashed at rest and shown once; every admin action requires the admin token and is throttled; checkpoints are published on a second host; the monitor runs from machines that are not ours every ten minutes; backups are nightly, with an off-machine mirror the runbook installs once a destination is configured; the key rotates yearly and on suspicion, with retired keys published; the witness runs on another operator's machine, or the site says it does not yet.
|
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`. 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,
|
|
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. Every change made there or with `log-admin` is recorded: who (the name entered at the browser prompt and the address, `bearer` for an API client, or the user and host for the command line), what (`tenant.add`, `tenant.disable`, `token.add`, `token.revoke`), which tenant, and the detail, never the token itself; the page shows it under Activity, `GET /admin/audit?tenant=&limit=` and `log-admin audit` list it, and a tenant reads their own rows at `GET /t/<name>/audit` with their token. Nothing else 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, their own usage (`GET /t/<name>/usage?month=`), and the administrative actions on their tenant (`GET /t/<name>/audit`, into `audit.json`), 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.8",
|
|
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": {
|