@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
package/src/config.ts CHANGED
@@ -12,15 +12,21 @@
12
12
  * docs:
13
13
  * url: https://example.com/mcp
14
14
  * headers: { Authorization: "Bearer ${DOCS_TOKEN}" }
15
+ * principal: { id: "${USER}" } # optional: who runs this gateway (default: anonymous)
16
+ * roles: # optional: who may use which tools (deny by default)
17
+ * maintainers: { members: [alice], tools: ["github.*"] }
18
+ * readers: { members: ["*"], tools: ["github.read_*", "github.get_*"] }
15
19
  * approvals: # optional: make "ask" really ask
16
20
  * timeoutSeconds: 50
17
21
  * ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
22
+ * approvers: # optional: each approver's own channel (policy rule `approvers:`)
23
+ * alice: { ntfy: { topic: "${ALICE_NTFY_TOPIC}" } }
18
24
  * email:
19
25
  * smtp: { host: smtp.example.com, port: 465, user: "${SMTP_USER}", pass: "${SMTP_PASS}" }
20
26
  * from: adtl@example.com
21
27
  * to: you@example.com
22
28
  *
23
- * `${VAR}` in command, args, env, cwd, url, headers and approval settings is
29
+ * `${VAR}` in command, args, env, cwd, url, headers, approval settings and principal.id is
24
30
  * replaced from the gateway's environment. A missing variable is a config error,
25
31
  * unless a default is given: `${VAR:-default}`.
26
32
  */
@@ -88,10 +94,50 @@ export const ApprovalsConfigSchema = z
88
94
  timeoutSeconds: z.number().int().positive().max(3600).default(50),
89
95
  ntfy: NtfyConfigSchema.optional(),
90
96
  email: EmailConfigSchema.optional(),
97
+ /**
98
+ * Also ask in the MCP client (elicitation), when it supports it. Off by
99
+ * default: a client that accepts elicitations by itself would approve calls.
100
+ */
101
+ elicitation: z.boolean().default(false),
102
+ /**
103
+ * Each approver's own channel, by principal id. Policy rules with
104
+ * `approvers: [role]` ask only these people, so the answer names who approved.
105
+ */
106
+ approvers: z.record(z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
91
107
  })
92
108
  .strict();
93
109
  export type ApprovalsConfig = z.infer<typeof ApprovalsConfigSchema>;
94
110
 
111
+ /** `server.tool` (or `server.prompt`), with `*` wildcards, as in policy rules. */
112
+ const QualifiedPattern = z
113
+ .string()
114
+ .min(1)
115
+ .refine((p) => /^[^.]+\..+$/.test(p), { message: 'Use "server.name" with optional * wildcards, e.g. "github.read_*" or "github.*"' });
116
+
117
+ export const PrincipalConfigSchema = z
118
+ .object({
119
+ type: z.enum(["user", "service"]).default("user"),
120
+ id: z.string().min(1),
121
+ claims: z.record(z.unknown()).optional(),
122
+ })
123
+ .strict();
124
+ export type PrincipalConfig = z.infer<typeof PrincipalConfigSchema>;
125
+
126
+ export const RoleSchema = z
127
+ .object({
128
+ /** Principal ids; "*" means everyone, including an anonymous caller. */
129
+ members: z.array(z.string().min(1)).nonempty("A role needs at least one member (\"*\" for everyone)"),
130
+ tools: z.array(QualifiedPattern).default([]),
131
+ prompts: z.array(QualifiedPattern).default([]),
132
+ /** Resource URI patterns, e.g. "github://acme/*". */
133
+ resources: z.array(z.string().min(1)).default([]),
134
+ })
135
+ .strict()
136
+ .refine((r) => r.tools.length + r.prompts.length + r.resources.length > 0, {
137
+ message: "A role must grant at least one of tools, prompts or resources",
138
+ });
139
+ export type RoleConfig = z.infer<typeof RoleSchema>;
140
+
95
141
  export const GatewayConfigSchema = z
