@adtl/gateway 0.7.0 → 0.8.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 (80) hide show
  1. package/README.md +143 -11
  2. package/dist/audit.d.ts +3 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js.map +1 -1
  5. package/dist/cli.js +10 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +167 -713
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +64 -7
  10. package/dist/config.js.map +1 -1
  11. package/dist/elicitation.d.ts +7 -1
  12. package/dist/elicitation.d.ts.map +1 -1
  13. package/dist/elicitation.js +21 -14
  14. package/dist/elicitation.js.map +1 -1
  15. package/dist/gateway.d.ts +11 -1
  16. package/dist/gateway.d.ts.map +1 -1
  17. package/dist/gateway.js +13 -5
  18. package/dist/gateway.js.map +1 -1
  19. package/dist/hooks/agentLinks.d.ts +18 -0
  20. package/dist/hooks/agentLinks.d.ts.map +1 -0
  21. package/dist/hooks/agentLinks.js +48 -0
  22. package/dist/hooks/agentLinks.js.map +1 -0
  23. package/dist/hooks/claudeCode.d.ts +4 -0
  24. package/dist/hooks/claudeCode.d.ts.map +1 -1
  25. package/dist/hooks/claudeCode.js +35 -8
  26. package/dist/hooks/claudeCode.js.map +1 -1
  27. package/dist/hooks/index.d.ts +1 -0
  28. package/dist/hooks/index.d.ts.map +1 -1
  29. package/dist/hooks/index.js +1 -0
  30. package/dist/hooks/index.js.map +1 -1
  31. package/dist/hooks/run.d.ts.map +1 -1
  32. package/dist/hooks/run.js +5 -2
  33. package/dist/hooks/run.js.map +1 -1
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +3 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/init/suggest.d.ts +3 -1
  39. package/dist/init/suggest.d.ts.map +1 -1
  40. package/dist/init/suggest.js +8 -0
  41. package/dist/init/suggest.js.map +1 -1
  42. package/dist/invoker.d.ts +56 -6
  43. package/dist/invoker.d.ts.map +1 -1
  44. package/dist/invoker.js +121 -19
  45. package/dist/invoker.js.map +1 -1
  46. package/dist/openfga.d.ts +13 -0
  47. package/dist/openfga.d.ts.map +1 -0
  48. package/dist/openfga.js +101 -0
  49. package/dist/openfga.js.map +1 -0
  50. package/dist/startup.d.ts +10 -1
  51. package/dist/startup.d.ts.map +1 -1
  52. package/dist/startup.js +82 -11
  53. package/dist/startup.js.map +1 -1
  54. package/dist/totp.d.ts +42 -0
  55. package/dist/totp.d.ts.map +1 -0
  56. package/dist/totp.js +176 -0
  57. package/dist/totp.js.map +1 -0
  58. package/dist/totpCli.d.ts +11 -0
  59. package/dist/totpCli.d.ts.map +1 -0
  60. package/dist/totpCli.js +46 -0
  61. package/dist/totpCli.js.map +1 -0
  62. package/package.json +8 -7
  63. package/playground/server.mjs +13 -3
  64. package/src/audit.ts +3 -1
  65. package/src/cli.ts +10 -1
  66. package/src/config.ts +71 -7
  67. package/src/elicitation.ts +35 -17
  68. package/src/gateway.ts +18 -5
  69. package/src/hooks/agentLinks.ts +58 -0
  70. package/src/hooks/claudeCode.ts +40 -9
  71. package/src/hooks/index.ts +1 -0
  72. package/src/hooks/run.ts +5 -2
  73. package/src/index.ts +3 -0
  74. package/src/init/suggest.ts +10 -1
  75. package/src/invoker.ts +153 -21
  76. package/src/openfga.ts +101 -0
  77. package/src/startup.ts +79 -11
  78. package/src/totp.ts +191 -0
  79. package/src/totpCli.ts +52 -0
  80. package/src/types/qrcode-terminal.d.ts +4 -0
package/src/config.ts CHANGED
@@ -12,11 +12,16 @@
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.*"] }
18
22
  * readers: { members: ["*"], tools: ["github.read_*", "github.get_*"] }
19
23
  * cedar: { policies: ./adtl.cedar } # optional: attribute-based rules (see cedar.ts)
