@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.
- package/README.md +146 -11
- package/dist/audit.d.ts +3 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/cedar.d.ts +27 -0
- package/dist/cedar.d.ts.map +1 -0
- package/dist/cedar.js +134 -0
- package/dist/cedar.js.map +1 -0
- package/dist/config.d.ts +235 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +54 -0
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts +11 -5
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +23 -12
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/agentLinks.d.ts +18 -0
- package/dist/hooks/agentLinks.d.ts.map +1 -0
- package/dist/hooks/agentLinks.js +48 -0
- package/dist/hooks/agentLinks.js.map +1 -0
- package/dist/hooks/claudeCode.d.ts +3 -0
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +27 -7
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +3 -1
- package/dist/hooks/run.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/init/suggest.d.ts +3 -1
- package/dist/init/suggest.d.ts.map +1 -1
- package/dist/init/suggest.js +8 -0
- package/dist/init/suggest.js.map +1 -1
- package/dist/invoker.d.ts +54 -6
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +115 -19
- package/dist/invoker.js.map +1 -1
- package/dist/startup.d.ts +15 -1
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +80 -7
- package/dist/startup.js.map +1 -1
- package/package.json +15 -4
- package/src/audit.ts +3 -1
- package/src/cedar.ts +140 -0
- package/src/config.ts +57 -0
- package/src/gateway.ts +26 -14
- package/src/hooks/agentLinks.ts +58 -0
- package/src/hooks/claudeCode.ts +31 -7
- package/src/hooks/index.ts +1 -0
- package/src/hooks/run.ts +3 -1
- package/src/index.ts +1 -0
- package/src/init/suggest.ts +10 -1
- package/src/invoker.ts +146 -21
- 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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
100
|
-
return
|
|
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
|
|
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
|
-
|
|
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.
|
|
146
|
-
modeFrom: this.modeOverride ? "override" : "default",
|
|
147
|
-
failOpen:
|
|
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(
|
|
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
|
-
{
|
|
163
|
-
|
|
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) ||
|
|
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) =>
|
|
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
|
|
222
|
-
const
|
|
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 (!
|
|
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
|
-
...(
|
|
336
|
+
...(authorizers.length ? { authorizer: authorizers } : {}),
|
|
270
337
|
...(principal ? { principal } : {}),
|
|
271
338
|
...(approverMembers.size ? { approverRoles: approverMembers } : {}),
|
|
272
339
|
...(loaded.gateway.approvals?.elicitation ? { elicitApprovals: true } : {}),
|