@adtl/gateway 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -3
- package/dist/audit.d.ts +9 -4
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +82 -2
- package/dist/cli.js.map +1 -1
- package/dist/gateway.d.ts +22 -2
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +189 -14
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/claudeCode.d.ts +43 -0
- package/dist/hooks/claudeCode.d.ts.map +1 -0
- package/dist/hooks/claudeCode.js +148 -0
- package/dist/hooks/claudeCode.js.map +1 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/preset.d.ts +16 -0
- package/dist/hooks/preset.d.ts.map +1 -0
- package/dist/hooks/preset.js +32 -0
- package/dist/hooks/preset.js.map +1 -0
- package/dist/hooks/run.d.ts +9 -0
- package/dist/hooks/run.d.ts.map +1 -0
- package/dist/hooks/run.js +44 -0
- package/dist/hooks/run.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/init/index.d.ts +4 -0
- package/dist/init/index.d.ts.map +1 -0
- package/dist/init/index.js +4 -0
- package/dist/init/index.js.map +1 -0
- package/dist/init/presets.d.ts +30 -0
- package/dist/init/presets.d.ts.map +1 -0
- package/dist/init/presets.js +34 -0
- package/dist/init/presets.js.map +1 -0
- package/dist/init/run.d.ts +18 -0
- package/dist/init/run.d.ts.map +1 -0
- package/dist/init/run.js +60 -0
- package/dist/init/run.js.map +1 -0
- package/dist/init/suggest.d.ts +50 -0
- package/dist/init/suggest.d.ts.map +1 -0
- package/dist/init/suggest.js +82 -0
- package/dist/init/suggest.js.map +1 -0
- package/dist/startup.d.ts +13 -2
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +25 -6
- package/dist/startup.js.map +1 -1
- package/package.json +3 -3
- package/src/audit.ts +10 -5
- package/src/cli.ts +75 -2
- package/src/gateway.ts +238 -13
- package/src/hooks/claudeCode.ts +194 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/preset.ts +47 -0
- package/src/hooks/run.ts +49 -0
- package/src/index.ts +2 -0
- package/src/init/index.ts +3 -0
- package/src/init/presets.ts +64 -0
- package/src/init/run.ts +79 -0
- package/src/init/suggest.ts +127 -0
- package/src/startup.ts +25 -7
package/src/gateway.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* tools/list → every upstream's tools, renamed <upstream>__<tool>
|
|
6
6
|
* tools/call → request interceptors (adtl.policy) → upstream → response
|
|
7
7
|
* interceptors (adtl.label) → agent
|
|
8
|
+
* prompts/* → like tools: renamed <upstream>__<prompt>, results labeled
|
|
9
|
+
* resources/* → URIs pass through unchanged and are routed to the upstream
|
|
10
|
+
* that lists them (or whose template matches). Results are
|
|
11
|
+
* labeled. A URI more than one upstream could serve is refused,
|
|
12
|
+
* since ADTL could not tell which server to label it as.
|
|
8
13
|
*
|
|
9
14
|
* When a call is stopped, the agent receives a normal tool result with
|
|
10
15
|
* `isError: true` explaining why, so the model can tell the user.
|
|
@@ -20,10 +25,24 @@
|
|
|
20
25
|
import { randomUUID } from "node:crypto";
|
|
21
26
|
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
22
27
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
28
|
+
import { UriTemplate } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
|
|
23
29
|
import {
|
|
24
30
|
CallToolRequestSchema,
|
|
31
|
+
ErrorCode,
|
|
32
|
+
GetPromptRequestSchema,
|
|
33
|
+
ListPromptsRequestSchema,
|
|
34
|
+
ListResourcesRequestSchema,
|
|
35
|
+
ListResourceTemplatesRequestSchema,
|
|
25
36
|
ListToolsRequestSchema,
|
|
37
|
+
McpError,
|
|
38
|
+
ReadResourceRequestSchema,
|
|
26
39
|
type CallToolResult,
|
|
40
|
+
type GetPromptResult,
|
|
41
|
+
type Prompt,
|
|
42
|
+
type ReadResourceResult,
|
|
43
|
+
type Resource,
|
|
44
|
+
type ResourceTemplate,
|
|
45
|
+
type ServerCapabilities,
|
|
27
46
|
type Tool,
|
|
28
47
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
29
48
|
import type { InvocationContext } from "@adtl/interceptors";
|
|
@@ -32,7 +51,7 @@ import type { AuditEntry, AuditSink } from "./audit.js";
|
|
|
32
51
|
import { TOOL_SEPARATOR } from "./config.js";
|
|
33
52
|
import type { Blocked, ChainOutcome, InterceptorInvoker } from "./invoker.js";
|
|
34
53
|
|
|
35
|
-
export const GATEWAY_VERSION = "0.
|
|
54
|
+
export const GATEWAY_VERSION = "0.3.1";
|
|
36
55
|
|
|
37
56
|
/** `_meta` key on gateway-generated error results describing why a call was stopped. */
|
|
38
57
|
export const GATEWAY_DECISION_KEY = "adtl/gateway";
|
|
@@ -43,6 +62,21 @@ interface Route {
|
|
|
43
62
|
annotations?: Tool["annotations"];
|
|
44
63
|
}
|
|
45
64
|
|
|
65
|
+
interface PromptRoute {
|
|
66
|
+
upstream: string;
|
|
67
|
+
prompt: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface TemplateRoute {
|
|
71
|
+
upstream: string;
|
|
72
|
+
template: UriTemplate;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Page<T> {
|
|
76
|
+
items: T[];
|
|
77
|
+
nextCursor?: string | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
46
80
|
export interface GatewayLogger {
|
|
47
81
|
info(msg: string): void;
|
|
48
82
|
warn(msg: string): void;
|
|
@@ -110,6 +144,10 @@ export class AdtlGateway {
|
|
|
110
144
|
private readonly log: GatewayLogger;
|
|
111
145
|
private readonly approvals: ApprovalBroker | undefined;
|
|
112
146
|
private routes = new Map<string, Route>();
|
|
147
|
+
private promptRoutes = new Map<string, PromptRoute>();
|
|
148
|
+
/** Resource URI → the upstreams that list it. */
|
|
149
|
+
private resourceRoutes = new Map<string, Set<string>>();
|
|
150
|
+
private templateRoutes: TemplateRoute[] = [];
|
|
113
151
|
|
|
114
152
|
constructor(opts: AdtlGatewayOptions) {
|
|
115
153
|
this.upstreams = opts.upstreams;
|
|
@@ -122,10 +160,15 @@ export class AdtlGateway {
|
|
|
122
160
|
|
|
123
161
|
/** The downstream MCP server the agent connects to. */
|
|
124
162
|
createServer(): Server {
|
|
163
|
+
// Offer resources and prompts only if some upstream does (the SDK refuses handlers for undeclared capabilities).
|
|
164
|
+
const offers = (c: "resources" | "prompts") => [...this.upstreams.values()].some((u) => u.getServerCapabilities()?.[c]);
|
|
165
|
+
const capabilities: ServerCapabilities = { tools: {} };
|
|
166
|
+
if (offers("resources")) capabilities.resources = {};
|
|
167
|
+
if (offers("prompts")) capabilities.prompts = {};
|
|
125
168
|
const server = new Server(
|
|
126
169
|
{ name: "adtl-gateway", version: GATEWAY_VERSION },
|
|
127
170
|
{
|
|
128
|
-
capabilities
|
|
171
|
+
capabilities,
|
|
129
172
|
instructions:
|
|
130
173
|
"Tools from several MCP servers, named <server>__<tool>. ADTL checks each call against trust labels; " +
|
|
131
174
|
"if a call is stopped, the result explains why.",
|
|
@@ -141,32 +184,214 @@ export class AdtlGateway {
|
|
|
141
184
|
extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, message } });
|
|
142
185
|
return this.callTool(req.params.name, req.params.arguments ?? {}, progress ? { progress } : {});
|
|
143
186
|
});
|
|
187
|
+
if (capabilities.resources) {
|
|
188
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await this.listResources() }));
|
|
189
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
|
|
190
|
+
resourceTemplates: await this.listResourceTemplates(),
|
|
191
|
+
}));
|
|
192
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => this.readResource(req.params.uri));
|
|
193
|
+
}
|
|
194
|
+
if (capabilities.prompts) {
|
|
195
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await this.listPrompts() }));
|
|
196
|
+
server.setRequestHandler(GetPromptRequestSchema, async (req) => this.getPrompt(req.params.name, req.params.arguments));
|
|
197
|
+
}
|
|
144
198
|
return server;
|
|
145
199
|
}
|
|
146
200
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
201
|
+
/** Every upstream's items from one list method, following cursors. Upstreams that fail are skipped with a warning. */
|
|
202
|
+
private async listAll<T>(
|
|
203
|
+
method: string,
|
|
204
|
+
capability: "tools" | "resources" | "prompts",
|
|
205
|
+
page: (client: Client, cursor?: string) => Promise<Page<T>>,
|
|
206
|
+
): Promise<{ upstream: string; item: T }[]> {
|
|
207
|
+
const out: { upstream: string; item: T }[] = [];
|
|
150
208
|
for (const [upstream, client] of this.upstreams) {
|
|
209
|
+
if (capability !== "tools" && !client.getServerCapabilities()?.[capability]) continue;
|
|
151
210
|
let cursor: string | undefined;
|
|
152
211
|
try {
|
|
153
212
|
do {
|
|
154
|
-
const
|
|
155
|
-
for (const
|
|
156
|
-
|
|
157
|
-
routes.set(name, { upstream, tool: t.name, ...(t.annotations ? { annotations: t.annotations } : {}) });
|
|
158
|
-
tools.push({ ...t, name });
|
|
159
|
-
}
|
|
160
|
-
cursor = page.nextCursor;
|
|
213
|
+
const p = await page(client, cursor);
|
|
214
|
+
for (const item of p.items) out.push({ upstream, item });
|
|
215
|
+
cursor = p.nextCursor;
|
|
161
216
|
} while (cursor);
|
|
162
217
|
} catch (err) {
|
|
163
|
-
this.log.warn(
|
|
218
|
+
this.log.warn(`${method} failed for ${upstream}: ${(err as Error).message}`);
|
|
164
219
|
}
|
|
165
220
|
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async listTools(): Promise<Tool[]> {
|
|
225
|
+
const all = await this.listAll("tools/list", "tools", async (c, cursor) => {
|
|
226
|
+
const p = await c.listTools(cursor ? { cursor } : {});
|
|
227
|
+
return { items: p.tools, nextCursor: p.nextCursor };
|
|
228
|
+
});
|
|
229
|
+
const routes = new Map<string, Route>();
|
|
230
|
+
const tools = all.map(({ upstream, item: t }) => {
|
|
231
|
+
const name = `${upstream}${TOOL_SEPARATOR}${t.name}`;
|
|
232
|
+
routes.set(name, { upstream, tool: t.name, ...(t.annotations ? { annotations: t.annotations } : {}) });
|
|
233
|
+
return { ...t, name };
|
|
234
|
+
});
|
|
166
235
|
this.routes = routes;
|
|
167
236
|
return tools;
|
|
168
237
|
}
|
|
169
238
|
|
|
239
|
+
async listPrompts(): Promise<Prompt[]> {
|
|
240
|
+
const all = await this.listAll("prompts/list", "prompts", async (c, cursor) => {
|
|
241
|
+
const p = await c.listPrompts(cursor ? { cursor } : {});
|
|
242
|
+
return { items: p.prompts, nextCursor: p.nextCursor };
|
|
243
|
+
});
|
|
244
|
+
const routes = new Map<string, PromptRoute>();
|
|
245
|
+
const prompts = all.map(({ upstream, item: p }) => {
|
|
246
|
+
const name = `${upstream}${TOOL_SEPARATOR}${p.name}`;
|
|
247
|
+
routes.set(name, { upstream, prompt: p.name });
|
|
248
|
+
return { ...p, name };
|
|
249
|
+
});
|
|
250
|
+
this.promptRoutes = routes;
|
|
251
|
+
return prompts;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async listResources(): Promise<Resource[]> {
|
|
255
|
+
const all = await this.listAll("resources/list", "resources", async (c, cursor) => {
|
|
256
|
+
const p = await c.listResources(cursor ? { cursor } : {});
|
|
257
|
+
return { items: p.resources, nextCursor: p.nextCursor };
|
|
258
|
+
});
|
|
259
|
+
const routes = new Map<string, Set<string>>();
|
|
260
|
+
for (const { upstream, item } of all) {
|
|
261
|
+
const ups = routes.get(item.uri) ?? new Set<string>();
|
|
262
|
+
ups.add(upstream);
|
|
263
|
+
routes.set(item.uri, ups);
|
|
264
|
+
}
|
|
265
|
+
for (const [uri, ups] of routes) {
|
|
266
|
+
if (ups.size > 1) this.log.warn(`resource ${uri} is offered by ${[...ups].join(", ")}; reading it will be refused`);
|
|
267
|
+
}
|
|
268
|
+
this.resourceRoutes = routes;
|
|
269
|
+
return all.map((a) => a.item);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async listResourceTemplates(): Promise<ResourceTemplate[]> {
|
|
273
|
+
const all = await this.listAll("resources/templates/list", "resources", async (c, cursor) => {
|
|
274
|
+
const p = await c.listResourceTemplates(cursor ? { cursor } : {});
|
|
275
|
+
return { items: p.resourceTemplates, nextCursor: p.nextCursor };
|
|
276
|
+
});
|
|
277
|
+
const routes: TemplateRoute[] = [];
|
|
278
|
+
for (const { upstream, item } of all) {
|
|
279
|
+
try {
|
|
280
|
+
routes.push({ upstream, template: new UriTemplate(item.uriTemplate) });
|
|
281
|
+
} catch (err) {
|
|
282
|
+
this.log.warn(`ignoring resource template ${item.uriTemplate} from ${upstream}: ${(err as Error).message}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
this.templateRoutes = routes;
|
|
286
|
+
return all.map((a) => a.item);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The one upstream that serves `uri`: an exact listing wins, then template matches. */
|
|
290
|
+
private async resourceUpstream(uri: string): Promise<string> {
|
|
291
|
+
if (!this.resourceRoutes.has(uri)) await this.listResources();
|
|
292
|
+
let candidates = [...(this.resourceRoutes.get(uri) ?? [])];
|
|
293
|
+
if (candidates.length === 0) {
|
|
294
|
+
if (this.templateRoutes.length === 0) await this.listResourceTemplates();
|
|
295
|
+
candidates = [...new Set(this.templateRoutes.filter((t) => t.template.match(uri) !== null).map((t) => t.upstream))];
|
|
296
|
+
}
|
|
297
|
+
if (candidates.length === 0) throw new McpError(ErrorCode.InvalidParams, `Unknown resource: ${uri}`);
|
|
298
|
+
if (candidates.length > 1) {
|
|
299
|
+
throw new McpError(
|
|
300
|
+
ErrorCode.InvalidParams,
|
|
301
|
+
`Resource ${uri} could come from more than one server (${candidates.join(", ")}). ` +
|
|
302
|
+
`ADTL can't tell which server to label it as, so it was not read.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return candidates[0]!;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async readResource(uri: string): Promise<ReadResourceResult> {
|
|
309
|
+
const upstream = await this.resourceUpstream(uri);
|
|
310
|
+
const client = this.upstreams.get(upstream)!;
|
|
311
|
+
return this.forwardLabeled("resources/read", upstream, { resource: uri }, { uri }, (params) =>
|
|
312
|
+
client.readResource(params as { uri: string }),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async getPrompt(name: string, args?: Record<string, string>): Promise<GetPromptResult> {
|
|
317
|
+
let route = this.promptRoutes.get(name);
|
|
318
|
+
if (!route) {
|
|
319
|
+
await this.listPrompts();
|
|
320
|
+
route = this.promptRoutes.get(name);
|
|
321
|
+
}
|
|
322
|
+
if (!route) throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`);
|
|
323
|
+
const client = this.upstreams.get(route.upstream)!;
|
|
324
|
+
return this.forwardLabeled(
|
|
325
|
+
"prompts/get",
|
|
326
|
+
route.upstream,
|
|
327
|
+
{ prompt: route.prompt },
|
|
328
|
+
{ name: route.prompt, ...(args ? { arguments: args } : {}) },
|
|
329
|
+
(params) => client.getPrompt(params as { name: string; arguments?: Record<string, string> }),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Forward a resources/read or prompts/get request through the interceptor
|
|
335
|
+
* chain: request interceptors → upstream → response interceptors (adtl.label).
|
|
336
|
+
* These requests have no tool result to carry an error, so a stopped request
|
|
337
|
+
* becomes a JSON-RPC error.
|
|
338
|
+
*/
|
|
339
|
+
private async forwardLabeled<R extends object>(
|
|
340
|
+
event: "resources/read" | "prompts/get",
|
|
341
|
+
upstream: string,
|
|
342
|
+
target: { resource: string } | { prompt: string },
|
|
343
|
+
params: Record<string, unknown>,
|
|
344
|
+
send: (params: Record<string, unknown>) => Promise<R>,
|
|
345
|
+
): Promise<R> {
|
|
346
|
+
const started = performance.now();
|
|
347
|
+
const context: InvocationContext = {
|
|
348
|
+
sessionId: this.sessionId,
|
|
349
|
+
timestamp: new Date().toISOString(),
|
|
350
|
+
principal: { type: "anonymous" },
|
|
351
|
+
};
|
|
352
|
+
const config = { server: upstream };
|
|
353
|
+
const audit = this.invoker.modeOverride === "audit" ? { mode: "audit" as const } : {};
|
|
354
|
+
const finish = (entry: Pick<AuditEntry, "outcome"> & Partial<AuditEntry>) =>
|
|
355
|
+
this.audit?.write({
|
|
356
|
+
ts: context.timestamp!,
|
|
357
|
+
sessionId: this.sessionId,
|
|
358
|
+
event,
|
|
359
|
+
server: upstream,
|
|
360
|
+
...target,
|
|
361
|
+
...entry,
|
|
362
|
+
durationMs: Math.round(performance.now() - started),
|
|
363
|
+
});
|
|
364
|
+
const what = "resource" in target ? `resource ${target.resource}` : `prompt ${upstream}${TOOL_SEPARATOR}${target.prompt}`;
|
|
365
|
+
|
|
366
|
+
const req = await this.invoker.runRequest({ event, payload: { method: event, params }, config, context });
|
|
367
|
+
if (req.blocked) {
|
|
368
|
+
finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, warnings: req.warnings });
|
|
369
|
+
this.log.info(`${req.blocked.decision} ${what}: ${req.blocked.reason}`);
|
|
370
|
+
throw new McpError(ErrorCode.InvalidRequest, `ADTL blocked this request: ${req.blocked.reason}`);
|
|
371
|
+
}
|
|
372
|
+
const forwarded = (req.payload as { params: Record<string, unknown> }).params;
|
|
373
|
+
|
|
374
|
+
let result: R;
|
|
375
|
+
try {
|
|
376
|
+
result = await send(forwarded);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
finish({ outcome: "upstream_error", reason: (err as Error).message, ...audit, warnings: req.warnings });
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const res = await this.invoker.runResponse({ event, payload: { method: event, params: forwarded, result }, config, context });
|
|
383
|
+
const warnings = [...req.warnings, ...res.warnings];
|
|
384
|
+
if (res.blocked) {
|
|
385
|
+
// Fail closed: unlabeled content must not reach the agent.
|
|
386
|
+
finish({ outcome: "blocked", decision: res.blocked.decision, reason: res.blocked.reason, warnings });
|
|
387
|
+
this.log.warn(`${what} withheld: ${res.blocked.reason}`);
|
|
388
|
+
throw new McpError(ErrorCode.InternalError, `ADTL could not label this content, so it was withheld: ${res.blocked.reason}`);
|
|
389
|
+
}
|
|
390
|
+
const label = infoOf(res, "adtl.label");
|
|
391
|
+
finish({ outcome: "forwarded", ...audit, label: label.label, sessionLabel: label.sessionLabel, warnings });
|
|
392
|
+
return (res.payload as { result: R }).result;
|
|
393
|
+
}
|
|
394
|
+
|
|
170
395
|
async callTool(name: string, args: Record<string, unknown>, opts: CallOptions = {}): Promise<CallToolResult> {
|
|
171
396
|
const started = performance.now();
|
|
172
397
|
let route = this.routes.get(name);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code hooks adapter: puts Claude Code's built-in tools (WebFetch, Read,
|
|
3
|
+
* Bash, …) and MCP servers that don't go through the gateway under the same
|
|
4
|
+
* labels and policy as gateway calls.
|
|
5
|
+
*
|
|
6
|
+
* PreToolUse runs the policy against the session label:
|
|
7
|
+
* block → deny, ask → Claude Code's permission prompt,
|
|
8
|
+
* allow → no answer, so Claude Code's own permissions apply
|
|
9
|
+
* PostToolUse labels the tool's output and adds it to the session label
|
|
10
|
+
* PostToolUseFailure same: a failed tool's error output still reaches the model
|
|
11
|
+
*
|
|
12
|
+
* Built-in tools are labeled as the server `claude-code` (see preset.ts); MCP
|
|
13
|
+
* tools `mcp__<server>__<tool>` as `<server>`. Tools of the gateway's own MCP
|
|
14
|
+
* server (`mcp__adtl__*` by default) are skipped: the gateway checks those.
|
|
15
|
+
*
|
|
16
|
+
* Hooks and gateway share one session label through a FileSessionStore keyed
|
|
17
|
+
* by Claude Code's session id, which the gateway reads from
|
|
18
|
+
* `CLAUDE_CODE_SESSION_ID`.
|
|
19
|
+
*
|
|
20
|
+
* Errors fail closed: a PreToolUse hook that can't decide denies the call.
|
|
21
|
+
*/
|
|
22
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
23
|
+
import {
|
|
24
|
+
callHash,
|
|
25
|
+
evaluatePolicy,
|
|
26
|
+
labelResult,
|
|
27
|
+
toolEgress,
|
|
28
|
+
type AdtlConfig,
|
|
29
|
+
type Decision,
|
|
30
|
+
type PolicyDecision,
|
|
31
|
+
type SessionStore,
|
|
32
|
+
} from "@adtl/labels";
|
|
33
|
+
import type { AuditEntry, AuditSink } from "../audit.js";
|
|
34
|
+
import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
|
|
35
|
+
|
|
36
|
+
export interface HookInput {
|
|
37
|
+
session_id?: string;
|
|
38
|
+
hook_event_name?: string;
|
|
39
|
+
tool_name?: string;
|
|
40
|
+
tool_input?: unknown;
|
|
41
|
+
tool_response?: unknown;
|
|
42
|
+
/** PostToolUseFailure: the error the tool returned. */
|
|
43
|
+
error?: unknown;
|
|
44
|
+
cwd?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HookOptions {
|
|
48
|
+
config: AdtlConfig;
|
|
49
|
+
sessions: SessionStore;
|
|
50
|
+
/** Name of the gateway in Claude Code's MCP config; its tools are skipped. Default `adtl`. */
|
|
51
|
+
gatewayServer?: string;
|
|
52
|
+
/** `audit`: decide and log, but never deny or ask. */
|
|
53
|
+
mode?: "active" | "audit";
|
|
54
|
+
audit?: AuditSink;
|
|
55
|
+
/** Files the agent must not change: the ADTL config and the session directory. */
|
|
56
|
+
protect?: { configPath?: string; sessionsDir?: string };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What the hook process prints and exits with. */
|
|
60
|
+
export interface HookOutcome {
|
|
61
|
+
stdout?: Record<string, unknown>;
|
|
62
|
+
stderr?: string;
|
|
63
|
+
exitCode: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const NOTHING: HookOutcome = { exitCode: 0 };
|
|
67
|
+
|
|
68
|
+
/** `config` with labels for Claude Code's built-in tools; rules under `servers.claude-code` win. */
|
|
69
|
+
export function withClaudeCodePreset(config: AdtlConfig): AdtlConfig {
|
|
70
|
+
const own = config.servers[CLAUDE_CODE_SERVER];
|
|
71
|
+
return {
|
|
72
|
+
...config,
|
|
73
|
+
servers: {
|
|
74
|
+
...config.servers,
|
|
75
|
+
[CLAUDE_CODE_SERVER]: {
|
|
76
|
+
acceptServerClaims: own?.acceptServerClaims ?? false,
|
|
77
|
+
...(own?.defaults ? { defaults: own.defaults } : {}),
|
|
78
|
+
tools: { ...CLAUDE_CODE_PRESET, ...own?.tools },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Which ADTL server and tool a Claude Code tool name is, or undefined for the gateway's own tools. */
|
|
85
|
+
export function routeTool(toolName: string, gatewayServer = "adtl"): { server: string; tool: string } | undefined {
|
|
86
|
+
const m = /^mcp__(.+?)__(.+)$/.exec(toolName);
|
|
87
|
+
if (!m) return { server: CLAUDE_CODE_SERVER, tool: toolName };
|
|
88
|
+
if (m[1] === gatewayServer) return undefined;
|
|
89
|
+
return { server: m[1]!, tool: m[2]! };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
93
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function inside(path: string, dir: string): boolean {
|
|
97
|
+
const r = relative(dir, path);
|
|
98
|
+
return r === "" || (!r.startsWith(`..${sep}`) && r !== ".." && !isAbsolute(r));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A best-effort guard against the agent clearing its own taint by editing the
|
|
103
|
+
* config or deleting session files. Not a sandbox: Bash can always find a way.
|
|
104
|
+
*/
|
|
105
|
+
function protectedTarget(input: HookInput, route: { server: string; tool: string }, protect: HookOptions["protect"]): { decision: Decision; reason: string } | undefined {
|
|
106
|
+
if (!protect || route.server !== CLAUDE_CODE_SERVER) return undefined;
|
|
107
|
+
const args = isRecord(input.tool_input) ? input.tool_input : {};
|
|
108
|
+
const cwd = input.cwd ?? process.cwd();
|
|
109
|
+
const path = typeof args.file_path === "string" ? args.file_path : typeof args.notebook_path === "string" ? args.notebook_path : undefined;
|
|
110
|
+
if (path !== undefined && ["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(route.tool)) {
|
|
111
|
+
const abs = resolve(cwd, path);
|
|
112
|
+
if (protect.configPath && abs === resolve(protect.configPath)) {
|
|
113
|
+
return { decision: "block", reason: "The agent may not change the ADTL config; edit it yourself" };
|
|
114
|
+
}
|
|
115
|
+
if (protect.sessionsDir && inside(abs, resolve(protect.sessionsDir))) {
|
|
116
|
+
return { decision: "block", reason: "The agent may not change ADTL session labels" };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const command = typeof args.command === "string" ? args.command : undefined;
|
|
120
|
+
if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
|
|
121
|
+
const marks = [".adtl/sessions", protect.sessionsDir].filter((m): m is string => !!m);
|
|
122
|
+
if (marks.some((m) => command.includes(m))) {
|
|
123
|
+
return { decision: "ask", reason: "This command touches ADTL session labels" };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
|
|
130
|
+
const event = input.hook_event_name;
|
|
131
|
+
if (event !== "PreToolUse" && event !== "PostToolUse" && event !== "PostToolUseFailure") return NOTHING;
|
|
132
|
+
if (typeof input.tool_name !== "string" || typeof input.session_id !== "string" || input.session_id === "") {
|
|
133
|
+
return failure(event, "hook input has no tool_name or session_id");
|
|
134
|
+
}
|
|
135
|
+
const route = routeTool(input.tool_name, opts.gatewayServer);
|
|
136
|
+
if (!route) return NOTHING;
|
|
137
|
+
const config = withClaudeCodePreset(opts.config);
|
|
138
|
+
const ts = new Date().toISOString();
|
|
139
|
+
const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const };
|
|
140
|
+
|
|
141
|
+
if (event !== "PreToolUse") {
|
|
142
|
+
const label = labelResult({ ...route, result: input.tool_response ?? input.error ?? null }, config);
|
|
143
|
+
const sessionLabel = opts.sessions.observe(input.session_id, label);
|
|
144
|
+
opts.audit?.write({ ...base, outcome: "forwarded", label, sessionLabel, durationMs: 0 });
|
|
145
|
+
return NOTHING;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const hash = callHash(route.server, route.tool, input.tool_input ?? {});
|
|
149
|
+
const session = opts.sessions.get(input.session_id);
|
|
150
|
+
const guard = protectedTarget(input, route, opts.protect);
|
|
151
|
+
const policy: PolicyDecision = evaluatePolicy(config.policy, {
|
|
152
|
+
session,
|
|
153
|
+
...route,
|
|
154
|
+
egress: toolEgress(config, route.server, route.tool),
|
|
155
|
+
});
|
|
156
|
+
const d: PolicyDecision =
|
|
157
|
+
guard && (guard.decision === "block" || policy.decision === "allow")
|
|
158
|
+
? { decision: guard.decision, reason: guard.reason, matched: ["adtl-self-protection"] }
|
|
159
|
+
: policy;
|
|
160
|
+
if (d.decision === "allow") return NOTHING;
|
|
161
|
+
|
|
162
|
+
const audit = opts.mode === "audit";
|
|
163
|
+
const entry: AuditEntry = {
|
|
164
|
+
...base,
|
|
165
|
+
outcome: audit ? "forwarded" : d.decision === "ask" ? "asked" : "blocked",
|
|
166
|
+
decision: d.decision,
|
|
167
|
+
...(audit ? { mode: "audit" as const } : {}),
|
|
168
|
+
reason: d.reason,
|
|
169
|
+
matched: d.matched,
|
|
170
|
+
callHash: hash,
|
|
171
|
+
sessionLabel: session,
|
|
172
|
+
durationMs: 0,
|
|
173
|
+
};
|
|
174
|
+
opts.audit?.write(entry);
|
|
175
|
+
if (audit) return NOTHING;
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
exitCode: 0,
|
|
179
|
+
stdout: {
|
|
180
|
+
hookSpecificOutput: {
|
|
181
|
+
hookEventName: "PreToolUse",
|
|
182
|
+
permissionDecision: d.decision === "ask" ? "ask" : "deny",
|
|
183
|
+
permissionDecisionReason:
|
|
184
|
+
d.decision === "ask" ? `ADTL: ${d.reason}. Approve only if you expected this call.` : `ADTL blocked this call: ${d.reason}.`,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Fail closed: exit 2 stops a PreToolUse call and shows the reason to Claude. */
|
|
191
|
+
export function failure(event: string | undefined, why: string): HookOutcome {
|
|
192
|
+
const stopped = event === "PreToolUse" ? " The call was stopped." : "";
|
|
193
|
+
return { exitCode: 2, stderr: `ADTL hook error: ${why}.${stopped}` };
|
|
194
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Labels for Claude Code's built-in tools, used when `servers.claude-code`
|
|
3
|
+
* doesn't override them. Built-in tools are labeled as the server `claude-code`.
|
|
4
|
+
*
|
|
5
|
+
* - Local reads (Read, Grep, Glob) are trusted but private: your own files.
|
|
6
|
+
* Files written from untrusted content aren't tracked until the v0.4 ledger.
|
|
7
|
+
* - Web tools bring in untrusted content, and their URL or query can carry
|
|
8
|
+
* data out, so their egress is external.
|
|
9
|
+
* - Bash can do anything: its output has unknown integrity, and its egress is
|
|
10
|
+
* unknown.
|
|
11
|
+
* - Edits and bookkeeping tools return nothing worth labeling.
|
|
12
|
+
*/
|
|
13
|
+
import type { ToolRule } from "@adtl/labels";
|
|
14
|
+
|
|
15
|
+
export const CLAUDE_CODE_SERVER = "claude-code";
|
|
16
|
+
|
|
17
|
+
const localRead: ToolRule = { integrity: "trusted", confidentiality: "private", egress: "none" };
|
|
18
|
+
const localWrite: ToolRule = { integrity: "trusted", confidentiality: "public", egress: "none" };
|
|
19
|
+
const web: ToolRule = { integrity: "untrusted", confidentiality: "public", egress: "external" };
|
|
20
|
+
const shell: ToolRule = { integrity: "unknown", confidentiality: "private", egress: "unknown" };
|
|
21
|
+
|
|
22
|
+
export const CLAUDE_CODE_PRESET: Record<string, ToolRule> = {
|
|
23
|
+
Read: localRead,
|
|
24
|
+
Grep: localRead,
|
|
25
|
+
Glob: localRead,
|
|
26
|
+
LS: localRead,
|
|
27
|
+
NotebookRead: localRead,
|
|
28
|
+
Write: localWrite,
|
|
29
|
+
Edit: localWrite,
|
|
30
|
+
MultiEdit: localWrite,
|
|
31
|
+
NotebookEdit: localWrite,
|
|
32
|
+
TodoWrite: localWrite,
|
|
33
|
+
AskUserQuestion: localWrite,
|
|
34
|
+
Skill: localWrite,
|
|
35
|
+
ToolSearch: localWrite,
|
|
36
|
+
EnterPlanMode: localWrite,
|
|
37
|
+
ExitPlanMode: localWrite,
|
|
38
|
+
WebFetch: web,
|
|
39
|
+
WebSearch: web,
|
|
40
|
+
Bash: shell,
|
|
41
|
+
BashOutput: shell,
|
|
42
|
+
KillShell: localWrite,
|
|
43
|
+
Monitor: shell,
|
|
44
|
+
// A sub-agent's own tool calls go through these hooks too; its answer mixes what it saw.
|
|
45
|
+
Agent: { integrity: "unknown", confidentiality: "private", egress: "none" },
|
|
46
|
+
Task: { integrity: "unknown", confidentiality: "private", egress: "none" },
|
|
47
|
+
};
|
package/src/hooks/run.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `adtl-gateway hook --config adtl.yaml`: the command Claude Code runs for
|
|
3
|
+
* PreToolUse, PostToolUse and PostToolUseFailure. Reads the hook event from
|
|
4
|
+
* stdin and answers on stdout (JSON) or stderr (with exit code 2).
|
|
5
|
+
*
|
|
6
|
+
* The config needs no `gateway:` section; if there is one, its `audit` log and
|
|
7
|
+
* `mode` are used, so hook and gateway write to the same audit log.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
import { FileSessionStore, defaultSessionsDir } from "@adtl/labels";
|
|
12
|
+
import { parseConfigText } from "@adtl/interceptors";
|
|
13
|
+
import { parse as parseYaml } from "yaml";
|
|
14
|
+
import { JsonlAuditLog } from "../audit.js";
|
|
15
|
+
import { failure, handleHook, type HookInput, type HookOutcome } from "./claudeCode.js";
|
|
16
|
+
|
|
17
|
+
export interface RunHookOptions {
|
|
18
|
+
configPath: string;
|
|
19
|
+
stdin: string;
|
|
20
|
+
gatewayServer?: string;
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
25
|
+
let input: HookInput;
|
|
26
|
+
try {
|
|
27
|
+
input = JSON.parse(opts.stdin) as HookInput;
|
|
28
|
+
} catch {
|
|
29
|
+
// Without the event name we can't tell whether this is a PreToolUse call; refuse it to be safe.
|
|
30
|
+
return failure("PreToolUse", "hook input on stdin is not JSON");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const path = resolve(input.cwd ?? process.cwd(), opts.configPath);
|
|
34
|
+
const text = await readFile(path, "utf8");
|
|
35
|
+
const config = parseConfigText(text, path);
|
|
36
|
+
const gw = ((parseYaml(text) ?? {}) as { gateway?: { audit?: unknown; mode?: unknown } }).gateway ?? {};
|
|
37
|
+
const sessionsDir = defaultSessionsDir(opts.env ?? process.env);
|
|
38
|
+
return handleHook(input, {
|
|
39
|
+
config,
|
|
40
|
+
sessions: new FileSessionStore(sessionsDir),
|
|
41
|
+
...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
|
|
42
|
+
mode: gw.mode === "audit" ? "audit" : "active",
|
|
43
|
+
...(typeof gw.audit === "string" ? { audit: new JsonlAuditLog(resolve(dirname(path), gw.audit)) } : {}),
|
|
44
|
+
protect: { configPath: path, sessionsDir },
|
|
45
|
+
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return failure(input.hook_event_name, (err as Error).message);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { PRESETS, choosePreset, type Preset } from "./presets.js";
|
|
2
|
+
export { mergeIntoDocument, ruleFromHints, suggest, type MergeReport, type ServerScan, type ServerSuggestion, type ToolSuggestion } from "./suggest.js";
|
|
3
|
+
export { runInit, type InitOptions, type InitResult } from "./run.js";
|