@adtl/gateway 0.5.1 → 0.6.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.
Files changed (97) hide show
  1. package/README.md +97 -8
  2. package/dist/approvals.d.ts +22 -6
  3. package/dist/approvals.d.ts.map +1 -1
  4. package/dist/approvals.js +56 -26
  5. package/dist/approvals.js.map +1 -1
  6. package/dist/audit.d.ts +8 -0
  7. package/dist/audit.d.ts.map +1 -1
  8. package/dist/audit.js.map +1 -1
  9. package/dist/auditCli.d.ts +2 -0
  10. package/dist/auditCli.d.ts.map +1 -1
  11. package/dist/auditCli.js +13 -10
  12. package/dist/auditCli.js.map +1 -1
  13. package/dist/cli.js +17 -1
  14. package/dist/cli.js.map +1 -1
  15. package/dist/config.d.ts +285 -21
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +49 -1
  18. package/dist/config.js.map +1 -1
  19. package/dist/elicitation.d.ts +25 -0
  20. package/dist/elicitation.d.ts.map +1 -0
  21. package/dist/elicitation.js +46 -0
  22. package/dist/elicitation.js.map +1 -0
  23. package/dist/gateway.d.ts +26 -1
  24. package/dist/gateway.d.ts.map +1 -1
  25. package/dist/gateway.js +85 -8
  26. package/dist/gateway.js.map +1 -1
  27. package/dist/hooks/claudeCode.d.ts +4 -0
  28. package/dist/hooks/claudeCode.d.ts.map +1 -1
  29. package/dist/hooks/claudeCode.js +8 -1
  30. package/dist/hooks/claudeCode.js.map +1 -1
  31. package/dist/hooks/run.d.ts.map +1 -1
  32. package/dist/hooks/run.js +6 -3
  33. package/dist/hooks/run.js.map +1 -1
  34. package/dist/index.d.ts +5 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/init/presets.d.ts.map +1 -1
  39. package/dist/init/presets.js +54 -0
  40. package/dist/init/presets.js.map +1 -1
  41. package/dist/invoker.d.ts +21 -1
  42. package/dist/invoker.d.ts.map +1 -1
  43. package/dist/invoker.js +19 -2
  44. package/dist/invoker.js.map +1 -1
  45. package/dist/ledgerCli.d.ts +1 -1
  46. package/dist/ledgerCli.d.ts.map +1 -1
  47. package/dist/ledgerCli.js +9 -3
  48. package/dist/ledgerCli.js.map +1 -1
  49. package/dist/ntfy.d.ts +3 -1
  50. package/dist/ntfy.d.ts.map +1 -1
  51. package/dist/ntfy.js +2 -1
  52. package/dist/ntfy.js.map +1 -1
  53. package/dist/playgroundCli.d.ts +8 -0
  54. package/dist/playgroundCli.d.ts.map +1 -0
  55. package/dist/playgroundCli.js +57 -0
  56. package/dist/playgroundCli.js.map +1 -0
  57. package/dist/receipt.d.ts +62 -0
  58. package/dist/receipt.d.ts.map +1 -0
  59. package/dist/receipt.js +117 -0
  60. package/dist/receipt.js.map +1 -0
  61. package/dist/receiptCli.d.ts +8 -0
  62. package/dist/receiptCli.d.ts.map +1 -0
  63. package/dist/receiptCli.js +87 -0
  64. package/dist/receiptCli.js.map +1 -0
  65. package/dist/roles.d.ts +9 -0
  66. package/dist/roles.d.ts.map +1 -0
  67. package/dist/roles.js +41 -0
  68. package/dist/roles.js.map +1 -0
  69. package/dist/startup.d.ts +9 -0
  70. package/dist/startup.d.ts.map +1 -1
  71. package/dist/startup.js +69 -7
  72. package/dist/startup.js.map +1 -1
  73. package/package.json +5 -4
  74. package/playground/fake-github.mjs +98 -0
  75. package/playground/fake-memory.mjs +130 -0
  76. package/playground/index.html +411 -0
  77. package/playground/playground.yaml +40 -0
  78. package/playground/server.mjs +290 -0
  79. package/src/approvals.ts +63 -26
  80. package/src/audit.ts +5 -0
  81. package/src/auditCli.ts +11 -8
  82. package/src/cli.ts +16 -1
  83. package/src/config.ts +54 -1
  84. package/src/elicitation.ts +69 -0
  85. package/src/gateway.ts +96 -9
  86. package/src/hooks/claudeCode.ts +13 -2
  87. package/src/hooks/run.ts +6 -3
  88. package/src/index.ts +5 -0
  89. package/src/init/presets.ts +54 -0
  90. package/src/invoker.ts +39 -2
  91. package/src/ledgerCli.ts +9 -3
  92. package/src/ntfy.ts +4 -1
  93. package/src/playgroundCli.ts +60 -0
  94. package/src/receipt.ts +148 -0
  95. package/src/receiptCli.ts +85 -0
  96. package/src/roles.ts +55 -0
  97. package/src/startup.ts +68 -6
