@adtl/gateway 0.7.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 (50) hide show
  1. package/README.md +90 -9
  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/config.d.ts +190 -0
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/config.js +29 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/gateway.d.ts +7 -1
  10. package/dist/gateway.d.ts.map +1 -1
  11. package/dist/gateway.js +10 -4
  12. package/dist/gateway.js.map +1 -1
  13. package/dist/hooks/agentLinks.d.ts +18 -0
  14. package/dist/hooks/agentLinks.d.ts.map +1 -0
  15. package/dist/hooks/agentLinks.js +48 -0
  16. package/dist/hooks/agentLinks.js.map +1 -0
  17. package/dist/hooks/claudeCode.d.ts +3 -0
  18. package/dist/hooks/claudeCode.d.ts.map +1 -1
  19. package/dist/hooks/claudeCode.js +27 -7
  20. package/dist/hooks/claudeCode.js.map +1 -1
  21. package/dist/hooks/index.d.ts +1 -0
  22. package/dist/hooks/index.d.ts.map +1 -1
  23. package/dist/hooks/index.js +1 -0
  24. package/dist/hooks/index.js.map +1 -1
  25. package/dist/hooks/run.d.ts.map +1 -1
  26. package/dist/hooks/run.js +3 -1
  27. package/dist/hooks/run.js.map +1 -1
  28. package/dist/init/suggest.d.ts +3 -1
  29. package/dist/init/suggest.d.ts.map +1 -1
  30. package/dist/init/suggest.js +8 -0
  31. package/dist/init/suggest.js.map +1 -1
  32. package/dist/invoker.d.ts +54 -6
  33. package/dist/invoker.d.ts.map +1 -1
  34. package/dist/invoker.js +115 -19
  35. package/dist/invoker.js.map +1 -1
  36. package/dist/startup.d.ts +9 -0
  37. package/dist/startup.d.ts.map +1 -1
  38. package/dist/startup.js +56 -6
  39. package/dist/startup.js.map +1 -1
  40. package/package.json +4 -4
  41. package/src/audit.ts +3 -1
  42. package/src/config.ts +31 -0
  43. package/src/gateway.ts +12 -4
  44. package/src/hooks/agentLinks.ts +58 -0
  45. package/src/hooks/claudeCode.ts +31 -7
  46. package/src/hooks/index.ts +1 -0
  47. package/src/hooks/run.ts +3 -1
  48. package/src/init/suggest.ts +10 -1
  49. package/src/invoker.ts +146 -21
  50. package/src/startup.ts +53 -6
package/src/config.ts CHANGED
@@ -12,6 +12,10 @@
12
12
  * docs:
13
13
  * url: https://example.com/mcp
14
14
  * headers: { Authorization: "Bearer ${DOCS_TOKEN}" }
15
+ * interceptorServers: # optional: more interceptor servers, run beside ADTL's
16
+ * pii: { url: https://pii.example.com/mcp }
17
+ * interceptorOverrides: # optional: per-interceptor execution policy (SEP-2624)
18
+ * adtl.label: { timeoutMs: 10000 }
15
19
  * principal: { id: "${USER}" } # optional: who runs this gateway (default: anonymous)
16
20
  * roles: # optional: who may use which tools (deny by default)
17
21
  * maintainers: { members: [alice], tools: ["github.*"] }
@@ -162,6 +166,24 @@ export const CedarConfigSchema = z
162
166
  .strict();
163
167
  export type CedarConfig = z.infer<typeof CedarConfigSchema>;
164
168
 
169
+ const PhaseSchema = z.enum(["request", "response"]);
170
+
171
+ /** SEP-2624 InterceptorOverrides, set by the gateway owner for one interceptor. */
172
+ export const InterceptorOverridesSchema = z
173
+ .object({
174
+ failOpen: z.boolean().optional(),
175
+ priorityHint: z
176
+ .union([z.number().int(), z.object({ request: z.number().int().optional(), response: z.number().int().optional() }).strict()])
177
+ .optional(),
178
+ mode: z.enum(["active", "audit"]).optional(),
179
+ timeoutMs: z.number().int().positive().max(600_000).optional(),
180
+ hooks: z
181
+ .array(z.object({ events: z.array(z.string().min(1)).min(1), phase: PhaseSchema }).strict())
182
+ .min(1, "hooks: list at least one hook, or leave it out to keep the declared hooks")
183
+ .optional(),
184
+ })
185
+ .strict();
186
+
165
187
  export const GatewayConfigSchema = z
166
188
  .object({
167
189
  upstreams: z
@@ -172,6 +194,13 @@ export const GatewayConfigSchema = z
172
194
  }),
173
195
  /** How to start the interceptor server. Default: the bundled adtl-interceptors with this same config file. */
174
196
  interceptors: StdioUpstreamSchema.optional(),
197
+ /** More interceptor servers (stdio or Streamable HTTP) whose interceptors run beside ADTL's. */
198
+ interceptorServers: z
199
+ .record(UpstreamSchema)
200
+ .refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
201
+ message: "Interceptor server ids may only contain letters, digits and '-'",
202
+ })
203
+ .default({}),
175
204
  /** Path of a JSONL audit log. Relative paths are resolved against the config file's directory. */