24
+ * openfga: { url: http://localhost:8080, storeId: "${FGA_STORE_ID}" } # optional: relationship rules (see openfga.ts)
20
25
  * approvals: # optional: make "ask" really ask
21
26
  * timeoutSeconds: 50
22
27
  * ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
@@ -27,7 +32,7 @@
27
32
  * from: adtl@example.com
28
33
  * to: you@example.com
29
34
  *
30
- * `${VAR}` in command, args, env, cwd, url, headers, approval settings and principal.id is
35
+ * `${VAR}` in command, args, env, cwd, url, headers, approval settings, openfga settings and principal.id is
31
36
  * replaced from the gateway's environment. A missing variable is a config error,
32
37
  * unless a default is given: `${VAR:-default}`.
33
38
  */
@@ -40,7 +45,7 @@ export const StdioUpstreamSchema = z
40
45
  .object({
41
46
  command: z.string().min(1),
42
47
  args: z.array(z.string()).default([]),
43
- env: z.record(z.string()).default({}),
48
+ env: z.record(z.string(), z.string()).default({}),
44
49
  cwd: z.string().optional(),
45
50
  })
46
51
  .strict();
@@ -48,7 +53,7 @@ export const StdioUpstreamSchema = z
48
53
  export const HttpUpstreamSchema = z
49
54
  .object({
50
55
  url: z.string().min(1),
51
- headers: z.record(z.string()).default({}),
56
+ headers: z.record(z.string(), z.string()).default({}),
52
57
  })
53
58
  .strict();
54
59
 
@@ -100,11 +105,16 @@ export const ApprovalsConfigSchema = z
100
105
  * default: a client that accepts elicitations by itself would approve calls.
101
106
  */
102
107
  elicitation: z.boolean().default(false),
108
+ /**
109
+ * Ask in the MCP client for a code from an authenticator app (`adtl-gateway
110
+ * totp setup`), instead of Allow or Deny. Wins over `elicitation`.
111
+ */
112
+ totp: z.boolean().default(false),
103
113
  /**
104
114
  * Each approver's own channel, by principal id. Policy rules with
105
115
  * `approvers: [role]` ask only these people, so the answer names who approved.
106
116
  */
107
- approvers: z.record(z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
117
+ approvers: z.record(z.string(), z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
108
118
  })
109
119
  .strict();
110
120
  export type ApprovalsConfig = z.infer<typeof ApprovalsConfigSchema>;
@@ -119,7 +129,7 @@ export const PrincipalConfigSchema = z
119
129
  .object({
120
130
  type: z.enum(["user", "service"]).default("user"),
121
131
  id: z.string().min(1),
122
- claims: z.record(z.unknown()).optional(),
132
+ claims: z.record(z.string(), z.unknown()).optional(),
123
133
  })
124
134
  .strict();
125
135
  export type PrincipalConfig = z.infer<typeof PrincipalConfigSchema>;
@@ -162,16 +172,66 @@ export const CedarConfigSchema = z
162
172
  .strict();
163
173
  export type CedarConfig = z.infer<typeof CedarConfigSchema>;
164
174
 
175
+ export const OpenFgaConfigSchema = z
176
+ .object({
177
+ url: z.string().min(1),
178
+ storeId: z.string().min(1),
179
+ modelId: z.string().min(1).optional(),
180
+ token: z.string().min(1).optional(),
181
+ /** Relation checked when a tool has no rule of its own. */
182
+ relation: z.string().min(1).default("can_use"),
183
+ timeoutMs: z.number().int().positive().max(60_000).default(2000),
184
+ /** Per-tool rules, keyed `server.tool`. `object` is `type:id`, and `{argument}` is taken from the call. */
185
+ tools: z
186
+ .record(
187
+ z.string(),
188
+ z
189
+ .object({
190
+ relation: z.string().min(1).optional(),
191
+ object: z.string().regex(/^[^:\s]+:.+$/, 'Use "type:id", e.g. "repo:{owner}/{repo}"').optional(),
192
+ })
193
+ .strict(),
194
+ )
195
+ .default({}),
196
+ })
197
+ .strict();
198
+ export type OpenFgaConfig = z.infer<typeof OpenFgaConfigSchema>;
199
+
200
+ const PhaseSchema = z.enum(["request", "response"]);
201
+
202
+ /** SEP-2624 InterceptorOverrides, set by the gateway owner for one interceptor. */
203
+ export const InterceptorOverridesSchema = z
204
+ .object({
205
+ failOpen: z.boolean().optional(),
206
+ priorityHint: z
207
+ .union([z.number().int(), z.object({ request: z.number().int().optional(), response: z.number().int().optional() }).strict()])
208
+ .optional(),
209
+ mode: z.enum(["active", "audit"]).optional(),
210
+ timeoutMs: z.number().int().positive().max(600_000).optional(),
211
+ hooks: z
212
+ .array(z.object({ events: z.array(z.string().min(1)).min(1), phase: PhaseSchema }).strict())
213
+ .min(1, "hooks: list at least one hook, or leave it out to keep the declared hooks")
214
+ .optional(),
215
+ })
216
+ .strict();
217
+
165
218
  export const GatewayConfigSchema = z
166
219
  .object({
167
220
  upstreams: z
168
- .record(UpstreamSchema)
221
+ .record(z.string(), UpstreamSchema)
169
222
  .refine((u) => Object.keys(u).length > 0, "At least one upstream server is required")
170
223
  .refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
171
224
  message: "Upstream ids may only contain letters, digits and '-' (they prefix tool names as <id>__<tool>)",
172
225
  }),
173
226
  /** How to start the interceptor server. Default: the bundled adtl-interceptors with this same config file. */
174
227
  interceptors: StdioUpstreamSchema.optional(),
228
+ /** More interceptor servers (stdio or Streamable HTTP) whose interceptors run beside ADTL's. */
229
+ interceptorServers: z
230
+ .record(z.string(), UpstreamSchema)
231
+ .refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
232
+ message: "Interceptor server ids may only contain letters, digits and '-'",
233
+ })
234
+ .default({}),
175
235
  /** Path of a JSONL audit log. Relative paths are resolved against the config file's directory. */