96
142
  .object({
97
143
  upstreams: z
@@ -113,6 +159,13 @@ export const GatewayConfigSchema = z
113
159
  approvals: ApprovalsConfigSchema.optional(),
114
160
  /** Timeout for each interceptor invocation, in milliseconds. */
115
161
  interceptorTimeoutMs: z.number().int().positive().default(5000),
162
+ /** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
163
+ principal: PrincipalConfigSchema.optional(),
164
+ /** Role-based authorization: when present, a call no role grants is denied. */
165
+ roles: z
166
+ .record(RoleSchema)
167
+ .refine((r) => Object.keys(r).length > 0, "roles: needs at least one role (remove it to allow everyone)")
168
+ .optional(),
116
169
  })
117
170
  .strict();
118
171
  export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Approvals inside the MCP client, through `elicitation/create`: the person
3
+ * using the client (for example Claude Code) sees the request and answers
4
+ * Allow or Deny there, no phone needed.
5
+ *
6
+ * The answer is turned into the same signed token a push button would send,
7
+ * so it answers only the one pending call it was asked about. Declining or
8
+ * dismissing the prompt counts as Deny. The gateway uses this channel only
9
+ * when `gateway.approvals.elicitation` is on and the client declares the
10
+ * elicitation capability, and never for rules with named approvers: the
11
+ * client can't say who answered.
12
+ */
13
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
+ import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js";
15
+ import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
16
+
17
+ interface Listening {
18
+ answer: AnswerFn;
19
+ signal: AbortSignal;
20
+ }
21
+
22
+ /** True if the connected client can answer elicitation requests. */
23
+ export function supportsElicitation(server: Server): boolean {
24
+ return server.getClientCapabilities()?.elicitation !== undefined;
25
+ }
26
+
27
+ function message(request: ApprovalRequest): string {
28
+ const rule = request.matched.join(", ") || "default";
29
+ return `ADTL: allow ${request.server}.${request.tool}?\n\n${request.reason}\n\nRule: ${rule}. Allow runs this one call; Deny or no answer stops it.`;
30
+ }
31
+
32
+ export class ElicitationChannel implements Notifier {
33
+ readonly name = "elicitation";
34
+ private readonly listening = new Map<string, Listening>();
35
+
36
+ constructor(private readonly server: Server) {}
37
+
38
+ async listen(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void> {
39
+ this.listening.set(request.id, { answer, signal });
40
+ await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
41
+ this.listening.delete(request.id);
42
+ }
43
+
44
+ async notify(event: ApprovalEvent): Promise<void> {
45
+ if (event.kind !== "pending") return; // the client sees the call's result; no other notices
46
+ const l = this.listening.get(event.request.id);
47
+ if (!l) throw new Error("not listening for this request");
48
+ // A plain request, not Server.elicitInput(): clients that declare only `elicitation: {}` support forms too.
49
+ const result = await this.server.request(
50
+ {
51
+ method: "elicitation/create",
52
+ params: {
53
+ message: message(event.request),
54
+ requestedSchema: {
55
+ type: "object",
56
+ properties: {
57
+ decision: { type: "string", title: "Decision", enum: ["allow", "deny"], description: "allow runs this one call; deny stops it" },
58
+ },
59
+ required: ["decision"],
60
+ },
61
+ },
62
+ },
63
+ ElicitResultSchema,
64
+ { signal: l.signal, timeout: Math.max(1, event.request.expiresAt.getTime() - Date.now()) },
65
+ );
66
+ const allow = result.action === "accept" && result.content?.decision === "allow";
67
+ l.answer(allow ? event.tokens.allow : event.tokens.deny);
68
+ }
69
+ }
package/src/gateway.ts CHANGED
@@ -23,6 +23,8 @@
23
23
  *
24
24
  * An optional Authorizer decides first whether the caller may use the tool,
25
25
  * resource or prompt at all; a denial stops the call before the policy runs.
26
+ * The lists the agent sees leave out what the authorizer would deny, so the
27
+ * agent doesn't try it; hiding is a convenience, and every call is still checked.
26
28
  *
27
29
  * In audit mode (the invoker runs every interceptor with `mode: "audit"`)
28
30
  * nothing is stopped or changed: calls the policy would stop are forwarded,
@@ -54,12 +56,13 @@ import {
54
56
  import type { InvocationContext } from "@adtl/interceptors";
55
57
  import { callBindingHash } from "@adtl/labels";
56
58
  import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
59
+ import { ElicitationChannel, supportsElicitation } from "./elicitation.js";
57
60
  import type { AuditEntry, AuditSink } from "./audit.js";
58
61
  import { authorize, type AuthorizationRequest, type Authorizer, type Denial, type Principal } from "./authorizer.js";
59
62
  import { TOOL_SEPARATOR } from "./config.js";
60
- import type { Blocked, PhaseOutcome, InterceptorInvoker } from "./invoker.js";
63
+ import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
61
64
 
62
- export const GATEWAY_VERSION = "0.5.1";
65
+ export const GATEWAY_VERSION = "0.6.0";
63
66
 
64
67
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
65
68
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -108,6 +111,20 @@ export interface AdtlGatewayOptions {
108
111
  authorizer?: Authorizer;
109
112
  /** The caller, passed to the authorizer and the interceptors. Default: anonymous. */
110
113
  principal?: Principal;
114
+ /** Members of each role that policy rules name as `approvers:`. */
115
+ approverRoles?: Map<string, string[]>;
116
+ /** Also ask for approval in the MCP client, when it supports elicitation. */
117
+ elicitApprovals?: boolean;
118
+ }
119
+
120
+ /**
121
+ * Who may answer an "ask" whose rules name approver roles: someone in one of
122
+ * the roles of every list. Sorted, for stable notifications and tests.
123
+ */
124
+ export function eligibleApprovers(lists: string[][], roles: Map<string, string[]>): string[] {
125
+ const sets = lists.map((list) => new Set(list.flatMap((role) => roles.get(role) ?? [])));
126
+ const [first, ...rest] = sets;
127
+ return [...(first ?? [])].filter((id) => id !== "*" && rest.every((s) => s.has(id))).sort();
111
128
  }
112
129
 
113
130
  export interface CallOptions {
@@ -157,6 +174,10 @@ export class AdtlGateway {
157
174
  private readonly approvals: ApprovalBroker | undefined;
158
175
  private readonly authorizer: Authorizer | undefined;
159
176
  private readonly principal: Principal;
177
+ private readonly approverRoles: Map<string, string[]>;
178
+ private readonly elicitApprovals: boolean;
179
+ /** The server the agent is connected to, for elicitation. */
180
+ private server: Server | undefined;
160
181
  private routes = new Map<string, Route>();
161
182
  private promptRoutes = new Map<string, PromptRoute>();
162
183
  /** Resource URI → the upstreams that list it. */
@@ -172,6 +193,8 @@ export class AdtlGateway {
172
193
  this.approvals = opts.approvals;
173
194
  this.authorizer = opts.authorizer;
174
195
  this.principal = opts.principal ?? { type: "anonymous" };
196
+ this.approverRoles = opts.approverRoles ?? new Map();
197
+ this.elicitApprovals = opts.elicitApprovals === true;
175
198
  }
176
199
 
177
200
  /** Ask the authorizer, if any. Returns the denial; in audit mode the caller logs it and carries on. */
@@ -195,7 +218,7 @@ export class AdtlGateway {
195
218
  "if a call is stopped, the result explains why.",
196
219
  },
197
220
  );
198
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.listTools() }));
221
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.visibleTools() }));
199
222
  server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
