@agent-custody/receipts 0.2.0 → 0.4.0

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
@@ -1,6 +1,6 @@
1
1
  # agent-custody
2
2
 
3
- Chain of custody for AI agents: signed, independently verifiable receipts for every tool call.
3
+ Chain of custody for AI agents: a signed receipt for every tool call, checkable with public keys alone. What each check proves, and against whom, is [a table below](#what-a-receipt-proves-and-what-it-does-not).
4
4
 
5
5
  Two producers, one receipt format, one verifier.
6
6
 
@@ -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): fifteen runnable examples, one per aspect of the code, all executed by the test suite
12
+ - [Tutorials](docs/tutorials.md): eighteen 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
@@ -75,6 +75,8 @@ flowchart LR
75
75
  P -. "public key" .-> V
76
76
  G -. "public key" .-> V
77
77
  A -. "traces (unchanged)" .-> O
78
+ G -. "one span per receipt, OTLP" .-> O
79
+ S -. "one span per receipt, OTLP" .-> O
78
80
  ```
79
81
 
80
82
  Three parties hold keys. The **principal** signs a grant saying which agent may use which tools until when. The **issuer**, gateway or SDK, signs every receipt and every tree head. The **verifier** holds only public keys and needs no access to the issuer, the agent, or the upstream system.
@@ -106,6 +108,7 @@ Every receipt names its issuer, and the verifier prints what that issuer kind is
106
108
  | 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
109
  | Go, Java, Rust, any language with HTTP | sidecar | decide then record | record | [examples/languages](examples/languages), each run against a live sidecar |
108
110
  | 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) |
111
+ | any REST API, as tools the agent reaches through the gateway | gateway, `rest` upstream | yes | | a stand-in HTTP API; [usage.md](docs/usage.md#setup-step-by-step) |
109
112
 
110
113
  The framework packages are optional peer dependencies. Each adapter imports only from its own package.
111
114
 
@@ -170,7 +173,7 @@ Every field carries a provenance label. This is the design decision that matters
170
173
  bun install # from the repository root, once for the workspace
171
174
  cd packages/receipts
172
175
  node scripts/demo.ts # gateway: keys, grant, policy, four tool calls, verification, a tampering attempt; then the SDK wrapping the same tool
173
- node examples/01-keys-and-signing.ts # first of fifteen step-by-step examples, see docs/tutorials.md
176
+ node examples/01-keys-and-signing.ts # first of eighteen step-by-step examples, see docs/tutorials.md
174
177
  bun run test # this package; `bun run test` at the root runs every package
175
178
  ```
176
179
 
@@ -208,7 +211,8 @@ const refund = issuer.wrap("stripe.refund", async (args: { amount: number }) =>
208
211
  | the principal really delegated this scope to this agent | anyone with the principal's key | operator, agent | grant signed by principal key, checked for scope and validity window | done |
209
212
  | the policy decision was made against this exact policy | anyone | operator swapping policies | sha256 of policy text in the receipt | done |
210
213
  | facts the policy relied on were not asserted by the agent | anyone reading the receipt | agent | facts fetched by the gateway, recorded with their own digests, labelled `observed` | done |
211
- | the upstream system actually executed the action | third party | operator | needs the upstream's own signed response embedded verbatim | **not done**, depends on the tool provider |
214
+ | the side effect was committed to the log before it happened | anyone with a log copy | operator, log outage | for tools in `precommit`, a signed authorization logged first, embedded in the receipt with its leaf position, checked to precede the receipt | done |
215
+ | the upstream system actually executed the action | anyone with the upstream's key or the provider's secret | operator | the upstream signs its result for the receipt, or the provider's own delivery signature (Stripe, GitHub) is embedded and checked | done, for upstreams that sign |
212
216
  | the operator itself cannot mint a false receipt | regulator, counterparty | operator | needs a TEE-hosted signer or a federated log | **not done** |
213
217
  | which model produced the call | anyone | operator | no hosted provider signs model identity | **not possible today**, labelled `claimed` |
214
218
  | an SDK receipt reflects what the tool really did | anyone | agent's own process | none; the SDK shares a process with the agent | **by design not claimed**; issuer kind `sdk` says so |
@@ -224,13 +228,15 @@ src/log.ts Merkle log: append, root, inclusion and consistency proofs, v
224
228
  src/log-sink.ts where leaves go: the local file, or a remote log over HTTP; plus the reference log server
225
229
  src/policy.ts Cedar evaluation wrapper, fail-closed
226
230
  src/delegation.ts signed delegation grants
227
- src/receipt.ts receipt statement types and provenance labels
228
- src/issue.ts sign, log, and write a receipt; shared by both producers
231
+ src/receipt.ts receipt and authorization statement types and provenance labels
232
+ src/issue.ts sign, log, and write a receipt, or commit an authorization first; shared by both producers
229
233
  src/gateway.ts the MCP proxy: scope check, facts, policy, forward, receipt
230
234
  src/sdk/index.ts the interceptor: policy decision, record, wrap(tool fn)
231
235
  src/sdk/claude.ts Claude Code command hook and Claude Agent SDK in-process hooks
232
236
  src/sdk/openai-agents.ts, vercel-ai.ts, langchain.ts framework adapters, tested against the real packages
233
237
  src/sidecar.ts the SDK issuer behind a local HTTP API, for agents in other languages
238
+ src/otel.ts OpenTelemetry export: one OTLP/HTTP span per receipt, after the receipt, no SDK dependency
239
+ src/rest.ts the REST connector: an HTTP API described as tools, standing where an MCP upstream stands
234
240
  src/upstream.ts attested execution: an upstream signs its result for the receipt; the verifier checks it with the upstream key
235
241
  vectors/ conformance vectors: receipts, keys, logs, proofs, and expected verdicts; `bun run vectors` regenerates them
236
242
  src/verify.ts offline verification, the human-readable report, and the audit that a later log extends an earlier one
@@ -239,7 +245,7 @@ src/retention.ts pruning the log: leaves become their hashes, bundles are remo
239
245
  src/index.ts the package's public surface; adapters are exported on ./sdk/<framework> subpaths
240
246
  tsconfig.build.json emits dist/ (JavaScript plus declarations) for consumers; the repo itself runs the .ts directly
241
247
  scripts/ fake Stripe upstream (signs its results with --key), a second fake upstream, fixture builders for gateway and SDK, demo
242
- examples/ fifteen runnable tutorials, plus examples/languages/: Python, Go, Java, and Rust clients of the sidecar, run by the test suite, one per aspect; each is run by the test suite
248
+ examples/ eighteen 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
243
249
  test/ unit tests per module, end-to-end gateway test, SDK and hook tests,
244
250
  adapter tests against the real packages, and a test that runs every policy in docs/policies.md
245
251
  docs/ tutorials, usage (gateway), sdk, policies, verification
@@ -271,12 +277,18 @@ The design is two producers feeding one verifier. The SDK is the top of the funn
271
277
  - 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.
272
278
  - 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).