176
205
  audit: z.string().min(1).optional(),
177
206
  /**
@@ -183,6 +212,8 @@ export const GatewayConfigSchema = z
183
212
  approvals: ApprovalsConfigSchema.optional(),
184
213
  /** Timeout for each interceptor invocation, in milliseconds. */
185
214
  interceptorTimeoutMs: z.number().int().positive().default(5000),
215
+ /** Per-interceptor overrides of failOpen, priorityHint, mode, timeoutMs and hooks, by interceptor name. */
216
+ interceptorOverrides: z.record(InterceptorOverridesSchema).default({}),
186
217
  /** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
187
218
  principal: PrincipalConfigSchema.optional(),
188
219
  /** Role-based authorization: when present, a call no role grants is denied. */
package/src/gateway.ts CHANGED
@@ -62,7 +62,7 @@ import { authorize, type AuthorizationRequest, type Authorizer, type Denial, typ
62
62
  import { TOOL_SEPARATOR } from "./config.js";
63
63
  import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
64
64
 
65
- export const GATEWAY_VERSION = "0.7.0";
65
+ export const GATEWAY_VERSION = "0.8.0";
66
66
 
67
67
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
68
68
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -115,6 +115,8 @@ export interface AdtlGatewayOptions {
115
115
  approverRoles?: Map<string, string[]>;
116
116
  /** Also ask for approval in the MCP client, when it supports elicitation. */
117
117
  elicitApprovals?: boolean;
118
+ /** The (sub-)agent this gateway serves, recorded on every audit entry. */
119
+ agent?: { id: string; type?: string };
118
120
  }
119
121
 
120
122
  /**
@@ -174,6 +176,7 @@ export class AdtlGateway {
174
176
  private readonly approvals: ApprovalBroker | undefined;
175
177
  private readonly authorizers: Authorizer[];
176
178
  private readonly principal: Principal;
179
+ private readonly agent: { agent: { id: string; type?: string } } | Record<string, never>;
177
180
  private readonly approverRoles: Map<string, string[]>;
178
181
  private readonly elicitApprovals: boolean;
179
182
  /** The server the agent is connected to, for elicitation. */
@@ -193,6 +196,7 @@ export class AdtlGateway {
193
196
  this.approvals = opts.approvals;
194
197
  this.authorizers = opts.authorizer === undefined ? [] : [opts.authorizer].flat();
195
198
  this.principal = opts.principal ?? { type: "anonymous" };
199
+ this.agent = opts.agent ? { agent: opts.agent } : {};
196
200
  this.approverRoles = opts.approverRoles ?? new Map();
197
201
  this.elicitApprovals = opts.elicitApprovals === true;
198
202
  }
@@ -450,6 +454,7 @@ export class AdtlGateway {
450
454
  this.audit?.write({
451
455
  ts: context.timestamp!,
452
456
  sessionId: this.sessionId,
457
+ ...this.agent,
453
458
  event,
454
459
  server: upstream,
455
460
  ...target,
@@ -501,7 +506,7 @@ export class AdtlGateway {
501
506
  }
502
507
  const label = infoOf(res, "adtl.label");
503
508
  finish({ outcome: "forwarded", ...audit, label: label.label, ...(label.ledger ? { ledger: label.ledger } : {}),
504
- ...(label.records ? { records: label.records } : {}), sessionLabel: label.sessionLabel, warnings });
509
+ ...(label.records ? { records: label.records } : {}), ...(label.recordIds ? { recordIds: label.recordIds } : {}), sessionLabel: label.sessionLabel, warnings });
505
510
  return (res.payload as { result: R }).result;
506
511
  }
507
512
 
@@ -525,7 +530,7 @@ export class AdtlGateway {
525
530
  server: route.upstream,
526
531
  ...(route.annotations ? { toolAnnotations: route.annotations } : {}),
527
532
  };
528
- const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
533
+ const base = { sessionId: this.sessionId, ...this.agent, event: "tools/call" as const, server: route.upstream, tool: route.tool };
529
534
  let denied: Denial | undefined;
530
535
  const ran: InterceptorRun[] = [];
531
536
  const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
@@ -630,7 +635,9 @@ export class AdtlGateway {
630
635
  // 2. Forward to the upstream server.
631
636
  let result: CallToolResult;
632
637
  try {
633
- result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments })) as CallToolResult;
638
+ // A mutator may add request _meta (e.g. fides-gateway labels); pass it on.
639
+ const meta = (forwarded as { _meta?: Record<string, unknown> })._meta;
640
+ result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments, ...(meta ? { _meta: meta } : {}) })) as CallToolResult;
634
641
  } catch (err) {
635
642
  const reason = (err as Error).message;
636
643
  finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), ...approved, reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