200
223
  const progressToken = req.params._meta?.progressToken;
201
224
  const progress =
@@ -206,16 +229,17 @@ export class AdtlGateway {
206
229
  return this.callTool(req.params.name, req.params.arguments ?? {}, progress ? { progress } : {});
207
230
  });
208
231
  if (capabilities.resources) {
209
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await this.listResources() }));
232
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await this.visibleResources() }));
210
233
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
211
234
  resourceTemplates: await this.listResourceTemplates(),
212
235
  }));
213
236
  server.setRequestHandler(ReadResourceRequestSchema, async (req) => this.readResource(req.params.uri));
214
237
  }
215
238
  if (capabilities.prompts) {
216
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await this.listPrompts() }));
239
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await this.visiblePrompts() }));
217
240
  server.setRequestHandler(GetPromptRequestSchema, async (req) => this.getPrompt(req.params.name, req.params.arguments));
218
241
  }
242
+ this.server = server;
219
243
  return server;
220
244
  }
221
245
 
@@ -242,6 +266,49 @@ export class AdtlGateway {
242
266
  return out;
243
267
  }
244
268
 
269
+ /** tools/list as the caller sees it: without the tools the authorizer would deny. */
270
+ async visibleTools(): Promise<Tool[]> {
271
+ const tools = await this.listTools();
272
+ return this.hideDenied(tools, (t) => {
273
+ const r = this.routes.get(t.name)!;
274
+ return { event: "tools/call", server: r.upstream, tool: r.tool };
275
+ });
276
+ }
277
+
278
+ /** prompts/list as the caller sees it. */
279
+ async visiblePrompts(): Promise<Prompt[]> {
280
+ const prompts = await this.listPrompts();
281
+ return this.hideDenied(prompts, (p) => {
282
+ const r = this.promptRoutes.get(p.name)!;
283
+ return { event: "prompts/get", server: r.upstream, prompt: r.prompt };
284
+ });
285
+ }
286
+
287
+ /** resources/list as the caller sees it. A URI several upstreams offer is listed as before (reading it is refused). */
288
+ async visibleResources(): Promise<Resource[]> {
289
+ const resources = await this.listResources();
290
+ return this.hideDenied(resources, (r) => {
291
+ const ups = this.resourceRoutes.get(r.uri);
292
+ return ups?.size === 1 ? { event: "resources/read", server: [...ups][0]!, resource: r.uri } : undefined;
293
+ });
294
+ }
295
+
296
+ /**
297
+ * Leave out items the authorizer would deny, asked without arguments. Not in
298
+ * audit mode, which changes nothing. Hiding only saves the agent a denied
299
+ * call: routes are kept, and a call to a hidden item is still authorized.
300
+ */
301
+ private async hideDenied<T>(items: T[], request: (item: T) => Omit<AuthorizationRequest, "principal"> | undefined): Promise<T[]> {
302
+ if (!this.authorizer || this.invoker.modeOverride === "audit") return items;
303
+ const shown = await Promise.all(
304
+ items.map(async (item) => {
305
+ const r = request(item);
306
+ return r === undefined || (await this.authorize(r)) === undefined;
307
+ }),
308
+ );
309
+ return items.filter((_, i) => shown[i]);
310
+ }
311
+
245
312
  async listTools(): Promise<Tool[]> {
246
313
  const all = await this.listAll("tools/list", "tools", async (c, cursor) => {
247
314
  const p = await c.listTools(cursor ? { cursor } : {});
@@ -374,6 +441,7 @@ export class AdtlGateway {
374
441
  const config = { server: upstream };
375
442
  const audit = this.invoker.modeOverride === "audit" ? { mode: "audit" as const } : {};
376
443
  let denied: Denial | undefined;
444
+ const ran: InterceptorRun[] = [];
377
445
  const finish = (entry: Pick<AuditEntry, "outcome"> & Partial<AuditEntry>) =>
378
446
  this.audit?.write({
379
447
  ts: context.timestamp!,
@@ -383,6 +451,7 @@ export class AdtlGateway {
383
451
  ...target,
384
452
  ...(denied ? { denied } : {}),
385
453
  ...entry,
454
+ ...(ran.length ? { interceptors: [...ran] } : {}),
386
455
  durationMs: Math.round(performance.now() - started),
387
456
  });
388
457
  const what = "resource" in target ? `resource ${target.resource}` : `prompt ${upstream}${TOOL_SEPARATOR}${target.prompt}`;
@@ -401,6 +470,7 @@ export class AdtlGateway {
401
470
  if (denied) this.log.info(`audit: would deny ${what}: ${denied.reason}`);
402
471
 
403
472
  const req = await this.invoker.runRequest({ event, payload: { method: event, params }, config, context });
473
+ ran.push(...req.ran);
404
474
  if (req.blocked) {
405
475
  finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, warnings: req.warnings });
406
476
  this.log.info(`${req.blocked.decision} ${what}: ${req.blocked.reason}`);
@@ -417,6 +487,7 @@ export class AdtlGateway {
417
487
  }
418
488
 
419
489
  const res = await this.invoker.runResponse({ event, payload: { method: event, params: forwarded, result }, config, context });
490
+ ran.push(...res.ran);
420
491
  const warnings = [...req.warnings, ...res.warnings];
421
492
  if (res.blocked) {
422
493
  // Fail closed: unlabeled content must not reach the agent.
@@ -452,8 +523,16 @@ export class AdtlGateway {
452
523
  };
453
524
  const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
454
525
  let denied: Denial | undefined;
526
+ const ran: InterceptorRun[] = [];
455
527
  const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
456
- this.audit?.write({ ts: context.timestamp!, ...base, ...(denied ? { denied } : {}), ...entry, durationMs: Math.round(performance.now() - started) });
528
+ this.audit?.write({
529
+ ts: context.timestamp!,
530
+ ...base,
531
+ ...(denied ? { denied } : {}),
532
+ ...entry,
533
+ ...(ran.length ? { interceptors: [...ran] } : {}),
534
+ durationMs: Math.round(performance.now() - started),
535
+ });
457
536
 
458
537
  // 0. Authorization: may this caller use this tool at all? A denial wins over any policy decision.
459
538
  denied = await this.authorize({ event: "tools/call", server: route.upstream, tool: route.tool, arguments: args });
@@ -472,6 +551,7 @@ export class AdtlGateway {
472
551
  // 1. Request phase: adtl.policy decides whether the call may run.
473
552
  const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
474
553
  let req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
554
+ ran.push(...req.ran);
475
555
  const policy = infoOf(req, "adtl.policy");
476
556
  let approval: ApprovalResult | undefined;
477
557
  if (req.blocked) {
@@ -481,8 +561,12 @@ export class AdtlGateway {
481
561
  const stopped = { decision: b.decision, reason: b.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel };
482
562
  const matched = Array.isArray(policy.matched) ? (policy.matched as string[]) : [];
483
563
  const hash = b.confirmation?.callHash ?? policy.callHash;
484
- if (b.decision === "ask" && this.approvals?.canApprove && typeof hash === "string") {
485
- this.log.info(`ask ${name}: waiting for approval (${b.reason})`);
564
+ const lists = Array.isArray(policy.approvers) ? (policy.approvers as string[][]) : undefined;
565
+ const approvers = lists ? eligibleApprovers(lists, this.approverRoles) : undefined;
566
+ // The client's own prompt, if enabled and supported; never for named approvers (it can't say who answered).
567
+ const elicit = this.elicitApprovals && !approvers && this.server && supportsElicitation(this.server) ? [new ElicitationChannel(this.server)] : [];
568
+ if (b.decision === "ask" && this.approvals && (this.approvals.canApprove || elicit.length > 0) && typeof hash === "string") {
569
+ this.log.info(`ask ${name}: waiting for approval${approvers ? ` from ${approvers.join(", ") || "nobody"}` : ""} (${b.reason})`);
486
570
  approval = await this.waitForApproval(opts, () =>
487
571
  this.approvals!.request({
488
572
  sessionId: this.sessionId,
@@ -492,7 +576,8 @@ export class AdtlGateway {
492
576
  reason: b.reason,
493
577
  matched,
494
578
  ...(policy.sessionLabel !== undefined ? { sessionLabel: policy.sessionLabel } : {}),
495
- }),
579
+ ...(approvers ? { approvers } : {}),
580
+ }, elicit),
496
581
  );
497
582
  this.log.info(`ask ${name}: ${approval.outcome}`);
498
583
  if (approval.outcome !== "approved") {
@@ -511,6 +596,7 @@ export class AdtlGateway {
511
596
  ...(approval.approver ? { principal: { type: "user" as const, id: approval.approver } } : {}),
512
597
  }];
513
598
  req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context: { ...context, confirmations } });
599
+ ran.push(...req.ran);
514
600
  const sent = callBindingHash({ event: "tools/call", phase: "request", target: route.upstream, payload: req.payload });
515
601
  const recheck = req.blocked?.reason ?? (sent !== c.callHash ? "the call changed after it was approved" : undefined);
516
602
  if (recheck !== undefined) {
@@ -554,6 +640,7 @@ export class AdtlGateway {
554
640
  config,
555
641
  context,
556
642
  });
643
+ ran.push(...res.ran);
557
644
  if (res.blocked) {
558
645
  // Fail closed: an unlabeled result must not reach the agent.
559
646
  finish({ outcome: "blocked", decision: res.blocked.decision, reason: res.blocked.reason, callHash: policy.callHash, warnings: [...req.warnings, ...res.warnings] });
@@ -49,6 +49,9 @@ export interface HookInput {
49
49
  /** PostToolUseFailure: the error the tool returned. */
50
50
  error?: unknown;
51
51
  cwd?: string;
52
+ /** Set when the hook fires inside a subagent (or an in-process teammate). */
53
+ agent_id?: string;
54
+ agent_type?: string;
52
55
  }
53
56
 
54
57
  export interface HookOptions {
@@ -62,7 +65,7 @@ export interface HookOptions {
62
65
  /** Label ledger shared with the gateway. */
63
66
  ledger?: Ledger;
64
67
  /** Files the agent must not change: the ADTL config, the session directory and the ledger. */
65
- protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string };
68
+ protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
66
69
  }
67
70
 
68
71
  /** What the hook process prints and exits with. */
@@ -132,6 +135,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
132
135
  if (protect.auditPath && abs.startsWith(resolve(protect.auditPath))) {
133
136
  return { decision: "block", reason: "The agent may not change the ADTL audit log" };
134
137
  }
138
+ if (protect.signingKeyPath && abs === resolve(protect.signingKeyPath)) {
139
+ return { decision: "block", reason: "The agent may not touch the ADTL signing key" };
140
+ }
135
141
  }
136
142
  const command = typeof args.command === "string" ? args.command : undefined;
137
143
  if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
@@ -146,6 +152,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
146
152
  if (protect.auditPath && command.includes(basename(protect.auditPath))) {
147
153
  return { decision: "ask", reason: "This command touches the ADTL audit log" };
148
154
  }
155
+ if (protect.signingKeyPath && command.includes(basename(protect.signingKeyPath))) {
156
+ return { decision: "ask", reason: "This command touches the ADTL signing key" };
157
+ }
149
158
  }
150
159
  return undefined;
151
160
  }
@@ -173,7 +182,9 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
173
182
  if (!route) return NOTHING;
174
183
  const config = withClaudeCodePreset(opts.config);
175
184
  const ts = new Date().toISOString();
176
- const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const };
185
+ const agent =
186
+ typeof input.agent_id === "string" ? { agent: { id: input.agent_id, ...(typeof input.agent_type === "string" ? { type: input.agent_type } : {}) } } : {};
187
+ const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
177
188
 
178
189
  if (event !== "PreToolUse") {
179
190
  const output = input.tool_response ?? input.error ?? null;
package/src/hooks/run.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { readFile } from "node:fs/promises";
10
10
  import { dirname, resolve } from "node:path";
11
- import { FileSessionStore, defaultSessionsDir } from "@adtl/labels";
11
+ import { FileSessionStore, defaultSessionsDir, defaultSigningKeyPath, trySigner } from "@adtl/labels";
12
12
  import { Ledger, defaultLedgerPath } from "@adtl/ledger";
13
13
  import { parseConfigText } from "@adtl/interceptors";
14
14
  import { parse as parseYaml } from "yaml";
@@ -38,7 +38,10 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
38
38
  const env = opts.env ?? process.env;
39
39
  const sessionsDir = defaultSessionsDir(env);
40
40
  const ledgerPath = config.ledger.path ? resolve(dirname(path), config.ledger.path) : defaultLedgerPath(env);
41
- const ledger = config.ledger.enabled ? new Ledger(ledgerPath, { retentionDays: config.ledger.retentionDays }) : undefined;
41
+ const signer = config.ledger.enabled ? trySigner(defaultSigningKeyPath(env)) : undefined;
42
+ const ledger = config.ledger.enabled
43
+ ? new Ledger(ledgerPath, { retentionDays: config.ledger.retentionDays, ...(signer ? { signer } : {}) })
44
+ : undefined;
42
45
  const auditPath = typeof gw.audit === "string" ? resolve(dirname(path), gw.audit) : undefined;
43
46
  return handleHook(input, {
44
47
  config,
@@ -47,7 +50,7 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
47
50
  ...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
48
51
  mode: gw.mode === "audit" ? "audit" : "active",
49
52
  ...(auditPath ? { audit: new JsonlAuditLog(auditPath) } : {}),
50
- protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}) },
53
+ protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env) },
51
54
  });
52
55
  } catch (err) {
53
56
  return failure(input.hook_event_name, (err as Error).message);
package/src/index.ts CHANGED
@@ -11,3 +11,8 @@ export * from "./init/index.js";
11
11
  export * from "./hooks/index.js";
12
12
  export * from "./ledgerCli.js";
13
13
  export * from "./auditCli.js";
14
+ export * from "./roles.js";
15
+ export * from "./elicitation.js";
16
+ export * from "./receipt.js";
17
+ export * from "./receiptCli.js";
18
+ export * from "./playgroundCli.js";
@@ -53,6 +53,60 @@ export const PRESETS: Preset[] = [
53
53
  write: { integrity: "untrusted", confidentiality: "public", egress: "external" },
54
54
  note: "preset fetch: web pages are untrusted; a fetched URL can carry data out, so fetch is external egress",
55
55
  },
56
+ {
57
+ id: "slack",
58
+ // Both popular servers report this name: @modelcontextprotocol/server-slack (no tool hints, so its
59
+ // tools are listed below) and korotovsky/slack-mcp-server (marks reads with readOnlyHint).
60
+ serverNames: ["Slack MCP Server"],
61
+ // Anyone in the workspace, or a shared channel, can write messages and profiles.
62
+ defaults: { integrity: "untrusted", confidentiality: "private", egress: "external" },
63
+ read: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
64
+ write: { egress: "external" },
65
+ tools: {
66
+ slack_list_channels: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
67
+ slack_get_channel_history: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
68
+ slack_get_thread_replies: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
69
+ slack_get_users: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
70
+ slack_get_user_profile: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
71
+ slack_post_message: { integrity: "trusted", confidentiality: "public", egress: "external" },
72
+ slack_reply_to_thread: { integrity: "trusted", confidentiality: "public", egress: "external" },
73
+ slack_add_reaction: { integrity: "trusted", confidentiality: "public", egress: "external" },
74
+ },
75
+ note: "preset slack: messages are untrusted (anyone in the workspace can write them) and private; posting is external egress (set public for channels with outside guests)",
76
+ },
77
+ {
78
+ id: "gmail",
79
+ // @gongrzhe/server-gmail-autoauth-mcp, the most used Gmail server. It sets no tool hints.
80
+ serverNames: ["gmail"],
81
+ // Anyone can send you email.
82
+ defaults: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
83
+ read: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
84
+ write: { egress: "internal" },
85
+ tools: {
86
+ read_email: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
87
+ search_emails: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
88
+ list_email_labels: { integrity: "untrusted", confidentiality: "private", egress: "none" },
89
+ list_filters: { integrity: "trusted", confidentiality: "private", egress: "none" },
90
+ get_filter: { integrity: "trusted", confidentiality: "private", egress: "none" },
91
+ // Saves the attachment to a local folder.
92
+ download_attachment: { integrity: "untrusted", confidentiality: "private", egress: "internal" },
93
+ send_email: { integrity: "trusted", confidentiality: "public", egress: "external" },
94
+ draft_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
95
+ modify_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
96
+ delete_email: { integrity: "trusted", confidentiality: "public", egress: "internal" },
97
+ batch_modify_emails: { integrity: "trusted", confidentiality: "public", egress: "internal" },
98
+ batch_delete_emails: { integrity: "trusted", confidentiality: "public", egress: "internal" },
99
+ create_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
100
+ update_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
101
+ delete_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
102
+ get_or_create_label: { integrity: "trusted", confidentiality: "public", egress: "internal" },
103
+ delete_filter: { integrity: "trusted", confidentiality: "public", egress: "internal" },
104
+ // Filters can forward matching mail to any address.
105
+ create_filter: { integrity: "trusted", confidentiality: "public", egress: "external" },
106
+ create_filter_from_template: { integrity: "trusted", confidentiality: "public", egress: "external" },
107
+ },
108
+ note: "preset gmail: email is untrusted (anyone can send it) and private; sending, and filters that can forward mail, are external egress",
109
+ },
56
110
  ];
