@agent-custody/receipts 0.1.5 → 0.1.6

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