@@ -663,6 +670,7 @@ export class AdtlGateway {
663
670
  label: label.label,
664
671
  ...(label.ledger ? { ledger: label.ledger } : {}),
665
672
  ...(label.records ? { records: label.records } : {}),
673
+ ...(label.recordIds ? { recordIds: label.recordIds } : {}),
666
674
  sessionLabel: label.sessionLabel,
667
675
  warnings: [...req.warnings, ...res.warnings],
668
676
  });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which session a Claude Code sub-agent belongs to.
3
+ *
4
+ * Claude Code's SubagentStart hook reports the parent's `session_id` with the
5
+ * new sub-agent's `agent_id`. Recording that link lets the sub-agent's later
6
+ * hook calls use the parent's label even if they ever report a session id of
7
+ * their own: the sub-agent starts from what its parent has seen, and what it
8
+ * reads taints the parent. Links live beside the session files, which the
9
+ * hooks already protect from the agent.
10
+ */
11
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { createHash } from "node:crypto";
13
+ import { join } from "node:path";
14
+
15
+ export interface AgentLinks {
16
+ /** The session `agentId` was started in, if known. */
17
+ parentOf(agentId: string): string | undefined;
18
+ link(agentId: string, sessionId: string): void;
19
+ }
20
+
21
+ const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
22
+
23
+ export class FileAgentLinks implements AgentLinks {
24
+ private readonly dir: string;
25
+
26
+ constructor(sessionsDir: string) {
27
+ this.dir = join(sessionsDir, "agents");
28
+ }
29
+
30
+ private pathOf(agentId: string): string {
31
+ const name = SAFE_ID.test(agentId) ? agentId : `h-${createHash("sha256").update(agentId).digest("hex")}`;
32
+ return join(this.dir, `${name}.parent`);
33
+ }
34
+
35
+ parentOf(agentId: string): string | undefined {
36
+ try {
37
+ return readFileSync(this.pathOf(agentId), "utf8").trim() || undefined;
38
+ } catch (err) {
39
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
40
+ throw err;
41
+ }
42
+ }
43
+
44
+ link(agentId: string, sessionId: string): void {
45
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
46
+ writeFileSync(this.pathOf(agentId), sessionId, { encoding: "utf8", mode: 0o600 });
47
+ }
48
+ }
49
+
50
+ export class InMemoryAgentLinks implements AgentLinks {
51
+ private readonly links = new Map<string, string>();
52
+ parentOf(agentId: string): string | undefined {
53
+ return this.links.get(agentId);
54
+ }
55
+ link(agentId: string, sessionId: string): void {
56
+ this.links.set(agentId, sessionId);
57
+ }
58
+ }
@@ -8,6 +8,8 @@
8
8
  * allow → no answer, so Claude Code's own permissions apply
9
9
  * PostToolUse labels the tool's output and adds it to the session label
10
10
  * PostToolUseFailure same: a failed tool's error output still reaches the model
11
+ * SubagentStart links the new sub-agent to its parent's session, so its
12
+ * calls start from the parent's label and taint it
11
13
  *
12
14
  * Built-in tools are labeled as the server `claude-code` (see preset.ts); MCP
