@adtl/gateway 0.6.0 → 0.8.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 (60) hide show
  1. package/README.md +146 -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/cedar.d.ts +27 -0
  6. package/dist/cedar.d.ts.map +1 -0
  7. package/dist/cedar.js +134 -0
  8. package/dist/cedar.js.map +1 -0
  9. package/dist/config.d.ts +235 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +54 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/gateway.d.ts +11 -5
  14. package/dist/gateway.d.ts.map +1 -1
  15. package/dist/gateway.js +23 -12
  16. package/dist/gateway.js.map +1 -1
  17. package/dist/hooks/agentLinks.d.ts +18 -0
  18. package/dist/hooks/agentLinks.d.ts.map +1 -0
  19. package/dist/hooks/agentLinks.js +48 -0
  20. package/dist/hooks/agentLinks.js.map +1 -0
  21. package/dist/hooks/claudeCode.d.ts +3 -0
  22. package/dist/hooks/claudeCode.d.ts.map +1 -1
  23. package/dist/hooks/claudeCode.js +27 -7
  24. package/dist/hooks/claudeCode.js.map +1 -1
  25. package/dist/hooks/index.d.ts +1 -0
  26. package/dist/hooks/index.d.ts.map +1 -1
  27. package/dist/hooks/index.js +1 -0
  28. package/dist/hooks/index.js.map +1 -1
  29. package/dist/hooks/run.d.ts.map +1 -1
  30. package/dist/hooks/run.js +3 -1
  31. package/dist/hooks/run.js.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/init/suggest.d.ts +3 -1
  37. package/dist/init/suggest.d.ts.map +1 -1
  38. package/dist/init/suggest.js +8 -0
  39. package/dist/init/suggest.js.map +1 -1
  40. package/dist/invoker.d.ts +54 -6
  41. package/dist/invoker.d.ts.map +1 -1
  42. package/dist/invoker.js +115 -19
  43. package/dist/invoker.js.map +1 -1
  44. package/dist/startup.d.ts +15 -1
  45. package/dist/startup.d.ts.map +1 -1
  46. package/dist/startup.js +80 -7
  47. package/dist/startup.js.map +1 -1
  48. package/package.json +15 -4
  49. package/src/audit.ts +3 -1
  50. package/src/cedar.ts +140 -0
  51. package/src/config.ts +57 -0
  52. package/src/gateway.ts +26 -14
  53. package/src/hooks/agentLinks.ts +58 -0
  54. package/src/hooks/claudeCode.ts +31 -7
  55. package/src/hooks/index.ts +1 -0
  56. package/src/hooks/run.ts +3 -1
  57. package/src/index.ts +1 -0
  58. package/src/init/suggest.ts +10 -1
  59. package/src/invoker.ts +146 -21
  60. package/src/startup.ts +75 -8
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,70 @@ 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
+ /** Interceptor names offered by more than one server: each must be invoked on its own server, so names must be unique. */
164
+ export function duplicateInterceptors(bound: BoundInterceptor[]): string[] {
165
+ const servers = new Map<string, string[]>();
166
+ for (const b of bound) servers.set(b.definition.name, [...(servers.get(b.definition.name) ?? []), b.server ?? "?"]);
167
+ return [...servers].filter(([, s]) => s.length > 1).map(([name, s]) => `${name} (offered by ${s.join(", ")})`);
168
+ }
169
+
170
+ /**
171
+ * Mutators that would run after `name` on a phase both hook, so they see (and
172
+ * could change or drop) what it wrote. Used to warn when another server's
173
+ * mutator runs after adtl.label.
174
+ */
175
+ export function mutatorsAfter(bound: BoundInterceptor[], name: string, overrides: Record<string, InterceptorOverrides> = {}): string[] {
176
+ const target = bound.find((b) => b.definition.name === name);
177
+ if (!target) return [];
178
+ const hooksOf = (b: BoundInterceptor) => overrides[b.definition.name]?.hooks ?? b.definition.hooks;
179
+ const shares = (b: BoundInterceptor, phase: Phase) =>
180
+ 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)))));
181
+ const after: string[] = [];
182
+ for (const b of bound) {
183
+ if (b === target || b.definition.type !== "mutation") continue;
184
+ const late = (["request", "response"] as const).some((phase) => {
185
+ if (!shares(b, phase)) return false;
186
+ const p = resolvePriority(b.definition, overrides[b.definition.name], phase);
187
+ const t = resolvePriority(target.definition, overrides[name], phase);
188
+ return p > t || (p === t && b.definition.name.localeCompare(name) > 0);
189
+ });
190
+ if (late) after.push(b.definition.name);
191
+ }
192
+ return after;
101
193
  }
