@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.
Files changed (65) hide show
  1. package/README.md +63 -3
  2. package/dist/audit.d.ts +9 -4
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +1 -1
  5. package/dist/audit.js.map +1 -1
  6. package/dist/cli.js +82 -2
  7. package/dist/cli.js.map +1 -1
  8. package/dist/gateway.d.ts +22 -2
  9. package/dist/gateway.d.ts.map +1 -1
  10. package/dist/gateway.js +189 -14
  11. package/dist/gateway.js.map +1 -1
  12. package/dist/hooks/claudeCode.d.ts +43 -0
  13. package/dist/hooks/claudeCode.d.ts.map +1 -0
  14. package/dist/hooks/claudeCode.js +148 -0
  15. package/dist/hooks/claudeCode.js.map +1 -0
  16. package/dist/hooks/index.d.ts +4 -0
  17. package/dist/hooks/index.d.ts.map +1 -0
  18. package/dist/hooks/index.js +4 -0
  19. package/dist/hooks/index.js.map +1 -0
  20. package/dist/hooks/preset.d.ts +16 -0
  21. package/dist/hooks/preset.d.ts.map +1 -0
  22. package/dist/hooks/preset.js +32 -0
  23. package/dist/hooks/preset.js.map +1 -0
  24. package/dist/hooks/run.d.ts +9 -0
  25. package/dist/hooks/run.d.ts.map +1 -0
  26. package/dist/hooks/run.js +44 -0
  27. package/dist/hooks/run.js.map +1 -0
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/init/index.d.ts +4 -0
  33. package/dist/init/index.d.ts.map +1 -0
  34. package/dist/init/index.js +4 -0
  35. package/dist/init/index.js.map +1 -0
  36. package/dist/init/presets.d.ts +30 -0
  37. package/dist/init/presets.d.ts.map +1 -0
  38. package/dist/init/presets.js +34 -0
  39. package/dist/init/presets.js.map +1 -0
  40. package/dist/init/run.d.ts +18 -0
  41. package/dist/init/run.d.ts.map +1 -0
  42. package/dist/init/run.js +60 -0
  43. package/dist/init/run.js.map +1 -0
  44. package/dist/init/suggest.d.ts +50 -0
  45. package/dist/init/suggest.d.ts.map +1 -0
  46. package/dist/init/suggest.js +82 -0
  47. package/dist/init/suggest.js.map +1 -0
  48. package/dist/startup.d.ts +13 -2
  49. package/dist/startup.d.ts.map +1 -1
  50. package/dist/startup.js +25 -6
  51. package/dist/startup.js.map +1 -1
  52. package/package.json +3 -3
  53. package/src/audit.ts +10 -5
  54. package/src/cli.ts +75 -2
  55. package/src/gateway.ts +238 -13
  56. package/src/hooks/claudeCode.ts +194 -0
  57. package/src/hooks/index.ts +3 -0
  58. package/src/hooks/preset.ts +47 -0
  59. package/src/hooks/run.ts +49 -0
  60. package/src/index.ts +2 -0
  61. package/src/init/index.ts +3 -0
  62. package/src/init/presets.ts +64 -0
  63. package/src/init/run.ts +79 -0
  64. package/src/init/suggest.ts +127 -0
  65. 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.2.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: { tools: {} },
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
- async listTools(): Promise<Tool[]> {
148
- const routes = new Map<string, Route>();
149
- const tools: Tool[] = [];
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 page = await client.listTools(cursor ? { cursor } : {});
155
- for (const t of page.tools) {
156
- const name = `${upstream}${TOOL_SEPARATOR}${t.name}`;
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(`tools/list failed for ${upstream}: ${(err as Error).message}`);
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,3 @@
1
+ export * from "./claudeCode.js";
2
+ export * from "./preset.js";
3
+ export * from "./run.js";
@@ -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
+ };
@@ -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
@@ -6,3 +6,5 @@ export * from "./ntfy.js";
6
6
  export * from "./email.js";
7
7
  export * from "./gateway.js";
8
8
  export * from "./startup.js";
9
+ export * from "./init/index.js";
10
+ export * from "./hooks/index.js";
@@ -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";