@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 +7 -3
- package/dist/cli.js +13 -0
- package/dist/config.d.ts +21 -2
- package/dist/config.js +8 -5
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +45 -15
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/log.d.ts +1 -0
- package/dist/log.js +63 -31
- package/dist/receipt.d.ts +2 -0
- package/dist/retention.d.ts +14 -0
- package/dist/retention.js +54 -0
- package/docs/policies.md +63 -0
- package/docs/usage.md +3 -1
- package/docs/verification.md +10 -0
- package/package.json +5 -1
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:
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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
|
|
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
package/dist/index.js
CHANGED
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/docs/verification.md
CHANGED
|
@@ -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.
|
|
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": [
|