102
194
 
103
195
  export class InterceptorInvoker {
@@ -106,13 +198,15 @@ export class InterceptorInvoker {
106
198
  private readonly timeoutMs = 5000,
107
199
  /** Overrides the mode every interceptor declares. */
108
200
  readonly modeOverride?: InterceptorMode,
201
+ /** Per-interceptor overrides, by name. Check them with `checkOverrides` first. */
202
+ private readonly overrides: Record<string, InterceptorOverrides> = {},
109
203
  ) {}
110
204
 
111
205
  /** Discover the interceptors an interceptor server offers. */
112
- static async discover(client: Client): Promise<BoundInterceptor[]> {
206
+ static async discover(client: Client, opts: { server?: string; sendConfig?: boolean } = {}): Promise<BoundInterceptor[]> {
113
207
  const res = await request(client, LIST_METHOD, {}, 10_000);
114
208
  const defs = (res.interceptors ?? []) as InterceptorDefinition[];
115
- return defs.map((definition) => ({ client, definition }));
209
+ return defs.map((definition) => ({ client, definition, ...opts }));
116
210
  }
117
211
 
118
212
  get definitions(): InterceptorDefinition[] {
@@ -133,34 +227,63 @@ export class InterceptorInvoker {
133
227
  return out;
134
228
  }
135
229
 
230
+ private override(i: BoundInterceptor): InterceptorOverrides | undefined {
231
+ return this.overrides[i.definition.name];
232
+ }
233
+
234
+ private mode(i: BoundInterceptor): InterceptorMode {
235
+ return this.modeOverride ?? this.override(i)?.mode ?? i.definition.mode ?? "active";
236
+ }
237
+
136
238
  private isAudit(i: BoundInterceptor): boolean {
137
- return (this.modeOverride ?? i.definition.mode) === "audit";
239
+ return this.mode(i) === "audit";
240
+ }
241
+
242
+ private failOpen(i: BoundInterceptor): boolean {
243
+ return (this.override(i)?.failOpen ?? i.definition.failOpen) === true;
138
244
  }
139
245
 
140
- /** Record that `i` runs in `phase`, with its resolved policy. The gateway has no failOpen overrides. */
246
+ private timeout(i: BoundInterceptor): number {
247
+ return this.override(i)?.timeoutMs ?? this.timeoutMs;
248
+ }
249
+
250
+ /** Record that `i` runs in `phase`, with its resolved policy. */
141
251
  private run(i: BoundInterceptor, phase: Phase, out: PhaseOutcome): InterceptorRun {
252
+ const o = this.override(i);
142
253
  const r: InterceptorRun = {
143
254
  name: i.definition.name,
144
255
  phase,
145
- mode: this.modeOverride ?? i.definition.mode ?? "active",
146
- modeFrom: this.modeOverride ? "override" : "default",
147
- failOpen: i.definition.failOpen === true,
148
- failOpenFrom: "default",
256
+ mode: this.mode(i),
257
+ modeFrom: this.modeOverride || o?.mode ? "override" : "default",
258
+ failOpen: this.failOpen(i),
259
+ failOpenFrom: o?.failOpen !== undefined ? "override" : "default",
260
+ timeoutMs: this.timeout(i),
149
261
  };
150
262
  out.ran.push(r);
151
263
  return r;
152
264
  }
153
265
 
154
266
  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));
267
+ return this.interceptors.filter(
268
+ (i) => i.definition.type === type && matches(this.override(i)?.hooks ?? i.definition.hooks, event, phase),
269
+ );
156
270
  }