176
236
  audit: z.string().min(1).optional(),
177
237
  /**
@@ -183,15 +243,19 @@ export const GatewayConfigSchema = z
183
243
  approvals: ApprovalsConfigSchema.optional(),
184
244
  /** Timeout for each interceptor invocation, in milliseconds. */
185
245
  interceptorTimeoutMs: z.number().int().positive().default(5000),
246
+ /** Per-interceptor overrides of failOpen, priorityHint, mode, timeoutMs and hooks, by interceptor name. */
247
+ interceptorOverrides: z.record(z.string(), InterceptorOverridesSchema).default({}),
186
248
  /** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
187
249
  principal: PrincipalConfigSchema.optional(),
188
250
  /** Role-based authorization: when present, a call no role grants is denied. */
189
251
  roles: z
190
- .record(RoleSchema)
252
+ .record(z.string(), RoleSchema)
191
253
  .refine((r) => Object.keys(r).length > 0, "roles: needs at least one role (remove it to allow everyone)")
192
254
  .optional(),
193
255
  /** Cedar authorization. With roles as well, both must allow. */
194
256
  cedar: CedarConfigSchema.optional(),
257
+ /** OpenFGA authorization. With roles or cedar as well, all must allow. */
258
+ openfga: OpenFgaConfigSchema.optional(),
195
259
  })
196
260
  .strict();
197
261
  export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
@@ -9,10 +9,15 @@
9
9
  * when `gateway.approvals.elicitation` is on and the client declares the
10
10
  * elicitation capability, and never for rules with named approvers: the
11
11
  * client can't say who answered.
12
+ *
13
+ * With `gateway.approvals.totp`, the prompt asks for a code from the person's
14
+ * authenticator app instead of Allow or Deny, so a client that accepts
15
+ * prompts by itself can't approve anything. Three wrong codes count as Deny.
12
16
  */
13
17
  import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
18
  import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js";
15
19
  import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
20
+ import type { TotpVerifier } from "./totp.js";
16
21
 