13
15
  * tools `mcp__<server>__<tool>` as `<server>`. Tools of the gateway's own MCP
@@ -33,11 +35,13 @@ import {
33
35
  type AdtlConfig,
34
36
  type Decision,
35
37
  type PolicyDecision,
38
+ type Label,
36
39
  type SessionStore,
37
40
  join,
38
41
  } from "@adtl/labels";
39
42
  import { chunkHashes, valueHashes, type Ledger } from "@adtl/ledger";
40
43
  import type { AuditEntry, AuditSink } from "../audit.js";
44
+ import type { AgentLinks } from "./agentLinks.js";
41
45
  import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
42
46
 
43
47
  export interface HookInput {
@@ -64,6 +68,8 @@ export interface HookOptions {
64
68
  audit?: AuditSink;
65
69
  /** Label ledger shared with the gateway. */
66
70
  ledger?: Ledger;
71
+ /** Sub-agent → parent session links, from SubagentStart. */
72
+ agents?: AgentLinks;
67
73
  /** Files the agent must not change: the ADTL config, the session directory and the ledger. */
68
74
  protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
69
75
  }
@@ -172,19 +178,37 @@ export function addedLines(output: unknown): string[] {
172
178
  return out;
173
179
  }
174
180
 
181
+ /**
182
+ * The session whose label a hook call reads and adds to: a linked sub-agent's
183
+ * parent session, else the call's own. When the two differ, the sub-agent's own
184
+ * label is read too, so nothing it saw is lost.
185
+ */
186
+ function sessionFor(input: HookInput & { session_id: string }, opts: HookOptions): { id: string; get(): Label } {
187
+ const parent = typeof input.agent_id === "string" ? opts.agents?.parentOf(input.agent_id) : undefined;
188
+ if (!parent || parent === input.session_id) return { id: input.session_id, get: () => opts.sessions.get(input.session_id) };
189
+ return { id: parent, get: () => join(opts.sessions.get(parent), opts.sessions.get(input.session_id)) };
190
+ }
191
+
175
192
  export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
176
193
  const event = input.hook_event_name;
194
+ if (event === "SubagentStart") {
195
+ if (typeof input.agent_id === "string" && typeof input.session_id === "string" && input.session_id !== "") {
196
+ opts.agents?.link(input.agent_id, input.session_id);
197
+ }
198
+ return NOTHING;
199
+ }
177
200
  if (event !== "PreToolUse" && event !== "PostToolUse" && event !== "PostToolUseFailure") return NOTHING;
178
201
  if (typeof input.tool_name !== "string" || typeof input.session_id !== "string" || input.session_id === "") {
179
202
  return failure(event, "hook input has no tool_name or session_id");
180
203
  }
204
+ const session = sessionFor(input as HookInput & { session_id: string }, opts);
181
205
  const route = routeTool(input.tool_name, opts.gatewayServer);
182
206
  if (!route) return NOTHING;
183
207
  const config = withClaudeCodePreset(opts.config);
184
208
  const ts = new Date().toISOString();
185
209
  const agent =
186
210
  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 };
211
+ const base = { ts, sessionId: session.id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
188
212
 
189
213
  if (event !== "PreToolUse") {
190
214
  const output = input.tool_response ?? input.error ?? null;
@@ -197,20 +221,20 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
197
221
  ledger = { chunks: match.chunks, writers: match.writers };
198
222
  }
199
223
  // What the tool was given came from the session as it was before this output.
200
- const before = opts.sessions.get(input.session_id);
224
+ const before = session.get();
201
225
  const written = [...valueHashes(input.tool_input ?? {}), ...addedLines(output).flatMap(chunkHashes)];
202
- opts.ledger.record(written, before, input.session_id, route);
226
+ opts.ledger.record(written, before, session.id, route);
203
227
  }
204
- const sessionLabel = opts.sessions.observe(input.session_id, label);
228
+ const sessionLabel = join(session.get(), opts.sessions.observe(session.id, label));
205
229
  opts.audit?.write({ ...base, outcome: "forwarded", label, ...(ledger ? { ledger } : {}), sessionLabel, durationMs: 0 });
206
230
  return NOTHING;
207
231
  }
208
232
 
209
233
  const hash = callHash(route.server, route.tool, input.tool_input ?? {});
210
- const session = opts.sessions.get(input.session_id);
234
+ const current = session.get();
211
235
  const guard = protectedTarget(input, route, opts.protect);