57
111
 
58
112
  /** The preset for an upstream: by the name the server reports, then by the upstream id. */
package/src/invoker.ts CHANGED
@@ -55,12 +55,31 @@ export interface Blocked {
55
55
  confirmation?: ConfirmationRequest;
56
56
  }
57
57
 
58
+ /**
59
+ * One interceptor invocation, with the execution policy it ran under. SEP-2624
60
+ * (Security Best Practice 3) asks invokers to log the resolved `mode` and
61
+ * `failOpen`, and whether each came from an invoker override or the
62
+ * interceptor's declared default.
63
+ */
64
+ export interface InterceptorRun {
65
+ name: string;
66
+ phase: Phase;
67
+ mode: InterceptorMode;
68
+ modeFrom: "override" | "default";
69
+ failOpen: boolean;
70
+ failOpenFrom: "override" | "default";
71
+ /** Set when the invocation errored or timed out. */
72
+ failed?: true;
73
+ }
74
+
58
75
  export interface PhaseOutcome {
59
76
  payload: unknown;
60
77
  blocked?: Blocked;
61
78
  results: InterceptorResult[];
62
79
  /** Interceptors that failed but were allowed to (failOpen or audit mode). */
63
80
  warnings: string[];
81
+ /** Every interceptor invoked in this phase. */
82
+ ran: InterceptorRun[];
64
83
  }