273
279
 
280
+ - A log for someone else: `hashOnly` sends leaf hashes so the log never holds a receipt; the reference server runs several tenant logs at `/t/<tenant>/` with their own tokens and ids; tree heads name their log and the verifier checks it with `--log-id`. Phase 1 of the hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6).
281
+ - OpenTelemetry export: with `otel` in either config, every receipt is also one span at the collector the team already runs, trace id equal to the receipt id, attributes for tool, agent, principal, status, decision, and log position; after the receipt, best effort, never on the evidence path.
282
+ - The REST connector: a plain HTTP API described as tools in the gateway config, credentials from the environment, so an agent's direct API calls become receipted, policy-checked tool calls through the gateway.
283
+ - Pre-commit authorization for consequential tools: named in `precommit`, a call is signed and logged before it is forwarded, withheld if the log will not take it, and its receipt carries the committed authorization with proof that it precedes the execution.
284
+
274
285
  **Next, in the order it pays off**
275
286
 
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.
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.
287
+ 1. The hosted log, [issue #6](https://github.com/ch4r10t33r/agent-custody/issues/6), phases 2 and 3: the Postgres store with one writer per tenant, tokens and rate limits, the signer as its own process, published checkpoints, and well-known keys fetched with `--log-url`. Phase 1 is done; it comes before everything below.
288
+ 2. Post-quantum signatures: ML-DSA beside Ed25519 in the same DSSE envelope, hybrid by default when a PQ key is present, in every signed artefact and in the browser verifier. [Issue #11](https://github.com/ch4r10t33r/agent-custody/issues/11).
289
+ 3. An HTTP transport for the gateway, with the grant presented per connection, for a shared deployment rather than one process per agent session.
290
+ 4. Delegation chains for sub-agents.
291
+ 5. Receiver-attested receipts for agent-to-agent calls.
292
+ 6. A TEE-hosted signer, then SD-JWT redaction, then ZK proofs of policy compliance. Not before.
281
293
 
282
294
  A Python SDK follows the same shape once the TypeScript adapters have settled.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from "node:util";
3
3
  import { readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
4
5
  import { loadConfig, loadSdkConfig } from "./config.js";
5
6
  import { generateKeyPair, loadPrivateKey, loadPublicKey, writeKeyPair } from "./crypto.js";
6
7
  import { createDelegation } from "./delegation.js";
@@ -29,10 +30,24 @@ const USAGE = `agent-custody <command>
29
30
  prune --log <log.jsonl> --before <ISO instant> [--receipts <dir>]
30
31
  retention on the receipt log: replaces older leaves with their hashes, so proofs still verify and the content is gone
31
32
  log --file <log.jsonl> --key <log.key> [--port 8787] [--host 127.0.0.1] [--token-env <NAME>] reference log server
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]
33
- audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--json]
33
+ verify <bundle.json> --issuer-key <pub> [--principal-key <pub>] [--log-key <pub>] [--log-id <id>] [--upstream-key <pub>] [--stripe-secret-env NAME] [--github-secret-env NAME] [--log <log.jsonl>] [--json]
34
+ audit --older <bundle.json> --newer <bundle.json> (--log <log.jsonl> | --log-url <url>) --issuer-key <pub> [--log-key <pub>] [--log-id <id>] [--json]
34
35
  checks that the newer receipt's log extends the older one's: nothing between them was rewritten
35
36
  `;
37
+ /** The tenants file for `log --tenants`: paths relative to the file, tokens from the environment, ids default to the tenant name. */
38
+ function loadTenants(path) {
39
+ const raw = JSON.parse(readFileSync(path, "utf8"));
40
+ const out = {};
41
+ for (const [name, t] of Object.entries(raw)) {
42
+ if (!/^[A-Za-z0-9_.-]+$/.test(name) || typeof t?.file !== "string")
43
+ throw new Error(`tenants: "${name}" needs a file, and its name must be a plain identifier`);
44
+ const token = t.tokenEnv ? process.env[t.tokenEnv] : undefined;
45
+ if (t.tokenEnv && !token)
46
+ throw new Error(`tenants: environment variable ${t.tokenEnv} for "${name}" is not set`);
47
+ out[name] = { file: resolve(dirname(resolve(path)), t.file), ...(token ? { tokens: [token] } : {}), ...(t.logId ? { logId: t.logId } : {}) };
48
+ }
49
+ return out;
50
+ }
36
51
  async function main(argv) {
37
52
  const [cmd, ...rest] = argv;
38
53
  switch (cmd) {
@@ -116,7 +131,7 @@ async function main(argv) {
116
131
  case "log": {
117
132
  const { values } = parseArgs({
118
133
  args: rest,
119
- options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" } },
134
+ options: { file: { type: "string" }, key: { type: "string" }, port: { type: "string", default: "8787" }, host: { type: "string", default: "127.0.0.1" }, "token-env": { type: "string" }, "log-id": { type: "string" }, tenants: { type: "string" } },
120
135
  });
121
136
  if (!values.file || !values.key)
122
137
  throw new Error("log needs --file and --key");
@@ -124,8 +139,10 @@ async function main(argv) {
124
139
  if (values["token-env"] && !token)
125
140
  throw new Error(`log: environment variable ${values["token-env"]} is not set`);
126
141
  const key = loadPrivateKey(values.key);
127
- const running = await serveLog(values.file, key, { port: Number(values.port), host: values.host, ...(token ? { tokens: [token] } : {}) });
128
- console.error(`agent-custody log: ${running.url} keyid=${key.keyid} file=${values.file} ${token ? "bearer token required" : "open, anyone may append"}`);
142
+ // --tenants names a JSON file { "<tenant>": { "file": "...", "tokenEnv": "NAME", "logId": "..." } }; each is reached at /t/<tenant>/.
143
+ const tenants = values.tenants ? loadTenants(values.tenants) : undefined;
144
+ const running = await serveLog(values.file, key, { port: Number(values.port), host: values.host, ...(token ? { tokens: [token] } : {}), ...(values["log-id"] ? { logId: values["log-id"] } : {}), ...(tenants ? { tenants } : {}) });
145
+ console.error(`agent-custody log: ${running.url} keyid=${key.keyid} file=${values.file}${values["log-id"] ? ` log=${values["log-id"]}` : ""} ${token ? "bearer token required" : "open, anyone may append"}${tenants ? ` tenants=${Object.keys(tenants).join(",")}` : ""}`);
129
146
  await new Promise((resolve) => process.once("SIGINT", resolve));
130
147
  await running.close();
131
148
  return 0;
@@ -139,6 +156,7 @@ async function main(argv) {
139
156
  "gateway-key": { type: "string", multiple: true },
140
157
  "principal-key": { type: "string", multiple: true },
141
158
  "log-key": { type: "string", multiple: true },
159
+ "log-id": { type: "string" },
142
160
  "upstream-key": { type: "string", multiple: true },
143
161
  "stripe-secret-env": { type: "string" },
144
162
  "github-secret-env": { type: "string" },
@@ -155,6 +173,7 @@ async function main(argv) {
155
173
  issuerKeys: issuerKeyFiles.map(loadPublicKey),
156
174
  principalKeys: (values["principal-key"] ?? []).map(loadPublicKey),
157
175
  ...(values["log-key"] ? { logKeys: values["log-key"].map(loadPublicKey) } : {}),
176
+ ...(values["log-id"] ? { logId: values["log-id"] } : {}),
158
177
  ...(values["upstream-key"] ? { upstreamKeys: values["upstream-key"].map(loadPublicKey) } : {}),
159
178
  ...(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"]) } : {}) } } : {}),
160
179
  ...(values.log ? { logFile: values.log } : {}),
@@ -172,6 +191,7 @@ async function main(argv) {
172
191
  "log-url": { type: "string" },
173
192
  "issuer-key": { type: "string", multiple: true },
174
193
  "log-key": { type: "string", multiple: true },
194
+ "log-id": { type: "string" },
175
195
  json: { type: "boolean", default: false },
176
196
  },
177
197
  });
@@ -193,7 +213,7 @@ async function main(argv) {
193
213
  throw new Error(`log refused the consistency query: ${res.status}`);
194
214
  proof = (await res.json()).hashes;
195
215
  }
196
- const result = auditExtends(older, newer, proof, keyFiles.map(loadPublicKey));
216
+ const result = auditExtends(older, newer, proof, keyFiles.map(loadPublicKey), values["log-id"]);
197
217
  if (values.json)
198
218
  console.log(JSON.stringify(result, null, 2));
199
219
  else {
package/dist/config.d.ts CHANGED
@@ -1,4 +1,50 @@
1
1
  import { z } from "zod";
2
+ /** One REST endpoint offered to the agent as a tool. `{name}` segments in the path come from the call's arguments; the rest go to the query on GET and DELETE, or to a JSON body otherwise. */
3
+ declare const RestToolSchema: z.ZodObject<{
4
+ name: z.ZodString;
5
+ description: z.ZodOptional<z.ZodString>;
6
+ method: z.ZodDefault<z.ZodEnum<{
7
+ DELETE: "DELETE";
8
+ GET: "GET";
9
+ PATCH: "PATCH";
10
+ POST: "POST";
11
+ PUT: "PUT";
12
+ }>>;
13
+ path: z.ZodString;
14
+ query: z.ZodOptional<z.ZodArray<z.ZodString>>;
15
+ body: z.ZodDefault<z.ZodEnum<{
16
+ json: "json";
17
+ none: "none";
18
+ }>>;
19
+ inputSchema: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
20
+ }, z.core.$strip>;
21
+ export type RestToolConfig = z.infer<typeof RestToolSchema>;
22
+ /** A plain HTTP API as an upstream: no MCP server needed. Secrets come from the environment through headerEnv, never from the file or the agent. */
23
+ declare const RestUpstreamSchema: z.ZodObject<{
24
+ baseUrl: z.ZodString;
25
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
26
+ headerEnv: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
27
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
28
+ tools: z.ZodArray<z.ZodObject<{
29
+ name: z.ZodString;
30
+ description: z.ZodOptional<z.ZodString>;
31
+ method: z.ZodDefault<z.ZodEnum<{
32
+ DELETE: "DELETE";
33
+ GET: "GET";
34
+ PATCH: "PATCH";
35
+ POST: "POST";
36
+ PUT: "PUT";
37
+ }>>;
38
+ path: z.ZodString;
39
+ query: z.ZodOptional<z.ZodArray<z.ZodString>>;
40
+ body: z.ZodDefault<z.ZodEnum<{
41
+ json: "json";
42
+ none: "none";
43
+ }>>;
44
+ inputSchema: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
45
+ }, z.core.$strip>>;
46
+ }, z.core.$strip>;
47
+ export type RestUpstreamConfig = z.infer<typeof RestUpstreamSchema>;
2
48
  declare const UpstreamSchema: z.ZodUnion<readonly [z.ZodObject<{
3
49
  command: z.ZodString;
4
50
  args: z.ZodDefault<z.ZodArray<z.ZodString>>;
@@ -6,6 +52,31 @@ declare const UpstreamSchema: z.ZodUnion<readonly [z.ZodObject<{
6
52
  }, z.core.$strip>, z.ZodObject<{
7
53
  url: z.ZodString;
8
54
  tokenEnv: z.ZodOptional<z.ZodString>;
55
+ }, z.core.$strip>, z.ZodObject<{
56
+ rest: z.ZodObject<{
57
+ baseUrl: z.ZodString;
58
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
59
+ headerEnv: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
60
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
61
+ tools: z.ZodArray<z.ZodObject<{
62
+ name: z.ZodString;
63
+ description: z.ZodOptional<z.ZodString>;
64
+ method: z.ZodDefault<z.ZodEnum<{
65
+ DELETE: "DELETE";
66
+ GET: "GET";
67
+ PATCH: "PATCH";
68
+ POST: "POST";
69
+ PUT: "PUT";
70
+ }>>;
71
+ path: z.ZodString;
72
+ query: z.ZodOptional<z.ZodArray<z.ZodString>>;
73
+ body: z.ZodDefault<z.ZodEnum<{
74
+ json: "json";
75
+ none: "none";
76
+ }>>;
77
+ inputSchema: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
78
+ }, z.core.$strip>>;
79
+ }, z.core.$strip>;
9
80
  }, z.core.$strip>]>;
10
81
  export type UpstreamConfig = z.infer<typeof UpstreamSchema>;
11
82
  declare const FactSchema: z.ZodObject<{
@@ -26,6 +97,31 @@ export declare const GatewayConfigSchema: z.ZodObject<{
26
97
  }, z.core.$strip>, z.ZodObject<{
27
98
  url: z.ZodString;
28
99
  tokenEnv: z.ZodOptional<z.ZodString>;
100
+ }, z.core.$strip>, z.ZodObject<{
101
+ rest: z.ZodObject<{
102
+ baseUrl: z.ZodString;
103
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
104
+ headerEnv: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
105
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
106
+ tools: z.ZodArray<z.ZodObject<{
107
+ name: z.ZodString;
108
+ description: z.ZodOptional<z.ZodString>;
109
+ method: z.ZodDefault<z.ZodEnum<{
110
+ DELETE: "DELETE";
111
+ GET: "GET";
112
+ PATCH: "PATCH";
113
+ POST: "POST";
114
+ PUT: "PUT";
115
+ }>>;
116
+ path: z.ZodString;
117
+ query: z.ZodOptional<z.ZodArray<z.ZodString>>;
118
+ body: z.ZodDefault<z.ZodEnum<{
119
+ json: "json";
120
+ none: "none";
121
+ }>>;
122
+ inputSchema: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
123
+ }, z.core.$strip>>;
124
+ }, z.core.$strip>;
29
125
  }, z.core.$strip>]>>;
30
126
  upstreams: z.ZodOptional<z.ZodArray<z.ZodIntersection<z.ZodUnion<readonly [z.ZodObject<{
31
127
  command: z.ZodString;
@@ -34,6 +130,31 @@ export declare const GatewayConfigSchema: z.ZodObject<{
34
130
  }, z.core.$strip>, z.ZodObject<{
35
131
  url: z.ZodString;
36
132
  tokenEnv: z.ZodOptional<z.ZodString>;
133
+ }, z.core.$strip>, z.ZodObject<{
134
+ rest: z.ZodObject<{
135
+ baseUrl: z.ZodString;
136
+ headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
137
+ headerEnv: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
138
+ timeoutMs: z.ZodDefault<z.ZodNumber>;
139
+ tools: z.ZodArray<z.ZodObject<{
140
+ name: z.ZodString;
141
+ description: z.ZodOptional<z.ZodString>;
142
+ method: z.ZodDefault<z.ZodEnum<{
143
+ DELETE: "DELETE";
144
+ GET: "GET";
145
+ PATCH: "PATCH";
146
+ POST: "POST";
147
+ PUT: "PUT";
148
+ }>>;
149
+ path: z.ZodString;
150
+ query: z.ZodOptional<z.ZodArray<z.ZodString>>;
151
+ body: z.ZodDefault<z.ZodEnum<{
152
+ json: "json";
153
+ none: "none";
154
+ }>>;
155
+ inputSchema: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
156
+ }, z.core.$strip>>;
157
+ }, z.core.$strip>;
37
158
  }, z.core.$strip>]>, z.ZodObject<{
38
159
  name: z.ZodString;
39
160
  }, z.core.$strip>>>>;
@@ -47,11 +168,18 @@ export declare const GatewayConfigSchema: z.ZodObject<{
47
168
  forTools: z.ZodArray<z.ZodString>;
48
169
  optional: z.ZodDefault<z.ZodBoolean>;
49
170
  }, z.core.$strip>>>;
171
+ precommit: z.ZodDefault<z.ZodArray<z.ZodString>>;
50
172
  receiptsDir: z.ZodString;
51
173
  logFile: z.ZodOptional<z.ZodString>;
52
174
  log: z.ZodOptional<z.ZodObject<{
53
175
  url: z.ZodString;
54
176
  tokenEnv: z.ZodOptional<z.ZodString>;
177
+ hashOnly: z.ZodOptional<z.ZodBoolean>;
178
+ }, z.core.$strip>>;
179
+ otel: z.ZodOptional<z.ZodObject<{
180
+ url: z.ZodString;
181
+ headersEnv: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
182
+ serviceName: z.ZodOptional<z.ZodString>;
55
183
  }, z.core.$strip>>;
56
184
  }, z.core.$strip>;
57
185
  export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
@@ -70,6 +198,12 @@ export declare const SdkConfigSchema: z.ZodObject<{
70
198
  log: z.ZodOptional<z.ZodObject<{
71
199
  url: z.ZodString;
72
200
  tokenEnv: z.ZodOptional<z.ZodString>;
201
+ hashOnly: z.ZodOptional<z.ZodBoolean>;
202
+ }, z.core.$strip>>;
203
+ otel: z.ZodOptional<z.ZodObject<{
204
+ url: z.ZodString;
205
+ headersEnv: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
206
+ serviceName: z.ZodOptional<z.ZodString>;
73
207
  }, z.core.$strip>>;
74
208
  framework: z.ZodOptional<z.ZodString>;
75
209
  }, z.core.$strip>;
package/dist/config.js CHANGED
@@ -2,12 +2,37 @@ import { z } from "zod";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, resolve } from "node:path";
4
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() });
5
+ const LogSchema = z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional(), /** send leaf hashes only; the log never holds the receipt. Use it for any log run by someone else */ hashOnly: z.boolean().optional() });
6
6
  const oneLog = { message: "exactly one of logFile or log is required" };
7
+ /** Optional OpenTelemetry export: every receipt also becomes a span at this OTLP/HTTP collector, after it is issued. Never on the evidence path. */
8
+ const OtelSchema = z.object({ url: z.string().url(), headersEnv: z.record(z.string(), z.string().min(1)).optional(), serviceName: z.string().min(1).optional() });
7
9
  const hasOneLog = (c) => (c.logFile ? 1 : 0) + (c.log ? 1 : 0) === 1;
10
+ /** One REST endpoint offered to the agent as a tool. `{name}` segments in the path come from the call's arguments; the rest go to the query on GET and DELETE, or to a JSON body otherwise. */
11
+ const RestToolSchema = z.object({
12
+ name: z.string().min(1),
13
+ description: z.string().optional(),
14
+ method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET"),
15
+ path: z.string().min(1),
16
+ /** argument names sent as query parameters, when the default placement is not wanted */
17
+ query: z.array(z.string().min(1)).optional(),
18
+ /** "none" sends no body even on POST */
19
+ body: z.enum(["json", "none"]).default("json"),
20
+ /** the JSON Schema the agent sees; default accepts any object */
21
+ inputSchema: z.record(z.string(), z.unknown()).default({ type: "object" }),
22
+ });
23
+ /** A plain HTTP API as an upstream: no MCP server needed. Secrets come from the environment through headerEnv, never from the file or the agent. */
24
+ const RestUpstreamSchema = z.object({
25
+ baseUrl: z.string().url(),
26
+ headers: z.record(z.string(), z.string()).optional(),
27
+ /** header name to environment variable, e.g. { "authorization": "STRIPE_BEARER" }; a missing variable fails at startup */
28
+ headerEnv: z.record(z.string(), z.string().min(1)).optional(),
29
+ timeoutMs: z.number().int().positive().default(30_000),
30
+ tools: z.array(RestToolSchema).min(1),
31
+ });
8
32
  const UpstreamSchema = z.union([
9
33
  z.object({ command: z.string(), args: z.array(z.string()).default([]), env: z.record(z.string(), z.string()).optional() }),
10
34
  z.object({ url: z.string().url(), tokenEnv: z.string().min(1).optional() }),
35
+ z.object({ rest: RestUpstreamSchema }),
11
36
  ]);
12
37
  const FactSchema = z.object({
13
38
  /** key under context.facts */
@@ -23,7 +48,7 @@ const FactSchema = z.object({
23
48
  });
24
49
  export const GatewayConfigSchema = z.object({
25
50
  identity: z.object({ keyFile: z.string() }),
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 */
51
+ /** the upstream: an MCP server to spawn over stdio, an MCP URL to reach over Streamable HTTP with an optional bearer token from the environment, or a REST API described as tools */
27
52
  upstream: UpstreamSchema.optional(),
28
53
  /** several upstreams behind one gateway and one grant; each tool name must belong to exactly one of them */
29
54
  upstreams: z.array(UpstreamSchema.and(z.object({ name: z.string().min(1) }))).min(1).optional(),
@@ -31,9 +56,15 @@ export const GatewayConfigSchema = z.object({
31
56
  trustedPrincipalKeys: z.array(z.string()).min(1),
32
57
  policyFile: z.string(),
33
58
  facts: z.array(FactSchema).default([]),
59
+ /**
60
+ * Consequential tools, by name or "*" for all: before forwarding one of these, the gateway commits a signed
61
+ * authorization to the log and refuses the call if the log will not take it. Evidence then precedes the side effect.
62
+ */
63
+ precommit: z.array(z.string().min(1)).default([]),
34
64
  receiptsDir: z.string(),
35
65
  logFile: z.string().optional(),
36
66
  log: LogSchema.optional(),
67
+ otel: OtelSchema.optional(),
37
68
  }).refine(hasOneLog, oneLog).refine((c) => (c.upstream ? 1 : 0) + (c.upstreams ? 1 : 0) === 1, { message: "exactly one of upstream or upstreams is required" });
38
69
  /** Loads a config file and resolves every path relative to the file's directory. */
39
70
  export function loadConfig(path) {
@@ -60,6 +91,7 @@ export const SdkConfigSchema = z.object({
60
91
  receiptsDir: z.string(),
61
92
  logFile: z.string().optional(),
62
93
  log: LogSchema.optional(),
94
+ otel: OtelSchema.optional(),
63
95
  /** free-text label of the host framework, e.g. "claude-code", "openai-agents" */
64
96
  framework: z.string().optional(),
65
97
  }).refine(hasOneLog, oneLog);
package/dist/gateway.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type CallToolResult, type Tool } from "@modelcontextprotocol/sdk/types.js";
2
2
  import type { GatewayConfig } from "./config.ts";
3
3
  import { type Delegation } from "./delegation.ts";
4
+ import { type LogSink } from "./log-sink.ts";
5
+ import { type ReceiptExporter } from "./otel.ts";
4
6
  export declare const GATEWAY_VERSION = "0.1.0";
5
7
  export declare const RECEIPT_META_KEY = "agent-custody/receipt";
6
8
  export declare const MODEL_META_KEY = "agent-custody/model";
@@ -23,6 +25,12 @@ export interface Gateway {
23
25
  handleCall(params: CallParams): Promise<CallToolResult>;
24
26
  close(): Promise<void>;
25
27
  }
26
- export declare function createGateway(cfg: GatewayConfig): Promise<Gateway>;
28
+ export interface GatewayOptions {
29
+ /** the log to append to, in place of the one the config names; for embedding and tests */
30
+ log?: LogSink;
31
+ /** told about every receipt after it is written, in place of the exporter the config names */
32
+ exporter?: ReceiptExporter;
33
+ }
34
+ export declare function createGateway(cfg: GatewayConfig, options?: GatewayOptions): Promise<Gateway>;
27
35
  /** Exposes the gateway as an MCP server over stdio. Everything diagnostic must go to stderr. */
28
36
  export declare function serveStdio(gw: Gateway): Promise<void>;
package/dist/gateway.js CHANGED
@@ -1,4 +1,4 @@
1
- // The MCP gateway: sits between an agent and one upstream MCP server, enforces scope + Cedar policy,
1
+ // The MCP gateway: sits between an agent and its upstreams, MCP servers or REST APIs, enforces scope + Cedar policy,
2
2
  // and emits a signed, logged receipt for every tool call, allowed or denied.
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { readFileSync } from "node:fs";
@@ -12,7 +12,9 @@ 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 { openExporter } from "./otel.js";
15
16
  import { upstreamEvidenceOf } from "./upstream.js";
17
+ import { restUpstream } from "./rest.js";
16
18
  import { evaluate, policyDigest } from "./policy.js";
17
19
  export const GATEWAY_VERSION = "0.1.0";
18
20
  export const RECEIPT_META_KEY = "agent-custody/receipt";
@@ -55,7 +57,7 @@ function extractValue(result) {
55
57
  return text.text;
56
58
  }
57
59
  }
58
- export async function createGateway(cfg) {
60
+ export async function createGateway(cfg, options = {}) {
59
61
  const gatewayKey = loadPrivateKey(cfg.identity.keyFile);
60
62
  const trusted = cfg.trustedPrincipalKeys.map(loadPublicKey);
61
63
  const grantEnvelope = JSON.parse(readFileSync(cfg.grantFile, "utf8"));
@@ -68,7 +70,9 @@ export async function createGateway(cfg) {
68
70
  const principalKeyid = grant.keyid;
69
71
  const policyText = readFileSync(cfg.policyFile, "utf8");
70
72
  const pDigest = policyDigest(policyText);
71
- const issuer = createIssuer(gatewayKey, cfg.receiptsDir, openLog(cfg, gatewayKey));
73
+ const issuer = createIssuer(gatewayKey, cfg.receiptsDir, options.log ?? openLog(cfg, gatewayKey), { exporter: options.exporter ?? openExporter(cfg) });
74
+ const precommit = new Set(cfg.precommit);
75
+ const consequential = (tool) => precommit.has("*") || precommit.has(tool);
72
76
  // One gateway, one grant, one session, and as many upstreams as the agent's job needs. Each tool name belongs to
73
77
  // exactly one upstream, decided at startup, so a receipt's tool is unambiguous and consumed facts flow across them.
74
78
  const upstreamConfigs = cfg.upstreams ? cfg.upstreams.map((u) => ({ name: u.name, cfg: u })) : [{ name: "upstream", cfg: cfg.upstream }];
@@ -76,14 +80,19 @@ export async function createGateway(cfg) {
76
80
  const owner = new Map();
77
81
  const advertised = [];
78
82
  for (const { name, cfg: u } of upstreamConfigs) {
79
- const client = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
80
- if ("url" in u) {
83
+ let client;
84
+ if ("rest" in u) {
85
+ client = restUpstream(name, u.rest);
86
+ }
87
+ else if ("url" in u) {
88
+ client = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
81
89
  const token = u.tokenEnv ? process.env[u.tokenEnv] : undefined;
82
90
  if (u.tokenEnv && !token)
83
91
  throw new Error(`upstream ${name}: environment variable ${u.tokenEnv} is not set`);
84
92
  await client.connect(new StreamableHTTPClientTransport(new URL(u.url), token ? { requestInit: { headers: { authorization: `Bearer ${token}` } } } : {}));
85
93
  }
86
94
  else {
95
+ client = new Client({ name: "agent-custody-gateway", version: GATEWAY_VERSION });
87
96
  await client.connect(new StdioClientTransport({ command: u.command, args: u.args, env: u.env, stderr: "inherit" }));
88
97
  }
89
98
  upstreams.set(name, client);
@@ -141,6 +150,7 @@ export async function createGateway(cfg) {
141
150
  let facts = {};
142
151
  let policy;
143
152
  let execution;
153
+ let authorization;
144
154
  if (!delegation.scopes.includes(tool)) {
145
155
  policy = { decision: "deny", reasons: [], errors: [`tool "${tool}" is not in the delegation scopes`], policyDigest: pDigest };
146
156
  }
@@ -158,7 +168,32 @@ export async function createGateway(cfg) {
158
168
  policy = { decision: "deny", reasons: [], errors: [String(e instanceof Error ? e.message : e)], policyDigest: pDigest };
159
169
  }
160
170
  }
161
- if (policy.decision === "allow") {
171
+ const head = {
172
+ receiptId,
173
+ timestamp,
174
+ issuer: { kind: "gateway", keyid: issuer.keyid, version: GATEWAY_VERSION },
175
+ principal: { id: delegation.principal, keyid: principalKeyid, provenance: "attested" },
176
+ agent: { id: delegation.agent, provenance: "attested" },
177
+ delegation: { envelope: grantEnvelope, provenance: "attested" },
178
+ tool: { name: tool, provenance: "observed", ...(owner.has(tool) && upstreamConfigs.length > 1 ? { upstream: owner.get(tool) } : {}) },
179
+ request: { args, argsDigest: digestOf(args), provenance: "claimed" },
180
+ facts,
181
+ consumed: { factIds: consumedNow, provenance: "observed" },
182
+ };
183
+ if (policy.decision === "allow" && consequential(tool)) {
184
+ // A consequential call is committed to the log before it goes out, so that evidence of the side effect exists
185
+ // before the side effect does. If the log will not take the authorization, the call is not forwarded.
186
+ try {
187
+ authorization = await issuer.authorize({ ...head, policy: { ...policy, provenance: "observed" } });
188
+ }
189
+ catch (e) {
190
+ execution = { status: "withheld", reason: `the log did not commit the authorization, so the call was not forwarded: ${String(e instanceof Error ? e.message : e)}`, provenance: "observed" };
191
+ }
192
+ }
193
+ if (execution) {
194
+ // withheld: nothing was forwarded
195
+ }
196
+ else if (policy.decision === "allow") {
162
197
  try {
163
198
  // The upstream learns which receipt this call is, and who the grant says is calling. An upstream that keeps
164
199
  // state, such as the memory server, cites the receipt as the source of what it stores.
@@ -176,19 +211,11 @@ export async function createGateway(cfg) {
176
211
  execution = { status: "denied", reason: [...policy.reasons, ...policy.errors].join("; ") || "no permit policy matched", provenance: "observed" };
177
212
  }
178
213
  await issuer.issue({
179
- receiptId,
180
- timestamp,
181
- issuer: { kind: "gateway", keyid: issuer.keyid, version: GATEWAY_VERSION },
182
- principal: { id: delegation.principal, keyid: principalKeyid, provenance: "attested" },
183
- agent: { id: delegation.agent, provenance: "attested" },
184
- delegation: { envelope: grantEnvelope, provenance: "attested" },
214
+ ...head,
185
215
  session: { id: null, toolUseId: null, provenance: "claimed" },
186
216
  model: { id: typeof modelClaim === "string" ? modelClaim : null, provenance: "claimed" },
187
- tool: { name: tool, provenance: "observed", ...(owner.has(tool) && upstreamConfigs.length > 1 ? { upstream: owner.get(tool) } : {}) },
188
- request: { args, argsDigest: digestOf(args), provenance: "claimed" },
189
- facts,
190
- consumed: { factIds: consumedNow, provenance: "observed" },
191
217
  policy: { ...policy, provenance: "observed" },
218
+ ...(authorization ? { authorization } : {}),
192
219
  execution,
193
220
  });
194
221
  const meta = { [RECEIPT_META_KEY]: receiptId };
@@ -198,6 +225,8 @@ export async function createGateway(cfg) {
198
225
  return refuse(`Denied by policy: ${execution.reason}`);
199
226
  case "error":
200
227
  return refuse(`Upstream error: ${execution.error}`);
228
+ case "withheld":
229
+ return refuse(`Not executed: ${execution.reason}`);
201
230
  default: {
202
231
  const result = execution.result;
203
232
  return { ...result, _meta: { ...result._meta, ...meta } };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,11 @@
1
+ export { AUTHORIZATION_PREDICATE_TYPE, buildAuthorizationStatement } from "./receipt.ts";
2
+ export type { AuthorizationBundle, AuthorizationPredicate, AuthorizationStatement } from "./receipt.ts";
3
+ export type { GatewayOptions } from "./gateway.ts";
4
+ export { buildRequest, restUpstream } from "./rest.ts";
5
+ export { openExporter, otlpExporter, spanFor } from "./otel.ts";
6
+ export type { OtelConfig, OtlpOptions, ReceiptExporter } from "./otel.ts";
7
+ export type { IssuerOptions } from "./issue.ts";
8
+ export type { RestOptions, UpstreamClient } from "./rest.ts";
1
9
  export * from "./config.ts";
2
10
  export * from "./crypto.ts";
3
11
  export * from "./delegation.ts";
package/dist/index.js CHANGED
@@ -1,4 +1,7 @@
1
1
  // Public surface of @agent-custody/receipts. Framework adapters live on subpaths, ./sdk/<framework>, because they import optional peers.
2
+ export { AUTHORIZATION_PREDICATE_TYPE, buildAuthorizationStatement } from "./receipt.js";
3
+ export { buildRequest, restUpstream } from "./rest.js";
4
+ export { openExporter, otlpExporter, spanFor } from "./otel.js";
2
5
  export * from "./config.js";
3
6
  export * from "./crypto.js";
4
7
  export * from "./delegation.js";