212
236
  const policy: PolicyDecision = evaluatePolicy(config.policy, {
213
- session,
237
+ session: current,
214
238
  ...route,
215
239
  egress: toolEgress(config, route.server, route.tool),
216
240
  });
@@ -229,7 +253,7 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
229
253
  reason: d.reason,
230
254
  matched: d.matched,
231
255
  callHash: hash,
232
- sessionLabel: session,
256
+ sessionLabel: current,
233
257
  durationMs: 0,
234
258
  };
235
259
  opts.audit?.write(entry);
@@ -1,3 +1,4 @@
1
1
  export * from "./claudeCode.js";
2
2
  export * from "./preset.js";
3
3
  export * from "./run.js";
4
+ export * from "./agentLinks.js";
package/src/hooks/run.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `adtl-gateway hook --config adtl.yaml`: the command Claude Code runs for
3
- * PreToolUse, PostToolUse and PostToolUseFailure. Reads the hook event from
3
+ * PreToolUse, PostToolUse, PostToolUseFailure and SubagentStart. Reads the hook event from
4
4
  * stdin and answers on stdout (JSON) or stderr (with exit code 2).
5
5
  *
6
6
  * The config needs no `gateway:` section; if there is one, its `audit` log and
@@ -13,6 +13,7 @@ import { Ledger, defaultLedgerPath } from "@adtl/ledger";
13
13
  import { parseConfigText } from "@adtl/interceptors";
14
14
  import { parse as parseYaml } from "yaml";
15
15
  import { JsonlAuditLog } from "../audit.js";
16
+ import { FileAgentLinks } from "./agentLinks.js";
16
17
  import { failure, handleHook, type HookInput, type HookOutcome } from "./claudeCode.js";
17
18
 
18
19
  export interface RunHookOptions {
@@ -46,6 +47,7 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
46
47
  return handleHook(input, {
47
48
  config,
48
49
  sessions: new FileSessionStore(sessionsDir),
50
+ agents: new FileAgentLinks(sessionsDir),
49
51
  ...(ledger ? { ledger } : {}),
50
52
  ...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
51
53
  mode: gw.mode === "audit" ? "audit" : "active",
@@ -4,13 +4,15 @@
4
4
  * Without a preset, rules come from the MCP tool annotations. Those are the
5
5
  * server's own claims, so they only decide egress; integrity is always
6
6
  * untrusted and confidentiality private until the owner says otherwise.
7
+ * The draft action-metadata extension's `inputMetadata.destination`, when a
8
+ * tool declares one, decides egress ahead of the legacy hints.
7
9
  * Missing hints take the MCP spec defaults (readOnlyHint false, openWorldHint true).
8
10
  *
9
11
  * Merging never changes an entry that is already in the file: the owner's
10
12
  * choices win, and `init` can be re-run to pick up new tools.
11
13
  */
12
14
  import type { Tool } from "@modelcontextprotocol/sdk/types.js";
13
- import type { ToolRule } from "@adtl/labels";
15
+ import { destinationEgress, readActionMetadata, type ToolRule } from "@adtl/labels";
14
16
  import { isMap, isScalar, type Document, type YAMLMap } from "yaml";
15
17
  import { choosePreset, PRESETS, type Preset } from "./presets.js";
16
18
 
@@ -40,6 +42,13 @@ const UNKNOWN_DEFAULTS: ToolRule = { integrity: "untrusted", confidentiality: "p
40
42
 
41
43
  /** A rule from the tool's MCP annotations alone. */
42
44
  export function ruleFromHints(annotations: Tool["annotations"]): Omit<ToolSuggestion, "name"> {
45
+ const meta = readActionMetadata(annotations);
46
+ const declared = destinationEgress(meta.destination);
47
+ if (declared) {
48
+ const extra = [meta.outcome, meta.requiresReview ? "requires review" : undefined].filter(Boolean).join(", ");
49
+ const why = `action-metadata: destination ${meta.destination}${extra ? `, ${extra}` : ""} (review)`;
50
+ return { rule: { integrity: "untrusted", confidentiality: "private", egress: declared }, why };
51
+ }
43
52
  const readOnly = annotations?.readOnlyHint === true;
44
53
  const openWorld = annotations?.openWorldHint !== false;
45
54
  const egress = readOnly ? (openWorld ? "external" : "none") : openWorld ? "public" : "internal";
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) {