@@ -0,0 +1,290 @@
1
+ // `adtl-gateway playground`: a local web playground for ADTL. It runs the real
2
+ // gateway in this process, in front of fake GitHub and fake memory servers,
3
+ // and shows every call: the label on its result, the session label, the
4
+ // policy decision, the audit log and what actually reached GitHub. "Ask"
5
+ // decisions come up in the page with Allow / Deny buttons (a notifier that
6
+ // answers like ntfy does).
7
+ //
8
+ // Listens on 127.0.0.1 only. Everything it writes (ledger, audit log, posted
9
+ // comments) goes to a temporary folder that is removed on reset and exit.
10
+
11
+ import { createServer } from "node:http";
12
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
+ import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
18
+ import { parseConfigText } from "@adtl/interceptors";
19
+ import {
20
+ AdtlGateway,
21
+ ApprovalBroker,
22
+ GATEWAY_DECISION_KEY,
23
+ InterceptorInvoker,
24
+ JsonlAuditLog,
25
+ bundledInterceptorsCli,
26
+ connectUpstream,
27
+ loadGatewayConfig,
28
+ } from "../dist/index.js";
29
+
30
+ const here = fileURLToPath(new URL(".", import.meta.url));
31
+ const FAKE_GITHUB = join(here, "fake-github.mjs");
32
+ const FAKE_MEMORY = join(here, "fake-memory.mjs");
33
+ export const DEFAULT_CONFIG = readFileSync(join(here, "playground.yaml"), "utf8");
34
+ export const DEFAULT_PORT = 4317;
35
+ const TRUST_KEY = "io.modelcontextprotocol/trust-annotations";
36
+
37
+ /** Answers "ask" decisions from the page, the way the ntfy notifier answers them from a phone. */
38
+ class PageNotifier {
39
+ name = "playground";
40
+ pending = new Map();
41
+
42
+ listen(request, answer, signal) {
43
+ this.pending.set(request.id, { request, answer });
44
+ return new Promise((resolve) => signal.addEventListener("abort", () => {
45
+ this.pending.delete(request.id);
46
+ resolve();
47
+ }));
48
+ }
49
+
50
+ async notify(event) {
51
+ if (event.kind === "pending") {
52
+ const p = this.pending.get(event.request.id);
53
+ if (p) p.tokens = event.tokens;
54
+ }
55
+ }
56
+
57
+ answer(id, decision) {
58
+ const p = this.pending.get(id);
59
+ return !!p?.tokens && p.answer(p.tokens[decision], "you, in the playground");
60
+ }
61
+
62
+ list() {
63
+ return [...this.pending.values()].filter((p) => p.tokens).map(({ request: r }) => ({
64
+ id: r.id, tool: `${r.server}__${r.tool}`, reason: r.reason, matched: r.matched, sessionLabel: r.sessionLabel,
65
+ }));
66
+ }
67
+ }
68
+
69
+ async function stdio(script, env = {}) {
70
+ const client = new Client({ name: "playground-agent", version: "0.0.0" });
71
+ await client.connect(new StdioClientTransport({ command: process.execPath, args: [script], env: { ...getDefaultEnvironment(), ...env }, stderr: "ignore" }));
72
+ return client;
73
+ }
74
+
75
+ function readJsonl(path) {
76
+ return existsSync(path) ? readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : [];
77
+ }
78
+
79
+ async function close(s) {
80
+ if (!s) return;
81
+ await Promise.allSettled(s.closers.map((c) => c.close()));
82
+ rmSync(s.work, { recursive: true, force: true });
83
+ }
84
+
85
+ /** Fresh servers, ledger, audit log and session. Throws without side effects if the config is invalid. */
86
+ async function build({ adtl, config }) {
87
+ if (adtl) parseConfigText(config); // a broken adtl.yaml fails here, before anything starts
88
+ const work = mkdtempSync(join(tmpdir(), "adtl-playground-"));
89
+ const posted = join(work, "posted.jsonl");
90
+ const s = { adtl, config, work, posted, audit: join(work, "audit.jsonl"), timeline: [], sessionNo: 1, closers: [], auditSeen: 0 };
91
+ try {
92
+ if (adtl) {
93
+ const path = join(work, "adtl.yaml");
94
+ writeFileSync(
95
+ path,
96
+ config.replace(/^(ledger|gateway):[\s\S]*$/m, "") +
97
+ `\nledger:\n path: ${JSON.stringify(join(work, "ledger.db"))}\n` +
98
+ `gateway:\n audit: ${JSON.stringify(s.audit)}\n upstreams:\n` +
99
+ ` github:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(FAKE_GITHUB)}]\n` +
100
+ ` env: { FAKE_GITHUB_POSTED: ${JSON.stringify(posted)} }\n` +
101
+ ` memory:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(FAKE_MEMORY)}]\n`,
102
+ );
103
+ const loaded = await loadGatewayConfig(path);
104
+ const interceptors = await connectUpstream("interceptors", {
105
+ command: process.execPath, args: [bundledInterceptorsCli(), "--config", path], env: {},
106
+ }, work);
107
+ s.closers.push(interceptors);
108
+ s.invoker = new InterceptorInvoker(await InterceptorInvoker.discover(interceptors), loaded.gateway.interceptorTimeoutMs);
109
+ s.upstreams = new Map();
110
+ for (const [id, u] of Object.entries(loaded.gateway.upstreams)) {
111
+ const c = await connectUpstream(id, u, work);
112
+ s.closers.push(c);
113
+ s.upstreams.set(id, c);
114
+ }
115
+ s.notifier = new PageNotifier();
116
+ s.approvals = new ApprovalBroker({ notifiers: [s.notifier], timeoutMs: 120_000 });
117
+ s.auditLog = new JsonlAuditLog(s.audit);
118
+ } else {
119
+ s.upstreams = new Map([
120
+ ["github", await stdio(FAKE_GITHUB, { FAKE_GITHUB_POSTED: posted })],
121
+ ["memory", await stdio(FAKE_MEMORY)],
122
+ ]);
123
+ s.closers.push(...s.upstreams.values());
124
+ }
125
+ } catch (err) {
126
+ await close(s);
127
+ throw err;
128
+ }
129
+ return s;
130
+ }
131
+
132
+ /**
133
+ * Start the playground. Resolves once it is listening, with its URL and a
134
+ * `close()` that stops the server and every child process. Port 0 picks a free port.
135
+ */
136
+ export async function startPlayground({ port = DEFAULT_PORT, host = "127.0.0.1", warn = (m) => console.error(`[adtl] ${m}`) } = {}) {
137
+ let state;
138
+
139
+ function newSession(bump = true) {
140
+ const s = state;
141
+ if (bump) s.sessionNo++;
142
+ s.sessionId = `session-${s.sessionNo}`;
143
+ s.sessionLabel = undefined;
144
+ if (s.adtl) {
145
+ s.gateway = new AdtlGateway({
146
+ sessionId: s.sessionId, upstreams: s.upstreams, invoker: s.invoker, audit: s.auditLog, approvals: s.approvals,
147
+ logger: { info: () => {}, warn },
148
+ });
149
+ }
150
+ s.timeline.push({ kind: "session", session: s.sessionId });
151
+ }
152
+
153
+ /** Start over. If the new setup fails (e.g. a broken adtl.yaml), the old one keeps running. */
154
+ async function reset({ adtl = true, config = state?.config ?? DEFAULT_CONFIG } = {}) {
155
+ const next = await build({ adtl, config });
156
+ const old = state;
157
+ state = next;
158
+ newSession(false);
159
+ await close(old);
160
+ }
161
+
162
+ async function tools() {
163
+ const s = state;
164
+ if (s.adtl) return (await s.gateway.listTools()).map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
165
+ const out = [];
166
+ for (const [id, c] of s.upstreams) {
167
+ for (const t of (await c.listTools()).tools) out.push({ name: `${id}__${t.name}`, description: t.description, inputSchema: t.inputSchema });
168
+ }
169
+ return out;
170
+ }
171
+
172
+ async function call(name, args) {
173
+ const s = state;
174
+ const entry = { kind: "call", session: s.sessionId, tool: name, args, pending: true };
175
+ s.timeline.push(entry);
176
+ try {
177
+ let r;
178
+ if (s.adtl) {
179
+ r = await s.gateway.callTool(name, args);
180
+ } else {
181
+ const [id, tool] = name.split("__");
182
+ r = await s.upstreams.get(id).callTool({ name: tool, arguments: args });
183
+ }
184
+ entry.text = r.content?.map((c) => c.text ?? "").join("") ?? "";
185
+ entry.isError = !!r.isError;
186
+ entry.label = r._meta?.[TRUST_KEY];
187
+ entry.decision = r._meta?.[GATEWAY_DECISION_KEY];
188
+ } catch (err) {
189
+ entry.text = err.message;
190
+ entry.isError = true;
191
+ }
192
+ if (s.adtl) {
193
+ const audit = readJsonl(s.audit);
194
+ const mine = audit.slice(s.auditSeen).filter((e) => e.sessionId === s.sessionId).at(-1);
195
+ s.auditSeen = audit.length;
196
+ if (mine) {
197
+ entry.audit = mine;
198
+ if (mine.sessionLabel) s.sessionLabel = mine.sessionLabel;
199
+ }
200
+ }
201
+ entry.pending = false;
202
+ return entry;
203
+ }
204
+
205
+ function snapshot() {
206
+ const s = state;
207
+ return {
208
+ adtl: s.adtl,
209
+ config: s.config,
210
+ session: s.sessionId,
211
+ sessionLabel: s.sessionLabel,
212
+ timeline: s.timeline,
213
+ audit: s.adtl ? readJsonl(s.audit) : [],
214
+ posted: readJsonl(s.posted),
215
+ approvals: s.notifier?.list() ?? [],
216
+ };
217
+ }
218
+
219
+ const page = () => readFileSync(join(here, "index.html"));
220
+
221
+ async function body(req) {
222
+ let raw = "";
223
+ for await (const chunk of req) raw += chunk;
224
+ return raw ? JSON.parse(raw) : {};
225
+ }
226
+
227
+ // One action at a time, except approvals: an "ask" holds its call open until the page answers.
228
+ let queue = Promise.resolve();
229
+ const serial = (fn) => (queue = queue.then(fn, fn));
230
+
231
+ const routes = {
232
+ "GET /": async (_req, res) => res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page()),
233
+ "GET /api/state": async () => snapshot(),
234
+ // Not queued: listing tools must not wait behind a call that is waiting for approval.
235
+ "GET /api/tools": async () => tools(),
236
+ "POST /api/reset": async (req) => {
237
+ const b = await body(req);
238
+ return serial(async () => {
239
+ await reset({ adtl: b.adtl ?? state.adtl, config: b.config ?? (b.defaults ? DEFAULT_CONFIG : state.config) });
240
+ return snapshot();
241
+ });
242
+ },
243
+ "POST /api/session": async () => serial(async () => (newSession(), snapshot())),
244
+ "POST /api/call": async (req) => {
245
+ const b = await body(req);
246
+ return serial(() => call(b.name, b.args ?? {}));
247
+ },
248
+ "POST /api/approve": async (req) => {
249
+ const b = await body(req);
250
+ return { ok: state.notifier?.answer(b.id, b.decision === "allow" ? "allow" : "deny") ?? false };
251
+ },
252
+ };
253
+
254
+ await reset();
255
+
256
+ const server = createServer(async (req, res) => {
257
+ const route = routes[`${req.method} ${req.url.split("?")[0]}`];
258
+ if (!route) return res.writeHead(404).end("not found");
259
+ try {
260
+ const out = await route(req, res);
261
+ if (!res.headersSent) res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(out ?? {}));
262
+ } catch (err) {
263
+ if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: err.message }));
264
+ }
265
+ });
266
+
267
+ try {
268
+ await new Promise((resolve, reject) => {
269
+ server.once("error", reject);
270
+ server.listen(port, host, resolve);
271
+ });
272
+ } catch (err) {
273
+ await close(state);
274
+ if (err.code === "EADDRINUSE") {
275
+ throw new Error(`Port ${port} is in use (another playground running?). Try --port ${port + 1}`);
276
+ }
277
+ throw err;
278
+ }
279
+ const actual = server.address().port;
280
+ return {
281
+ url: `http://localhost:${actual}`,
282
+ port: actual,
283
+ async close() {
284
+ const closed = new Promise((resolve) => server.close(() => resolve()));
285
+ server.closeAllConnections?.();
286
+ await closed;
287
+ await close(state);
288
+ },
289
+ };
290
+ }
package/src/approvals.ts CHANGED
@@ -10,6 +10,11 @@
10
10
  * decision and an expiry. A token only ever answers the one pending call it