65
84
 
66
85
  export interface InvokeOptions {
@@ -101,14 +120,14 @@ export class InterceptorInvoker {
101
120
  }
102
121
 
103
122
  async runRequest(opts: InvokeOptions): Promise<PhaseOutcome> {
104
- const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [] };
123
+ const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [], ran: [] };
105
124
  if (!(await this.mutate("request", opts, out))) return out;
106
125
  await this.validate("request", opts, out);
107
126
  return out;
108
127
  }
109
128
 
110
129
  async runResponse(opts: InvokeOptions): Promise<PhaseOutcome> {
111
- const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [] };
130
+ const out: PhaseOutcome = { payload: opts.payload, results: [], warnings: [], ran: [] };
112
131
  if (!(await this.validate("response", opts, out))) return out;
113
132
  await this.mutate("response", opts, out);
114
133
  return out;
@@ -118,6 +137,20 @@ export class InterceptorInvoker {
118
137
  return (this.modeOverride ?? i.definition.mode) === "audit";
119
138
  }
120
139
 
140
+ /** Record that `i` runs in `phase`, with its resolved policy. The gateway has no failOpen overrides. */
141
+ private run(i: BoundInterceptor, phase: Phase, out: PhaseOutcome): InterceptorRun {
142
+ const r: InterceptorRun = {
143
+ name: i.definition.name,
144
+ phase,
145
+ mode: this.modeOverride ?? i.definition.mode ?? "active",
146
+ modeFrom: this.modeOverride ? "override" : "default",
147
+ failOpen: i.definition.failOpen === true,
148
+ failOpenFrom: "default",
149
+ };
150
+ out.ran.push(r);
151
+ return r;
152
+ }
153
+
121
154
  private select(type: InterceptorDefinition["type"], event: string, phase: Phase): BoundInterceptor[] {
122
155
  return this.interceptors.filter((i) => i.definition.type === type && hooks(i.definition, event, phase));
123
156
  }
