@agent-custody/receipts 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -234,7 +234,8 @@ src/sidecar.ts the SDK issuer behind a local HTTP API, for agents in other l
234
234
  src/upstream.ts attested execution: an upstream signs its result for the receipt; the verifier checks it with the upstream key
235
235
  vectors/ conformance vectors: receipts, keys, logs, proofs, and expected verdicts; `bun run vectors` regenerates them
236
236
  src/verify.ts offline verification, the human-readable report, and the audit that a later log extends an earlier one
237
- src/cli.ts keygen, grant, gateway, hook, serve, log, verify, audit
237
+ src/cli.ts keygen, grant, gateway, hook, serve, log, prune, verify, audit
238
+ src/retention.ts pruning the log: leaves become their hashes, bundles are removed, proofs survive
238
239
  src/index.ts the package's public surface; adapters are exported on ./sdk/<framework> subpaths
239
240
  tsconfig.build.json emits dist/ (JavaScript plus declarations) for consumers; the repo itself runs the .ts directly
240
241
  scripts/ fake Stripe upstream (signs its results with --key), a second fake upstream, fixture builders for gateway and SDK, demo
@@ -255,6 +256,8 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
255
256
  - SDK core: policy decision, record, and a generic `wrap(tool, fn)` for any framework whose tools are functions.
256
257
  - Claude Code command hook for PreToolUse, PostToolUse, and PostToolUseFailure, with blocking on deny.
257
258
  - Claude Agent SDK in-process hooks over the same handler.
259
+ - Logarithmic appends: the Merkle log caches complete subtrees, so issuing a receipt costs the same at the millionth leaf as at the first; measured at 0.15 ms per receipt and about half a millisecond per gateway call including policy, a fact lookup, and the upstream signature.
260
+ - Retention on the log: `prune` replaces leaves older than a cutoff with their hashes and removes their bundles, so proofs still verify and the content is gone.
258
261
  - Several upstreams under one gateway and one grant, each tool owned by exactly one, with the receipt naming which served the call; consumed facts flow across them.
259
262
  - Attested execution: an upstream that holds a key signs its result for the receipt, the gateway embeds it, and a verifier given the upstream key reports the execution as attested rather than observed. The memory server and the demo upstream sign.
260
263
  - HTTP upstreams: the gateway reaches an already-running MCP server over Streamable HTTP with a bearer token from the environment, as well as spawning one over stdio.
@@ -270,7 +273,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
270
273
  **Next, in the order it pays off**
271
274
 
272
275
  1. OpenTelemetry export: emit each receipt as a span with the receipt id and issuer kind as attributes, so existing collectors and dashboards carry them without a new pipeline.