157
271
 
158
272
  private async invoke(i: BoundInterceptor, phase: Phase, opts: InvokeOptions, payload: unknown): Promise<InterceptorResult> {
273
+ const timeoutMs = this.timeout(i);
159
274
  const res = await request(
160
275
  i.client,
161
276
  INVOKE_METHOD,
162
- { name: i.definition.name, event: opts.event, phase, payload, config: opts.config, timeoutMs: this.timeoutMs, context: opts.context },
163
- this.timeoutMs,
277
+ {
278
+ name: i.definition.name,
279
+ event: opts.event,
280
+ phase,
281
+ payload,
282
+ ...(i.sendConfig === false ? {} : { config: opts.config }),
283
+ timeoutMs,
284
+ context: opts.context,
285
+ },
286
+ timeoutMs,
164
287
  );
165
288
  return res as unknown as InterceptorResult;
166
289
  }
@@ -168,7 +291,7 @@ export class InterceptorInvoker {
168
291
  /** Handle an interceptor failure. Returns false if the phase must stop. */
169
292
  private failed(i: BoundInterceptor, err: unknown, out: PhaseOutcome): boolean {
170
293
  const msg = `${i.definition.name} failed: ${err instanceof Error ? err.message : String(err)}`;
171
- if (this.isAudit(i) || i.definition.failOpen === true) {
294
+ if (this.isAudit(i) || this.failOpen(i)) {
172
295
  out.warnings.push(msg);
173
296
  return true;
174
297
  }
@@ -179,7 +302,9 @@ export class InterceptorInvoker {
179
302
  /** Sequential mutations, atomic: on failure the original payload is kept. */
180
303
  private async mutate(phase: Phase, opts: InvokeOptions, out: PhaseOutcome): Promise<boolean> {
181
304
  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),
305
+ (a, b) =>
306
+ resolvePriority(a.definition, this.override(a), phase) - resolvePriority(b.definition, this.override(b), phase) ||
307
+ a.definition.name.localeCompare(b.definition.name),
183
308
  );
184
309
  let payload = out.payload;
185
310
  for (const i of selected) {
package/src/startup.ts CHANGED
@@ -14,7 +14,8 @@ import { parse as parseYaml } from "yaml";
14
14
  import { ZodError } from "zod";
15
15
  import { ApprovalBroker, type Notifier } from "./approvals.js";
16
16
  import { JsonlAuditLog } from "./audit.js";
17
- import type { Principal } from "./authorizer.js";
17
+ import type { Authorizer, Principal } from "./authorizer.js";
18
+ import { CedarAuthorizer } from "./cedar.js";
18
19
  import {
19
20
  GatewayConfigSchema,
20
21
  expandApprovals,
@@ -27,7 +28,7 @@ import {
27
28
  } from "./config.js";
28
29
  import { EmailNotifier } from "./email.js";
29
30
  import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
30
- import { InterceptorInvoker } from "./invoker.js";
31
+ import { InterceptorInvoker, checkOverrides, duplicateInterceptors, mutatorsAfter } from "./invoker.js";
31
32
  import { NtfyNotifier } from "./ntfy.js";
32
33
  import { RoleAuthorizer } from "./roles.js";
33
34
 
@@ -170,16 +171,44 @@ export function gatewayPrincipal(cfg: GatewayConfig, env: NodeJS.ProcessEnv): Pr
170
171
  return { type: p.type, id, ...(p.claims ? { claims: p.claims } : {}) };
171
172
  }
172
173
 
174
+ /**
175
+ * The configured authorizers, in the order they are asked: roles, then cedar.
176
+ * Cedar files are read and parsed here, so a bad policy fails startup.
177
+ */
178
+ export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): Promise<Authorizer[]> {
179
+ const out: Authorizer[] = [];
180
+ if (cfg.roles) out.push(new RoleAuthorizer(cfg.roles));
181
+ if (cfg.cedar) {
182
+ try {
183
+ out.push(await CedarAuthorizer.load(cfg.cedar, (p) => resolve(baseDir, p)));
184
+ } catch (err) {
185
+ throw new GatewayConfigError((err as Error).message);
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
173
191
  /**
174
192
  * Under Claude Code, which passes `CLAUDE_CODE_SESSION_ID` to stdio MCP
175
193
  * servers, the gateway uses that id and keeps session labels in files, so the
176
194
  * Claude Code hooks (`adtl-gateway hook`) add to the same session label.
195
+ *
196
+ * `ADTL_SESSION_ID` does the same for any agent framework, and wins over
197
+ * Claude Code's: a sub-agent's gateway started with its parent's session id
198
+ * starts from the parent's label, and what it reads taints the parent too.
177
199
  */
178
200
  export function sharedSession(env: NodeJS.ProcessEnv): { sessionId: string; dir: string } | undefined {
179
- const sessionId = env.CLAUDE_CODE_SESSION_ID?.trim();
201
+ const sessionId = env.ADTL_SESSION_ID?.trim() || env.CLAUDE_CODE_SESSION_ID?.trim();
180
202
  return sessionId ? { sessionId, dir: defaultSessionsDir(env) } : undefined;
181
203
  }
182
204
 
205
+ /** `ADTL_AGENT_ID` (and `ADTL_AGENT_TYPE`): which agent this gateway serves, for the audit log. */
206
+ export function gatewayAgent(env: NodeJS.ProcessEnv): { id: string; type?: string } | undefined {
207
+ const id = env.ADTL_AGENT_ID?.trim();
208
+ const type = env.ADTL_AGENT_TYPE?.trim();
209
+ return id ? { id, ...(type ? { type } : {}) } : undefined;
210
+ }
211
+
183
212
  export interface StartedGateway {
184
213
  gateway: AdtlGateway;
185
214
  close(): Promise<void>;
@@ -193,6 +222,7 @@ export async function startGateway(
193
222
  // Config problems that need no process: fail before starting any.
194
223
  const approverMembers = approverRoles(loaded.labels, loaded.gateway);
195
224
  const baseDir = dirname(loaded.path);
225
+ const authorizers = await gatewayAuthorizers(loaded.gateway, baseDir);
196
226
  const log = opts.logger ?? { info: (m: string) => console.error(`[adtl-gateway] ${m}`), warn: (m: string) => console.error(`[adtl-gateway] warning: ${m}`) };
197
227
  const clients: Client[] = [];
198
228
  const closeAll = () => Promise.allSettled(clients.map((c) => c.close())).then(() => undefined);
@@ -218,15 +248,48 @@ export async function startGateway(
218
248
  if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
219
249
  throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
220
250
  }
221
- const bound = await InterceptorInvoker.discover(interceptors);
222
- const names = bound.map((b) => b.definition.name);
251
+ const own = await InterceptorInvoker.discover(interceptors, { server: "interceptors" });
252
+ const ownNames = own.map((b) => b.definition.name);
223
253
  for (const required of [POLICY_INTERCEPTOR, LABEL_INTERCEPTOR]) {
224
- if (!names.includes(required)) {
254
+ if (!ownNames.includes(required)) {
225
255
  throw new GatewayConfigError(`The interceptor server does not offer ${required}; refusing to start without it`);
226
256
  }
227
257
  }
258
+ // More interceptor servers (SEP-2624: interceptors may come from N servers, each invoked where it was advertised).
259
+ // A server that can't be reached stops startup: the owner asked for its interceptors.
260
+ const bound = [...own];
261
+ for (const [id, server] of Object.entries(loaded.gateway.interceptorServers)) {
262
+ const where = `gateway.interceptorServers.${id}`;
263
+ let client: Client;
264
+ try {
265
+ client = await connectUpstream(`interceptors:${id}`, expandUpstream(id, server, env), baseDir);
266
+ } catch (err) {
267
+ throw new GatewayConfigError(`${where}: could not connect: ${err instanceof Error ? err.message : String(err)}`);
268
+ }
269
+ clients.push(client);
270
+ if (!client.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
271
+ throw new GatewayConfigError(`${where} does not declare the ${INTERCEPTORS_EXTENSION} extension`);
272
+ }
273
+ bound.push(...(await InterceptorInvoker.discover(client, { server: id, sendConfig: false })));
274
+ }
275
+ const duplicates = duplicateInterceptors(bound);
276
+ if (duplicates.length) throw new GatewayConfigError(`Interceptor names must be unique across servers: ${duplicates.join("; ")}`);
277
+ const names = bound.map((b) => b.definition.name);
278
+ const overrides = loaded.gateway.interceptorOverrides;
279
+ const problems = checkOverrides(bound.map((b) => b.definition), overrides);
280
+ if (problems.length) throw new GatewayConfigError(`gateway.interceptorOverrides: ${problems.join("; ")}`);
281
+ const late = mutatorsAfter(bound, LABEL_INTERCEPTOR, overrides);
282
+ if (late.length) {
283
+ log.warn(`${late.join(", ")} run after ${LABEL_INTERCEPTOR} and could change or drop its label; give them a lower priorityHint`);
284
+ }
285
+ for (const name of [POLICY_INTERCEPTOR, LABEL_INTERCEPTOR]) {
286
+ const o = overrides[name];
287
+ if (o?.mode === "audit" || o?.failOpen === true || o?.hooks) {
288
+ log.warn(`gateway.interceptorOverrides relaxes ${name} (${Object.keys(o).join(", ")}): calls it would stop may get through`);
289
+ }
290
+ }
228
291
  const audit = loaded.gateway.mode === "audit";
229
- const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined);
292
+ const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined, overrides);
230
293
  log.info(`interceptors: ${names.join(", ")}`);
231
294
  if (shared) log.info(`sharing session labels with Claude Code hooks (${shared.dir})`);
232
295
  if (audit) log.warn("audit mode: calls are logged but never stopped or changed");
@@ -260,13 +323,17 @@ export async function startGateway(
260
323
  log.info(`roles: ${Object.keys(roles).join(", ")} (caller: ${principal?.id ?? "anonymous"})`);
261
324
  if (!principal) log.warn('roles are set but gateway.principal is not: only roles with members ["*"] apply');
262
325
  }
326
+ for (const a of authorizers) if (a.name !== "roles") log.info(`authorizer: ${a.name}`);
327
+ const agent = gatewayAgent(env);
328
+ if (agent) log.info(`agent: ${agent.id}${agent.type ? ` (${agent.type})` : ""}`);
263
329
  const gateway = new AdtlGateway({
264
330
  ...(shared ? { sessionId: shared.sessionId } : {}),
331
+ ...(agent ? { agent } : {}),
265
332
  upstreams,
266
333
  invoker,
267
334
  ...(auditLog ? { audit: auditLog } : {}),
268
335
  ...(approvals ? { approvals } : {}),
269
- ...(roles ? { authorizer: new RoleAuthorizer(roles) } : {}),
336
+ ...(authorizers.length ? { authorizer: authorizers } : {}),
270
337
  ...(principal ? { principal } : {}),
271
338
  ...(approverMembers.size ? { approverRoles: approverMembers } : {}),
272
339
  ...(loaded.gateway.approvals?.elicitation ? { elicitApprovals: true } : {}),