17
22
  interface Listening {
18
23
  answer: AnswerFn;
@@ -29,11 +34,22 @@ function message(request: ApprovalRequest): string {
29
34
  return `ADTL: allow ${request.server}.${request.tool}?\n\n${request.reason}\n\nRule: ${rule}. Allow runs this one call; Deny or no answer stops it.`;
30
35
  }
31
36
 
37
+ function codeMessage(request: ApprovalRequest, wrong: number): string {
38
+ const rule = request.matched.join(", ") || "default";
39
+ const again = wrong > 0 ? "That code was not right. " : "";
40
+ return `ADTL: allow ${request.server}.${request.tool}?\n\n${request.reason}\n\nRule: ${rule}. ${again}Enter the code from your authenticator app to run this one call; decline or no answer stops it.`;
41
+ }
42
+
43
+ const MAX_CODE_ATTEMPTS = 3;
44
+
32
45
  export class ElicitationChannel implements Notifier {
33
46
  readonly name = "elicitation";
34
47
  private readonly listening = new Map<string, Listening>();
35
48
 
36
- constructor(private readonly server: Server) {}
49
+ constructor(
50
+ private readonly server: Server,
51
+ private readonly totp?: TotpVerifier,
52
+ ) {}
37
53
 
38
54
  async listen(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void> {
39
55
  this.listening.set(request.id, { answer, signal });
@@ -46,22 +62,24 @@ export class ElicitationChannel implements Notifier {
46
62
  const l = this.listening.get(event.request.id);
47
63
  if (!l) throw new Error("not listening for this request");
48
64
  // A plain request, not Server.elicitInput(): clients that declare only `elicitation: {}` support forms too.
49
- const result = await this.server.request(
50
- {
51
- method: "elicitation/create",
52
- params: {
53
- message: message(event.request),
54
- requestedSchema: {
55
- type: "object",
56
- properties: {
57
- decision: { type: "string", title: "Decision", enum: ["allow", "deny"], description: "allow runs this one call; deny stops it" },
58
- },
59
- required: ["decision"],
60
- },
61
- },
62
- },
63
- ElicitResultSchema,
64
- { signal: l.signal, timeout: Math.max(1, event.request.expiresAt.getTime() - Date.now()) },
65
+ const ask = (text: string, properties: Record<string, unknown>, required: string) =>
66
+ this.server.request(
67
+ { method: "elicitation/create", params: { message: text, requestedSchema: { type: "object", properties, required: [required] } } } as never,
68
+ ElicitResultSchema,
69
+ { signal: l.signal, timeout: Math.max(1, event.request.expiresAt.getTime() - Date.now()) },
70
+ );
71
+ if (this.totp) {
72
+ for (let wrong = 0; wrong < MAX_CODE_ATTEMPTS; wrong++) {
73
+ const r = await ask(codeMessage(event.request, wrong), { code: { type: "string", title: "Code", description: "6 digits from your authenticator app" } }, "code");
74
+ if (r.action !== "accept") break;
75
+ if (this.totp.verify(String(r.content?.code ?? ""))) return void l.answer(event.tokens.allow);
76
+ }
77
+ return void l.answer(event.tokens.deny);
78
+ }
79
+ const result = await ask(
80
+ message(event.request),
81
+ { decision: { type: "string", title: "Decision", enum: ["allow", "deny"], description: "allow runs this one call; deny stops it" } },
82
+ "decision",
65
83
  );
66
84
  const allow = result.action === "accept" && result.content?.decision === "allow";
67
85
  l.answer(allow ? event.tokens.allow : event.tokens.deny);
package/src/gateway.ts CHANGED
@@ -57,12 +57,13 @@ import type { InvocationContext } from "@adtl/interceptors";
57
57
  import { callBindingHash } from "@adtl/labels";
58
58
  import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
59
59
  import { ElicitationChannel, supportsElicitation } from "./elicitation.js";
60
+ import type { TotpVerifier } from "./totp.js";
60
61
  import type { AuditEntry, AuditSink } from "./audit.js";
61
62
  import { authorize, type AuthorizationRequest, type Authorizer, type Denial, type Principal } from "./authorizer.js";
62
63
  import { TOOL_SEPARATOR } from "./config.js";
63
64
  import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
64
65
 
65
- export const GATEWAY_VERSION = "0.7.0";
66
+ export const GATEWAY_VERSION = "0.8.1";
66
67
 
67
68
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
68
69
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -115,6 +116,10 @@ export interface AdtlGatewayOptions {
115
116
  approverRoles?: Map<string, string[]>;
116
117
  /** Also ask for approval in the MCP client, when it supports elicitation. */
117
118
  elicitApprovals?: boolean;
119
+ /** Ask for a code from an authenticator app in the MCP client, instead of Allow or Deny. */
120
+ totp?: TotpVerifier;
121
+ /** The (sub-)agent this gateway serves, recorded on every audit entry. */
122
+ agent?: { id: string; type?: string };
118
123
  }
119
124
 
120
125
  /**
@@ -174,8 +179,10 @@ export class AdtlGateway {
174
179
  private readonly approvals: ApprovalBroker | undefined;
175
180
  private readonly authorizers: Authorizer[];
176
181
  private readonly principal: Principal;
182
+ private readonly agent: { agent: { id: string; type?: string } } | Record<string, never>;
177
183
  private readonly approverRoles: Map<string, string[]>;
178
184
  private readonly elicitApprovals: boolean;
185
+ private readonly totp: TotpVerifier | undefined;
179
186
  /** The server the agent is connected to, for elicitation. */
180
187
  private server: Server | undefined;
181
188
  private routes = new Map<string, Route>();
@@ -193,8 +200,10 @@ export class AdtlGateway {
193
200
  this.approvals = opts.approvals;
194
201
  this.authorizers = opts.authorizer === undefined ? [] : [opts.authorizer].flat();
195
202
  this.principal = opts.principal ?? { type: "anonymous" };
203
+ this.agent = opts.agent ? { agent: opts.agent } : {};
196
204
  this.approverRoles = opts.approverRoles ?? new Map();
197
205
  this.elicitApprovals = opts.elicitApprovals === true;
206
+ this.totp = opts.totp;
198
207
  }
199
208
 
200
209
  /** Ask each authorizer in turn. Returns the first denial; in audit mode the caller logs it and carries on. */
@@ -450,6 +459,7 @@ export class AdtlGateway {
450
459
  this.audit?.write({
451
460
  ts: context.timestamp!,
452
461
  sessionId: this.sessionId,
462
+ ...this.agent,
453
463
  event,
454
464
  server: upstream,
455
465
  ...target,
@@ -501,7 +511,7 @@ export class AdtlGateway {
501
511
  }
502
512
  const label = infoOf(res, "adtl.label");
503
513
  finish({ outcome: "forwarded", ...audit, label: label.label, ...(label.ledger ? { ledger: label.ledger } : {}),
504
- ...(label.records ? { records: label.records } : {}), sessionLabel: label.sessionLabel, warnings });
514
+ ...(label.records ? { records: label.records } : {}), ...(label.recordIds ? { recordIds: label.recordIds } : {}), sessionLabel: label.sessionLabel, warnings });
505
515
  return (res.payload as { result: R }).result;
506
516
  }
507
517
 
@@ -525,7 +535,7 @@ export class AdtlGateway {
525
535
  server: route.upstream,
526
536
  ...(route.annotations ? { toolAnnotations: route.annotations } : {}),
527
537
  };
528
- const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
538
+ const base = { sessionId: this.sessionId, ...this.agent, event: "tools/call" as const, server: route.upstream, tool: route.tool };
529
539
  let denied: Denial | undefined;
530
540
  const ran: InterceptorRun[] = [];
531
541
  const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
@@ -568,7 +578,7 @@ export class AdtlGateway {
568
578
  const lists = Array.isArray(policy.approvers) ? (policy.approvers as string[][]) : undefined;
569
579
  const approvers = lists ? eligibleApprovers(lists, this.approverRoles) : undefined;
570
580
  // The client's own prompt, if enabled and supported; never for named approvers (it can't say who answered).
571
- const elicit = this.elicitApprovals && !approvers && this.server && supportsElicitation(this.server) ? [new ElicitationChannel(this.server)] : [];
581
+ const elicit = (this.elicitApprovals || this.totp) && !approvers && this.server && supportsElicitation(this.server) ? [new ElicitationChannel(this.server, this.totp)] : [];
572
582
  if (b.decision === "ask" && this.approvals && (this.approvals.canApprove || elicit.length > 0) && typeof hash === "string") {
573
583
  this.log.info(`ask ${name}: waiting for approval${approvers ? ` from ${approvers.join(", ") || "nobody"}` : ""} (${b.reason})`);
574
584
  approval = await this.waitForApproval(opts, () =>
@@ -630,7 +640,9 @@ export class AdtlGateway {
630
640
  // 2. Forward to the upstream server.
631
641
  let result: CallToolResult;
632
642
  try {
633
- result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments })) as CallToolResult;
643
+ // A mutator may add request _meta (e.g. fides-gateway labels); pass it on.
644
+ const meta = (forwarded as { _meta?: Record<string, unknown> })._meta;
645
+ result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments, ...(meta ? { _meta: meta } : {}) })) as CallToolResult;
634
646
  } catch (err) {
635
647
  const reason = (err as Error).message;
636
648
  finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), ...approved, reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
@@ -663,6 +675,7 @@ export class AdtlGateway {
663
675
  label: label.label,
664
676
  ...(label.ledger ? { ledger: label.ledger } : {}),
665
677
  ...(label.records ? { records: label.records } : {}),
678
+ ...(label.recordIds ? { recordIds: label.recordIds } : {}),
666
679
  sessionLabel: label.sessionLabel,
667
680
  warnings: [...req.warnings, ...res.warnings],
668
681
  });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which session a Claude Code sub-agent belongs to.
3
+ *
4
+ * Claude Code's SubagentStart hook reports the parent's `session_id` with the
5
+ * new sub-agent's `agent_id`. Recording that link lets the sub-agent's later
6
+ * hook calls use the parent's label even if they ever report a session id of
7
+ * their own: the sub-agent starts from what its parent has seen, and what it
8
+ * reads taints the parent. Links live beside the session files, which the
9
+ * hooks already protect from the agent.
10
+ */
11
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { createHash } from "node:crypto";
13
+ import { join } from "node:path";
14
+
15
+ export interface AgentLinks {
16
+ /** The session `agentId` was started in, if known. */
17
+ parentOf(agentId: string): string | undefined;
18
+ link(agentId: string, sessionId: string): void;
19
+ }
20
+
21
+ const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
22
+
23
+ export class FileAgentLinks implements AgentLinks {
24
+ private readonly dir: string;
25
+
26
+ constructor(sessionsDir: string) {
27
+ this.dir = join(sessionsDir, "agents");
28
+ }
29
+
30
+ private pathOf(agentId: string): string {
31
+ const name = SAFE_ID.test(agentId) ? agentId : `h-${createHash("sha256").update(agentId).digest("hex")}`;
32
+ return join(this.dir, `${name}.parent`);
33
+ }
34
+
35
+ parentOf(agentId: string): string | undefined {
36
+ try {
37
+ return readFileSync(this.pathOf(agentId), "utf8").trim() || undefined;
38
+ } catch (err) {
39
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
40
+ throw err;
41
+ }
42
+ }
43
+
44
+ link(agentId: string, sessionId: string): void {
45
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
46
+ writeFileSync(this.pathOf(agentId), sessionId, { encoding: "utf8", mode: 0o600 });
47
+ }
48
+ }
49
+
50
+ export class InMemoryAgentLinks implements AgentLinks {
51
+ private readonly links = new Map<string, string>();
52
+ parentOf(agentId: string): string | undefined {
53
+ return this.links.get(agentId);
54
+ }
55
+ link(agentId: string, sessionId: string): void {
56
+ this.links.set(agentId, sessionId);
57
+ }
58
+ }
@@ -8,6 +8,8 @@
8
8
  * allow → no answer, so Claude Code's own permissions apply
9
9
  * PostToolUse labels the tool's output and adds it to the session label
10
10
  * PostToolUseFailure same: a failed tool's error output still reaches the model
11
+ * SubagentStart links the new sub-agent to its parent's session, so its
12
+ * calls start from the parent's label and taint it
11
13
  *
12
14
  * Built-in tools are labeled as the server `claude-code` (see preset.ts); MCP
13
15
  * tools `mcp__<server>__<tool>` as `<server>`. Tools of the gateway's own MCP
@@ -24,7 +26,7 @@
24
26
  *
25
27
  * Errors fail closed: a PreToolUse hook that can't decide denies the call.
26
28
  */
27
- import { basename, isAbsolute, relative, resolve, sep } from "node:path";
29
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
28
30
  import {
29
31
  callHash,
30
32
  evaluatePolicy,
@@ -33,11 +35,14 @@ 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 { TOTP_KEYCHAIN_SERVICE } from "../totp.js";
45
+ import type { AgentLinks } from "./agentLinks.js";
41
46
  import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
42
47
 
43
48
  export interface HookInput {
@@ -64,8 +69,10 @@ export interface HookOptions {
64
69
  audit?: AuditSink;
65
70
  /** Label ledger shared with the gateway. */
66
71
  ledger?: Ledger;
72
+ /** Sub-agent → parent session links, from SubagentStart. */
73
+ agents?: AgentLinks;
67
74
  /** Files the agent must not change: the ADTL config, the session directory and the ledger. */
68
- protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
75
+ protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string; totpPath?: string };
69
76
  }
70
77
 
71
78
  /** What the hook process prints and exits with. */
@@ -138,6 +145,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
138
145
  if (protect.signingKeyPath && abs === resolve(protect.signingKeyPath)) {
139
146
  return { decision: "block", reason: "The agent may not touch the ADTL signing key" };
140
147
  }
148
+ if (protect.totpPath && abs.startsWith(dirname(resolve(protect.totpPath)) + sep + "totp-")) {
149
+ return { decision: "block", reason: "The agent may not touch the ADTL TOTP secret" };
150
+ }
141
151
  }
142
152
  const command = typeof args.command === "string" ? args.command : undefined;
143
153
  if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
@@ -155,6 +165,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
155
165
  if (protect.signingKeyPath && command.includes(basename(protect.signingKeyPath))) {
156
166
  return { decision: "ask", reason: "This command touches the ADTL signing key" };
157
167
  }
168
+ if ([TOTP_KEYCHAIN_SERVICE, "totp-secret", "totp-last-step", protect.totpPath].some((m) => m && command.includes(m))) {
169
+ return { decision: "ask", reason: "This command touches the ADTL TOTP secret" };
170
+ }
158
171
  }
159
172
  return undefined;
160
173
  }
@@ -172,19 +185,37 @@ export function addedLines(output: unknown): string[] {
172
185
  return out;
173
186
  }
174
187
 
188
+ /**
189
+ * The session whose label a hook call reads and adds to: a linked sub-agent's
190
+ * parent session, else the call's own. When the two differ, the sub-agent's own
191
+ * label is read too, so nothing it saw is lost.
192
+ */
193
+ function sessionFor(input: HookInput & { session_id: string }, opts: HookOptions): { id: string; get(): Label } {
194
+ const parent = typeof input.agent_id === "string" ? opts.agents?.parentOf(input.agent_id) : undefined;
195
+ if (!parent || parent === input.session_id) return { id: input.session_id, get: () => opts.sessions.get(input.session_id) };
196
+ return { id: parent, get: () => join(opts.sessions.get(parent), opts.sessions.get(input.session_id)) };
197
+ }
198
+
175
199
  export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
176
200
  const event = input.hook_event_name;
201
+ if (event === "SubagentStart") {
202
+ if (typeof input.agent_id === "string" && typeof input.session_id === "string" && input.session_id !== "") {
203
+ opts.agents?.link(input.agent_id, input.session_id);
204
+ }
205
+ return NOTHING;
206
+ }
177
207
  if (event !== "PreToolUse" && event !== "PostToolUse" && event !== "PostToolUseFailure") return NOTHING;
178
208
  if (typeof input.tool_name !== "string" || typeof input.session_id !== "string" || input.session_id === "") {
179
209
  return failure(event, "hook input has no tool_name or session_id");
180
210
  }
211
+ const session = sessionFor(input as HookInput & { session_id: string }, opts);
181
212
  const route = routeTool(input.tool_name, opts.gatewayServer);
182
213
  if (!route) return NOTHING;
183
214
  const config = withClaudeCodePreset(opts.config);
184
215
  const ts = new Date().toISOString();
185
216
  const agent =
186
217
  typeof input.agent_id === "string" ? { agent: { id: input.agent_id, ...(typeof input.agent_type === "string" ? { type: input.agent_type } : {}) } } : {};
187
- const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
218
+ const base = { ts, sessionId: session.id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
188
219
 
189
220
  if (event !== "PreToolUse") {
190
221
  const output = input.tool_response ?? input.error ?? null;
@@ -197,20 +228,20 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
197
228
  ledger = { chunks: match.chunks, writers: match.writers };
198
229
  }
199
230
  // What the tool was given came from the session as it was before this output.
200
- const before = opts.sessions.get(input.session_id);
231
+ const before = session.get();
201
232
  const written = [...valueHashes(input.tool_input ?? {}), ...addedLines(output).flatMap(chunkHashes)];
202
- opts.ledger.record(written, before, input.session_id, route);
233
+ opts.ledger.record(written, before, session.id, route);
203
234
  }
204
- const sessionLabel = opts.sessions.observe(input.session_id, label);
235
+ const sessionLabel = join(session.get(), opts.sessions.observe(session.id, label));
205
236
  opts.audit?.write({ ...base, outcome: "forwarded", label, ...(ledger ? { ledger } : {}), sessionLabel, durationMs: 0 });
206
237
  return NOTHING;
207
238
  }
