@agent-custody/receipts 0.1.0 → 0.1.2
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 +20 -19
- package/dist/cli.js +81 -3
- package/dist/config.d.ts +10 -2
- package/dist/config.js +12 -6
- package/dist/gateway.js +3 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/issue.d.ts +6 -2
- package/dist/issue.js +9 -8
- package/dist/log-sink.d.ts +53 -0
- package/dist/log-sink.js +136 -0
- package/dist/log.d.ts +6 -0
- package/dist/log.js +62 -0
- package/dist/sdk/claude.d.ts +1 -1
- package/dist/sdk/claude.js +4 -4
- package/dist/sdk/index.d.ts +5 -2
- package/dist/sdk/index.js +6 -4
- package/dist/sdk/langchain.d.ts +2 -2
- package/dist/sdk/langchain.js +4 -4
- package/dist/sdk/openai-agents.js +5 -4
- package/dist/sdk/vercel-ai.js +3 -3
- package/dist/sidecar.d.ts +12 -0
- package/dist/sidecar.js +93 -0
- package/dist/verify.d.ts +15 -2
- package/dist/verify.js +30 -3
- package/docs/sdk.md +27 -2
- package/docs/tutorials.md +3 -0
- package/docs/usage.md +33 -0
- package/docs/verification.md +22 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Two producers, one receipt format, one verifier.
|
|
|
9
9
|
|
|
10
10
|
Anyone holding the public keys can verify a receipt offline. The agent is not trusted. The layer around it is, and the receipt says exactly how far that trust extends, starting with who issued it.
|
|
11
11
|
|
|
12
|
-
- [Tutorials](docs/tutorials.md):
|
|
12
|
+
- [Tutorials](docs/tutorials.md): fifteen runnable examples, one per aspect of the code, all executed by the test suite
|
|
13
13
|
- [Usage guide](docs/usage.md): gateway setup, wiring into Claude Desktop, Claude Code, or your own agent loop
|
|
14
14
|
- [The interceptor SDK](docs/sdk.md): Claude Code hooks, the Claude Agent SDK, adapters for the OpenAI Agents SDK, Vercel AI SDK and LangChain, and wrapping tool functions in anything else
|
|
15
15
|
- [Writing policies](docs/policies.md): how a tool call becomes a Cedar request, with tested examples
|
|
@@ -21,13 +21,7 @@ Anyone holding the public keys can verify a receipt offline. The agent is not tr
|
|
|
21
21
|
npm install @agent-custody/receipts # or bun add, pnpm add
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
bun install && bun run build # at the repository root
|
|
28
|
-
cd packages/receipts && bun pm pack # writes agent-custody-receipts-0.1.0.tgz here
|
|
29
|
-
npm install /path/to/agent-custody/packages/receipts/agent-custody-receipts-0.1.0.tgz # in your project
|
|
30
|
-
```
|
|
24
|
+
Published on npm as [`@agent-custody/receipts`](https://www.npmjs.com/package/@agent-custody/receipts): compiled JavaScript with type declarations, Node 22 or later, Apache-2.0.
|
|
31
25
|
|
|
32
26
|
Record receipts from inside your own agent, no gateway needed. Generate a signing key, point a config at it, wrap the functions the agent calls:
|
|
33
27
|
|
|
@@ -62,7 +56,7 @@ flowchart LR
|
|
|
62
56
|
G["agent-custody gateway<br/>scope check → fact lookups → Cedar policy"]
|
|
63
57
|
U["Upstream MCP server<br/>Stripe, database, GitHub, ..."]
|
|
64
58
|
R[("receipt bundles<br/>receipts/*.json")]
|
|
65
|
-
L[("Merkle log<br/>log
|
|
59
|
+
L[("Merkle log<br/>local file, or a remote log<br/>run by someone else")]
|
|
66
60
|
V["Verifier<br/>auditor, counterparty, CI job"]
|
|
67
61
|
O["Observability<br/>OTel, LangSmith, Arize"]
|
|
68
62
|
|
|
@@ -109,6 +103,9 @@ Every receipt names its issuer, and the verifier prints what that issuer kind is
|
|
|
109
103
|
| Vercel AI SDK | SDK | `wrapTools` | | a real `generateText` loop over the SDK's mock model |
|
|
110
104
|
| LangChain / LangGraph (JS) | SDK | `tool(issuer.wrap(fn))` | `ReceiptCallbackHandler` | real `StructuredTool` invocations |
|
|
111
105
|
| anything else | SDK | `issuer.wrap(name, fn)` | `issuer.record` | plain functions |
|
|
106
|
+
| Python: LangChain, OpenAI Agents SDK, Claude Agent SDK | sidecar + [Python package](../python/README.md) | `wrap_tools`, `claude_hook` PreToolUse deny, `client.wrap` | `ReceiptCallbackHandler` | the real Python packages, receipts checked by this verifier |
|
|
107
|
+
| Go, Java, Rust, any language with HTTP | sidecar | decide then record | record | [examples/languages](examples/languages), each run against a live sidecar |
|
|
108
|
+
| any MCP host in any language: Claude Agent SDK Python, OpenAI Agents Python | gateway | yes | | the gateway is an MCP server; [usage.md](docs/usage.md#python-hosts) |
|
|
112
109
|
|
|
113
110
|
The framework packages are optional peer dependencies. Each adapter imports only from its own package.
|
|
114
111
|
|
|
@@ -173,7 +170,7 @@ Every field carries a provenance label. This is the design decision that matters
|
|
|
173
170
|
bun install # from the repository root, once for the workspace
|
|
174
171
|
cd packages/receipts
|
|
175
172
|
node scripts/demo.ts # gateway: keys, grant, policy, four tool calls, verification, a tampering attempt; then the SDK wrapping the same tool
|
|
176
|
-
node examples/01-keys-and-signing.ts # first of
|
|
173
|
+
node examples/01-keys-and-signing.ts # first of fifteen step-by-step examples, see docs/tutorials.md
|
|
177
174
|
bun run test # this package; `bun run test` at the root runs every package
|
|
178
175
|
```
|
|
179
176
|
|
|
@@ -223,7 +220,8 @@ If a vendor tells you their receipts prove more than the first five rows, ask th
|
|
|
223
220
|
```
|
|
224
221
|
src/config.ts gateway and SDK config schemas, path resolution
|
|
225
222
|
src/crypto.ts canonical JSON, sha256, Ed25519 keys, DSSE sign/verify
|
|
226
|
-
src/log.ts Merkle log: append, root, inclusion
|
|
223
|
+
src/log.ts Merkle log: append, root, inclusion and consistency proofs, verify, JSONL persistence
|
|
224
|
+
src/log-sink.ts where leaves go: the local file, or a remote log over HTTP; plus the reference log server
|
|
227
225
|
src/policy.ts Cedar evaluation wrapper, fail-closed
|
|
228
226
|
src/delegation.ts signed delegation grants
|
|
229
227
|
src/receipt.ts receipt statement types and provenance labels
|
|
@@ -232,12 +230,13 @@ src/gateway.ts the MCP proxy: scope check, facts, policy, forward, receipt
|
|
|
232
230
|
src/sdk/index.ts the interceptor: policy decision, record, wrap(tool fn)
|
|
233
231
|
src/sdk/claude.ts Claude Code command hook and Claude Agent SDK in-process hooks
|
|
234
232
|
src/sdk/openai-agents.ts, vercel-ai.ts, langchain.ts framework adapters, tested against the real packages
|
|
235
|
-
src/
|
|
236
|
-
src/
|
|
233
|
+
src/sidecar.ts the SDK issuer behind a local HTTP API, for agents in other languages
|
|
234
|
+
src/verify.ts offline verification, the human-readable report, and the audit that a later log extends an earlier one
|
|
235
|
+
src/cli.ts keygen, grant, gateway, hook, serve, log, verify, audit
|
|
237
236
|
src/index.ts the package's public surface; adapters are exported on ./sdk/<framework> subpaths
|
|
238
237
|
tsconfig.build.json emits dist/ (JavaScript plus declarations) for consumers; the repo itself runs the .ts directly
|
|
239
238
|
scripts/ fake Stripe upstream, fixture builders for gateway and SDK, demo
|
|
240
|
-
examples/
|
|
239
|
+
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
|
|
241
240
|
test/ unit tests per module, end-to-end gateway test, SDK and hook tests,
|
|
242
241
|
adapter tests against the real packages, and a test that runs every policy in docs/policies.md
|
|
243
242
|
docs/ tutorials, usage (gateway), sdk, policies, verification
|
|
@@ -254,16 +253,18 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
|
|
|
254
253
|
- SDK core: policy decision, record, and a generic `wrap(tool, fn)` for any framework whose tools are functions.
|
|
255
254
|
- Claude Code command hook for PreToolUse, PostToolUse, and PostToolUseFailure, with blocking on deny.
|
|
256
255
|
- Claude Agent SDK in-process hooks over the same handler.
|
|
256
|
+
- Sidecar: the SDK issuer behind a local HTTP API (`serve`), with a Python package on PyPI-ready footing and Go, Java, and Rust clients, so agents in any language get the same receipts from one signing implementation.
|
|
257
|
+
- Consistency proofs between tree heads (RFC 9162), served by the log and checked by the `audit` command, so an auditor holding an old tree head can prove nothing before it was rewritten.
|
|
258
|
+
- Remote log: the issuer can append to a log run by someone else over HTTP, whose key then signs the tree heads, so a verifier learns the receipt was in a log the operator could not rewrite. Includes the reference log server, bearer-token auth, and a root endpoint for auditors.
|
|
257
259
|
- Framework adapters, each tested against the real package with a scripted model and no network: OpenAI Agents SDK (`wrapTools` enforces, `observeRunner` records from lifecycle events), Vercel AI SDK (`wrapTools` over a real `generateText` loop), LangChain (`ReceiptCallbackHandler` records, `issuer.wrap` enforces).
|
|
258
260
|
|
|
259
261
|
**Next, in the order it pays off**
|
|
260
262
|
|
|
261
263
|
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.
|
|
262
264
|
2. Embed upstream signed responses (Stripe webhook signatures, GitHub delivery signatures) so gateway execution can move from `observed` to `attested`.
|
|
263
|
-
3.
|
|
264
|
-
4.
|
|
265
|
-
5.
|
|
266
|
-
6.
|
|
267
|
-
7. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
|
|
265
|
+
3. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
|
|
266
|
+
4. Delegation chains for sub-agents.
|
|
267
|
+
5. Receiver-attested receipts for agent-to-agent calls.
|
|
268
|
+
6. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
|
|
268
269
|
|
|
269
270
|
A Python SDK follows the same shape once the TypeScript adapters have settled.
|
package/dist/cli.js
CHANGED
|
@@ -5,16 +5,23 @@ import { loadConfig, loadSdkConfig } from "./config.js";
|
|
|
5
5
|
import { generateKeyPair, loadPrivateKey, loadPublicKey, writeKeyPair } from "./crypto.js";
|
|
6
6
|
import { createDelegation } from "./delegation.js";
|
|
7
7
|
import { createGateway, serveStdio } from "./gateway.js";
|
|
8
|
+
import { serveLog } from "./log-sink.js";
|
|
9
|
+
import { serveSidecar } from "./sidecar.js";
|
|
10
|
+
import { MerkleLog } from "./log.js";
|
|
8
11
|
import { createSdkIssuer } from "./sdk/index.js";
|
|
9
12
|
import { handleHookEvent } from "./sdk/claude.js";
|
|
10
|
-
import { formatReport, verifyBundle } from "./verify.js";
|
|
13
|
+
import { auditExtends, formatReport, verifyBundle } from "./verify.js";
|
|
11
14
|
const USAGE = `agent-custody <command>
|
|
12
15
|
|
|
13
16
|
keygen --dir <dir> --name <name>
|
|
14
17
|
grant --key <principal.key> --principal <id> --agent <id> --scopes <a,b> [--ttl-hours 24] --out <file>
|
|
15
18
|
gateway --config <gateway.json>
|
|
16
19
|
hook [--config <sdk.json>] Claude Code hook command; reads the event on stdin (or AGENT_CUSTODY_CONFIG)
|
|
17
|
-
|
|
20
|
+
serve --config <sdk.json> [--port 8788] [--host 127.0.0.1] the SDK as a local HTTP API for agents in other languages
|
|
21
|
+
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>] [--log <log.jsonl>] [--json]
|
|
23
|
+
audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--json]
|
|
24
|
+
checks that the newer receipt's log extends the older one's: nothing between them was rewritten
|
|
18
25
|
`;
|
|
19
26
|
async function main(argv) {
|
|
20
27
|
const [cmd, ...rest] = argv;
|
|
@@ -71,10 +78,38 @@ async function main(argv) {
|
|
|
71
78
|
if (!configPath)
|
|
72
79
|
throw new Error("hook needs --config or AGENT_CUSTODY_CONFIG");
|
|
73
80
|
const input = JSON.parse(readFileSync(0, "utf8"));
|
|
74
|
-
const out = handleHookEvent(createSdkIssuer(loadSdkConfig(configPath)), input);
|
|
81
|
+
const out = await handleHookEvent(createSdkIssuer(loadSdkConfig(configPath)), input);
|
|
75
82
|
console.log(JSON.stringify(out));
|
|
76
83
|
return 0;
|
|
77
84
|
}
|
|
85
|
+
case "serve": {
|
|
86
|
+
const { values } = parseArgs({ args: rest, options: { config: { type: "string" }, port: { type: "string", default: "8788" }, host: { type: "string", default: "127.0.0.1" } } });
|
|
87
|
+
if (!values.config)
|
|
88
|
+
throw new Error("serve needs --config");
|
|
89
|
+
const issuer = createSdkIssuer(loadSdkConfig(values.config));
|
|
90
|
+
const running = await serveSidecar(issuer, { port: Number(values.port), host: values.host });
|
|
91
|
+
console.error(`agent-custody serve: ${running.url} agent=${issuer.agentId} keyid=${issuer.keyid} log=${issuer.log.kind}:${issuer.log.where}`);
|
|
92
|
+
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
93
|
+
await running.close();
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
case "log": {
|
|
97
|
+
const { values } = parseArgs({
|
|
98
|
+
args: rest,
|
|
99
|
+
options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" } },
|
|
100
|
+
});
|
|
101
|
+
if (!values.file || !values.key)
|
|
102
|
+
throw new Error("log needs --file and --key");
|
|
103
|
+
const token = values["token-env"] ? process.env[values["token-env"]] : undefined;
|
|
104
|
+
if (values["token-env"] && !token)
|
|
105
|
+
throw new Error(`log: environment variable ${values["token-env"]} is not set`);
|
|
106
|
+
const key = loadPrivateKey(values.key);
|
|
107
|
+
const running = await serveLog(values.file, key, { port: Number(values.port), host: values.host, ...(token ? { tokens: [token] } : {}) });
|
|
108
|
+
console.error(`agent-custody log: ${running.url} keyid=${key.keyid} file=${values.file} ${token ? "bearer token required" : "open, anyone may append"}`);
|
|
109
|
+
await new Promise((resolve) => process.once("SIGINT", resolve));
|
|
110
|
+
await running.close();
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
78
113
|
case "verify": {
|
|
79
114
|
const { values, positionals } = parseArgs({
|
|
80
115
|
args: rest,
|
|
@@ -83,6 +118,7 @@ async function main(argv) {
|
|
|
83
118
|
"issuer-key": { type: "string", multiple: true },
|
|
84
119
|
"gateway-key": { type: "string", multiple: true },
|
|
85
120
|
"principal-key": { type: "string", multiple: true },
|
|
121
|
+
"log-key": { type: "string", multiple: true },
|
|
86
122
|
log: { type: "string" },
|
|
87
123
|
json: { type: "boolean", default: false },
|
|
88
124
|
},
|
|
@@ -95,11 +131,53 @@ async function main(argv) {
|
|
|
95
131
|
const result = verifyBundle(bundle, {
|
|
96
132
|
issuerKeys: issuerKeyFiles.map(loadPublicKey),
|
|
97
133
|
principalKeys: (values["principal-key"] ?? []).map(loadPublicKey),
|
|
134
|
+
...(values["log-key"] ? { logKeys: values["log-key"].map(loadPublicKey) } : {}),
|
|
98
135
|
...(values.log ? { logFile: values.log } : {}),
|
|
99
136
|
});
|
|
100
137
|
console.log(values.json ? JSON.stringify(result, null, 2) : formatReport(result));
|
|
101
138
|
return result.ok ? 0 : 1;
|
|
102
139
|
}
|
|
140
|
+
case "audit": {
|
|
141
|
+
const { values } = parseArgs({
|
|
142
|
+
args: rest,
|
|
143
|
+
options: {
|
|
144
|
+
older: { type: "string" },
|
|
145
|
+
newer: { type: "string" },
|
|
146
|
+
log: { type: "string" },
|
|
147
|
+
"log-url": { type: "string" },
|
|
148
|
+
"issuer-key": { type: "string", multiple: true },
|
|
149
|
+
"log-key": { type: "string", multiple: true },
|
|
150
|
+
json: { type: "boolean", default: false },
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
const keyFiles = [...(values["issuer-key"] ?? []), ...(values["log-key"] ?? [])];
|
|
154
|
+
if (!values.older || !values.newer || keyFiles.length === 0)
|
|
155
|
+
throw new Error("audit needs --older, --newer, and at least one --issuer-key or --log-key");
|
|
156
|
+
if (!values.log === !values["log-url"])
|
|
157
|
+
throw new Error("audit needs exactly one of --log or --log-url");
|
|
158
|
+
const older = JSON.parse(readFileSync(values.older, "utf8")).treeHead;
|
|
159
|
+
const newer = JSON.parse(readFileSync(values.newer, "utf8")).treeHead;
|
|
160
|
+
const sizeOf = (env) => JSON.parse(Buffer.from(env.payload, "base64").toString()).treeSize;
|
|
161
|
+
const [m, n] = [sizeOf(older), sizeOf(newer)];
|
|
162
|
+
let proof;
|
|
163
|
+
if (values.log)
|
|
164
|
+
proof = new MerkleLog(values.log).consistencyProof(Math.min(m, n), Math.max(m, n));
|
|
165
|
+
else {
|
|
166
|
+
const res = await fetch(new URL(`consistency?old=${Math.min(m, n)}&new=${Math.max(m, n)}`, values["log-url"].endsWith("/") ? values["log-url"] : `${values["log-url"]}/`));
|
|
167
|
+
if (!res.ok)
|
|
168
|
+
throw new Error(`log refused the consistency query: ${res.status}`);
|
|
169
|
+
proof = (await res.json()).hashes;
|
|
170
|
+
}
|
|
171
|
+
const result = auditExtends(older, newer, proof, keyFiles.map(loadPublicKey));
|
|
172
|
+
if (values.json)
|
|
173
|
+
console.log(JSON.stringify(result, null, 2));
|
|
174
|
+
else {
|
|
175
|
+
for (const c of result.checks)
|
|
176
|
+
console.log(`${c.ok ? "PASS" : "FAIL"} ${c.name}${c.detail ? ` (${c.detail})` : ""}`);
|
|
177
|
+
console.log(`\nRESULT: ${result.ok ? "NEWER LOG EXTENDS OLDER LOG" : "NOT CONSISTENT"}`);
|
|
178
|
+
}
|
|
179
|
+
return result.ok ? 0 : 1;
|
|
180
|
+
}
|
|
103
181
|
default:
|
|
104
182
|
console.error(USAGE);
|
|
105
183
|
return cmd === undefined || cmd === "--help" || cmd === "-h" ? 0 : 2;
|
package/dist/config.d.ts
CHANGED
|
@@ -24,7 +24,11 @@ export declare const GatewayConfigSchema: z.ZodObject<{
|
|
|
24
24
|
forTools: z.ZodArray<z.ZodString>;
|
|
25
25
|
}, z.core.$strip>>>;
|
|
26
26
|
receiptsDir: z.ZodString;
|
|
27
|
-
logFile: z.ZodString
|
|
27
|
+
logFile: z.ZodOptional<z.ZodString>;
|
|
28
|
+
log: z.ZodOptional<z.ZodObject<{
|
|
29
|
+
url: z.ZodString;
|
|
30
|
+
tokenEnv: z.ZodOptional<z.ZodString>;
|
|
31
|
+
}, z.core.$strip>>;
|
|
28
32
|
}, z.core.$strip>;
|
|
29
33
|
export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
|
|
30
34
|
export type FactConfig = z.infer<typeof FactSchema>;
|
|
@@ -38,7 +42,11 @@ export declare const SdkConfigSchema: z.ZodObject<{
|
|
|
38
42
|
}, z.core.$strip>;
|
|
39
43
|
policyFile: z.ZodOptional<z.ZodString>;
|
|
40
44
|
receiptsDir: z.ZodString;
|
|
41
|
-
logFile: z.ZodString
|
|
45
|
+
logFile: z.ZodOptional<z.ZodString>;
|
|
46
|
+
log: z.ZodOptional<z.ZodObject<{
|
|
47
|
+
url: z.ZodString;
|
|
48
|
+
tokenEnv: z.ZodOptional<z.ZodString>;
|
|
49
|
+
}, z.core.$strip>>;
|
|
42
50
|
framework: z.ZodOptional<z.ZodString>;
|
|
43
51
|
}, z.core.$strip>;
|
|
44
52
|
export type SdkConfig = z.infer<typeof SdkConfigSchema>;
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
|
+
/** Where receipts are logged: a local file, or a log reached over HTTP whose bearer token comes from an environment variable. */
|
|
5
|
+
const LogSchema = z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional() });
|
|
6
|
+
const oneLog = { message: "exactly one of logFile or log is required" };
|
|
7
|
+
const hasOneLog = (c) => (c.logFile ? 1 : 0) + (c.log ? 1 : 0) === 1;
|
|
4
8
|
const FactSchema = z.object({
|
|
5
9
|
/** key under context.facts */
|
|
6
10
|
name: z.string().min(1),
|
|
@@ -23,8 +27,9 @@ export const GatewayConfigSchema = z.object({
|
|
|
23
27
|
policyFile: z.string(),
|
|
24
28
|
facts: z.array(FactSchema).default([]),
|
|
25
29
|
receiptsDir: z.string(),
|
|
26
|
-
logFile: z.string(),
|
|
27
|
-
|
|
30
|
+
logFile: z.string().optional(),
|
|
31
|
+
log: LogSchema.optional(),
|
|
32
|
+
}).refine(hasOneLog, oneLog);
|
|
28
33
|
/** Loads a config file and resolves every path relative to the file's directory. */
|
|
29
34
|
export function loadConfig(path) {
|
|
30
35
|
const cfg = GatewayConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
@@ -37,7 +42,7 @@ export function loadConfig(path) {
|
|
|
37
42
|
trustedPrincipalKeys: cfg.trustedPrincipalKeys.map(r),
|
|
38
43
|
policyFile: r(cfg.policyFile),
|
|
39
44
|
receiptsDir: r(cfg.receiptsDir),
|
|
40
|
-
logFile: r(cfg.logFile),
|
|
45
|
+
...(cfg.logFile ? { logFile: r(cfg.logFile) } : {}),
|
|
41
46
|
};
|
|
42
47
|
}
|
|
43
48
|
export const SdkConfigSchema = z.object({
|
|
@@ -48,10 +53,11 @@ export const SdkConfigSchema = z.object({
|
|
|
48
53
|
/** optional Cedar policy; when present, wrapped tools and PreToolUse hooks can deny */
|
|
49
54
|
policyFile: z.string().optional(),
|
|
50
55
|
receiptsDir: z.string(),
|
|
51
|
-
logFile: z.string(),
|
|
56
|
+
logFile: z.string().optional(),
|
|
57
|
+
log: LogSchema.optional(),
|
|
52
58
|
/** free-text label of the host framework, e.g. "claude-code", "openai-agents" */
|
|
53
59
|
framework: z.string().optional(),
|
|
54
|
-
});
|
|
60
|
+
}).refine(hasOneLog, oneLog);
|
|
55
61
|
export function loadSdkConfig(path) {
|
|
56
62
|
const cfg = SdkConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
57
63
|
const base = dirname(resolve(path));
|
|
@@ -61,6 +67,6 @@ export function loadSdkConfig(path) {
|
|
|
61
67
|
identity: { keyFile: r(cfg.identity.keyFile) },
|
|
62
68
|
...(cfg.policyFile ? { policyFile: r(cfg.policyFile) } : {}),
|
|
63
69
|
receiptsDir: r(cfg.receiptsDir),
|
|
64
|
-
logFile: r(cfg.logFile),
|
|
70
|
+
...(cfg.logFile ? { logFile: r(cfg.logFile) } : {}),
|
|
65
71
|
};
|
|
66
72
|
}
|
package/dist/gateway.js
CHANGED
|
@@ -10,6 +10,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
10
10
|
import { digestOf, loadPrivateKey, loadPublicKey } from "./crypto.js";
|
|
11
11
|
import { delegationValidAt, verifyDelegation } from "./delegation.js";
|
|
12
12
|
import { createIssuer } from "./issue.js";
|
|
13
|
+
import { openLog } from "./log-sink.js";
|
|
13
14
|
import { evaluate, policyDigest } from "./policy.js";
|
|
14
15
|
export const GATEWAY_VERSION = "0.1.0";
|
|
15
16
|
export const RECEIPT_META_KEY = "agent-custody/receipt";
|
|
@@ -54,7 +55,7 @@ export async function createGateway(cfg) {
|
|
|
54
55
|
const principalKeyid = grant.keyid;
|
|
55
56
|
const policyText = readFileSync(cfg.policyFile, "utf8");
|
|
56
57
|
const pDigest = policyDigest(policyText);
|
|
57
|
-
const issuer = createIssuer(gatewayKey, cfg.receiptsDir, cfg
|
|
58
|
+
const issuer = createIssuer(gatewayKey, cfg.receiptsDir, openLog(cfg, gatewayKey));
|
|
58
59
|
const upstream = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
|
|
59
60
|
await upstream.connect(new StdioClientTransport({ command: cfg.upstream.command, args: cfg.upstream.args, env: cfg.upstream.env, stderr: "inherit" }));
|
|
60
61
|
const callUpstream = async (name, args) => (await upstream.callTool({ name, arguments: args }));
|
|
@@ -107,7 +108,7 @@ export async function createGateway(cfg) {
|
|
|
107
108
|
else {
|
|
108
109
|
execution = { status: "denied", reason: [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched", provenance: "observed" };
|
|
109
110
|
}
|
|
110
|
-
issuer.issue({
|
|
111
|
+
await issuer.issue({
|
|
111
112
|
receiptId,
|
|
112
113
|
timestamp,
|
|
113
114
|
issuer: { kind: "gateway", keyid: issuer.keyid, version: GATEWAY_VERSION },
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ export * from "./delegation.ts";
|
|
|
4
4
|
export * from "./gateway.ts";
|
|
5
5
|
export * from "./issue.ts";
|
|
6
6
|
export * from "./log.ts";
|
|
7
|
+
export * from "./log-sink.ts";
|
|
7
8
|
export * from "./policy.ts";
|
|
8
9
|
export * from "./receipt.ts";
|
|
9
10
|
export * from "./verify.ts";
|
|
10
11
|
export * from "./sdk/index.ts";
|
|
12
|
+
export * from "./sidecar.ts";
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,9 @@ export * from "./delegation.js";
|
|
|
5
5
|
export * from "./gateway.js";
|
|
6
6
|
export * from "./issue.js";
|
|
7
7
|
export * from "./log.js";
|
|
8
|
+
export * from "./log-sink.js";
|
|
8
9
|
export * from "./policy.js";
|
|
9
10
|
export * from "./receipt.js";
|
|
10
11
|
export * from "./verify.js";
|
|
11
12
|
export * from "./sdk/index.js";
|
|
13
|
+
export * from "./sidecar.js";
|
package/dist/issue.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type KeyPair } from "./crypto.ts";
|
|
2
|
+
import { type LogSink } from "./log-sink.ts";
|
|
2
3
|
import { type ReceiptBundle, type ReceiptPredicate } from "./receipt.ts";
|
|
3
4
|
export interface Issuer {
|
|
4
5
|
keyid: string;
|
|
5
|
-
|
|
6
|
+
log: LogSink;
|
|
7
|
+
/** Signs the statement, appends it to the log, writes the bundle. Rejects if the log refuses the leaf; no bundle is written then. */
|
|
8
|
+
issue(predicate: ReceiptPredicate): Promise<ReceiptBundle>;
|
|
6
9
|
}
|
|
7
|
-
|
|
10
|
+
/** `log` is a sink, or a file path for the local log with tree heads signed by the issuer's key. */
|
|
11
|
+
export declare function createIssuer(key: KeyPair, receiptsDir: string, log: string | LogSink): Issuer;
|
package/dist/issue.js
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { canonicalize, dsseSign } from "./crypto.js";
|
|
5
|
-
import {
|
|
6
|
-
import { buildStatement, RECEIPT_TYPE
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { fileLog } from "./log-sink.js";
|
|
6
|
+
import { buildStatement, RECEIPT_TYPE } from "./receipt.js";
|
|
7
|
+
/** `log` is a sink, or a file path for the local log with tree heads signed by the issuer's key. */
|
|
8
|
+
export function createIssuer(key, receiptsDir, log) {
|
|
9
|
+
const sink = typeof log === "string" ? fileLog(log, key) : log;
|
|
9
10
|
mkdirSync(receiptsDir, { recursive: true });
|
|
10
11
|
return {
|
|
11
12
|
keyid: key.keyid,
|
|
12
|
-
|
|
13
|
+
log: sink,
|
|
14
|
+
async issue(predicate) {
|
|
13
15
|
const envelope = dsseSign(RECEIPT_TYPE, buildStatement(predicate), key);
|
|
14
|
-
const entry =
|
|
15
|
-
const
|
|
16
|
-
const bundle = { envelope, treeHead, inclusion: { leafIndex: entry.leafIndex, treeSize: entry.treeSize, hashes: entry.hashes } };
|
|
16
|
+
const entry = await sink.append(canonicalize(envelope));
|
|
17
|
+
const bundle = { envelope, treeHead: entry.treeHead, inclusion: entry.inclusion };
|
|
17
18
|
writeFileSync(join(receiptsDir, `${predicate.receiptId}.json`), JSON.stringify(bundle, null, 2));
|
|
18
19
|
return bundle;
|
|
19
20
|
},
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { type Envelope, type KeyPair } from "./crypto.ts";
|
|
3
|
+
import { type InclusionProof } from "./log.ts";
|
|
4
|
+
export interface LogAppend {
|
|
5
|
+
inclusion: InclusionProof;
|
|
6
|
+
/** signed TreeHead; the signature's keyid says who runs the log */
|
|
7
|
+
treeHead: Envelope;
|
|
8
|
+
}
|
|
9
|
+
export interface LogSink {
|
|
10
|
+
readonly kind: "file" | "http";
|
|
11
|
+
/** the file path or the URL, for reports */
|
|
12
|
+
readonly where: string;
|
|
13
|
+
append(leaf: string): Promise<LogAppend>;
|
|
14
|
+
}
|
|
15
|
+
/** A local JSONL Merkle log. Tree heads are signed with the given key, normally the issuer's own. */
|
|
16
|
+
export declare function fileLog(file: string, key: KeyPair): LogSink;
|
|
17
|
+
export interface HttpLogOptions {
|
|
18
|
+
/** sent as a bearer token; the log decides what it is worth */
|
|
19
|
+
token?: string;
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
/** A log reached over HTTP: POST <url>/append with {leaf}, expecting a LogAppend back. */
|
|
23
|
+
export declare function httpLog(url: string, opts?: HttpLogOptions): LogSink;
|
|
24
|
+
export interface LogConfig {
|
|
25
|
+
logFile?: string | undefined;
|
|
26
|
+
log?: {
|
|
27
|
+
url: string;
|
|
28
|
+
tokenEnv?: string | undefined;
|
|
29
|
+
} | undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The sink a config asks for: a remote log when `log` is set, otherwise the local file. */
|
|
32
|
+
export declare function openLog(cfg: LogConfig, key: KeyPair): LogSink;
|
|
33
|
+
export interface LogServerOptions {
|
|
34
|
+
/** bearer tokens accepted on append; when empty, anyone may append */
|
|
35
|
+
tokens?: string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The reference log server as a node:http request handler.
|
|
39
|
+
* POST /append {leaf} -> LogAppend, tree head signed with the log's key
|
|
40
|
+
* GET /root?size=N -> {treeSize, rootHash}, for auditors checking a tree head against the log
|
|
41
|
+
* GET /consistency?old=M&new=N -> {oldSize, newSize, hashes}, proof that the log at N extends the log at M
|
|
42
|
+
* GET /head -> {treeHead}, the current tree head signed with the log's key
|
|
43
|
+
*/
|
|
44
|
+
export declare function logHandler(file: string, key: KeyPair, opts?: LogServerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
45
|
+
export interface RunningLog {
|
|
46
|
+
url: string;
|
|
47
|
+
close(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
/** Starts the reference log server. Port 0 picks a free port. */
|
|
50
|
+
export declare function serveLog(file: string, key: KeyPair, opts: LogServerOptions & {
|
|
51
|
+
port: number;
|
|
52
|
+
host?: string;
|
|
53
|
+
}): Promise<RunningLog>;
|
package/dist/log-sink.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Where log leaves go.
|
|
2
|
+
// The file sink is the local Merkle log, with tree heads signed by the issuer's own key: tamper-evident, but the
|
|
3
|
+
// operator holds the file. The HTTP sink hands each leaf to a log run by someone else, who signs the tree head with
|
|
4
|
+
// their key. A verifier who trusts that key learns the receipt was in a log the operator cannot rewrite.
|
|
5
|
+
// logHandler and serveLog are the other side: a reference log server over node:http, the same code a hosted log runs.
|
|
6
|
+
import { timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { dsseSign } from "./crypto.js";
|
|
9
|
+
import { MerkleLog } from "./log.js";
|
|
10
|
+
import { TREEHEAD_TYPE } from "./receipt.js";
|
|
11
|
+
function appendSigned(log, key, leaf) {
|
|
12
|
+
const e = log.append(leaf);
|
|
13
|
+
const head = { treeSize: e.treeSize, rootHash: e.rootHash, timestamp: new Date().toISOString() };
|
|
14
|
+
return { inclusion: { leafIndex: e.leafIndex, treeSize: e.treeSize, hashes: e.hashes }, treeHead: dsseSign(TREEHEAD_TYPE, head, key) };
|
|
15
|
+
}
|
|
16
|
+
/** A local JSONL Merkle log. Tree heads are signed with the given key, normally the issuer's own. */
|
|
17
|
+
export function fileLog(file, key) {
|
|
18
|
+
const log = new MerkleLog(file);
|
|
19
|
+
return {
|
|
20
|
+
kind: "file",
|
|
21
|
+
where: file,
|
|
22
|
+
async append(leaf) {
|
|
23
|
+
return appendSigned(log, key, leaf);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** A log reached over HTTP: POST <url>/append with {leaf}, expecting a LogAppend back. */
|
|
28
|
+
export function httpLog(url, opts = {}) {
|
|
29
|
+
const f = opts.fetch ?? fetch;
|
|
30
|
+
const base = url.endsWith("/") ? url : `${url}/`;
|
|
31
|
+
return {
|
|
32
|
+
kind: "http",
|
|
33
|
+
where: url,
|
|
34
|
+
async append(leaf) {
|
|
35
|
+
const res = await f(new URL("append", base), {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "content-type": "application/json", ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}) },
|
|
38
|
+
body: JSON.stringify({ leaf }),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error(`log ${url} refused the append: ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
42
|
+
const body = (await res.json());
|
|
43
|
+
if (!body.inclusion || !body.treeHead)
|
|
44
|
+
throw new Error(`log ${url} returned a malformed append result`);
|
|
45
|
+
return body;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** The sink a config asks for: a remote log when `log` is set, otherwise the local file. */
|
|
50
|
+
export function openLog(cfg, key) {
|
|
51
|
+
if (cfg.log) {
|
|
52
|
+
const token = cfg.log.tokenEnv ? process.env[cfg.log.tokenEnv] : undefined;
|
|
53
|
+
if (cfg.log.tokenEnv && !token)
|
|
54
|
+
throw new Error(`log token: environment variable ${cfg.log.tokenEnv} is not set`);
|
|
55
|
+
return httpLog(cfg.log.url, token === undefined ? {} : { token });
|
|
56
|
+
}
|
|
57
|
+
if (!cfg.logFile)
|
|
58
|
+
throw new Error("config needs logFile or log.url");
|
|
59
|
+
return fileLog(cfg.logFile, key);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The reference log server as a node:http request handler.
|
|
63
|
+
* POST /append {leaf} -> LogAppend, tree head signed with the log's key
|
|
64
|
+
* GET /root?size=N -> {treeSize, rootHash}, for auditors checking a tree head against the log
|
|
65
|
+
* GET /consistency?old=M&new=N -> {oldSize, newSize, hashes}, proof that the log at N extends the log at M
|
|
66
|
+
* GET /head -> {treeHead}, the current tree head signed with the log's key
|
|
67
|
+
*/
|
|
68
|
+
export function logHandler(file, key, opts = {}) {
|
|
69
|
+
const log = new MerkleLog(file);
|
|
70
|
+
const tokens = opts.tokens ?? [];
|
|
71
|
+
const authorized = (req) => {
|
|
72
|
+
if (tokens.length === 0)
|
|
73
|
+
return true;
|
|
74
|
+
const h = req.headers.authorization ?? "";
|
|
75
|
+
const given = Buffer.from(h.startsWith("Bearer ") ? h.slice(7) : "");
|
|
76
|
+
return tokens.some((t) => {
|
|
77
|
+
const want = Buffer.from(t);
|
|
78
|
+
return want.length === given.length && timingSafeEqual(want, given);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
return async (req, res) => {
|
|
82
|
+
const json = (status, body) => {
|
|
83
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
84
|
+
res.end(JSON.stringify(body));
|
|
85
|
+
};
|
|
86
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
87
|
+
if (req.method === "POST" && url.pathname.endsWith("/append")) {
|
|
88
|
+
if (!authorized(req))
|
|
89
|
+
return json(401, { error: "unauthorized" });
|
|
90
|
+
let body = "";
|
|
91
|
+
for await (const chunk of req)
|
|
92
|
+
body += chunk;
|
|
93
|
+
let leaf;
|
|
94
|
+
try {
|
|
95
|
+
leaf = JSON.parse(body).leaf;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return json(400, { error: "body must be JSON {leaf}" });
|
|
99
|
+
}
|
|
100
|
+
if (typeof leaf !== "string" || leaf.length === 0)
|
|
101
|
+
return json(400, { error: "leaf must be a non-empty string" });
|
|
102
|
+
return json(200, appendSigned(log, key, leaf));
|
|
103
|
+
}
|
|
104
|
+
if (req.method === "GET" && url.pathname.endsWith("/root")) {
|
|
105
|
+
const size = url.searchParams.has("size") ? Number(url.searchParams.get("size")) : log.size;
|
|
106
|
+
if (!Number.isInteger(size) || size < 0 || size > log.size)
|
|
107
|
+
return json(400, { error: `size must be an integer in 0..${log.size}` });
|
|
108
|
+
return json(200, { treeSize: size, rootHash: log.root(size) });
|
|
109
|
+
}
|
|
110
|
+
if (req.method === "GET" && url.pathname.endsWith("/consistency")) {
|
|
111
|
+
const oldSize = Number(url.searchParams.get("old"));
|
|
112
|
+
const newSize = url.searchParams.has("new") ? Number(url.searchParams.get("new")) : log.size;
|
|
113
|
+
if (![oldSize, newSize].every(Number.isInteger) || oldSize < 0 || oldSize > newSize || newSize > log.size)
|
|
114
|
+
return json(400, { error: `old and new must be integers with 0 <= old <= new <= ${log.size}` });
|
|
115
|
+
return json(200, { oldSize, newSize, hashes: log.consistencyProof(oldSize, newSize) });
|
|
116
|
+
}
|
|
117
|
+
if (req.method === "GET" && url.pathname.endsWith("/head")) {
|
|
118
|
+
const head = { treeSize: log.size, rootHash: log.root(), timestamp: new Date().toISOString() };
|
|
119
|
+
return json(200, { treeHead: dsseSign(TREEHEAD_TYPE, head, key) });
|
|
120
|
+
}
|
|
121
|
+
return json(404, { error: "not found" });
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** Starts the reference log server. Port 0 picks a free port. */
|
|
125
|
+
export function serveLog(file, key, opts) {
|
|
126
|
+
const host = opts.host ?? "127.0.0.1";
|
|
127
|
+
const server = createServer((req, res) => {
|
|
128
|
+
void logHandler(file, key, opts)(req, res);
|
|
129
|
+
});
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
server.listen(opts.port, host, () => {
|
|
132
|
+
const { port } = server.address();
|
|
133
|
+
resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => server.close(() => r())) });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
package/dist/log.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ export interface InclusionProof {
|
|
|
4
4
|
hashes: string[];
|
|
5
5
|
}
|
|
6
6
|
export declare function leafHash(data: string): Buffer;
|
|
7
|
+
/** Proof that the tree of size newSize extends the tree of size oldSize. Empty when oldSize is 0 or equal to newSize. */
|
|
8
|
+
export declare function consistencyProof(leafHashes: Buffer[], oldSize: number, newSize?: number): string[];
|
|
9
|
+
/** RFC 9162 section 2.1.4.2. Pure: needs only the two sizes, the two roots, and the proof. */
|
|
10
|
+
export declare function verifyConsistency(oldSize: number, oldRootHex: string, newSize: number, newRootHex: string, proofHex: string[]): boolean;
|
|
7
11
|
export declare function rootOf(leafHashes: Buffer[], size?: number): string;
|
|
8
12
|
export declare function inclusionProof(leafHashes: Buffer[], leafIndex: number, treeSize?: number): InclusionProof;
|
|
9
13
|
/** RFC 9162 section 2.1.3.2 verification. Pure function: needs only the leaf hash, proof and claimed root. */
|
|
@@ -18,6 +22,8 @@ export declare class MerkleLog {
|
|
|
18
22
|
rootHash: string;
|
|
19
23
|
};
|
|
20
24
|
root(size?: number): string;
|
|
25
|
+
/** Proof that this log at newSize extends its own earlier state at oldSize. */
|
|
26
|
+
consistencyProof(oldSize: number, newSize?: number): string[];
|
|
21
27
|
/** Reads a log file and returns the root at the given size, for auditors holding a copy of the log. */
|
|
22
28
|
static rootFromFile(file: string, size: number): string;
|
|
23
29
|
}
|