@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.
- package/README.md +90 -9
- package/dist/audit.d.ts +3 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/config.d.ts +190 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +29 -0
- package/dist/config.js.map +1 -1
- package/dist/gateway.d.ts +7 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +10 -4
- 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/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 +9 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +56 -6
- package/dist/startup.js.map +1 -1
- package/package.json +4 -4
- package/src/audit.ts +3 -1
- package/src/config.ts +31 -0
- package/src/gateway.ts +12 -4
- 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/init/suggest.ts +10 -1
- package/src/invoker.ts +146 -21
- 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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -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:
|
|
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 =
|
|
224
|
+
const before = session.get();
|
|
201
225
|
const written = [...valueHashes(input.tool_input ?? {}), ...addedLines(output).flatMap(chunkHashes)];
|
|
202
|
-
opts.ledger.record(written, before,
|
|
226
|
+
opts.ledger.record(written, before, session.id, route);
|
|
203
227
|
}
|
|
204
|
-
const sessionLabel = opts.sessions.observe(
|
|
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
|
|
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:
|
|
256
|
+
sessionLabel: current,
|
|
233
257
|
durationMs: 0,
|
|
234
258
|
};
|
|
235
259
|
opts.audit?.write(entry);
|
package/src/hooks/index.ts
CHANGED
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
|
|
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",
|
package/src/init/suggest.ts
CHANGED
|
@@ -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
|
|
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
|
|
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) {
|