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