@agent-custody/receipts 0.1.5 → 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,10 +234,11 @@ 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
- scripts/ fake Stripe upstream, fixture builders for gateway and SDK, demo
241
+ scripts/ fake Stripe upstream (signs its results with --key), a second fake upstream, fixture builders for gateway and SDK, demo
241
242
  examples/ fifteen runnable tutorials, plus examples/languages/: Python, Go, Java, and Rust clients of the sidecar, run by the test suite, one per aspect; each is run by the test suite
242
243
  test/ unit tests per module, end-to-end gateway test, SDK and hook tests,
243
244
  adapter tests against the real packages, and a test that runs every policy in docs/policies.md
@@ -255,6 +256,9 @@ 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.
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.
258
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.
259
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.
260
264
  - Optional fact lookups: a lookup that references a call argument the call does not carry is skipped rather than denying, so policy can see the fact a write is about to supersede without refusing writes that supersede nothing.
@@ -269,7 +273,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
269
273
  **Next, in the order it pays off**
270
274
 
271
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.
272
- 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).
273
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.
274
278
  4. Delegation chains for sub-agents.
275
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/config.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  import { z } from "zod";
2
+ declare const UpstreamSchema: z.ZodUnion<readonly [z.ZodObject<{
3
+ command: z.ZodString;
4
+ args: z.ZodDefault<z.ZodArray<z.ZodString>>;
5
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
6
+ }, z.core.$strip>, z.ZodObject<{
7
+ url: z.ZodString;
8
+ tokenEnv: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strip>]>;
10
+ export type UpstreamConfig = z.infer<typeof UpstreamSchema>;
2
11
  declare const FactSchema: z.ZodObject<{
3
12
  name: z.ZodString;
4
13
  tool: z.ZodString;
@@ -10,14 +19,24 @@ export declare const GatewayConfigSchema: z.ZodObject<{
10
19
  identity: z.ZodObject<{
11
20
  keyFile: z.ZodString;
12
21
  }, z.core.$strip>;
13
- upstream: z.ZodUnion<readonly [z.ZodObject<{
22
+ upstream: z.ZodOptional<z.ZodUnion<readonly [z.ZodObject<{
14
23
  command: z.ZodString;
15
24
  args: z.ZodDefault<z.ZodArray<z.ZodString>>;
16
25
  env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
17
26
  }, z.core.$strip>, z.ZodObject<{
18
27
  url: z.ZodString;
19
28
  tokenEnv: z.ZodOptional<z.ZodString>;
20
- }, z.core.$strip>]>;
29
+ }, z.core.$strip>]>>;
30
+ upstreams: z.ZodOptional<z.ZodArray<z.ZodIntersection<z.ZodUnion<readonly [z.ZodObject<{
31
+ command: z.ZodString;
32
+ args: z.ZodDefault<z.ZodArray<z.ZodString>>;
33
+ env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
34
+ }, z.core.$strip>, z.ZodObject<{
35
+ url: z.ZodString;
36
+ tokenEnv: z.ZodOptional<z.ZodString>;
37
+ }, z.core.$strip>]>, z.ZodObject<{
38
+ name: z.ZodString;
39
+ }, z.core.$strip>>>>;
21
40
  grantFile: z.ZodString;
22
41
  trustedPrincipalKeys: z.ZodArray<z.ZodString>;
23
42
  policyFile: z.ZodString;
package/dist/config.js CHANGED
@@ -5,6 +5,10 @@ import { dirname, resolve } from "node:path";
5
5
  const LogSchema = z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional() });
6
6
  const oneLog = { message: "exactly one of logFile or log is required" };
7
7
  const hasOneLog = (c) => (c.logFile ? 1 : 0) + (c.log ? 1 : 0) === 1;
8
+ const UpstreamSchema = z.union([
9
+ z.object({ command: z.string(), args: z.array(z.string()).default([]), env: z.record(z.string(), z.string()).optional() }),
10
+ z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional() }),
11
+ ]);
8
12
  const FactSchema = z.object({
9
13
  /** key under context.facts */
10
14
  name: z.string().min(1),
@@ -20,10 +24,9 @@ const FactSchema = z.object({
20
24
  export const GatewayConfigSchema = z.object({
21
25
  identity: z.object({ keyFile: z.string() }),
22
26
  /** the upstream MCP server: a process to spawn over stdio, or a URL to reach over Streamable HTTP with an optional bearer token from the environment */
23
- upstream: z.union([
24
- z.object({ command: z.string(), args: z.array(z.string()).default([]), env: z.record(z.string(), z.string()).optional() }),
25
- z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional() }),
26
- ]),
27
+ upstream: UpstreamSchema.optional(),
28
+ /** several upstreams behind one gateway and one grant; each tool name must belong to exactly one of them */
29
+ upstreams: z.array(UpstreamSchema.and(z.object({ name: z.string().min(1) }))).min(1).optional(),
27
30
  grantFile: z.string(),
28
31
  trustedPrincipalKeys: z.array(z.string()).min(1),
29
32
  policyFile: z.string(),
@@ -31,7 +34,7 @@ export const GatewayConfigSchema = z.object({
31
34
  receiptsDir: z.string(),
32
35
  logFile: z.string().optional(),
33
36
  log: LogSchema.optional(),
34
- }).refine(hasOneLog, oneLog);
37
+ }).refine(hasOneLog, oneLog).refine((c) => (c.upstream ? 1 : 0) + (c.upstreams ? 1 : 0) === 1, { message: "exactly one of upstream or upstreams is required" });
35
38
  /** Loads a config file and resolves every path relative to the file's directory. */
36
39
  export function loadConfig(path) {
37
40
  const cfg = GatewayConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
package/dist/gateway.d.ts CHANGED
@@ -7,6 +7,8 @@ export declare const MODEL_META_KEY = "agent-custody/model";
7
7
  /** Set by the gateway on the call it forwards upstream: the receipt id, and the agent and principal from the attested grant. */
8
8
  export declare const AGENT_META_KEY = "agent-custody/agent";
9
9
  export declare const PRINCIPAL_META_KEY = "agent-custody/principal";
10
+ /** Set by the gateway on the forwarded call: the values of the facts it fetched itself for this call, by name, so an upstream can check a claimed value against what the gateway observed. */
11
+ export declare const OBSERVED_META_KEY = "agent-custody/observed";
10
12
  /** Set by an upstream on its result: the ids of the facts it served in this call. The gateway remembers them for the session. */
11
13
  export declare const FACTS_META_KEY = "agent-custody/facts";
12
14
  export interface CallParams {
package/dist/gateway.js CHANGED
@@ -20,6 +20,8 @@ export const MODEL_META_KEY = "agent-custody/model";
20
20
  /** Set by the gateway on the call it forwards upstream: the receipt id, and the agent and principal from the attested grant. */
21
21
  export const AGENT_META_KEY = "agent-custody/agent";
22
22
  export const PRINCIPAL_META_KEY = "agent-custody/principal";
23
+ /** Set by the gateway on the forwarded call: the values of the facts it fetched itself for this call, by name, so an upstream can check a claimed value against what the gateway observed. */
24
+ export const OBSERVED_META_KEY = "agent-custody/observed";
23
25
  /** Set by an upstream on its result: the ids of the facts it served in this call. The gateway remembers them for the session. */
24
26
  export const FACTS_META_KEY = "agent-custody/facts";
25
27
  /** Returns null when an optional lookup references a call argument that is absent. */
@@ -67,17 +69,42 @@ export async function createGateway(cfg) {
67
69
  const policyText = readFileSync(cfg.policyFile, "utf8");
68
70
  const pDigest = policyDigest(policyText);
69
71
  const issuer = createIssuer(gatewayKey, cfg.receiptsDir, openLog(cfg, gatewayKey));
70
- const upstream = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
71
- if ("url" in cfg.upstream) {
72
- const token = cfg.upstream.tokenEnv ? process.env[cfg.upstream.tokenEnv] : undefined;
73
- if (cfg.upstream.tokenEnv && !token)
74
- throw new Error(`upstream token: environment variable ${cfg.upstream.tokenEnv} is not set`);
75
- await upstream.connect(new StreamableHTTPClientTransport(new URL(cfg.upstream.url), token ? { requestInit: { headers: { authorization: `Bearer ${token}` } } } : {}));
76
- }
77
- else {
78
- await upstream.connect(new StdioClientTransport({ command: cfg.upstream.command, args: cfg.upstream.args, env: cfg.upstream.env, stderr: "inherit" }));
72
+ // One gateway, one grant, one session, and as many upstreams as the agent's job needs. Each tool name belongs to
73
+ // exactly one upstream, decided at startup, so a receipt's tool is unambiguous and consumed facts flow across them.
74
+ const upstreamConfigs = cfg.upstreams ? cfg.upstreams.map((u) => ({ name: u.name, cfg: u })) : [{ name: "upstream", cfg: cfg.upstream }];
75
+ const upstreams = new Map();
76
+ const owner = new Map();
77
+ const advertised = [];
78
+ for (const { name, cfg: u } of upstreamConfigs) {
79
+ const client = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
80
+ if ("url" in u) {
81
+ const token = u.tokenEnv ? process.env[u.tokenEnv] : undefined;
82
+ if (u.tokenEnv && !token)
83
+ throw new Error(`upstream ${name}: environment variable ${u.tokenEnv} is not set`);
84
+ await client.connect(new StreamableHTTPClientTransport(new URL(u.url), token ? { requestInit: { headers: { authorization: `Bearer ${token}` } } } : {}));
85
+ }
86
+ else {
87
+ await client.connect(new StdioClientTransport({ command: u.command, args: u.args, env: u.env, stderr: "inherit" }));
88
+ }
89
+ upstreams.set(name, client);
90
+ const { tools } = await client.listTools();
91
+ for (const t of tools) {
92
+ const other = owner.get(t.name);
93
+ if (other) {
94
+ for (const c of upstreams.values())
95
+ await c.close();
96
+ throw new Error(`tool "${t.name}" is offered by both upstream "${other}" and upstream "${name}"; a gateway needs one owner per tool`);
97
+ }
98
+ owner.set(t.name, name);
99
+ advertised.push(t);
100
+ }
79
101
  }
80
- const callUpstream = async (name, args, meta) => (await upstream.callTool({ name, arguments: args, ...(meta ? { _meta: meta } : {}) }));
102
+ const callUpstream = async (name, args, meta) => {
103
+ const via = owner.get(name);
104
+ if (!via)
105
+ throw new Error(`no upstream offers tool "${name}"`);
106
+ return (await upstreams.get(via).callTool({ name, arguments: args, ...(meta ? { _meta: meta } : {}) }));
107
+ };
81
108
  async function gatherFacts(tool, args, meta) {
82
109
  const facts = {};
83
110
  for (const f of cfg.facts.filter((f) => f.forTools.includes(tool))) {
@@ -135,7 +162,8 @@ export async function createGateway(cfg) {
135
162
  try {
136
163
  // The upstream learns which receipt this call is, and who the grant says is calling. An upstream that keeps
137
164
  // state, such as the memory server, cites the receipt as the source of what it stores.
138
- const result = await callUpstream(tool, args, upstreamMeta);
165
+ const observed = Object.fromEntries(Object.entries(facts).map(([k, f]) => [k, f.value]));
166
+ const result = await callUpstream(tool, args, { ...upstreamMeta, [OBSERVED_META_KEY]: observed });
139
167
  const upstreamSig = upstreamSignatureOf(result);
140
168
  execution = { status: result.isError ? "failed" : "executed", result, resultDigest: digestOf(result), provenance: "observed", ...(upstreamSig ? { upstream: { envelope: upstreamSig } } : {}) };
141
169
  noteServedFacts(result);
@@ -156,7 +184,7 @@ export async function createGateway(cfg) {
156
184
  delegation: { envelope: grantEnvelope, provenance: "attested" },
157
185
  session: { id: null, toolUseId: null, provenance: "claimed" },
158
186
  model: { id: typeof modelClaim === "string" ? modelClaim : null, provenance: "claimed" },
159
- tool: { name: tool, provenance: "observed" },
187
+ tool: { name: tool, provenance: "observed", ...(owner.has(tool) && upstreamConfigs.length > 1 ? { upstream: owner.get(tool) } : {}) },
160
188
  request: { args, argsDigest: digestOf(args), provenance: "claimed" },
161
189
  facts,
162
190
  consumed: { factIds: consumedNow, provenance: "observed" },
@@ -180,11 +208,13 @@ export async function createGateway(cfg) {
180
208
  agentId: delegation.agent,
181
209
  delegation,
182
210
  async listTools() {
183
- const { tools } = await upstream.listTools();
184
- return tools.filter((t) => delegation.scopes.includes(t.name));
211
+ return advertised.filter((t) => delegation.scopes.includes(t.name));
185
212
  },
186
213
  handleCall,
187
- close: () => upstream.close(),
214
+ async close() {
215
+ for (const c of upstreams.values())
216
+ await c.close();
217
+ },
188
218
  };
189
219
  }
190
220
  /** Exposes the gateway as an MCP server over stdio. Everything diagnostic must go to stderr. */
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) {
package/dist/receipt.d.ts CHANGED
@@ -60,9 +60,11 @@ export interface ReceiptPredicate {
60
60
  id: string | null;
61
61
  provenance: "claimed";
62
62
  };
63
+ /** upstream names which of several upstreams served the tool; absent when the gateway has one */
63
64
  tool: {
64
65
  name: string;
65
66
  provenance: Provenance;
67
+ upstream?: string;
66
68
  };
67
69
  request: {
68
70
  args: Record<string, unknown>;
@@ -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`.
package/docs/usage.md CHANGED
@@ -77,7 +77,7 @@ when {
77
77
  }
78
78
  ```
79
79
 
80
- `upstream` is spawned by the gateway exactly as an MCP host would spawn it. `env` is passed through, which is where upstream credentials go. The agent never sees them. An upstream that is already running is reached instead with `"upstream": { "url": "https://memory.internal/mcp", "tokenEnv": "MEMORY_TOKEN" }`, over Streamable HTTP with a bearer token from the environment; the shared memory server in `@agent-custody/state` is the usual case.
80
+ `upstream` is spawned by the gateway exactly as an MCP host would spawn it. `env` is passed through, which is where upstream credentials go. The agent never sees them. Several upstreams sit behind one gateway and one grant with `"upstreams": [{ "name": "memory", "command": ..., "args": [...] }, { "name": "payments", "url": ... }]` in place of `upstream`. Each tool name must be offered by exactly one of them, checked at startup; the receipt's `tool.upstream` says which served the call, and consumed facts flow across them, so a refund made after a memory read carries the facts the agent had been shown. An upstream that is already running is reached instead with `"upstream": { "url": "https://memory.internal/mcp", "tokenEnv": "MEMORY_TOKEN" }`, over Streamable HTTP with a bearer token from the environment; the shared memory server in `@agent-custody/state` is the usual case.
81
81
 
82
82
  `logFile` is the local Merkle log, with tree heads signed by the gateway's own key. To log to a server the operator does not control, replace it with `log`:
83
83
 
@@ -194,6 +194,8 @@ The receipt id is the file name under `receiptsDir`.
194
194
 
195
195
  The call the gateway forwards carries three `_meta` keys the agent cannot set: `agent-custody/receipt`, the id of the receipt being issued for this call; `agent-custody/agent` and `agent-custody/principal`, from the signed delegation grant. An upstream that keeps state can cite the receipt as the source of what it stores and record the attested caller rather than a claimed one. The memory server in `@agent-custody/state` does exactly that. Fact lookups carry the same keys, since they are the gateway acting for the same receipt.
196
196
 
197
+ The forwarded call also carries `agent-custody/observed`: the values of the facts the gateway fetched for this call, by name, so an upstream can check a value the agent claims against what the gateway itself saw. The memory server's evidence check works this way.
198
+
197
199
  The upstream can answer in kind. A result whose `_meta` carries `agent-custody/facts`, an array of fact ids, tells the gateway which facts it just served; the gateway remembers them for the rest of the session and every later receipt carries them as `consumed`, labelled `observed` because the gateway saw those results itself. That is what the agent had been shown by the time of each call, an upper bound on what it relied on, and it is what the state package's blast-radius query walks.
198
200
 
199
201
  ## Operational notes
@@ -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.5",
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": [