11
11
  * was made for, and only once. Anyone who can read the push channel can still
12
12
  * use the tokens sent on it, so that channel must stay private.
13
+ *
14
+ * A request can name its approvers (policy rule `approvers:`). It then goes
15
+ * only to those people's own channels, each with tokens signed for that
16
+ * person, and an answer counts only if it comes back on the same person's
17
+ * channel. So the result names who approved, and nobody else can.
13
18
  */
14
19
  import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
15
20
 
@@ -26,6 +31,8 @@ export interface ApprovalRequest {
26
31
  matched: string[];
27
32
  /** The session label that triggered the rule (no arguments or results). */
28
33
  sessionLabel?: unknown;
34
+ /** People who may answer, reached on their own channels. Absent: the shared channels. */
35
+ approvers?: string[];
29
36
  createdAt: Date;
30
37
  expiresAt: Date;
31
38
  }
@@ -70,9 +77,10 @@ export function errorText(err: unknown): string {
70
77
  export class ApprovalTokens {
71
78
  constructor(private readonly secret: Buffer = randomBytes(32)) {}
72
79
 
73
- sign(request: Pick<ApprovalRequest, "id" | "callHash" | "expiresAt">, decision: ApprovalDecision): string {
80
+ /** A token answering `request` with `decision`; with `approver`, valid only as that person's answer. */
81
+ sign(request: Pick<ApprovalRequest, "id" | "callHash" | "expiresAt">, decision: ApprovalDecision, approver?: string): string {
74
82
  const exp = Math.floor(request.expiresAt.getTime() / 1000);
75
- return `${TOKEN_PREFIX}.${request.id}.${decision}.${exp}.${this.mac(request.id, request.callHash, decision, exp)}`;
83
+ return `${TOKEN_PREFIX}.${request.id}.${decision}.${exp}.${this.mac(request.id, request.callHash, decision, exp, approver)}`;
76
84
  }
77
85
 
78
86
  /** Parse a token without verifying it. */
@@ -86,23 +94,26 @@ export class ApprovalTokens {
86
94
  }
87
95
 
88
96
  /** Verify a token against the pending request it claims to answer. */
89
- verify(token: string, request: Pick<ApprovalRequest, "id" | "callHash">, now: Date): ApprovalDecision | undefined {
97
+ verify(token: string, request: Pick<ApprovalRequest, "id" | "callHash">, now: Date, approver?: string): ApprovalDecision | undefined {
90
98
  const t = ApprovalTokens.parse(token);
91
99
  if (!t || t.id !== request.id) return undefined;
92
100
  if (now.getTime() > t.exp * 1000) return undefined;
93
- const expected = Buffer.from(this.mac(t.id, request.callHash, t.decision, t.exp));
101
+ const expected = Buffer.from(this.mac(t.id, request.callHash, t.decision, t.exp, approver));
94
102
  const given = Buffer.from(t.sig);
95
103
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined;
96
104
  return t.decision;
97
105
  }
98
106
 
99
- private mac(id: string, callHash: string, decision: ApprovalDecision, exp: number): string {
100
- return createHmac("sha256", this.secret).update(`${id}\n${callHash}\n${decision}\n${exp}`).digest("base64url");
107
+ private mac(id: string, callHash: string, decision: ApprovalDecision, exp: number, approver?: string): string {
108
+ const signed = `${id}\n${callHash}\n${decision}\n${exp}${approver === undefined ? "" : `\napprover:${approver}`}`;
109
+ return createHmac("sha256", this.secret).update(signed).digest("base64url");
101
110
  }
102
111
  }
103
112
 
104
113
  export interface ApprovalBrokerOptions {
105
114
  notifiers: Notifier[];
115
+ /** Each approver's own channels, by principal id. */
116
+ approverChannels?: Map<string, Notifier[]>;
106
117
  timeoutMs: number;
107
118
  tokens?: ApprovalTokens;
108
119
  now?: () => Date;
@@ -114,8 +125,15 @@ interface Pending {
114
125
  settle(decision: ApprovalDecision, channel: string, approver?: string): void;
115
126
  }
116
127
 
128
+ /** A channel a request goes to, and whose answers it accepts on it (undefined: a shared channel). */
129
+ interface Target {
130
+ notifier: Notifier;
131
+ approver?: string;
132
+ }
133
+
117
134
  export class ApprovalBroker {
118
135
  private readonly notifiers: Notifier[];
136
+ private readonly approverChannels: Map<string, Notifier[]>;
119
137
  private readonly timeoutMs: number;
120
138
  private readonly tokens: ApprovalTokens;
121
139
  private readonly now: () => Date;
@@ -124,19 +142,32 @@ export class ApprovalBroker {
124
142
 
125
143
  constructor(opts: ApprovalBrokerOptions) {
126
144
  this.notifiers = opts.notifiers;
145
+ this.approverChannels = opts.approverChannels ?? new Map();
127
146
  this.timeoutMs = opts.timeoutMs;
128
147
  this.tokens = opts.tokens ?? new ApprovalTokens();
129
148
  this.now = opts.now ?? (() => new Date());
130
149
  this.log = opts.log ?? { warn: () => {} };
131
150
  }
132
151
 
133
- /** True if at least one notifier can carry answers. Without one, "ask" stays a block. */
152
+ /** True if at least one channel can carry answers. Without one, "ask" stays a block. */
134
153
  get canApprove(): boolean {
135
- return this.notifiers.some((n) => n.listen);
154
+ return this.notifiers.some((n) => n.listen) || [...this.approverChannels.values()].some((ns) => ns.some((n) => n.listen));
155
+ }
156
+
157
+ /**
158
+ * Where a request goes: the named approvers' own channels plus notice-only
159
+ * ones, or else every shared channel plus `extra` (channels for this request
160
+ * only, such as the client's elicitation).
161
+ */
162
+ private targets(request: ApprovalRequest, extra: Notifier[]): Target[] {
163
+ if (!request.approvers) return [...this.notifiers, ...extra].map((notifier) => ({ notifier }));
164
+ const own = request.approvers.flatMap((approver) => (this.approverChannels.get(approver) ?? []).map((notifier) => ({ notifier, approver })));
165
+ // Shared channels that can answer would let anyone who reads them approve; notice-only ones (email) are fine.
166
+ return [...own, ...this.notifiers.filter((n) => !n.listen).map((notifier) => ({ notifier }))];
136
167
  }
137
168
 
138
169
  /** Ask for approval and wait for the answer, the timeout, or a failure to reach anyone. */
139
- async request(input: Omit<ApprovalRequest, "id" | "createdAt" | "expiresAt">): Promise<ApprovalResult> {
170
+ async request(input: Omit<ApprovalRequest, "id" | "createdAt" | "expiresAt">, extra: Notifier[] = []): Promise<ApprovalResult> {
140
171
  const createdAt = this.now();
141
172
  const request: ApprovalRequest = {
142
173
  ...input,
@@ -164,45 +195,51 @@ export class ApprovalBroker {
164
195
  finish({ outcome: decision === "allow" ? "approved" : "denied", channel, ...(approver ? { approver } : {}), waitedMs: waited() }),
165
196
  });
166
197
 
167
- // Start listening before anyone can answer, then send the request everywhere.
168
- const listeners = this.notifiers.filter((n) => n.listen);
169
- for (const n of listeners) {
170
- n.listen!(request, (token, approver) => this.answer(token, n.name, approver), abort.signal).catch((err: unknown) =>
198
+ // Start listening before anyone can answer, then send the request to every target.
199
+ const targets = this.targets(request, extra);
200
+ for (const { notifier: n, approver } of targets.filter((t) => t.notifier.listen)) {
201
+ n.listen!(request, (token, reportedBy) => this.answer(token, n.name, approver, reportedBy), abort.signal).catch((err: unknown) =>
171
202
  this.log.warn(`${n.name}: stopped listening for approvals: ${errorText(err)}`),
172
203
  );
173
204
  }
174
- const tokens = { allow: this.tokens.sign(request, "allow"), deny: this.tokens.sign(request, "deny") };
175
- const sent = this.notifiers.map((n) =>
176
- n.notify({ kind: "pending", request, tokens }).then(
205
+ const sent = targets.map(({ notifier: n, approver }) => {
206
+ const tokens = { allow: this.tokens.sign(request, "allow", approver), deny: this.tokens.sign(request, "deny", approver) };
207
+ return n.notify({ kind: "pending", request, tokens }).then(
177
208
  () => true,
178
209
  (err: unknown) => {
179
210
  this.log.warn(`${n.name}: could not send approval request: ${errorText(err)}`);
180
211
  return false;
181
212
  },
182
- ),
183
- );
213
+ );
214
+ });
184
215
  // Nobody who could answer was reached: deny now rather than wait out the timeout.
185
216
  // Notify-only channels (email) are not waited for.
186
- void Promise.all(sent.filter((_, i) => this.notifiers[i]!.listen)).then((ok) => {
217
+ void Promise.all(sent.filter((_, i) => targets[i]!.notifier.listen)).then((ok) => {
187
218
  if (!ok.includes(true)) finish({ outcome: "undeliverable", waitedMs: waited() });
188
219
  });
189
220
 
190
221
  const r = await result;
191
- this.broadcast({ kind: "resolved", request, result: r });
222
+ this.broadcast({ kind: "resolved", request, result: r }, [...new Set(targets.map((t) => t.notifier))]);
192
223
  return r;
193
224
  }
194
225
 
195
- /** Handle an answer token from a channel. Returns true if it settled a pending approval. */
196
- answer(token: string, channel: string, approver?: string): boolean {
226
+ /**
227
+ * Handle an answer token from a channel; `approver` is whose own channel it
228
+ * came on, `reportedBy` who a shared channel says answered (recorded, not
229
+ * trusted for anything). Returns true if it settled a pending approval.
230
+ */
231
+ answer(token: string, channel: string, approver?: string, reportedBy?: string): boolean {
197
232
  const parsed = ApprovalTokens.parse(token);
198
233
  const p = parsed && this.pending.get(parsed.id);
199
234
  if (!p) return false; // not ours, already answered, or garbage
200
- const decision = this.tokens.verify(token, p.request, this.now());
235
+ // A request with approvers takes answers only on their own channels.
236
+ if (p.request.approvers && (approver === undefined || !p.request.approvers.includes(approver))) return false;
237
+ const decision = this.tokens.verify(token, p.request, this.now(), approver);
201
238
  if (!decision) {
202
239
  this.log.warn(`${channel}: ignored an approval answer with a bad or expired signature`);
203
240
  return false;
204
241
  }
205
- p.settle(decision, channel, approver);
242
+ p.settle(decision, channel, approver ?? reportedBy);
206
243
  return true;
207
244
  }
208
245
 
@@ -211,8 +248,8 @@ export class ApprovalBroker {
211
248
  this.broadcast({ kind: "blocked", ...event });
212
249
  }
213
250
 
214
- private broadcast(event: ApprovalEvent): void {
215
- for (const n of this.notifiers) {
251
+ private broadcast(event: ApprovalEvent, notifiers: Notifier[] = this.notifiers): void {
252
+ for (const n of notifiers) {
216
253
  n.notify(event).catch((err: unknown) => this.log.warn(`${n.name}: could not send ${event.kind} notice: ${errorText(err)}`));
217
254
  }
218
255
  }
package/src/audit.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
19
19
  import { dirname } from "node:path";
20
20
  import { digest, sha256Hex } from "@adtl/labels";
21
+ import type { InterceptorRun } from "./invoker.js";
21
22
 
22
23
  export interface AuditEntry {
23
24
  ts: string;
@@ -32,6 +33,8 @@ export interface AuditEntry {
32
33
  outcome: "forwarded" | "blocked" | "asked" | "upstream_error";
33
34
  /** Present when the entry was written by the Claude Code hook rather than the gateway. */
34
35
  via?: "claude-code-hook";
36
+ /** Present when a Claude Code subagent made the call: its id and type (e.g. "Explore"). */
37
+ agent?: { id: string; type?: string };
35
38
  /** The policy's decision. In audit mode a call can be forwarded with decision "ask" or "block". */
36
39
  decision?: string;
37
40
  /** Present when the gateway ran in audit mode: nothing was stopped or changed. */
@@ -49,6 +52,8 @@ export interface AuditEntry {
49
52
  /** Present when the result carried record label stamps: how many, and which sessions wrote them. */
50
53
  records?: unknown;
51
54
  sessionLabel?: unknown;
55
+ /** Every interceptor invocation for this call, with its resolved mode and failOpen and where each came from (SEP-2624). */
56
+ interceptors?: InterceptorRun[];
52
57
  warnings?: string[];
53
58
  durationMs: number;
54
59
  }
package/src/auditCli.ts CHANGED
@@ -23,6 +23,16 @@ export interface AuditCommandResult {
23
23
  exitCode: number;
24
24
  }
25
25
 
26
+ /** The audit log: `file` if given, else `gateway.audit` from `config`. */
27
+ export async function resolveAuditPath(file: string | undefined, config: string | undefined, usage: string): Promise<string> {
28
+ if (file) return resolve(file);
29
+ if (!config) throw new Error(`Give the audit log file, or -c <adtl.yaml> with gateway.audit set\n\n${usage}`);
30
+ const abs = resolve(config);
31
+ const gw = ((parseYaml(await readFile(abs, "utf8")) ?? {}) as { gateway?: { audit?: unknown } }).gateway ?? {};
32
+ if (typeof gw.audit !== "string") throw new Error(`${config} has no gateway.audit`);
33
+ return resolve(dirname(abs), gw.audit);
34
+ }
35
+
26
36
  export async function runAuditCommand(argv: string[]): Promise<AuditCommandResult> {
27
37
  const [cmd, ...rest] = argv;
28
38
  let config: string | undefined;
@@ -37,14 +47,7 @@ export async function runAuditCommand(argv: string[]): Promise<AuditCommandResul
37
47
  if (cmd !== "verify") throw new Error(`Unknown audit command: ${cmd}\n\n${AUDIT_USAGE}`);
38
48
  if (files.length > 1) throw new Error(`Unexpected argument: ${files[1]}\n\n${AUDIT_USAGE}`);
39
49
 
40
- let path = files[0] && resolve(files[0]);
41
- if (!path) {
42
- if (!config) throw new Error(`Give the audit log file, or -c <adtl.yaml> with gateway.audit set\n\n${AUDIT_USAGE}`);
43
- const abs = resolve(config);
44
- const gw = ((parseYaml(await readFile(abs, "utf8")) ?? {}) as { gateway?: { audit?: unknown } }).gateway ?? {};
45
- if (typeof gw.audit !== "string") throw new Error(`${config} has no gateway.audit`);
46
- path = resolve(dirname(abs), gw.audit);
47
- }
50
+ const path = await resolveAuditPath(files[0], config, AUDIT_USAGE);
48
51
 
49
52
  const v = verifyAuditLog(path);
50
53
  if (!v.ok) return { stdout: `${path}: TAMPERED: ${v.problem}`, exitCode: 1 };
package/src/cli.ts CHANGED
@@ -8,6 +8,9 @@
8
8
  * adtl-gateway hook --config adtl.yaml [--gateway-server adtl]
9
9
  * adtl-gateway ledger stats|verify|declassify|declassified
10
10
  * adtl-gateway audit verify [<file>]
11
+ * adtl-gateway receipt list|<session>|verify <file>
12
+ * adtl-gateway key
13
+ * adtl-gateway playground [--port 4317] [--no-open]
11
14
  *
12
15
  * Point your MCP client (e.g. Claude Code's .mcp.json) at this command.
13
16
  * Logs go to stderr; stdout carries MCP JSON-RPC only.
@@ -16,6 +19,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
16
19
  import { EnvExpansionError } from "./config.js";
17
20
  import { GATEWAY_VERSION } from "./gateway.js";
18
21
  import { runAuditCommand } from "./auditCli.js";
22
+ import { runKeyCommand, runReceiptCommand } from "./receiptCli.js";
23
+ import { runPlayground } from "./playgroundCli.js";
19
24
  import { runHook } from "./hooks/run.js";
20
25
  import { runLedgerCommand } from "./ledgerCli.js";
21
26
  import { runInit, type InitResult } from "./init/run.js";
@@ -26,6 +31,9 @@ const USAGE = `Usage: adtl-gateway --config <adtl.yaml> [--audit]
26
31
  adtl-gateway hook --config <adtl.yaml> [--gateway-server <name>]
27
32
  adtl-gateway ledger stats|verify|declassify|declassified (see: adtl-gateway ledger --help)
28
33
  adtl-gateway audit verify [<file>] (see: adtl-gateway audit --help)
34
+ adtl-gateway receipt list|<session>|verify <file> (see: adtl-gateway receipt --help)
35
+ adtl-gateway key
36
+ adtl-gateway playground [--port <n>] [--no-open] (a local web UI with fake servers)
29
37
 
30
38
  Runs an MCP server over stdio that forwards tool calls to the upstream
31
39
  servers in the config, checking and labeling each call with ADTL.
@@ -104,6 +112,13 @@ async function main(): Promise<void> {
104
112
  process.exitCode = r.exitCode;
105
113
  return;
106
114
  }
115
+ if (process.argv[2] === "playground") return runPlayground(process.argv.slice(3));
116
+ if (process.argv[2] === "receipt" || process.argv[2] === "key") {
117
+ const r = process.argv[2] === "key" ? await runKeyCommand() : await runReceiptCommand(process.argv.slice(3));
118
+ console.log(r.stdout);
119
+ process.exitCode = r.exitCode;
120
+ return;
121
+ }
107
122
  const args = parseArgs(process.argv.slice(2));
108
123
  // --help and --version print to stdout and exit; MCP is never served in these modes.
109
124
  if (args.help) return void console.log(USAGE);
@@ -190,7 +205,7 @@ async function init(path: string, write: boolean, from?: string): Promise<void>
190
205
  }
191
206
 
192
207
  main().catch((err: unknown) => {
193
- const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit"].includes(process.argv[2] ?? "");
208
+ const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit", "receipt", "key", "playground"].includes(process.argv[2] ?? "");
194
209
  console.error(known ? `[adtl-gateway] ${(err as Error).message}` : err);
195
210
  // For Claude Code hooks only exit code 2 stops the call; any other code lets it run.
196
211
  process.exit(process.argv[2] === "hook" ? 2 : 1);