@adtl/gateway 0.7.0 → 0.8.1

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 (80) hide show
  1. package/README.md +143 -11
  2. package/dist/audit.d.ts +3 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js.map +1 -1
  5. package/dist/cli.js +10 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +167 -713
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +64 -7
  10. package/dist/config.js.map +1 -1
  11. package/dist/elicitation.d.ts +7 -1
  12. package/dist/elicitation.d.ts.map +1 -1
  13. package/dist/elicitation.js +21 -14
  14. package/dist/elicitation.js.map +1 -1
  15. package/dist/gateway.d.ts +11 -1
  16. package/dist/gateway.d.ts.map +1 -1
  17. package/dist/gateway.js +13 -5
  18. package/dist/gateway.js.map +1 -1
  19. package/dist/hooks/agentLinks.d.ts +18 -0
  20. package/dist/hooks/agentLinks.d.ts.map +1 -0
  21. package/dist/hooks/agentLinks.js +48 -0
  22. package/dist/hooks/agentLinks.js.map +1 -0
  23. package/dist/hooks/claudeCode.d.ts +4 -0
  24. package/dist/hooks/claudeCode.d.ts.map +1 -1
  25. package/dist/hooks/claudeCode.js +35 -8
  26. package/dist/hooks/claudeCode.js.map +1 -1
  27. package/dist/hooks/index.d.ts +1 -0
  28. package/dist/hooks/index.d.ts.map +1 -1
  29. package/dist/hooks/index.js +1 -0
  30. package/dist/hooks/index.js.map +1 -1
  31. package/dist/hooks/run.d.ts.map +1 -1
  32. package/dist/hooks/run.js +5 -2
  33. package/dist/hooks/run.js.map +1 -1
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +3 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/init/suggest.d.ts +3 -1
  39. package/dist/init/suggest.d.ts.map +1 -1
  40. package/dist/init/suggest.js +8 -0
  41. package/dist/init/suggest.js.map +1 -1
  42. package/dist/invoker.d.ts +56 -6
  43. package/dist/invoker.d.ts.map +1 -1
  44. package/dist/invoker.js +121 -19
  45. package/dist/invoker.js.map +1 -1
  46. package/dist/openfga.d.ts +13 -0
  47. package/dist/openfga.d.ts.map +1 -0
  48. package/dist/openfga.js +101 -0
  49. package/dist/openfga.js.map +1 -0
  50. package/dist/startup.d.ts +10 -1
  51. package/dist/startup.d.ts.map +1 -1
  52. package/dist/startup.js +82 -11
  53. package/dist/startup.js.map +1 -1
  54. package/dist/totp.d.ts +42 -0
  55. package/dist/totp.d.ts.map +1 -0
  56. package/dist/totp.js +176 -0
  57. package/dist/totp.js.map +1 -0
  58. package/dist/totpCli.d.ts +11 -0
  59. package/dist/totpCli.d.ts.map +1 -0
  60. package/dist/totpCli.js +46 -0
  61. package/dist/totpCli.js.map +1 -0
  62. package/package.json +8 -7
  63. package/playground/server.mjs +13 -3
  64. package/src/audit.ts +3 -1
  65. package/src/cli.ts +10 -1
  66. package/src/config.ts +71 -7
  67. package/src/elicitation.ts +35 -17
  68. package/src/gateway.ts +18 -5
  69. package/src/hooks/agentLinks.ts +58 -0
  70. package/src/hooks/claudeCode.ts +40 -9
  71. package/src/hooks/index.ts +1 -0
  72. package/src/hooks/run.ts +5 -2
  73. package/src/index.ts +3 -0
  74. package/src/init/suggest.ts +10 -1
  75. package/src/invoker.ts +153 -21
  76. package/src/openfga.ts +101 -0
  77. package/src/startup.ts +79 -11
  78. package/src/totp.ts +191 -0
  79. package/src/totpCli.ts +52 -0
  80. package/src/types/qrcode-terminal.d.ts +4 -0
package/src/invoker.ts CHANGED
@@ -11,8 +11,11 @@
11
11
  * their mutations are not applied. An interceptor that fails (error or
12
12
  * timeout) blocks the message unless it declares `failOpen: true`.
13
13
  *
