@agent-custody/receipts 0.1.1 → 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 +9 -4
- package/dist/cli.js +13 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/sidecar.d.ts +12 -0
- package/dist/sidecar.js +93 -0
- package/docs/sdk.md +23 -0
- package/docs/tutorials.md +1 -0
- package/docs/usage.md +25 -0
- 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
|
|
@@ -103,6 +103,9 @@ Every receipt names its issuer, and the verifier prints what that issuer kind is
|
|
|
103
103
|
| Vercel AI SDK | SDK | `wrapTools` | | a real `generateText` loop over the SDK's mock model |
|
|
104
104
|
| LangChain / LangGraph (JS) | SDK | `tool(issuer.wrap(fn))` | `ReceiptCallbackHandler` | real `StructuredTool` invocations |
|
|
105
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) |
|
|
106
109
|
|
|
107
110
|
The framework packages are optional peer dependencies. Each adapter imports only from its own package.
|
|
108
111
|
|
|
@@ -167,7 +170,7 @@ Every field carries a provenance label. This is the design decision that matters
|
|
|
167
170
|
bun install # from the repository root, once for the workspace
|
|
168
171
|
cd packages/receipts
|
|
169
172
|
node scripts/demo.ts # gateway: keys, grant, policy, four tool calls, verification, a tampering attempt; then the SDK wrapping the same tool
|
|
170
|
-
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
|
|
171
174
|
bun run test # this package; `bun run test` at the root runs every package
|
|
172
175
|
```
|
|
173
176
|
|
|
@@ -227,12 +230,13 @@ src/gateway.ts the MCP proxy: scope check, facts, policy, forward, receipt
|
|
|
227
230
|
src/sdk/index.ts the interceptor: policy decision, record, wrap(tool fn)
|
|
228
231
|
src/sdk/claude.ts Claude Code command hook and Claude Agent SDK in-process hooks
|
|
229
232
|
src/sdk/openai-agents.ts, vercel-ai.ts, langchain.ts framework adapters, tested against the real packages
|
|
233
|
+
src/sidecar.ts the SDK issuer behind a local HTTP API, for agents in other languages
|
|
230
234
|
src/verify.ts offline verification, the human-readable report, and the audit that a later log extends an earlier one
|
|
231
|
-
src/cli.ts keygen, grant, gateway, hook, log, verify, audit
|
|
235
|
+
src/cli.ts keygen, grant, gateway, hook, serve, log, verify, audit
|
|
232
236
|
src/index.ts the package's public surface; adapters are exported on ./sdk/<framework> subpaths
|
|
233
237
|
tsconfig.build.json emits dist/ (JavaScript plus declarations) for consumers; the repo itself runs the .ts directly
|
|
234
238
|
scripts/ fake Stripe upstream, fixture builders for gateway and SDK, demo
|
|
235
|
-
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
|
|
236
240
|
test/ unit tests per module, end-to-end gateway test, SDK and hook tests,
|
|
237
241
|
adapter tests against the real packages, and a test that runs every policy in docs/policies.md
|
|
238
242
|
docs/ tutorials, usage (gateway), sdk, policies, verification
|
|
@@ -249,6 +253,7 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
|
|
|
249
253
|
- SDK core: policy decision, record, and a generic `wrap(tool, fn)` for any framework whose tools are functions.
|
|
250
254
|
- Claude Code command hook for PreToolUse, PostToolUse, and PostToolUseFailure, with blocking on deny.
|
|
251
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.
|
|
252
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.
|
|
253
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.
|
|
254
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).
|
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 { serveSidecar } from "./sidecar.js";
|
|
9
10
|
import { MerkleLog } from "./log.js";
|
|
10
11
|
import { createSdkIssuer } from "./sdk/index.js";
|
|
11
12
|
import { handleHookEvent } from "./sdk/claude.js";
|
|
@@ -16,6 +17,7 @@ const USAGE = `agent-custody <command>
|
|
|
16
17
|
grant --key <principal.key> --principal <id> --agent <id> --scopes <a,b> [--ttl-hours 24] --out <file>
|
|
17
18
|
gateway --config <gateway.json>
|
|
18
19
|
hook [--config <sdk.json>] Claude Code hook command; reads the event on stdin (or AGENT_CUSTODY_CONFIG)
|
|
20
|
+
serve --config <sdk.json> [--port 8788] [--host 127.0.0.1] the SDK as a local HTTP API for agents in other languages
|
|
19
21
|
log --file <log.jsonl> --key <log.key> [--port 8787] [--host 127.0.0.1] [--token-env <NAME>] reference log server
|
|
20
22
|
verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--log <log.jsonl>] [--json]
|
|
21
23
|
audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--json]
|
|
@@ -80,6 +82,17 @@ async function main(argv) {
|
|
|
80
82
|
console.log(JSON.stringify(out));
|
|
81
83
|
return 0;
|
|
82
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
|
+
}
|
|
83
96
|
case "log": {
|
|
84
97
|
const { values } = parseArgs({
|
|
85
98
|
args: rest,
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import type { SdkIssuer } from "./sdk/index.ts";
|
|
3
|
+
export declare function sidecarHandler(issuer: SdkIssuer): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
4
|
+
export interface RunningSidecar {
|
|
5
|
+
url: string;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
/** Starts the sidecar. Port 0 picks a free port. Host defaults to loopback on purpose. */
|
|
9
|
+
export declare function serveSidecar(issuer: SdkIssuer, opts: {
|
|
10
|
+
port: number;
|
|
11
|
+
host?: string;
|
|
12
|
+
}): Promise<RunningSidecar>;
|
package/dist/sidecar.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The sidecar: the SDK issuer behind a local HTTP API, so agents written in any language can decide and record.
|
|
2
|
+
// Same config file, same receipts, same key. Everything it records is claimed, exactly as with the in-process SDK:
|
|
3
|
+
// the sidecar trusts what the agent's process tells it. Bind it to localhost; it is a per-host companion, not a service.
|
|
4
|
+
// GET /health -> { agentId, keyid, log: { kind, where } }
|
|
5
|
+
// POST /decide ToolEvent -> PolicyDecision | null
|
|
6
|
+
// POST /record { event, outcome, policy? } -> ReceiptBundle, or 4xx/5xx with { error }
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
const isRecord = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
9
|
+
function parseEvent(v) {
|
|
10
|
+
if (!isRecord(v) || typeof v.tool !== "string" || v.tool.length === 0)
|
|
11
|
+
throw new Error("event needs a non-empty string tool");
|
|
12
|
+
const args = isRecord(v.args) ? v.args : v.args === undefined ? {} : { input: v.args };
|
|
13
|
+
const ev = { tool: v.tool, args };
|
|
14
|
+
if (typeof v.model === "string")
|
|
15
|
+
ev.model = v.model;
|
|
16
|
+
if (isRecord(v.session))
|
|
17
|
+
ev.session = { id: typeof v.session.id === "string" ? v.session.id : null, toolUseId: typeof v.session.toolUseId === "string" ? v.session.toolUseId : null };
|
|
18
|
+
return ev;
|
|
19
|
+
}
|
|
20
|
+
function parseOutcome(v) {
|
|
21
|
+
if (!isRecord(v) || typeof v.status !== "string")
|
|
22
|
+
throw new Error("outcome needs a status");
|
|
23
|
+
switch (v.status) {
|
|
24
|
+
case "executed":
|
|
25
|
+
case "failed":
|
|
26
|
+
return { status: v.status, result: v.result ?? null };
|
|
27
|
+
case "denied":
|
|
28
|
+
return { status: "denied", reason: typeof v.reason === "string" ? v.reason : "denied" };
|
|
29
|
+
case "error":
|
|
30
|
+
return { status: "error", error: typeof v.error === "string" ? v.error : "error" };
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`unknown outcome status ${v.status}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parsePolicy(v) {
|
|
36
|
+
if (v === undefined || v === null)
|
|
37
|
+
return null;
|
|
38
|
+
if (!isRecord(v) || (v.decision !== "allow" && v.decision !== "deny") || !Array.isArray(v.reasons) || !Array.isArray(v.errors) || typeof v.policyDigest !== "string")
|
|
39
|
+
throw new Error("policy must be a PolicyDecision from /decide");
|
|
40
|
+
return { decision: v.decision, reasons: v.reasons.map(String), errors: v.errors.map(String), policyDigest: v.policyDigest };
|
|
41
|
+
}
|
|
42
|
+
export function sidecarHandler(issuer) {
|
|
43
|
+
return async (req, res) => {
|
|
44
|
+
const json = (status, body) => {
|
|
45
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
46
|
+
res.end(JSON.stringify(body));
|
|
47
|
+
};
|
|
48
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
49
|
+
try {
|
|
50
|
+
if (req.method === "GET" && url.pathname === "/health")
|
|
51
|
+
return json(200, { agentId: issuer.agentId, keyid: issuer.keyid, log: { kind: issuer.log.kind, where: issuer.log.where } });
|
|
52
|
+
if (req.method !== "POST")
|
|
53
|
+
return json(404, { error: "not found" });
|
|
54
|
+
let raw = "";
|
|
55
|
+
for await (const chunk of req)
|
|
56
|
+
raw += chunk;
|
|
57
|
+
let body;
|
|
58
|
+
try {
|
|
59
|
+
body = JSON.parse(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return json(400, { error: "body must be JSON" });
|
|
63
|
+
}
|
|
64
|
+
if (url.pathname === "/decide")
|
|
65
|
+
return json(200, issuer.decide(parseEvent(body)));
|
|
66
|
+
if (url.pathname === "/record") {
|
|
67
|
+
if (!isRecord(body))
|
|
68
|
+
return json(400, { error: "body must be {event, outcome, policy?}" });
|
|
69
|
+
const bundle = await issuer.record(parseEvent(body.event), parseOutcome(body.outcome), parsePolicy(body.policy));
|
|
70
|
+
return json(200, bundle);
|
|
71
|
+
}
|
|
72
|
+
return json(404, { error: "not found" });
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
76
|
+
return json(/needs|must|unknown outcome/.test(msg) ? 400 : 502, { error: msg });
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Starts the sidecar. Port 0 picks a free port. Host defaults to loopback on purpose. */
|
|
81
|
+
export function serveSidecar(issuer, opts) {
|
|
82
|
+
const host = opts.host ?? "127.0.0.1";
|
|
83
|
+
const handler = sidecarHandler(issuer);
|
|
84
|
+
const server = createServer((req, res) => {
|
|
85
|
+
void handler(req, res);
|
|
86
|
+
});
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
server.listen(opts.port, host, () => {
|
|
89
|
+
const { port } = server.address();
|
|
90
|
+
resolve({ url: `http://${host}:${port}/`, close: () => new Promise((r) => server.close(() => r())) });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
package/docs/sdk.md
CHANGED
|
@@ -177,3 +177,26 @@ The three framework packages are optional peer dependencies. Each adapter import
|
|
|
177
177
|
## What an SDK receipt is worth
|
|
178
178
|
|
|
179
179
|
A verified SDK receipt establishes that a process holding the application key reported this call, at this time, with these arguments and this result, and that the record has not changed since. It does not establish that the process reported every call, that the arguments are what the tool really received, or that anyone outside the process checked anything. The verifier prints exactly that sentence under `ISSUER`. Keep it in the dashboard too.
|
|
180
|
+
|
|
181
|
+
## Other languages: the sidecar
|
|
182
|
+
|
|
183
|
+
The interceptor above is TypeScript. Agents in any other language get the same receipts through the sidecar: the SDK issuer behind a local HTTP API, started from the same config file.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
agent-custody serve --config sdk.json # 127.0.0.1:8788 by default; --port and --host to change
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
| | |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `GET /health` | `{ agentId, keyid, log: { kind, where } }` |
|
|
192
|
+
| `POST /decide` with a `ToolEvent` `{ tool, args, model?, session? }` | the `PolicyDecision`, or `null` when no policy is configured |
|
|
193
|
+
| `POST /record` with `{ event, outcome, policy? }` | the `ReceiptBundle`; `outcome` is `{ status: "executed" \| "failed", result }`, `{ status: "denied", reason }`, or `{ status: "error", error }` |
|
|
194
|
+
|
|
195
|
+
The client's loop is decide, run the tool, record. A malformed body gets a 400 and nothing is written; a log that refuses the leaf gets a 502 and nothing is written. Bind the sidecar to loopback: it is a per-host companion holding the signing key, not a shared service, and everything it records is `claimed` exactly as with the in-process SDK, because it trusts what the client reports.
|
|
196
|
+
|
|
197
|
+
**Python** has a real package, [packages/python](../../python/README.md): `pip install agent-custody`, a standard-library client with `decide`, `record`, and `wrap`, and adapters for LangChain callbacks, OpenAI Agents function tools, and Claude Agent SDK hooks, each tested against the real package with receipts checked by this verifier.
|
|
198
|
+
|
|
199
|
+
**Go, Java, Rust, and Python without the package** each have a complete client in [examples/languages](../examples/languages): one file, standard library where the language has an HTTP client, decide then record. The test suite runs every one of them against a live sidecar. Any language with an HTTP client is the same forty lines.
|
|
200
|
+
|
|
201
|
+
The gateway needs none of this. It is an MCP server, so a Python or Go agent host that speaks MCP puts it in front of its tools with a config change; [usage.md](usage.md) shows the Python hosts.
|
|
202
|
+
|
package/docs/tutorials.md
CHANGED
|
@@ -24,6 +24,7 @@ Suggested reading order is the numbering. Output lands in `examples-out/`, which
|
|
|
24
24
|
| 12 | inside a receipt | [12-read-a-receipt.ts](../examples/12-read-a-receipt.ts) | the bundle's three parts, the in-toto statement, every predicate field with its provenance, the tree head | `src/receipt.ts` |
|
|
25
25
|
| 13 | a log run by someone else | [13-remote-log.ts](../examples/13-remote-log.ts) | the reference log server on a free port, an SDK config that logs to it, a tree head signed by the log's key, verification failing without that key and passing with it, the root endpoint, a refused token | `src/log-sink.ts` |
|
|
26
26
|
| 14 | proving history was not rewritten | [14-audit-history.ts](../examples/14-audit-history.ts) | three receipts and a kept tree head, a consistency proof that passes, the operator rewriting one leaf and appending a fourth call, the audit failing while the fourth receipt still verifies alone | `src/log.ts`, `src/verify.ts` |
|
|
27
|
+
| 15 | agents in other languages | [15-sidecar.ts](../examples/15-sidecar.ts) | the sidecar on a free port, a client written as a Python or Go program would write it: decide, run, record; a denial recorded without running the tool; both receipts verified | `src/sidecar.ts` |
|
|
27
28
|
|
|
28
29
|
## How policies are defined, in one paragraph
|
|
29
30
|
|
package/docs/usage.md
CHANGED
|
@@ -124,6 +124,31 @@ Claude sees only the tools inside the grant's scopes. Every call it makes produc
|
|
|
124
124
|
claude mcp add stripe -- node /abs/path/agent-custody/packages/receipts/src/cli.ts gateway --config /abs/path/gateway.json
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
+
### Python hosts
|
|
128
|
+
|
|
129
|
+
The gateway is language-neutral: any host that can launch a stdio MCP server can use it. Claude Agent SDK for Python:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from claude_agent_sdk import ClaudeAgentOptions, query
|
|
133
|
+
|
|
134
|
+
options = ClaudeAgentOptions(mcp_servers={"stripe": {"command": "node", "args": ["/abs/path/agent-custody/packages/receipts/src/cli.ts", "gateway", "--config", "/abs/path/gateway.json"]}})
|
|
135
|
+
async for message in query(prompt="Refund customer cust_123 by 50 dollars", options=options):
|
|
136
|
+
...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
OpenAI Agents SDK for Python:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from agents import Agent, Runner
|
|
143
|
+
from agents.mcp import MCPServerStdio
|
|
144
|
+
|
|
145
|
+
async with MCPServerStdio(params={"command": "node", "args": ["/abs/path/agent-custody/packages/receipts/src/cli.ts", "gateway", "--config", "/abs/path/gateway.json"]}) as stripe:
|
|
146
|
+
agent = Agent(name="support", instructions="...", mcp_servers=[stripe])
|
|
147
|
+
result = await Runner.run(agent, "Refund customer cust_123 by 50 dollars")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
With the npm package installed globally, `"command": "agent-custody", "args": ["gateway", "--config", ...]` replaces the node invocation. Every tool the agent sees comes through the gateway; denied calls never reach Stripe and still produce a receipt. For receipts from tools that are plain Python functions rather than MCP servers, use the sidecar and the Python package, in [sdk.md](sdk.md).
|
|
151
|
+
|
|
127
152
|
### Your own agent loop (TypeScript)
|
|
128
153
|
|
|
129
154
|
This is what [scripts/demo.ts](../scripts/demo.ts) does.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-custody/receipts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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": {
|