273
- 2. Provider-native upstream signatures (Stripe webhook signatures, GitHub delivery signatures) as adapters onto the upstream attestation field.
276
+ 2. Provider-native upstream signatures (Stripe webhook signatures, GitHub delivery signatures) as adapters onto the upstream attestation field. [Issue #7](https://github.com/ch4r10t33r/agent-custody/issues/7).
274
277
  3. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
275
278
  4. Delegation chains for sub-agents.
276
279
  5. Receiver-attested receipts for agent-to-agent calls.
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ import { generateKeyPair, loadPrivateKey, loadPublicKey, writeKeyPair } from "./
6
6
  import { createDelegation } from "./delegation.js";
7
7
  import { createGateway, serveStdio } from "./gateway.js";
8
8
  import { serveLog } from "./log-sink.js";
9
+ import { pruneLog } from "./retention.js";
9
10
  import { serveSidecar } from "./sidecar.js";
10
11
  import { MerkleLog } from "./log.js";
11
12
  import { createSdkIssuer } from "./sdk/index.js";
@@ -18,6 +19,8 @@ const USAGE = `agent-custody <command>
18
19
  gateway --config <gateway.json>
19
20
  hook [--config <sdk.json>] Claude Code hook command; reads the event on stdin (or AGENT_CUSTODY_CONFIG)
20
21
  serve --config <sdk.json> [--port 8788] [--host 127.0.0.1] the SDK as a local HTTP API for agents in other languages
22
+ prune --log <log.jsonl> --before <ISO instant> [--receipts <dir>]
23
+ retention on the receipt log: replaces older leaves with their hashes, so proofs still verify and the content is gone
21
24
  log --file <log.jsonl> --key <log.key> [--port 8787] [--host 127.0.0.1] [--token-env <NAME>] reference log server
22
25
  verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--upstream-key <pub>] [--log <log.jsonl>] [--json]
23
26
  audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--json]
@@ -93,6 +96,16 @@ async function main(argv) {
93
96
  await running.close();
94
97
  return 0;
95
98
  }
99
+ case "prune": {
100
+ const { values } = parseArgs({ args: rest, options: { log: { type: "string" }, before: { type: "string" }, receipts: { type: "string" } } });
101
+ if (!values.log || !values.before)
102
+ throw new Error("prune needs --log and --before");
103
+ const r = pruneLog(values.log, new Date(values.before).toISOString(), values.receipts);
104
+ console.log(`pruned ${r.pruned.length} leaf(s), kept ${r.kept}, removed ${r.bundlesRemoved} bundle file(s)`);
105
+ for (const p of r.pruned)
106
+ console.log(` leaf ${p.leafIndex} ${p.timestamp} receipt ${p.receiptId ?? "?"}`);
107
+ return 0;
108
+ }
96
109
  case "log": {
97
110
  const { values } = parseArgs({
98
111
  args: rest,
package/dist/index.d.ts CHANGED
@@ -11,3 +11,4 @@ export * from "./verify.ts";
11
11
  export * from "./sdk/index.ts";
12
12
  export * from "./sidecar.ts";
13
13
  export * from "./upstream.ts";
14
+ export * from "./retention.ts";
package/dist/index.js CHANGED
@@ -12,3 +12,4 @@ export * from "./verify.js";
12
12
  export * from "./sdk/index.js";
13
13
  export * from "./sidecar.js";
14
14
  export * from "./upstream.js";
15
+ export * from "./retention.js";
package/dist/log.d.ts CHANGED
@@ -14,6 +14,7 @@ export declare function inclusionProof(leafHashes: Buffer[], leafIndex: number,
14
14
  export declare function verifyInclusion(leaf: Buffer, proof: InclusionProof, rootHex: string): boolean;
15
15
  export declare class MerkleLog {
16
16
  private hashes;
17
+ private readonly tree;
17
18
  private readonly file;
18
19
  constructor(file: string);
19
20
  get size(): number;
package/dist/log.js CHANGED
@@ -20,34 +20,55 @@ function split(n) {
20
20
  k *= 2;
21
21
  return k;
22
22
  }
23
- function mth(leaves, lo, hi) {
24
- const n = hi - lo;
25
- if (n === 0)
26
- return createHash("sha256").digest();
27
- if (n === 1)
28
- return leaves[lo];
29
- const k = split(n);
30
- return nodeHash(mth(leaves, lo, lo + k), mth(leaves, lo + k, hi));
31
- }
32
- function path(m, leaves, lo, hi) {
33
- const n = hi - lo;
34
- if (n <= 1)
35
- return [];
36
- const k = split(n);
37
- return m < k
38
- ? [...path(m, leaves, lo, lo + k), mth(leaves, lo + k, hi)]
39
- : [...path(m - k, leaves, lo + k, hi), mth(leaves, lo, lo + k)];
23
+ /**
24
+ * Subtree hashes over a growing list of leaves. A subtree over an aligned, complete, power-of-two range never changes
25
+ * once its leaves exist, so those are cached; everything else is recomputed from at most log(n) cached parts. That
26
+ * makes appends, roots, and proofs O(log n) instead of O(n), which is what keeps a long session's receipts cheap.
27
+ */
28
+ class SubtreeCache {
29
+ perfect = new Map();
30
+ leaves;
31
+ constructor(leaves) {
32
+ this.leaves = leaves;
33
+ }
34
+ mth(lo, hi) {
35
+ const n = hi - lo;
36
+ if (n === 0)
37
+ return createHash("sha256").digest();
38
+ if (n === 1)
39
+ return this.leaves[lo];
40
+ const aligned = (n & (n - 1)) === 0 && lo % n === 0;
41
+ const key = aligned ? `${lo}:${hi}` : "";
42
+ if (aligned) {
43
+ const hit = this.perfect.get(key);
44
+ if (hit)
45
+ return hit;
46
+ }
47
+ const k = split(n);
48
+ const h = nodeHash(this.mth(lo, lo + k), this.mth(lo + k, hi));
49
+ if (aligned)
50
+ this.perfect.set(key, h);
51
+ return h;
52
+ }
53
+ path(m, lo, hi) {
54
+ const n = hi - lo;
55
+ if (n <= 1)
56
+ return [];
57
+ const k = split(n);
58
+ return m < k ? [...this.path(m, lo, lo + k), this.mth(lo + k, hi)] : [...this.path(m - k, lo + k, hi), this.mth(lo, lo + k)];
59
+ }
60
+ subproof(m, lo, hi, b) {
61
+ const n = hi - lo;
62
+ if (m === n)
63
+ return b ? [] : [this.mth(lo, hi)];
64
+ const k = split(n);
65
+ return m <= k ? [...this.subproof(m, lo, lo + k, b), this.mth(lo + k, hi)] : [...this.subproof(m - k, lo + k, hi, false), this.mth(lo, lo + k)];
66
+ }
40
67
  }
68
+ const mth = (leaves, lo, hi) => new SubtreeCache(leaves).mth(lo, hi);
69
+ const path = (m, leaves, lo, hi) => new SubtreeCache(leaves).path(m, lo, hi);
41
70
  /** RFC 9162 section 2.1.4.1: SUBPROOF(m, D[n], b). */
42
- function subproof(m, leaves, lo, hi, b) {
43
- const n = hi - lo;
44
- if (m === n)
45
- return b ? [] : [mth(leaves, lo, hi)];
46
- const k = split(n);
47
- return m <= k
48
- ? [...subproof(m, leaves, lo, lo + k, b), mth(leaves, lo + k, hi)]
49
- : [...subproof(m - k, leaves, lo + k, hi, false), mth(leaves, lo, lo + k)];
50
- }
71
+ const subproof = (m, leaves, lo, hi, b) => new SubtreeCache(leaves).subproof(m, lo, hi, b);
51
72
  /** Proof that the tree of size newSize extends the tree of size oldSize. Empty when oldSize is 0 or equal to newSize. */
52
73
  export function consistencyProof(leafHashes, oldSize, newSize = leafHashes.length) {
53
74
  if (oldSize < 0 || oldSize > newSize || newSize > leafHashes.length)
@@ -134,13 +155,18 @@ export function verifyInclusion(leaf, proof, rootHex) {
134
155
  }
135
156
  export class MerkleLog {
136
157
  hashes = [];
158
+ tree;
137
159
  file;
138
160
  constructor(file) {
139
161
  this.file = file;
162
+ this.tree = new SubtreeCache(this.hashes);
140
163
  if (existsSync(file)) {
141
164
  for (const line of readFileSync(file, "utf8").split("\n")) {
142
- if (line.trim())
143
- this.hashes.push(leafHash(JSON.parse(line)));
165
+ if (!line.trim())
166
+ continue;
167
+ const parsed = JSON.parse(line);
168
+ // A pruned leaf keeps only its hash: the tree, its roots, and every proof are unchanged; the content is gone.
169
+ this.hashes.push(typeof parsed === "string" ? leafHash(parsed) : Buffer.from(parsed.pruned, "hex"));
144
170
  }
145
171
  }
146
172
  else {
@@ -155,14 +181,20 @@ export class MerkleLog {
155
181
  appendFileSync(this.file, JSON.stringify(leaf) + "\n");
156
182
  this.hashes.push(leafHash(leaf));
157
183
  const treeSize = this.hashes.length;
158
- return { ...inclusionProof(this.hashes, treeSize - 1, treeSize), rootHash: rootOf(this.hashes, treeSize) };
184
+ return { leafIndex: treeSize - 1, treeSize, hashes: this.tree.path(treeSize - 1, 0, treeSize).map((b) => b.toString("hex")), rootHash: this.tree.mth(0, treeSize).toString("hex") };
159
185
  }
160
186
  root(size = this.size) {
161
- return rootOf(this.hashes, size);
187
+ if (size < 0 || size > this.size)
188
+ throw new Error("size out of range");
189
+ return this.tree.mth(0, size).toString("hex");
162
190
  }
163
191
  /** Proof that this log at newSize extends its own earlier state at oldSize. */
164
192
  consistencyProof(oldSize, newSize = this.size) {
165
- return consistencyProof(this.hashes, oldSize, newSize);
193
+ if (oldSize < 0 || oldSize > newSize || newSize > this.size)
194
+ throw new Error("sizes out of range");
195
+ if (oldSize === 0 || oldSize === newSize)
196
+ return [];
197
+ return this.tree.subproof(oldSize, 0, newSize, true).map((b) => b.toString("hex"));
166
198
  }
167
199
  /** Reads a log file and returns the root at the given size, for auditors holding a copy of the log. */
168
200
  static rootFromFile(file, size) {
@@ -0,0 +1,14 @@
1
+ export interface PruneResult {
2
+ pruned: {
3
+ leafIndex: number;
4
+ receiptId: string | null;
5
+ timestamp: string | null;
6
+ }[];
7
+ kept: number;
8
+ bundlesRemoved: number;
9
+ }
10
+ /**
11
+ * Prunes every leaf whose receipt timestamp is before the cutoff. Leaves already pruned, and leaves that are not
12
+ * receipts, are left as they are. Rewrites the log file in place and deletes the pruned receipts' bundle files.
13
+ */
14
+ export declare function pruneLog(logFile: string, before: string, receiptsDir?: string): PruneResult;
@@ -0,0 +1,54 @@
1
+ // Retention on the receipt log. A receipt's request arguments and results hold values, and the log is append-only and
2
+ // hashed, so values cannot simply be deleted. Pruning replaces a leaf's content in the log file with its leaf hash:
3
+ // the Merkle tree, every root, and every inclusion and consistency proof for the remaining leaves are unchanged, while
4
+ // the pruned receipt's content is gone from the log and its bundle file is removed. A verifier holding a pruned
5
+ // receipt's bundle can still prove inclusion; nobody holding only the log can recover what the receipt said.
6
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { leafHash } from "./log.js";
9
+ function receiptOf(leaf) {
10
+ try {
11
+ const env = JSON.parse(leaf);
12
+ const st = JSON.parse(Buffer.from(env.payload, "base64").toString());
13
+ return { receiptId: st.predicate?.receiptId ?? null, timestamp: st.predicate?.timestamp ?? null };
14
+ }
15
+ catch {
16
+ return { receiptId: null, timestamp: null };
17
+ }
18
+ }
19
+ /**
20
+ * Prunes every leaf whose receipt timestamp is before the cutoff. Leaves already pruned, and leaves that are not
21
+ * receipts, are left as they are. Rewrites the log file in place and deletes the pruned receipts' bundle files.
22
+ */
23
+ export function pruneLog(logFile, before, receiptsDir) {
24
+ const lines = readFileSync(logFile, "utf8").split("\n").filter((l) => l.trim());
25
+ const out = [];
26
+ const result = { pruned: [], kept: 0, bundlesRemoved: 0 };
27
+ lines.forEach((line, i) => {
28
+ const parsed = JSON.parse(line);
29
+ if (typeof parsed !== "string") {
30
+ out.push(line);
31
+ return;
32
+ }
33
+ const { receiptId, timestamp } = receiptOf(parsed);
34
+ if (timestamp !== null && timestamp < before) {
35
+ out.push(JSON.stringify({ pruned: leafHash(parsed).toString("hex") }));
36
+ result.pruned.push({ leafIndex: i, receiptId, timestamp });
37
+ if (receiptsDir && receiptId) {
38
+ const bundle = join(receiptsDir, `${receiptId}.json`);
39
+ if (existsSync(bundle)) {
40
+ unlinkSync(bundle);
41
+ result.bundlesRemoved++;
42
+ }
43
+ }
44
+ }
45
+ else {
46
+ out.push(line);
47
+ result.kept++;
48
+ }
49
+ });
50
+ const tmp = `${logFile}.tmp`;
51
+ writeFileSync(tmp, out.join("\n") + "\n");
52
+ renameSync(tmp, logFile);
53
+ return result;
54
+ }
package/docs/policies.md CHANGED
@@ -104,6 +104,69 @@ permit(principal, action, resource)
104
104
  when { context.grant.principal == "user_456" };
105
105
  ```
106
106
 
107
+ ## Policies for memory
108
+
109
+ The memory server in `@agent-custody/state` is an upstream like any other, so its tools are governed by the same policy file with the same request shape. What differs is what is in the context:
110
+
111
+ | for | `context.args` carries | `context.facts` can carry |
112
+ | --- | --- | --- |
113
+ | `memory.write` | `subject`, `predicate`, `value`, `space`, and optionally `supersedes` and `evidence` | `target`, the fact being superseded, when the gateway is configured to look it up with `memory.get` |
114
+ | `memory.read` | the query, and `includeClaimed` or `requireVerified` when the caller asks for them | |
115
+ | `memory.retract`, `memory.forget`, `memory.hold`, `memory.release` | `factId` and `reason` | `target`, the fact being changed |
116
+ | `memory.sweep` | `before`, `space`, `reason` | |
117
+ | `memory.confirm` | `factId` | |
118
+
119
+ A looked-up `target` has `space`, `actor`, `provenance` (`claimed`, `attested`, or `verified`), `subject`, `predicate`, `value`, and `retracted`. Fields that would be null are absent, so test with `has`. The lookup config that makes `target` available is in the [state package README](../../state/README.md#the-memory-server).
120
+
121
+ **Confine an agent to its team's space.** Reads anywhere, writes only to one space.
122
+
123
+ ```cedar
124
+ permit(principal, action == Action::"memory.read", resource);
125
+ permit(principal, action == Action::"memory.write", resource)
126
+ when { context.args.space == "team:support" };
127
+ ```
128
+
129
+ **Keep quarantine closed.** Only a named reviewer may read claimed facts or lift them out of quarantine.
130
+
131
+ ```cedar
132
+ permit(principal, action == Action::"memory.read", resource)
133
+ unless { context.args has includeClaimed && context.args.includeClaimed == true && principal != Agent::"reviewer" };
134
+ permit(principal == Agent::"reviewer", action == Action::"memory.confirm", resource);
135
+ ```
136
+
137
+ **Require evidence for org memory.** A write to the org space must cite a fact the gateway fetched itself; the memory server then checks the value against it and writes it as verified, or refuses.
138
+
139
+ ```cedar
140
+ permit(principal, action == Action::"memory.write", resource)
141
+ when { context.args.space != "org" || context.args has evidence };
142
+ ```
143
+
144
+ **Protect attested org facts from being displaced or retracted.** Needs the `target` lookup. A self-reported org note can be replaced; an attested one cannot.
145
+
146
+ ```cedar
147
+ permit(principal, action in [Action::"memory.write", Action::"memory.retract"], resource);
148
+ forbid(principal, action in [Action::"memory.write", Action::"memory.retract"], resource)
149
+ when { context.facts has target && context.facts.target.space == "org" && context.facts.target.provenance == "attested" };
150
+ ```
151
+
152
+ **Erasure and holds belong to named roles.** Everyone else is denied by default.
153
+
154
+ ```cedar
155
+ permit(principal == Agent::"privacy-officer", action in [Action::"memory.forget", Action::"memory.sweep"], resource);
156
+ permit(principal == Agent::"legal", action in [Action::"memory.hold", Action::"memory.release"], resource);
157
+ ```
158
+
159
+ **A complete policy for a support agent.** The pieces above, together: read anywhere but not into quarantine, write team memory freely, write org memory only with evidence, retract only claimed facts, and no erasure or holds at all.
160
+
161
+ ```cedar
162
+ permit(principal, action == Action::"memory.read", resource)
163
+ unless { context.args has includeClaimed && context.args.includeClaimed == true };
164
+ permit(principal, action == Action::"memory.write", resource)
165
+ when { context.args.space == "team:support" || (context.args.space == "org" && context.args has evidence) };
166
+ permit(principal, action == Action::"memory.retract", resource)
167
+ when { context.facts has target && context.facts.target.provenance == "claimed" };
168
+ ```
169
+
107
170
  ## Gotchas
108
171
 
109
172
  - **Integers only.** `12.50` is not a Cedar value. Send `1250`.
@@ -173,3 +173,13 @@ On a gateway receipt the execution is `observed`: the gateway saw what the upstr
173
173
 
174
174
  For upstream authors, `signResult(result, key, receiptId, tool)` from `@agent-custody/receipts` does the signing; the receipt id arrives in the call's `_meta["agent-custody/receipt"]`. The memory server in `@agent-custody/state` signs when started with `--key`, and the demo's fake upstream does too. Provider-native signatures, such as Stripe's webhook signatures, are adapters on top of the same field and are not implemented yet.
175
175
 
176
+ ## Retention on the log
177
+
178
+ Receipts hold values: request arguments, results, facts. The log is append-only and hashed, so nothing can simply be deleted from it. `prune` is how retention reaches it without breaking a proof:
179
+
180
+ ```bash
181
+ node src/cli.ts prune --log log.jsonl --before 2026-06-01T00:00:00Z --receipts receipts
182
+ ```
183
+
184
+ Every leaf whose receipt is older than the cutoff is replaced in the file by its leaf hash, and the receipt's bundle file is deleted. The Merkle tree is built from leaf hashes, so every root, every inclusion proof, and every consistency proof for the remaining leaves is unchanged, and `--log` verification of later receipts still passes. Someone who kept a pruned receipt's bundle can still prove it was in the log; nobody holding only the log can recover what it said. Run it on the same schedule as memory retention.
185
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-custody/receipts",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Chain of custody for AI agents: signed, independently verifiable receipts for tool calls. MCP gateway + Cedar policy + Merkle transparency log",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -39,6 +39,10 @@
39
39
  "./sdk/langchain": {
40
40
  "types": "./dist/sdk/langchain.d.ts",
41
41
  "default": "./dist/sdk/langchain.js"
42
+ },
43
+ "./cli": {
44
+ "types": "./dist/cli.d.ts",
45
+ "default": "./dist/cli.js"
42
46
  }
43
47
  },
44
48
  "files": [