14
- * The invoker may override every interceptor's `mode`, as SEP-2624 allows
15
- * (e.g. audit in staging, active in production).
14
+ * The invoker may override execution policy, as SEP-2624 allows: every
15
+ * interceptor's `mode` at once (e.g. audit in staging, active in production),
16
+ * and per interceptor its `failOpen`, `priorityHint`, `mode`, `timeoutMs` and
17
+ * `hooks` (narrowed only, never widened). A global mode wins over a
18
+ * per-interceptor one, so `--audit` always stops nothing.
16
19
  */
17
20
  import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
21
  import {
@@ -21,6 +24,7 @@ import {
21
24
  LIST_METHOD,
22
25
  type ConfirmationRequest,
23
26
  type InterceptorDefinition,
27
+ type InterceptorHook,
24
28
  type InterceptorResult,
25
29
  type InvocationContext,
26
30
  type MutationResult,
@@ -34,17 +38,49 @@ const AnyResult = z.object({}).passthrough();
34
38
 
35
39
  type Loose = Record<string, unknown>;
36
40
 
37
- /** Untyped request: interceptor methods are extension methods the SDK does not type. */
41
+ /**
42
+ * Untyped request: interceptor methods are extension methods the SDK does not type.
43
+ * The timeout is enforced here too, so a client that ignores it can't hang a phase.
44
+ */
38
45
  function request(client: Client, method: string, params: unknown, timeout: number): Promise<Loose> {
39
46
  const r = client.request as unknown as (req: unknown, schema: unknown, opts: unknown) => Promise<Loose>;
40
- return r.call(client, { method, params }, AnyResult, { timeout });
47
+ const controller = new AbortController();
48
+ let timer: NodeJS.Timeout | undefined;
49
+ const expired = new Promise<never>((_, reject) => {
50
+ timer = setTimeout(() => {
51
+ controller.abort();
52
+ reject(new Error(`timed out after ${timeout} ms`));
53
+ }, timeout);
54
+ });
55
+ return Promise.race([r.call(client, { method, params }, AnyResult, { timeout, signal: controller.signal }), expired]).finally(() =>
56
+ clearTimeout(timer),
57
+ );
41
58
  }
42
59
 
43
60
  export type InterceptorMode = NonNullable<InterceptorDefinition["mode"]>;
44
61
 
62
+ /** Invoker-side execution policy for one interceptor (SEP-2624 "Invoker Overrides"). */
63
+ export interface InterceptorOverrides {
64
+ failOpen?: boolean;
65
+ /** Ignored for validators. */
66
+ priorityHint?: InterceptorDefinition["priorityHint"];
67
+ mode?: InterceptorMode;
68
+ timeoutMs?: number;
69
+ /** Must be a subset of the interceptor's declared hooks. */
70
+ hooks?: InterceptorHook[];
71
+ }
72
+
45
73
  export interface BoundInterceptor {
46
74
  client: Client;
47
75
  definition: InterceptorDefinition;
76
+ /** Id of the interceptor server that advertised it, for messages. */
77
+ server?: string;
78
+ /**
79
+ * Whether the gateway's per-call `config` (ADTL's server, tool and
80
+ * annotations) is sent. Other servers' interceptors get none, so their
81
+ * own defaults apply. Default: true.
82
+ */
83
+ sendConfig?: boolean;
48
84
  }
49
85
 
50
86
  export interface Blocked {
@@ -68,6 +104,7 @@ export interface InterceptorRun {
68
104
  modeFrom: "override" | "default";
69
105
  failOpen: boolean;
70
106
  failOpenFrom: "override" | "default";
107
+ timeoutMs: number;
71
108
  /** Set when the invocation errored or timed out. */
72
109
  failed?: true;
73
110
  }
@@ -89,15 +126,77 @@ export interface InvokeOptions {
89
126
  context: InvocationContext;
90
127
  }
91
128
 
92
- function priority(def: InterceptorDefinition, phase: Phase): number {
93
- const p = def.priorityHint;
129
+ /** SEP-2624 `resolvePriority`: the override's hint, else the interceptor's. */
130
+ export function resolvePriority(def: InterceptorDefinition, overrides: InterceptorOverrides | undefined, phase: Phase): number {
131
+ const p = overrides?.priorityHint ?? def.priorityHint;
94
132
  if (p === undefined) return 0;
95
133
  if (typeof p === "number") return p;
96
134
  return p[phase] ?? 0;
97
135
  }
98
136
 
99
- function hooks(def: InterceptorDefinition, event: string, phase: Phase): boolean {
100
- return def.hooks.some((h) => h.phase === phase && (h.events.includes(event) || h.events.includes("*")));
137
+ function matches(hooks: InterceptorHook[], event: string, phase: Phase): boolean {
138
+ return hooks.some((h) => h.phase === phase && (h.events.includes(event) || h.events.includes("*")));
139
+ }
140
+
141
+ /**
142
+ * Problems with `overrides` for these interceptors: an unknown name, or hooks
143
+ * that widen what the interceptor declares (which SEP-2624 says to reject).
144
+ */
145
+ export function checkOverrides(defs: InterceptorDefinition[], overrides: Record<string, InterceptorOverrides>): string[] {
146
+ const problems: string[] = [];
147
+ for (const [name, o] of Object.entries(overrides)) {
148
+ const def = defs.find((d) => d.name === name);
149
+ if (!def) {
150
+ problems.push(`${name}: no such interceptor (offered: ${defs.map((d) => d.name).join(", ")})`);
151
+ continue;
152
+ }
153
+ for (const h of o.hooks ?? []) {
154
+ for (const event of h.events) {
155
+ const declared = def.hooks.some((d) => d.phase === h.phase && (d.events.includes("*") || (event !== "*" && d.events.includes(event))));
156
+ if (!declared) problems.push(`${name}: hooks may only narrow the declared hooks, and ${event} (${h.phase}) is not declared`);
157
+ }
158
+ }
159
+ }
160
+ return problems;
161
+ }
162
+
163
+ /** Interceptors of a type this invoker doesn't run (e.g. the proposed `sink`). They are never invoked. */
164
+ export function unsupportedInterceptors(bound: BoundInterceptor[]): string[] {
165
+ return bound
166
+ .filter((b) => b.definition.type !== "validation" && b.definition.type !== "mutation")
167
+ .map((b) => `${b.definition.name} (${String(b.definition.type)})`);
168
+ }
169
+
170
+ /** Interceptor names offered by more than one server: each must be invoked on its own server, so names must be unique. */
171
+ export function duplicateInterceptors(bound: BoundInterceptor[]): string[] {
172
+ const servers = new Map<string, string[]>();
173
+ for (const b of bound) servers.set(b.definition.name, [...(servers.get(b.definition.name) ?? []), b.server ?? "?"]);
174
+ return [...servers].filter(([, s]) => s.length > 1).map(([name, s]) => `${name} (offered by ${s.join(", ")})`);
175
+ }
176
+
177
+ /**
178
+ * Mutators that would run after `name` on a phase both hook, so they see (and
179
+ * could change or drop) what it wrote. Used to warn when another server's
180
+ * mutator runs after adtl.label.
181
+ */
182
+ export function mutatorsAfter(bound: BoundInterceptor[], name: string, overrides: Record<string, InterceptorOverrides> = {}): string[] {
183
+ const target = bound.find((b) => b.definition.name === name);
184
+ if (!target) return [];
185
+ const hooksOf = (b: BoundInterceptor) => overrides[b.definition.name]?.hooks ?? b.definition.hooks;
186
+ const shares = (b: BoundInterceptor, phase: Phase) =>
187
+ hooksOf(b).some((h) => h.phase === phase && hooksOf(target).some((t) => t.phase === phase && (h.events.includes("*") || t.events.includes("*") || h.events.some((e) => t.events.includes(e)))));
188
+ const after: string[] = [];
189
+ for (const b of bound) {
190
+ if (b === target || b.definition.type !== "mutation") continue;
191
+ const late = (["request", "response"] as const).some((phase) => {
192
+ if (!shares(b, phase)) return false;
193
+ const p = resolvePriority(b.definition, overrides[b.definition.name], phase);
194
+ const t = resolvePriority(target.definition, overrides[name], phase);
195
+ return p > t || (p === t && b.definition.name.localeCompare(name) > 0);
196
+ });
197
+ if (late) after.push(b.definition.name);
198
+ }
199
+ return after;
101
200
  }
102
201
 
103
202
  export class InterceptorInvoker {
@@ -106,13 +205,15 @@ export class InterceptorInvoker {
106
205
  private readonly timeoutMs = 5000,
107
206
  /** Overrides the mode every interceptor declares. */
108
207
  readonly modeOverride?: InterceptorMode,
208
+ /** Per-interceptor overrides, by name. Check them with `checkOverrides` first. */
209
+ private readonly overrides: Record<string, InterceptorOverrides> = {},
109
210
  ) {}
110
211
 
111
212
  /** Discover the interceptors an interceptor server offers. */
112
- static async discover(client: Client): Promise<BoundInterceptor[]> {
213
+ static async discover(client: Client, opts: { server?: string; sendConfig?: boolean } = {}): Promise<BoundInterceptor[]> {
113
214
  const res = await request(client, LIST_METHOD, {}, 10_000);
114
215
  const defs = (res.interceptors ?? []) as InterceptorDefinition[];
115
- return defs.map((definition) => ({ client, definition }));
216
+ return defs.map((definition) => ({ client, definition, ...opts }));
116
217
  }
117
218
 
118
219
  get definitions(): InterceptorDefinition[] {
@@ -133,34 +234,63 @@ export class InterceptorInvoker {
133
234
  return out;
134
235
  }
135
236
 
237
+ private override(i: BoundInterceptor): InterceptorOverrides | undefined {
238
+ return this.overrides[i.definition.name];
239
+ }
240
+
241
+ private mode(i: BoundInterceptor): InterceptorMode {
242
+ return this.modeOverride ?? this.override(i)?.mode ?? i.definition.mode ?? "active";
243
+ }
244
+
136
245
  private isAudit(i: BoundInterceptor): boolean {
137
- return (this.modeOverride ?? i.definition.mode) === "audit";
246
+ return this.mode(i) === "audit";
247
+ }
248
+
249
+ private failOpen(i: BoundInterceptor): boolean {
250
+ return (this.override(i)?.failOpen ?? i.definition.failOpen) === true;
138
251
  }
139
252
 
140
- /** Record that `i` runs in `phase`, with its resolved policy. The gateway has no failOpen overrides. */
253
+ private timeout(i: BoundInterceptor): number {
254
+ return this.override(i)?.timeoutMs ?? this.timeoutMs;
255
+ }
256
+
257
+ /** Record that `i` runs in `phase`, with its resolved policy. */
141
258
  private run(i: BoundInterceptor, phase: Phase, out: PhaseOutcome): InterceptorRun {
259
+ const o = this.override(i);
142
260
  const r: InterceptorRun = {
143
261
  name: i.definition.name,
144
262
  phase,
145
- mode: this.modeOverride ?? i.definition.mode ?? "active",
146
- modeFrom: this.modeOverride ? "override" : "default",
147
- failOpen: i.definition.failOpen === true,
148
- failOpenFrom: "default",
263
+ mode: this.mode(i),
264
+ modeFrom: this.modeOverride || o?.mode ? "override" : "default",
265
+ failOpen: this.failOpen(i),
266
+ failOpenFrom: o?.failOpen !== undefined ? "override" : "default",
267
+ timeoutMs: this.timeout(i),
149
268
  };
150
269
  out.ran.push(r);
151
270
  return r;
152
271
  }
153
272
 
154
273
  private select(type: InterceptorDefinition["type"], event: string, phase: Phase): BoundInterceptor[] {
155
- return this.interceptors.filter((i) => i.definition.type === type && hooks(i.definition, event, phase));
274
+ return this.interceptors.filter(
275
+ (i) => i.definition.type === type && matches(this.override(i)?.hooks ?? i.definition.hooks, event, phase),
276
+ );
156
277
  }
157
278
 
158
279
  private async invoke(i: BoundInterceptor, phase: Phase, opts: InvokeOptions, payload: unknown): Promise<InterceptorResult> {
280
+ const timeoutMs = this.timeout(i);
159
281
  const res = await request(
160
282
  i.client,
161
283
  INVOKE_METHOD,
162
- { name: i.definition.name, event: opts.event, phase, payload, config: opts.config, timeoutMs: this.timeoutMs, context: opts.context },
163
- this.timeoutMs,
284
+ {
285
+ name: i.definition.name,
286
+ event: opts.event,
287
+ phase,
288
+ payload,
289
+ ...(i.sendConfig === false ? {} : { config: opts.config }),
290
+ timeoutMs,
291
+ context: opts.context,
292
+ },
293
+ timeoutMs,
164
294
  );
165
295
  return res as unknown as InterceptorResult;
166
296
  }
@@ -168,7 +298,7 @@ export class InterceptorInvoker {
168
298
  /** Handle an interceptor failure. Returns false if the phase must stop. */
169
299
  private failed(i: BoundInterceptor, err: unknown, out: PhaseOutcome): boolean {
170
300
  const msg = `${i.definition.name} failed: ${err instanceof Error ? err.message : String(err)}`;
171
- if (this.isAudit(i) || i.definition.failOpen === true) {
301
+ if (this.isAudit(i) || this.failOpen(i)) {
172
302
  out.warnings.push(msg);
173
303
  return true;
174
304
  }
@@ -179,7 +309,9 @@ export class InterceptorInvoker {
179
309
  /** Sequential mutations, atomic: on failure the original payload is kept. */
180
310
  private async mutate(phase: Phase, opts: InvokeOptions, out: PhaseOutcome): Promise<boolean> {
181
311
  const selected = this.select("mutation", opts.event, phase).sort(
182
- (a, b) => priority(a.definition, phase) - priority(b.definition, phase) || a.definition.name.localeCompare(b.definition.name),
312
+ (a, b) =>
313
+ resolvePriority(a.definition, this.override(a), phase) - resolvePriority(b.definition, this.override(b), phase) ||
314
+ a.definition.name.localeCompare(b.definition.name),
183
315
  );
184
316
  let payload = out.payload;
185
317
  for (const i of selected) {
package/src/openfga.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Relationship-based authorization with OpenFGA (`gateway.openfga`):
3
+ *
4
+ * openfga:
5
+ * url: http://localhost:8080
6
+ * storeId: "${FGA_STORE_ID}"
7
+ * modelId: "${FGA_MODEL_ID}" # optional: pin an authorization model
8
+ * token: "${FGA_TOKEN}" # optional: bearer token
9
+ * relation: can_use # optional: the default relation
10
+ * timeoutMs: 2000 # optional
11
+ * tools: # optional: per-resource rules, from the call's arguments
12
+ * github.add_issue_comment: { relation: can_comment, object: "repo:{owner}/{repo}" }
13
+ *
14
+ * Every call becomes one OpenFGA check:
15
+ *
16
+ * user user:<id>, service:<id>, or user:anonymous without an id
17
+ * relation the tool's rule, else `relation`
18
+ * object the tool's rule, else tool:<server>.<tool>, prompt:<server>.<name> or resource:<uri>
19
+ *
20
+ * Ids are percent-encoded, because OpenFGA ids can't hold `:`, `#`, `@` or
21
+ * spaces; ids too long for OpenFGA are replaced by their SHA-256. A rule's
22
+ * `{argument}` must be a string or number, or the call is denied. Tool lists
23
+ * have no arguments, so tools whose object needs them stay listed and are
24
+ * checked when called. An unreachable server or an error denies the call.
25
+ *
26
+ * Works with Auth0 FGA too, by pointing `url` at its API and passing a token.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import type { AuthorizationDecision, AuthorizationRequest, Authorizer } from "./authorizer.js";
30
+ import type { OpenFgaConfig } from "./config.js";
31
+
32
+ /** OpenFGA's limit on the id part of an object or user. */
33
+ const MAX_ID = 254;
34
+
35
+ function fgaId(value: string): string {
36
+ const id = encodeURIComponent(value);
37
+ return id.length <= MAX_ID ? id : `sha256-${createHash("sha256").update(value).digest("hex")}`;
38
+ }
39
+
40
+ const PLACEHOLDER = /\{([A-Za-z0-9_.-]+)\}/g;
41
+
42
+ export class OpenFgaAuthorizer implements Authorizer {
43
+ readonly name = "openfga";
44
+
45
+ constructor(
46
+ private readonly cfg: OpenFgaConfig,
47
+ private readonly fetchFn: typeof fetch = fetch,
48
+ ) {}
49
+
50
+ private user(req: AuthorizationRequest): string {
51
+ const p = req.principal;
52
+ if (p.type === "anonymous" || !p.id) return "user:anonymous";
53
+ return `${p.type === "service" ? "service" : "user"}:${fgaId(p.id)}`;
54
+ }
55
+
56
+ /** The object of this call, or a reason it can't be built. "listing" means: decide when called. */
57
+ private object(req: AuthorizationRequest, template?: string): { object: string } | { missing: string } | "listing" {
58
+ if (template !== undefined) {
59
+ if (req.arguments === undefined && template.search(PLACEHOLDER) !== -1) return "listing";
60
+ const colon = template.indexOf(":");
61
+ const type = template.slice(0, colon + 1);
62
+ let missing: string | undefined;
63
+ const id = template.slice(colon + 1).replace(PLACEHOLDER, (_, name: string) => {
64
+ const v = req.arguments?.[name];
65
+ if (typeof v !== "string" && typeof v !== "number") {
66
+ missing ??= name;
67
+ return "";
68
+ }
69
+ return String(v);
70
+ });
71
+ return missing !== undefined ? { missing } : { object: `${type}${fgaId(id)}` };
72
+ }
73
+ if (req.tool !== undefined) return { object: `tool:${fgaId(`${req.server}.${req.tool}`)}` };
74
+ if (req.prompt !== undefined) return { object: `prompt:${fgaId(`${req.server}.${req.prompt}`)}` };
75
+ return { object: `resource:${fgaId(req.resource ?? "")}` };
76
+ }
77
+
78
+ async check(req: AuthorizationRequest): Promise<AuthorizationDecision> {
79
+ const rule = req.tool !== undefined ? this.cfg.tools[`${req.server}.${req.tool}`] : undefined;
80
+ const target = this.object(req, rule?.object);
81
+ if (target === "listing") return { allow: true };
82
+ const what = req.tool ?? req.prompt ?? req.resource;
83
+ if ("missing" in target) return { allow: false, reason: `openfga: ${what} has no usable argument "${target.missing}"` };
84
+
85
+ const user = this.user(req);
86
+ const relation = rule?.relation ?? this.cfg.relation;
87
+ const res = await this.fetchFn(`${this.cfg.url.replace(/\/+$/, "")}/stores/${encodeURIComponent(this.cfg.storeId)}/check`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json", ...(this.cfg.token ? { authorization: `Bearer ${this.cfg.token}` } : {}) },
90
+ body: JSON.stringify({
91
+ tuple_key: { user, relation, object: target.object },
92
+ ...(this.cfg.modelId ? { authorization_model_id: this.cfg.modelId } : {}),
93
+ }),
94
+ signal: AbortSignal.timeout(this.cfg.timeoutMs),
95
+ });
96
+ if (!res.ok) throw new Error(`OpenFGA answered ${res.status}`);
97
+ const body = (await res.json()) as { allowed?: unknown };
98
+ if (body.allowed === true) return { allow: true };
99
+ return { allow: false, reason: `OpenFGA: ${user} has no ${relation} on ${target.object}` };
100
+ }
101
+ }
package/src/startup.ts CHANGED
@@ -28,7 +28,9 @@ import {
28
28
  } from "./config.js";
29
29
  import { EmailNotifier } from "./email.js";
30
30
  import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
31
- import { InterceptorInvoker } from "./invoker.js";
31
+ import { InterceptorInvoker, checkOverrides, duplicateInterceptors, mutatorsAfter, unsupportedInterceptors } from "./invoker.js";
32
+ import { OpenFgaAuthorizer } from "./openfga.js";
33
+ import { loadTotpVerifier } from "./totp.js";
32
34
  import { NtfyNotifier } from "./ntfy.js";
33
35
  import { RoleAuthorizer } from "./roles.js";
34
36
 
@@ -124,12 +126,13 @@ export function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log:
124
126
  const approverChannels = new Map<string, Notifier[]>(
125
127
  Object.entries(a.approvers ?? {}).map(([id, c]) => [id, [new NtfyNotifier({ ...c.ntfy, name: `ntfy:${id}` })]]),
126
128
  );
127
- if (notifiers.length === 0 && approverChannels.size === 0 && !a.elicitation) return undefined;
128
- if (!notifiers.some((n) => n.listen) && approverChannels.size === 0 && !a.elicitation) {
129
+ if (notifiers.length === 0 && approverChannels.size === 0 && !a.elicitation && !a.totp) return undefined;
130
+ if (!notifiers.some((n) => n.listen) && approverChannels.size === 0 && !a.elicitation && !a.totp) {
129
131
  log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
130
132
  }
131
133
  const approvers = approverChannels.size ? `; approvers: ${[...approverChannels.keys()].join(", ")}` : "";
132
- const shared = [...notifiers.map((n) => n.name), ...(a.elicitation ? ["elicitation (if the client supports it)"] : [])];
134
+ const inClient = a.totp ? ["authenticator code (if the client supports elicitation)"] : a.elicitation ? ["elicitation (if the client supports it)"] : [];
135
+ const shared = [...notifiers.map((n) => n.name), ...inClient];
133
136
  log.info(`approvals: ${shared.join(", ") || "none shared"}${approvers} (timeout ${a.timeoutSeconds}s)`);
134
137
  return new ApprovalBroker({ notifiers, approverChannels, timeoutMs: a.timeoutSeconds * 1000, log });
135
138
  }
@@ -175,7 +178,7 @@ export function gatewayPrincipal(cfg: GatewayConfig, env: NodeJS.ProcessEnv): Pr
175
178
  * The configured authorizers, in the order they are asked: roles, then cedar.
176
179
  * Cedar files are read and parsed here, so a bad policy fails startup.
177
180
  */
178
- export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): Promise<Authorizer[]> {
181
+ export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string, env: NodeJS.ProcessEnv = process.env): Promise<Authorizer[]> {
179
182
  const out: Authorizer[] = [];
180
183
  if (cfg.roles) out.push(new RoleAuthorizer(cfg.roles));
181
184
  if (cfg.cedar) {
@@ -185,6 +188,19 @@ export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): P
185
188
  throw new GatewayConfigError((err as Error).message);
186
189
  }
187
190
  }
191
+ if (cfg.openfga) {
192
+ const f = cfg.openfga;
193
+ const expand = (v: string, key: string) => expandEnv(v, env, `gateway.openfga.${key}`);
194
+ out.push(
195
+ new OpenFgaAuthorizer({
196
+ ...f,
197
+ url: expand(f.url, "url"),
198
+ storeId: expand(f.storeId, "storeId"),
199
+ ...(f.modelId !== undefined ? { modelId: expand(f.modelId, "modelId") } : {}),
200
+ ...(f.token !== undefined ? { token: expand(f.token, "token") } : {}),
201
+ }),
202
+ );
203
+ }
188
204
  return out;
189
205
  }
190
206
 
@@ -192,12 +208,23 @@ export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): P
192
208
  * Under Claude Code, which passes `CLAUDE_CODE_SESSION_ID` to stdio MCP
193
209
  * servers, the gateway uses that id and keeps session labels in files, so the
194
210
  * Claude Code hooks (`adtl-gateway hook`) add to the same session label.
211
+ *
212
+ * `ADTL_SESSION_ID` does the same for any agent framework, and wins over
213
+ * Claude Code's: a sub-agent's gateway started with its parent's session id
214
+ * starts from the parent's label, and what it reads taints the parent too.
195
215
  */
196
216
  export function sharedSession(env: NodeJS.ProcessEnv): { sessionId: string; dir: string } | undefined {
197
- const sessionId = env.CLAUDE_CODE_SESSION_ID?.trim();
217
+ const sessionId = env.ADTL_SESSION_ID?.trim() || env.CLAUDE_CODE_SESSION_ID?.trim();
198
218
  return sessionId ? { sessionId, dir: defaultSessionsDir(env) } : undefined;
199
219
  }
200
220
 
221
+ /** `ADTL_AGENT_ID` (and `ADTL_AGENT_TYPE`): which agent this gateway serves, for the audit log. */
222
+ export function gatewayAgent(env: NodeJS.ProcessEnv): { id: string; type?: string } | undefined {
223
+ const id = env.ADTL_AGENT_ID?.trim();
224
+ const type = env.ADTL_AGENT_TYPE?.trim();
225
+ return id ? { id, ...(type ? { type } : {}) } : undefined;
226
+ }
227
+
201
228
  export interface StartedGateway {
202
229
  gateway: AdtlGateway;
203
230
  close(): Promise<void>;
@@ -211,7 +238,7 @@ export async function startGateway(
211
238
  // Config problems that need no process: fail before starting any.
212
239
  const approverMembers = approverRoles(loaded.labels, loaded.gateway);
213
240
  const baseDir = dirname(loaded.path);
214
- const authorizers = await gatewayAuthorizers(loaded.gateway, baseDir);
241
+ const authorizers = await gatewayAuthorizers(loaded.gateway, baseDir, env);
215
242
  const log = opts.logger ?? { info: (m: string) => console.error(`[adtl-gateway] ${m}`), warn: (m: string) => console.error(`[adtl-gateway] warning: ${m}`) };
216
243
  const clients: Client[] = [];
217
244
  const closeAll = () => Promise.allSettled(clients.map((c) => c.close())).then(() => undefined);
@@ -237,15 +264,50 @@ export async function startGateway(
237
264
  if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
238
265
  throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
239
266
  }
240
- const bound = await InterceptorInvoker.discover(interceptors);
241
- const names = bound.map((b) => b.definition.name);
267
+ const own = await InterceptorInvoker.discover(interceptors, { server: "interceptors" });
268
+ const ownNames = own.map((b) => b.definition.name);
242
269
  for (const required of [POLICY_INTERCEPTOR, LABEL_INTERCEPTOR]) {
243
- if (!names.includes(required)) {
270
+ if (!ownNames.includes(required)) {
244
271
  throw new GatewayConfigError(`The interceptor server does not offer ${required}; refusing to start without it`);
245
272
  }
246
273
  }
274
+ // More interceptor servers (SEP-2624: interceptors may come from N servers, each invoked where it was advertised).
275
+ // A server that can't be reached stops startup: the owner asked for its interceptors.
276
+ const bound = [...own];
277
+ for (const [id, server] of Object.entries(loaded.gateway.interceptorServers)) {
278
+ const where = `gateway.interceptorServers.${id}`;
279
+ let client: Client;
280
+ try {
281
+ client = await connectUpstream(`interceptors:${id}`, expandUpstream(id, server, env), baseDir);
282
+ } catch (err) {
283
+ throw new GatewayConfigError(`${where}: could not connect: ${err instanceof Error ? err.message : String(err)}`);
284
+ }
285
+ clients.push(client);
286
+ if (!client.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
287
+ throw new GatewayConfigError(`${where} does not declare the ${INTERCEPTORS_EXTENSION} extension`);
288
+ }
289
+ bound.push(...(await InterceptorInvoker.discover(client, { server: id, sendConfig: false })));
290
+ }
291
+ const duplicates = duplicateInterceptors(bound);
292
+ if (duplicates.length) throw new GatewayConfigError(`Interceptor names must be unique across servers: ${duplicates.join("; ")}`);
293
+ const names = bound.map((b) => b.definition.name);
294
+ const overrides = loaded.gateway.interceptorOverrides;
295
+ const problems = checkOverrides(bound.map((b) => b.definition), overrides);
296
+ if (problems.length) throw new GatewayConfigError(`gateway.interceptorOverrides: ${problems.join("; ")}`);
297
+ const unsupported = unsupportedInterceptors(bound);
298
+ if (unsupported.length) log.warn(`not running interceptors of an unknown type: ${unsupported.join(", ")}`);
299
+ const late = mutatorsAfter(bound, LABEL_INTERCEPTOR, overrides);
300
+ if (late.length) {
301
+ log.warn(`${late.join(", ")} run after ${LABEL_INTERCEPTOR} and could change or drop its label; give them a lower priorityHint`);
302
+ }
303
+ for (const name of [POLICY_INTERCEPTOR, LABEL_INTERCEPTOR]) {
304
+ const o = overrides[name];
305
+ if (o?.mode === "audit" || o?.failOpen === true || o?.hooks) {
306
+ log.warn(`gateway.interceptorOverrides relaxes ${name} (${Object.keys(o).join(", ")}): calls it would stop may get through`);
307
+ }
308
+ }
247
309
  const audit = loaded.gateway.mode === "audit";
248
- const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined);
310
+ const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined, overrides);
249
311
  log.info(`interceptors: ${names.join(", ")}`);
250
312
  if (shared) log.info(`sharing session labels with Claude Code hooks (${shared.dir})`);
251
313
  if (audit) log.warn("audit mode: calls are logged but never stopped or changed");
@@ -280,8 +342,14 @@ export async function startGateway(
280
342
  if (!principal) log.warn('roles are set but gateway.principal is not: only roles with members ["*"] apply');
281
343
  }
282
344
  for (const a of authorizers) if (a.name !== "roles") log.info(`authorizer: ${a.name}`);
345
+ const totp = loaded.gateway.approvals?.totp ? loadTotpVerifier(env) : undefined;
346
+ if (loaded.gateway.approvals?.totp && !totp) throw new GatewayConfigError("gateway.approvals.totp is on, but no secret is set up: run adtl-gateway totp setup");
347
+ const agent = gatewayAgent(env);
348
+ if (agent) log.info(`agent: ${agent.id}${agent.type ? ` (${agent.type})` : ""}`);
283
349
  const gateway = new AdtlGateway({
284
350
  ...(shared ? { sessionId: shared.sessionId } : {}),
351
+ ...(agent ? { agent } : {}),
352
+ ...(totp ? { totp } : {}),
285
353
  upstreams,
286
354
  invoker,
287
355
  ...(auditLog ? { audit: auditLog } : {}),