@@ -150,10 +183,12 @@ export class InterceptorInvoker {
150
183
  );
151
184
  let payload = out.payload;
152
185
  for (const i of selected) {
186
+ const ran = this.run(i, phase, out);
153
187
  let r: MutationResult;
154
188
  try {
155
189
  r = (await this.invoke(i, phase, opts, payload)) as MutationResult;
156
190
  } catch (err) {
191
+ ran.failed = true;
157
192
  if (!this.failed(i, err, out)) return false;
158
193
  continue;
159
194
  }
@@ -167,11 +202,13 @@ export class InterceptorInvoker {
167
202
  /** Parallel validations. Only severity "error" blocks. */
168
203
  private async validate(phase: Phase, opts: InvokeOptions, out: PhaseOutcome): Promise<boolean> {
169
204
  const selected = this.select("validation", opts.event, phase);
205
+ const runs = selected.map((i) => this.run(i, phase, out));
170
206
  const settled = await Promise.allSettled(selected.map((i) => this.invoke(i, phase, opts, out.payload)));
171
207
  let ok = true;
172
208
  settled.forEach((s, idx) => {
173
209
  const i = selected[idx]!;
174
210
  if (s.status === "rejected") {
211
+ runs[idx]!.failed = true;
175
212
  if (!this.failed(i, s.reason, out)) ok = false;
176
213
  return;
177
214
  }