208
239
 
209
240
  const hash = callHash(route.server, route.tool, input.tool_input ?? {});
210
- const session = opts.sessions.get(input.session_id);
241
+ const current = session.get();
211
242
  const guard = protectedTarget(input, route, opts.protect);
212
243
  const policy: PolicyDecision = evaluatePolicy(config.policy, {
213
- session,
244
+ session: current,
214
245
  ...route,
215
246
  egress: toolEgress(config, route.server, route.tool),
216
247
  });
@@ -229,7 +260,7 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
229
260
  reason: d.reason,
230
261
  matched: d.matched,
231
262
  callHash: hash,
232
- sessionLabel: session,
263
+ sessionLabel: current,
233
264
  durationMs: 0,
234
265
  };
235
266
  opts.audit?.write(entry);
@@ -1,3 +1,4 @@
1
1
  export * from "./claudeCode.js";
2
2
  export * from "./preset.js";
3
3
  export * from "./run.js";
4
+ export * from "./agentLinks.js";
package/src/hooks/run.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `adtl-gateway hook --config adtl.yaml`: the command Claude Code runs for
3
- * PreToolUse, PostToolUse and PostToolUseFailure. Reads the hook event from
3
+ * PreToolUse, PostToolUse, PostToolUseFailure and SubagentStart. Reads the hook event from
4
4
  * stdin and answers on stdout (JSON) or stderr (with exit code 2).
