@agent-custody/receipts 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/dist/cli.js +24 -1
- package/dist/gateway.js +3 -3
- 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 +3 -4
- package/dist/retention.d.ts +14 -0
- package/dist/retention.js +54 -0
- package/dist/upstream.d.ts +37 -0
- package/dist/upstream.js +68 -0
- package/dist/verify.d.ts +3 -0
- package/dist/verify.js +17 -6
- package/docs/policies.md +63 -0
- package/docs/verification.md +21 -1
- package/package.json +5 -1
- package/vectors/audit.json +27 -27
- package/vectors/canonical.json +5 -5
- package/vectors/receipts.json +288 -137
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,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
|
+
- Provider-native deliveries: an upstream wrapping Stripe or GitHub attaches the signed webhook or delivery for the call; a verifier with the shared secret checks the HMAC, the timestamp, and the binding to the result, and reports the execution as attested by shared secret.
|
|
260
|
+
- 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.
|
|
261
|
+
- 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
262
|
- 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
263
|
- 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
264
|
- 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,10 +274,9 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
|
|
|
270
274
|
**Next, in the order it pays off**
|
|
271
275
|
|
|
272
276
|
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.
|
|
274
|
-
3.
|
|
275
|
-
4.
|
|
276
|
-
5.
|
|
277
|
-
6. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
|
|
277
|
+
2. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
|
|
278
|
+
3. Delegation chains for sub-agents.
|
|
279
|
+
4. Receiver-attested receipts for agent-to-agent calls.
|
|
280
|
+
5. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
|
|
278
281
|
|
|
279
282
|
A Python SDK follows the same shape once the TypeScript adapters have settled.
|
package/dist/cli.js
CHANGED
|
@@ -6,11 +6,19 @@ 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";
|
|
12
13
|
import { handleHookEvent } from "./sdk/claude.js";
|
|
13
14
|
import { auditExtends, formatReport, verifyBundle } from "./verify.js";
|
|
15
|
+
/** A shared secret from an environment variable; never from the command line, where it would land in shell history. */
|
|
16
|
+
function secretFrom(envName) {
|
|
17
|
+
const v = process.env[envName];
|
|
18
|
+
if (!v)
|
|
19
|
+
throw new Error(`environment variable ${envName} is not set`);
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
14
22
|
const USAGE = `agent-custody <command>
|
|
15
23
|
|
|
16
24
|
keygen --dir <dir> --name <name>
|
|
@@ -18,8 +26,10 @@ const USAGE = `agent-custody <command>
|
|
|
18
26
|
gateway --config <gateway.json>
|
|
19
27
|
hook [--config <sdk.json>] Claude Code hook command; reads the event on stdin (or AGENT_CUSTODY_CONFIG)
|
|
20
28
|
serve --config <sdk.json> [--port 8788] [--host 127.0.0.1] the SDK as a local HTTP API for agents in other languages
|
|
29
|
+
prune --log <log.jsonl> --before <ISO instant> [--receipts <dir>]
|
|
30
|
+
retention on the receipt log: replaces older leaves with their hashes, so proofs still verify and the content is gone
|
|
21
31
|
log --file <log.jsonl> --key <log.key> [--port 8787] [--host 127.0.0.1] [--token-env <NAME>] reference log server
|
|
22
|
-
verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--upstream-key <pub>] [--log <log.jsonl>] [--json]
|
|
32
|
+
verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--upstream-key <pub>] [--stripe-secret-env NAME] [--github-secret-env NAME] [--log <log.jsonl>] [--json]
|
|
23
33
|
audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--json]
|
|
24
34
|
checks that the newer receipt's log extends the older one's: nothing between them was rewritten
|
|
25
35
|
`;
|
|
@@ -93,6 +103,16 @@ async function main(argv) {
|
|
|
93
103
|
await running.close();
|
|
94
104
|
return 0;
|
|
95
105
|
}
|
|
106
|
+
case "prune": {
|
|
107
|
+
const { values } = parseArgs({ args: rest, options: { log: { type: "string" }, before: { type: "string" }, receipts: { type: "string" } } });
|
|
108
|
+
if (!values.log || !values.before)
|
|
109
|
+
throw new Error("prune needs --log and --before");
|
|
110
|
+
const r = pruneLog(values.log, new Date(values.before).toISOString(), values.receipts);
|
|
111
|
+
console.log(`pruned ${r.pruned.length} leaf(s), kept ${r.kept}, removed ${r.bundlesRemoved} bundle file(s)`);
|
|
112
|
+
for (const p of r.pruned)
|
|
113
|
+
console.log(` leaf ${p.leafIndex} ${p.timestamp} receipt ${p.receiptId ?? "?"}`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
96
116
|
case "log": {
|
|
97
117
|
const { values } = parseArgs({
|
|
98
118
|
args: rest,
|
|
@@ -120,6 +140,8 @@ async function main(argv) {
|
|
|
120
140
|
"principal-key": { type: "string", multiple: true },
|
|
121
141
|
"log-key": { type: "string", multiple: true },
|
|
122
142
|
"upstream-key": { type: "string", multiple: true },
|
|
143
|
+
"stripe-secret-env": { type: "string" },
|
|
144
|
+
"github-secret-env": { type: "string" },
|
|
123
145
|
log: { type: "string" },
|
|
124
146
|
json: { type: "boolean", default: false },
|
|
125
147
|
},
|
|
@@ -134,6 +156,7 @@ async function main(argv) {
|
|
|
134
156
|
principalKeys: (values["principal-key"] ?? []).map(loadPublicKey),
|
|
135
157
|
...(values["log-key"] ? { logKeys: values["log-key"].map(loadPublicKey) } : {}),
|
|
136
158
|
...(values["upstream-key"] ? { upstreamKeys: values["upstream-key"].map(loadPublicKey) } : {}),
|
|
159
|
+
...(values["stripe-secret-env"] || values["github-secret-env"] ? { providerSecrets: { ...(values["stripe-secret-env"] ? { stripe: secretFrom(values["stripe-secret-env"]) } : {}), ...(values["github-secret-env"] ? { github: secretFrom(values["github-secret-env"]) } : {}) } } : {}),
|
|
137
160
|
...(values.log ? { logFile: values.log } : {}),
|
|
138
161
|
});
|
|
139
162
|
console.log(values.json ? JSON.stringify(result, null, 2) : formatReport(result));
|
package/dist/gateway.js
CHANGED
|
@@ -12,7 +12,7 @@ import { digestOf, loadPrivateKey, loadPublicKey } from "./crypto.js";
|
|
|
12
12
|
import { delegationValidAt, verifyDelegation } from "./delegation.js";
|
|
13
13
|
import { createIssuer } from "./issue.js";
|
|
14
14
|
import { openLog } from "./log-sink.js";
|
|
15
|
-
import {
|
|
15
|
+
import { upstreamEvidenceOf } from "./upstream.js";
|
|
16
16
|
import { evaluate, policyDigest } from "./policy.js";
|
|
17
17
|
export const GATEWAY_VERSION = "0.1.0";
|
|
18
18
|
export const RECEIPT_META_KEY = "agent-custody/receipt";
|
|
@@ -164,8 +164,8 @@ export async function createGateway(cfg) {
|
|
|
164
164
|
// state, such as the memory server, cites the receipt as the source of what it stores.
|
|
165
165
|
const observed = Object.fromEntries(Object.entries(facts).map(([k, f]) => [k, f.value]));
|
|
166
166
|
const result = await callUpstream(tool, args, { ...upstreamMeta, [OBSERVED_META_KEY]: observed });
|
|
167
|
-
const
|
|
168
|
-
execution = { status: result.isError ? "failed" : "executed", result, resultDigest: digestOf(result), provenance: "observed", ...(
|
|
167
|
+
const evidence = upstreamEvidenceOf(result);
|
|
168
|
+
execution = { status: result.isError ? "failed" : "executed", result, resultDigest: digestOf(result), provenance: "observed", ...(evidence ? { upstream: evidence } : {}) };
|
|
169
169
|
noteServedFacts(result);
|
|
170
170
|
}
|
|
171
171
|
catch (e) {
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Envelope } from "./crypto.ts";
|
|
2
2
|
import type { InclusionProof } from "./log.ts";
|
|
3
3
|
import type { PolicyDecision } from "./policy.ts";
|
|
4
|
+
import type { UpstreamEvidence } from "./upstream.ts";
|
|
4
5
|
export declare const RECEIPT_TYPE = "application/vnd.in-toto+json";
|
|
5
6
|
export declare const RECEIPT_PREDICATE_TYPE = "https://agent-custody.dev/receipt/v0.2";
|
|
6
7
|
export declare const TREEHEAD_TYPE = "application/vnd.agent-custody.treehead+json";
|
|
@@ -90,10 +91,8 @@ export interface ReceiptPredicate {
|
|
|
90
91
|
result: unknown;
|
|
91
92
|
resultDigest: string;
|
|
92
93
|
provenance: Provenance;
|
|
93
|
-
/** an upstream's own signature over what it returned, bound to this receipt
|
|
94
|
-
upstream?:
|
|
95
|
-
envelope: Envelope;
|
|
96
|
-
};
|
|
94
|
+
/** an upstream's own signature over what it returned, bound to this receipt and checked with the upstream's key; or a provider's delivery, checked with the provider's shared secret */
|
|
95
|
+
upstream?: UpstreamEvidence;
|
|
97
96
|
} | {
|
|
98
97
|
status: "denied";
|
|
99
98
|
reason: string;
|
|
@@ -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/dist/upstream.d.ts
CHANGED
|
@@ -25,3 +25,40 @@ export type UpstreamCheck = {
|
|
|
25
25
|
};
|
|
26
26
|
/** For verifiers: the envelope must verify against a trusted upstream key and bind to this receipt, tool, and content. */
|
|
27
27
|
export declare function checkUpstream(envelope: Envelope, keys: PublicKeyRef[], expected: UpstreamAttestation): UpstreamCheck;
|
|
28
|
+
export interface ProviderAttestation {
|
|
29
|
+
provider: "stripe-webhook" | "github-delivery";
|
|
30
|
+
/** the delivery body exactly as received; the HMAC is over these bytes */
|
|
31
|
+
rawBody: string;
|
|
32
|
+
/** Stripe: the Stripe-Signature header; GitHub: the X-Hub-Signature-256 header */
|
|
33
|
+
signature: string;
|
|
34
|
+
/** dot path into the parsed body whose value must appear in the receipt's result, e.g. data.object.id */
|
|
35
|
+
bind: string;
|
|
36
|
+
/** GitHub: the X-GitHub-Delivery id, for the record */
|
|
37
|
+
deliveryId?: string;
|
|
38
|
+
}
|
|
39
|
+
export type UpstreamEvidence = {
|
|
40
|
+
envelope: Envelope;
|
|
41
|
+
} | ProviderAttestation;
|
|
42
|
+
export declare function isProviderAttestation(v: unknown): v is ProviderAttestation;
|
|
43
|
+
/** For upstreams wrapping a provider: attaches the provider's own delivery for this call. */
|
|
44
|
+
export declare function attachProviderAttestation<R extends CallToolResult>(result: R, attestation: ProviderAttestation): R;
|
|
45
|
+
/** For the gateway: whatever upstream evidence the result carries, a signed envelope or a provider delivery. */
|
|
46
|
+
export declare function upstreamEvidenceOf(result: CallToolResult): UpstreamEvidence | null;
|
|
47
|
+
export interface ProviderSecrets {
|
|
48
|
+
stripe?: string;
|
|
49
|
+
github?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface ProviderCheckContext {
|
|
52
|
+
/** the receipt's timestamp, for Stripe's timestamp tolerance */
|
|
53
|
+
timestamp: string;
|
|
54
|
+
/** the receipt's execution result; the bound value must appear in it */
|
|
55
|
+
result: unknown;
|
|
56
|
+
/** seconds a Stripe timestamp may differ from the receipt's; default 300 */
|
|
57
|
+
toleranceSeconds?: number;
|
|
58
|
+
}
|
|
59
|
+
/** Stripe: header `t=<unix>,v1=<hex>`, HMAC-SHA256 over `<t>.<rawBody>`; GitHub: header `sha256=<hex>` over rawBody. */
|
|
60
|
+
export declare function checkProvider(att: ProviderAttestation, secrets: ProviderSecrets, ctx: ProviderCheckContext): UpstreamCheck;
|
|
61
|
+
/** For fake providers and tests: a Stripe-Signature header for a body at a time. */
|
|
62
|
+
export declare function stripeSignature(rawBody: string, secret: string, unixSeconds: number): string;
|
|
63
|
+
/** For fake providers and tests: an X-Hub-Signature-256 header for a body. */
|
|
64
|
+
export declare function githubSignature(rawBody: string, secret: string): string;
|
package/dist/upstream.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Attested execution. An upstream that holds a key can sign what it returned, bound to the receipt the gateway is
|
|
2
|
+
// issuing, so the receipt's execution is no longer only what the gateway observed but what the upstream itself vouches
|
|
3
|
+
// for. The upstream puts a DSSE envelope on its result's _meta; the gateway embeds it; a verifier who trusts the
|
|
4
|
+
// upstream's key checks it. Provider-native formats, such as Stripe's webhook signatures, are adapters on top of this.
|
|
5
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
1
6
|
import { digestOf, dsseSign, dsseVerify } from "./crypto.js";
|
|
2
7
|
export const UPSTREAM_SIG_META_KEY = "agent-custody/upstream-signature";
|
|
3
8
|
export const UPSTREAM_TYPE = "application/vnd.agent-custody.upstream+json";
|
|
@@ -30,3 +35,66 @@ export function checkUpstream(envelope, keys, expected) {
|
|
|
30
35
|
return { ok: false, error: "signed content differs from the result in the receipt" };
|
|
31
36
|
return { ok: true, keyid: v.keyid };
|
|
32
37
|
}
|
|
38
|
+
export function isProviderAttestation(v) {
|
|
39
|
+
const p = v;
|
|
40
|
+
return !!p && (p.provider === "stripe-webhook" || p.provider === "github-delivery") && typeof p.rawBody === "string" && typeof p.signature === "string" && typeof p.bind === "string";
|
|
41
|
+
}
|
|
42
|
+
/** For upstreams wrapping a provider: attaches the provider's own delivery for this call. */
|
|
43
|
+
export function attachProviderAttestation(result, attestation) {
|
|
44
|
+
return { ...result, _meta: { ...result._meta, [UPSTREAM_SIG_META_KEY]: attestation } };
|
|
45
|
+
}
|
|
46
|
+
/** For the gateway: whatever upstream evidence the result carries, a signed envelope or a provider delivery. */
|
|
47
|
+
export function upstreamEvidenceOf(result) {
|
|
48
|
+
const v = result._meta?.[UPSTREAM_SIG_META_KEY];
|
|
49
|
+
if (isProviderAttestation(v))
|
|
50
|
+
return v;
|
|
51
|
+
const env = upstreamSignatureOf(result);
|
|
52
|
+
return env ? { envelope: env } : null;
|
|
53
|
+
}
|
|
54
|
+
const pathValue = (body, path) => path.split(".").reduce((v, k) => (v && typeof v === "object" ? v[k] : undefined), body);
|
|
55
|
+
/** Stripe: header `t=<unix>,v1=<hex>`, HMAC-SHA256 over `<t>.<rawBody>`; GitHub: header `sha256=<hex>` over rawBody. */
|
|
56
|
+
export function checkProvider(att, secrets, ctx) {
|
|
57
|
+
const secret = att.provider === "stripe-webhook" ? secrets.stripe : secrets.github;
|
|
58
|
+
if (!secret)
|
|
59
|
+
return { ok: false, error: `no ${att.provider === "stripe-webhook" ? "Stripe" : "GitHub"} secret given` };
|
|
60
|
+
const hmac = (data) => createHmac("sha256", secret).update(data).digest("hex");
|
|
61
|
+
const equal = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
62
|
+
if (att.provider === "stripe-webhook") {
|
|
63
|
+
const parts = Object.fromEntries(att.signature.split(",").map((kv) => kv.split("=")));
|
|
64
|
+
const t = parts.t;
|
|
65
|
+
const v1 = parts.v1;
|
|
66
|
+
if (!t || !v1)
|
|
67
|
+
return { ok: false, error: "Stripe-Signature header lacks t or v1" };
|
|
68
|
+
if (!equal(hmac(`${t}.${att.rawBody}`), v1))
|
|
69
|
+
return { ok: false, error: "Stripe signature does not verify with this secret" };
|
|
70
|
+
const skew = Math.abs(Number(t) * 1000 - Date.parse(ctx.timestamp)) / 1000;
|
|
71
|
+
if (!(skew <= (ctx.toleranceSeconds ?? 300)))
|
|
72
|
+
return { ok: false, error: `Stripe timestamp is ${Math.round(skew)}s from the receipt, beyond tolerance` };
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
const hex = att.signature.startsWith("sha256=") ? att.signature.slice(7) : "";
|
|
76
|
+
if (!hex || !equal(hmac(att.rawBody), hex))
|
|
77
|
+
return { ok: false, error: "GitHub signature does not verify with this secret" };
|
|
78
|
+
}
|
|
79
|
+
let body;
|
|
80
|
+
try {
|
|
81
|
+
body = JSON.parse(att.rawBody);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { ok: false, error: "delivery body is not JSON" };
|
|
85
|
+
}
|
|
86
|
+
const bound = pathValue(body, att.bind);
|
|
87
|
+
if (bound === undefined || bound === null || bound === "")
|
|
88
|
+
return { ok: false, error: `delivery has no value at ${att.bind}` };
|
|
89
|
+
if (!JSON.stringify(ctx.result).includes(JSON.stringify(bound).replace(/^"|"$/g, "")))
|
|
90
|
+
return { ok: false, error: `delivery's ${att.bind} (${String(bound)}) does not appear in the receipt's result` };
|
|
91
|
+
return { ok: true, keyid: `shared secret (${att.provider}, bound on ${att.bind})` };
|
|
92
|
+
}
|
|
93
|
+
/** For fake providers and tests: a Stripe-Signature header for a body at a time. */
|
|
94
|
+
export function stripeSignature(rawBody, secret, unixSeconds) {
|
|
95
|
+
return `t=${unixSeconds},v1=${createHmac("sha256", secret).update(`${unixSeconds}.${rawBody}`).digest("hex")}`;
|
|
96
|
+
}
|
|
97
|
+
/** For fake providers and tests: an X-Hub-Signature-256 header for a body. */
|
|
98
|
+
export function githubSignature(rawBody, secret) {
|
|
99
|
+
return `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
100
|
+
}
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Envelope, type PublicKeyRef } from "./crypto.ts";
|
|
2
|
+
import { type ProviderSecrets } from "./upstream.ts";
|
|
2
3
|
import { type ReceiptBundle, type ReceiptStatement, type TreeHead } from "./receipt.ts";
|
|
3
4
|
export interface Check {
|
|
4
5
|
name: string;
|
|
@@ -13,6 +14,8 @@ export interface VerifyOptions {
|
|
|
13
14
|
logKeys?: PublicKeyRef[];
|
|
14
15
|
/** keys of upstreams that sign their results; with one given, an execution carrying an upstream signature is checked and becomes attested */
|
|
15
16
|
upstreamKeys?: PublicKeyRef[];
|
|
17
|
+
/** shared secrets for provider-native deliveries; with the matching one given, an execution carrying a Stripe or GitHub delivery is checked */
|
|
18
|
+
providerSecrets?: ProviderSecrets;
|
|
16
19
|
/** If given, the root is recomputed from this log file at the receipt's tree size and compared. */
|
|
17
20
|
logFile?: string;
|
|
18
21
|
}
|
package/dist/verify.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { canonicalize, digestOf, dsseVerify } from "./crypto.js";
|
|
3
3
|
import { delegationValidAt, verifyDelegation } from "./delegation.js";
|
|
4
4
|
import { leafHash, MerkleLog, verifyConsistency, verifyInclusion } from "./log.js";
|
|
5
|
-
import { checkUpstream, contentDigest } from "./upstream.js";
|
|
5
|
+
import { checkProvider, checkUpstream, contentDigest, isProviderAttestation } from "./upstream.js";
|
|
6
6
|
import { RECEIPT_PREDICATE_TYPE, RECEIPT_TYPE, TREEHEAD_TYPE } from "./receipt.js";
|
|
7
7
|
const short = (s) => s.slice(0, 12);
|
|
8
8
|
export function verifyBundle(bundle, opts) {
|
|
@@ -45,10 +45,18 @@ export function verifyBundle(bundle, opts) {
|
|
|
45
45
|
add("principal is claimed, not attested", p.principal.provenance === "claimed", "no signed delegation in this receipt");
|
|
46
46
|
}
|
|
47
47
|
add("request args digest", digestOf(p.request.args) === p.request.argsDigest && st.subject[0]?.digest.sha256 === p.request.argsDigest);
|
|
48
|
-
if ((p.execution.status === "executed" || p.execution.status === "failed") && p.execution.upstream
|
|
48
|
+
if ((p.execution.status === "executed" || p.execution.status === "failed") && p.execution.upstream) {
|
|
49
49
|
const result = p.execution.result;
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
if (isProviderAttestation(p.execution.upstream)) {
|
|
51
|
+
if (opts.providerSecrets) {
|
|
52
|
+
const u = checkProvider(p.execution.upstream, opts.providerSecrets, { timestamp: p.timestamp, result });
|
|
53
|
+
add("upstream signature (provider secret)", u.ok, u.ok ? u.keyid : u.error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if ((opts.upstreamKeys?.length ?? 0) > 0) {
|
|
57
|
+
const u = checkUpstream(p.execution.upstream.envelope, opts.upstreamKeys, { receiptId: p.receiptId, tool: p.tool.name, contentDigest: contentDigest(result) });
|
|
58
|
+
add("upstream signature (upstream key)", u.ok, u.ok ? `keyid ${short(u.keyid)}` : u.error);
|
|
59
|
+
}
|
|
52
60
|
}
|
|
53
61
|
if (p.policy) {
|
|
54
62
|
const consistent = p.policy.decision === "allow" ? p.execution.status !== "denied" : p.execution.status === "denied";
|
|
@@ -137,8 +145,11 @@ export function formatReport(r) {
|
|
|
137
145
|
row("policy", "-", "(none evaluated)");
|
|
138
146
|
if (p.consumed)
|
|
139
147
|
row("consumed", p.consumed.provenance, p.consumed.factIds.length === 0 ? "(no facts shown before this call)" : p.consumed.factIds);
|
|
140
|
-
const upstreamCheck = r.checks.find((c) => c.name === "upstream signature (upstream key)");
|
|
148
|
+
const upstreamCheck = r.checks.find((c) => c.name === "upstream signature (upstream key)" || c.name === "upstream signature (provider secret)");
|
|
141
149
|
const hasUpstream = (p.execution.status === "executed" || p.execution.status === "failed") && !!p.execution.upstream;
|
|
142
|
-
|
|
150
|
+
const byProvider = hasUpstream && isProviderAttestation(p.execution.upstream);
|
|
151
|
+
const attestedAs = upstreamCheck?.ok ? (byProvider ? "attested (shared secret)" : "attested") : p.execution.provenance;
|
|
152
|
+
const note = !hasUpstream ? "" : upstreamCheck ? (upstreamCheck.ok ? ` (${byProvider ? upstreamCheck.detail : `signed by upstream ${upstreamCheck.detail}`})` : " (upstream signature FAILED)") : byProvider ? " (carries a provider delivery; pass the provider secret to check it)" : " (carries an upstream signature; pass --upstream-key to check it)";
|
|
153
|
+
row("execution", attestedAs, `${p.execution.status}${note}`);
|
|
143
154
|
return lines.join("\n");
|
|
144
155
|
}
|
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`.
|