5
5
  *
6
6
  * The config needs no `gateway:` section; if there is one, its `audit` log and
@@ -13,6 +13,8 @@ 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 { totpSecretPath } from "../totp.js";
17
+ import { FileAgentLinks } from "./agentLinks.js";
16
18
  import { failure, handleHook, type HookInput, type HookOutcome } from "./claudeCode.js";
17
19
 
18
20
  export interface RunHookOptions {
@@ -46,11 +48,12 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
46
48
  return handleHook(input, {
47
49
  config,
48
50
  sessions: new FileSessionStore(sessionsDir),
51
+ agents: new FileAgentLinks(sessionsDir),
49
52
  ...(ledger ? { ledger } : {}),
50
53
  ...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
51
54
  mode: gw.mode === "audit" ? "audit" : "active",
52
55
  ...(auditPath ? { audit: new JsonlAuditLog(auditPath) } : {}),
53
- protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env) },
56
+ protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env), totpPath: totpSecretPath(env) },
54
57
  });
55
58
  } catch (err) {
56
59
  return failure(input.hook_event_name, (err as Error).message);
package/src/index.ts CHANGED
@@ -13,6 +13,9 @@ export * from "./ledgerCli.js";
13
13
  export * from "./auditCli.js";
14
14
  export * from "./roles.js";
15
15
  export * from "./cedar.js";
16
+ export * from "./openfga.js";
17
+ export * from "./totp.js";
18
+ export * from "./totpCli.js";
16
19
  export * from "./elicitation.js";
17
20
  export * from "./receipt.js";
18
21
  export * from "./receiptCli.js";
@@ -4,13 +4,15 @@
4
4
  * Without a preset, rules come from the MCP tool annotations. Those are the
5
5
  * server's own claims, so they only decide egress; integrity is always
6
6
  * untrusted and confidentiality private until the owner says otherwise.
7
+ * The draft action-metadata extension's `inputMetadata.destination`, when a
8
+ * tool declares one, decides egress ahead of the legacy hints.
7
9
  * Missing hints take the MCP spec defaults (readOnlyHint false, openWorldHint true).
8
10
  *
9
11
  * Merging never changes an entry that is already in the file: the owner's
10
12
  * choices win, and `init` can be re-run to pick up new tools.
11
13
  */
12
14
  import type { Tool } from "@modelcontextprotocol/sdk/types.js";
13
- import type { ToolRule } from "@adtl/labels";
15
+ import { destinationEgress, readActionMetadata, type ToolRule } from "@adtl/labels";
14
16
  import { isMap, isScalar, type Document, type YAMLMap } from "yaml";
15
17
  import { choosePreset, PRESETS, type Preset } from "./presets.js";
16
18
 
@@ -40,6 +42,13 @@ const UNKNOWN_DEFAULTS: ToolRule = { integrity: "untrusted", confidentiality: "p
40
42
 
41
43
  /** A rule from the tool's MCP annotations alone. */
42
44
  export function ruleFromHints(annotations: Tool["annotations"]): Omit<ToolSuggestion, "name"> {
45
+ const meta = readActionMetadata(annotations);
46
+ const declared = destinationEgress(meta.destination);
47
+ if (declared) {
48
+ const extra = [meta.outcome, meta.requiresReview ? "requires review" : undefined].filter(Boolean).join(", ");
49
+ const why = `action-metadata: destination ${meta.destination}${extra ? `, ${extra}` : ""} (review)`;
50
+ return { rule: { integrity: "untrusted", confidentiality: "private", egress: declared }, why };
51
+ }
43
52
  const readOnly = annotations?.readOnlyHint === true;
44
53
  const openWorld = annotations?.openWorldHint !== false;
45
54
  const egress = readOnly ? (openWorld ? "external" : "none") : openWorld ? "public" : "internal";