@adtl/gateway 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +146 -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/cedar.d.ts +27 -0
  6. package/dist/cedar.d.ts.map +1 -0
  7. package/dist/cedar.js +134 -0
  8. package/dist/cedar.js.map +1 -0
  9. package/dist/config.d.ts +235 -0
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +54 -0
  12. package/dist/config.js.map +1 -1
  13. package/dist/gateway.d.ts +11 -5
  14. package/dist/gateway.d.ts.map +1 -1
  15. package/dist/gateway.js +23 -12
  16. package/dist/gateway.js.map +1 -1
  17. package/dist/hooks/agentLinks.d.ts +18 -0
  18. package/dist/hooks/agentLinks.d.ts.map +1 -0
  19. package/dist/hooks/agentLinks.js +48 -0
  20. package/dist/hooks/agentLinks.js.map +1 -0
  21. package/dist/hooks/claudeCode.d.ts +3 -0
  22. package/dist/hooks/claudeCode.d.ts.map +1 -1
  23. package/dist/hooks/claudeCode.js +27 -7
  24. package/dist/hooks/claudeCode.js.map +1 -1
  25. package/dist/hooks/index.d.ts +1 -0
  26. package/dist/hooks/index.d.ts.map +1 -1
  27. package/dist/hooks/index.js +1 -0
  28. package/dist/hooks/index.js.map +1 -1
  29. package/dist/hooks/run.d.ts.map +1 -1
  30. package/dist/hooks/run.js +3 -1
  31. package/dist/hooks/run.js.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +1 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/init/suggest.d.ts +3 -1
  37. package/dist/init/suggest.d.ts.map +1 -1
  38. package/dist/init/suggest.js +8 -0
  39. package/dist/init/suggest.js.map +1 -1
  40. package/dist/invoker.d.ts +54 -6
  41. package/dist/invoker.d.ts.map +1 -1
  42. package/dist/invoker.js +115 -19
  43. package/dist/invoker.js.map +1 -1
  44. package/dist/startup.d.ts +15 -1
  45. package/dist/startup.d.ts.map +1 -1
  46. package/dist/startup.js +80 -7
  47. package/dist/startup.js.map +1 -1
  48. package/package.json +15 -4
  49. package/src/audit.ts +3 -1
  50. package/src/cedar.ts +140 -0
  51. package/src/config.ts +57 -0
  52. package/src/gateway.ts +26 -14
  53. package/src/hooks/agentLinks.ts +58 -0
  54. package/src/hooks/claudeCode.ts +31 -7
  55. package/src/hooks/index.ts +1 -0
  56. package/src/hooks/run.ts +3 -1
  57. package/src/index.ts +1 -0
  58. package/src/init/suggest.ts +10 -1
  59. package/src/invoker.ts +146 -21
  60. package/src/startup.ts +75 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adtl/gateway",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Experimental: MCP gateway that labels every tool result and blocks risky tool calls using ADTL trust labels",
5
5
  "keywords": [
6
6
  "mcp",
@@ -47,18 +47,29 @@
47
47
  "url": "https://github.com/probita-ai/adtl/issues"
48
48
  },
49
49
  "dependencies": {
50
- "@adtl/interceptors": "0.6.0",
51
- "@adtl/labels": "0.6.0",
52
- "@adtl/ledger": "0.6.0",
50
+ "@adtl/interceptors": "0.8.0",
51
+ "@adtl/labels": "0.8.0",
52
+ "@adtl/ledger": "0.8.0",
53
53
  "@modelcontextprotocol/sdk": "1.31.0",
54
54
  "nodemailer": "10.0.12",
55
55
  "yaml": "2.9.1",
56
56
  "zod": "3.25.76"
57
57
  },
58
+ "peerDependencies": {
59
+ "@cedar-policy/cedar-wasm": "^4.13.0"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "@cedar-policy/cedar-wasm": {
63
+ "optional": true
64
+ }
65
+ },
58
66
  "engines": {
59
67
  "node": ">=22.13"
60
68
  },
61
69
  "publishConfig": {
62
70
  "access": "public"
71
+ },
72
+ "devDependencies": {
73
+ "@cedar-policy/cedar-wasm": "4.13.0"
63
74
  }
64
75
  }
package/src/audit.ts CHANGED
@@ -33,7 +33,7 @@ export interface AuditEntry {
33
33
  outcome: "forwarded" | "blocked" | "asked" | "upstream_error";
34
34
  /** Present when the entry was written by the Claude Code hook rather than the gateway. */
35
35
  via?: "claude-code-hook";
36
- /** Present when a Claude Code subagent made the call: its id and type (e.g. "Explore"). */
36
+ /** Present when a sub-agent made the call: its id and type (e.g. "Explore"), from Claude Code or `ADTL_AGENT_ID`. */
37
37
  agent?: { id: string; type?: string };
38
38
  /** The policy's decision. In audit mode a call can be forwarded with decision "ask" or "block". */
39
39
  decision?: string;
@@ -51,6 +51,8 @@ export interface AuditEntry {
51
51
  ledger?: unknown;
52
52
  /** Present when the result carried record label stamps: how many, and which sessions wrote them. */
53
53
  records?: unknown;
54
+ /** Present when the ledger knew record ids in the result (tool rule `recordIds`): how many, and which sessions wrote them. */
55
+ recordIds?: unknown;
54
56
  sessionLabel?: unknown;
55
57
  /** Every interceptor invocation for this call, with its resolved mode and failOpen and where each came from (SEP-2624). */
56
58
  interceptors?: InterceptorRun[];
package/src/cedar.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Attribute-based authorization with Cedar (`gateway.cedar`):
3
+ *
4
+ * cedar:
5
+ * policies: ./adtl.cedar # Cedar policies, relative to adtl.yaml
6
+ * entities: ./entities.json # optional: Cedar entities (groups, attributes)
7
+ * timeZone: Europe/Berlin # optional: for context.time (default: the gateway's)
8
+ *
9
+ * Every call becomes a Cedar request:
10
+ *
11
+ * principal User::"alice", Service::"ci", or Anonymous::"anonymous" without an id
12
+ * action Action::"tools/call", Action::"resources/read" or Action::"prompts/get"
13
+ * resource Tool::"github.add_issue_comment", Prompt::"github.triage" or Resource::"<uri>"
14
+ * context { server, principalId, listing, time: { hour, minute, weekday }, arguments }
15
+ *
16
+ * Cedar denies unless a policy permits, and a `forbid` wins over any permit.
17
+ * Tool lists are filtered with `listing: true` and no `arguments`, so a policy
18
+ * on arguments should start with `context.listing ||`.
19
+ *
20
+ * Needs the optional dependency @cedar-policy/cedar-wasm, loaded only when
21
+ * `gateway.cedar` is set.
22
+ */
23
+ import { readFileSync } from "node:fs";
24
+ import type { AuthorizationDecision, AuthorizationRequest, Authorizer } from "./authorizer.js";
25
+ import type { CedarConfig } from "./config.js";
26
+
27
+ type CedarValue = boolean | number | string | CedarValue[] | { [key: string]: CedarValue };
28
+
29
+ /** The parts of @cedar-policy/cedar-wasm used here. */
30
+ interface CedarWasm {
31
+ isAuthorized(call: unknown): CedarAnswer;
32
+ checkParsePolicySet(policies: unknown): { type: "success" } | { type: "failure"; errors: { message: string }[] };
33
+ checkParseEntities(call: unknown): { type: "success" } | { type: "failure"; errors: { message: string }[] };
34
+ }
35
+ type CedarAnswer =
36
+ | { type: "failure"; errors: { message: string }[] }
37
+ | { type: "success"; response: { decision: "allow" | "deny"; diagnostics: { reason: string[]; errors: { policyId: string; error: { message: string } }[] } } };
38
+
39
+ export class CedarAuthorizer implements Authorizer {
40
+ readonly name = "cedar";
41
+
42
+ private constructor(
43
+ private readonly cedar: CedarWasm,
44
+ private readonly policies: string,
45
+ private readonly entities: unknown[],
46
+ private readonly timeZone: string | undefined,
47
+ ) {}
48
+
49
+ /** Load the Cedar engine and the policy files. Throws on a missing package or files that don't parse. */
50
+ static async load(cfg: CedarConfig, resolvePath: (p: string) => string): Promise<CedarAuthorizer> {
51
+ let cedar: CedarWasm;
52
+ try {
53
+ cedar = (await import("@cedar-policy/cedar-wasm/nodejs")) as unknown as CedarWasm;
54
+ } catch {
55
+ throw new Error("gateway.cedar needs the Cedar engine: npm install @cedar-policy/cedar-wasm");
56
+ }
57
+ const policies = readFileSync(resolvePath(cfg.policies), "utf8");
58
+ const parsed = cedar.checkParsePolicySet({ staticPolicies: policies });
59
+ if (parsed.type === "failure") throw new Error(`gateway.cedar.policies: ${parsed.errors.map((e) => e.message).join("; ")}`);
60
+
61
+ let entities: unknown[] = [];
62
+ if (cfg.entities) {
63
+ const json: unknown = JSON.parse(readFileSync(resolvePath(cfg.entities), "utf8"));
64
+ if (!Array.isArray(json)) throw new Error("gateway.cedar.entities: expected a JSON array of Cedar entities");
65
+ const ok = cedar.checkParseEntities({ entities: json });
66
+ if (ok.type === "failure") throw new Error(`gateway.cedar.entities: ${ok.errors.map((e) => e.message).join("; ")}`);
67
+ entities = json;
68
+ }
69
+ return new CedarAuthorizer(cedar, policies, entities, cfg.timeZone);
70
+ }
71
+
72
+ check(req: AuthorizationRequest): AuthorizationDecision {
73
+ const { principal } = req;
74
+ const [type, id] =
75
+ req.event === "tools/call"
76
+ ? (["Tool", `${req.server}.${req.tool}`] as const)
77
+ : req.event === "prompts/get"
78
+ ? (["Prompt", `${req.server}.${req.prompt}`] as const)
79
+ : (["Resource", req.resource ?? ""] as const);
80
+ const context: Record<string, CedarValue> = {
81
+ server: req.server,
82
+ listing: req.arguments === undefined,
83
+ time: clock(new Date(), this.timeZone),
84
+ ...(principal.id !== undefined ? { principalId: principal.id } : {}),
85
+ ...(req.arguments !== undefined ? { arguments: toCedar(req.arguments) ?? {} } : {}),
86
+ };
87
+ const answer = this.cedar.isAuthorized({
88
+ principal:
89
+ principal.id === undefined
90
+ ? { type: "Anonymous", id: "anonymous" }
91
+ : { type: principal.type === "service" ? "Service" : "User", id: principal.id },
92
+ action: { type: "Action", id: req.event },
93
+ resource: { type, id },
94
+ context,
95
+ policies: { staticPolicies: this.policies },
96
+ entities: this.entities,
97
+ });
98
+ if (answer.type === "failure") throw new Error(answer.errors.map((e) => e.message).join("; "));
99
+ const { decision, diagnostics } = answer.response;
100
+ if (decision === "allow") return { allow: true };
101
+ const who = principal.id ?? "an anonymous caller";
102
+ const what = `the ${type.toLowerCase()} ${id}`;
103
+ return {
104
+ allow: false,
105
+ reason: diagnostics.reason.length
106
+ ? `Cedar policy ${diagnostics.reason.join(", ")} forbids ${who} to use ${what}`
107
+ : `No Cedar policy permits ${who} to use ${what}`,
108
+ };
109
+ }
110
+ }
111
+
112
+ /** Hour, minute and weekday ("Mon") in `timeZone`, for working-hours rules. */
113
+ export function clock(now: Date, timeZone: string | undefined): Record<string, CedarValue> {
114
+ const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", hour: "numeric", minute: "numeric", weekday: "short" }).formatToParts(now);
115
+ const part = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
116
+ return { hour: Number(part("hour")), minute: Number(part("minute")), weekday: part("weekday") };
117
+ }
118
+
119
+ /**
120
+ * Tool arguments as Cedar context values. Cedar has no null or fractions, so
121
+ * nulls are left out and fractions become strings. An object with `__entity`
122
+ * or `__extn` would be read as an entity or extension value, so the agent
123
+ * could name any entity; it is passed as a JSON string instead.
124
+ */
125
+ export function toCedar(v: unknown): CedarValue | undefined {
126
+ if (v === null || v === undefined) return undefined;
127
+ if (typeof v === "boolean" || typeof v === "string") return v;
128
+ if (typeof v === "number") return Number.isSafeInteger(v) ? v : String(v);
129
+ if (Array.isArray(v)) return v.map(toCedar).filter((x): x is CedarValue => x !== undefined);
130
+ if (typeof v === "object") {
131
+ if ("__entity" in v || "__extn" in v) return JSON.stringify(v);
132
+ const out: Record<string, CedarValue> = {};
133
+ for (const [k, x] of Object.entries(v)) {
134
+ const c = toCedar(x);
135
+ if (c !== undefined) out[k] = c;
136
+ }
137
+ return out;
138
+ }
139
+ return String(v);
140
+ }
package/src/config.ts CHANGED
@@ -12,10 +12,15 @@
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_*"] }
23
+ * cedar: { policies: ./adtl.cedar } # optional: attribute-based rules (see cedar.ts)
19
24
  * approvals: # optional: make "ask" really ask
20
25
  * timeoutSeconds: 50
21
26
  * ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
@@ -138,6 +143,47 @@ export const RoleSchema = z
138
143
  });
139
144
  export type RoleConfig = z.infer<typeof RoleSchema>;
140
145
 
146
+ export const CedarConfigSchema = z
147
+ .object({
148
+ /** Cedar policy file. Relative paths are resolved against the config file's directory. */
149
+ policies: z.string().min(1),
150
+ /** Optional Cedar entities JSON file: groups (`parents`) and attributes. */
151
+ entities: z.string().min(1).optional(),
152
+ /** IANA time zone for `context.time`, e.g. "Europe/Berlin". Default: the gateway's. */
153
+ timeZone: z
154
+ .string()
155
+ .min(1)
156
+ .refine((tz) => {
157
+ try {
158
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }, "Unknown time zone")
164
+ .optional(),
165
+ })
166
+ .strict();
167
+ export type CedarConfig = z.infer<typeof CedarConfigSchema>;
168
+
169
+ const PhaseSchema = z.enum(["request", "response"]);
170
+
171
+ /** SEP-2624 InterceptorOverrides, set by the gateway owner for one interceptor. */
172
+ export const InterceptorOverridesSchema = z
173
+ .object({
174
+ failOpen: z.boolean().optional(),
175
+ priorityHint: z
176
+ .union([z.number().int(), z.object({ request: z.number().int().optional(), response: z.number().int().optional() }).strict()])
177
+ .optional(),
178
+ mode: z.enum(["active", "audit"]).optional(),
179
+ timeoutMs: z.number().int().positive().max(600_000).optional(),
180
+ hooks: z
181
+ .array(z.object({ events: z.array(z.string().min(1)).min(1), phase: PhaseSchema }).strict())
182
+ .min(1, "hooks: list at least one hook, or leave it out to keep the declared hooks")
183
+ .optional(),
184
+ })
185
+ .strict();
186
+
141
187
  export const GatewayConfigSchema = z
142
188
  .object({
143
189
  upstreams: z
@@ -148,6 +194,13 @@ export const GatewayConfigSchema = z
148
194
  }),
149
195
  /** How to start the interceptor server. Default: the bundled adtl-interceptors with this same config file. */
150
196
  interceptors: StdioUpstreamSchema.optional(),
197
+ /** More interceptor servers (stdio or Streamable HTTP) whose interceptors run beside ADTL's. */
198
+ interceptorServers: z
199
+ .record(UpstreamSchema)
200
+ .refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
201
+ message: "Interceptor server ids may only contain letters, digits and '-'",
202
+ })
203
+ .default({}),
151
204
  /** Path of a JSONL audit log. Relative paths are resolved against the config file's directory. */
152
205
  audit: z.string().min(1).optional(),
153
206
  /**
@@ -159,6 +212,8 @@ export const GatewayConfigSchema = z
159
212
  approvals: ApprovalsConfigSchema.optional(),
160
213
  /** Timeout for each interceptor invocation, in milliseconds. */
161
214
  interceptorTimeoutMs: z.number().int().positive().default(5000),
215
+ /** Per-interceptor overrides of failOpen, priorityHint, mode, timeoutMs and hooks, by interceptor name. */
216
+ interceptorOverrides: z.record(InterceptorOverridesSchema).default({}),
162
217
  /** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
163
218
  principal: PrincipalConfigSchema.optional(),
164
219
  /** Role-based authorization: when present, a call no role grants is denied. */
@@ -166,6 +221,8 @@ export const GatewayConfigSchema = z
166
221
  .record(RoleSchema)
167
222
  .refine((r) => Object.keys(r).length > 0, "roles: needs at least one role (remove it to allow everyone)")
168
223
  .optional(),
224
+ /** Cedar authorization. With roles as well, both must allow. */
225
+ cedar: CedarConfigSchema.optional(),
169
226
  })
170
227
  .strict();
171
228
  export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
package/src/gateway.ts CHANGED
@@ -21,9 +21,9 @@
21
21
  * unanswered or undeliverable calls are stopped. Without an approval channel,
22
22
  * "ask" blocks.
23
23
  *
24
- * An optional Authorizer decides first whether the caller may use the tool,
25
- * resource or prompt at all; a denial stops the call before the policy runs.
26
- * The lists the agent sees leave out what the authorizer would deny, so the
24
+ * Optional Authorizers decide first whether the caller may use the tool,
25
+ * resource or prompt at all; any denial stops the call before the policy runs.
26
+ * The lists the agent sees leave out what an authorizer would deny, so the
27
27
  * agent doesn't try it; hiding is a convenience, and every call is still checked.
28
28
  *
29
29
  * In audit mode (the invoker runs every interceptor with `mode: "audit"`)
@@ -62,7 +62,7 @@ import { authorize, type AuthorizationRequest, type Authorizer, type Denial, typ
62
62
  import { TOOL_SEPARATOR } from "./config.js";
63
63
  import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
64
64
 
65
- export const GATEWAY_VERSION = "0.6.0";
65
+ export const GATEWAY_VERSION = "0.8.0";
66
66
 
67
67
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
68
68
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -107,14 +107,16 @@ export interface AdtlGatewayOptions {
107
107
  logger?: GatewayLogger;
108
108
  /** Asks humans about "ask" decisions and sends notices. */
109
109
  approvals?: ApprovalBroker;
110
- /** Decides whether the caller may use a tool, resource or prompt at all, before the policy. */
111
- authorizer?: Authorizer;
110
+ /** Decides whether the caller may use a tool, resource or prompt at all, before the policy. Several must all allow. */
111
+ authorizer?: Authorizer | Authorizer[];
112
112
  /** The caller, passed to the authorizer and the interceptors. Default: anonymous. */
113
113
  principal?: Principal;
114
114
  /** Members of each role that policy rules name as `approvers:`. */
115
115
  approverRoles?: Map<string, string[]>;
116
116
  /** Also ask for approval in the MCP client, when it supports elicitation. */
117
117
  elicitApprovals?: boolean;
118
+ /** The (sub-)agent this gateway serves, recorded on every audit entry. */
119
+ agent?: { id: string; type?: string };
118
120
  }
119
121
 
120
122
  /**
@@ -172,8 +174,9 @@ export class AdtlGateway {
172
174
  private readonly audit: AuditSink | undefined;
173
175
  private readonly log: GatewayLogger;
174
176
  private readonly approvals: ApprovalBroker | undefined;
175
- private readonly authorizer: Authorizer | undefined;
177
+ private readonly authorizers: Authorizer[];
176
178
  private readonly principal: Principal;
179
+ private readonly agent: { agent: { id: string; type?: string } } | Record<string, never>;
177
180
  private readonly approverRoles: Map<string, string[]>;
178
181
  private readonly elicitApprovals: boolean;
179
182
  /** The server the agent is connected to, for elicitation. */
@@ -191,15 +194,20 @@ export class AdtlGateway {
191
194
  this.audit = opts.audit;
192
195
  this.log = opts.logger ?? stderrLogger;
193
196
  this.approvals = opts.approvals;
194
- this.authorizer = opts.authorizer;
197
+ this.authorizers = opts.authorizer === undefined ? [] : [opts.authorizer].flat();
195
198
  this.principal = opts.principal ?? { type: "anonymous" };
199
+ this.agent = opts.agent ? { agent: opts.agent } : {};
196
200
  this.approverRoles = opts.approverRoles ?? new Map();
197
201
  this.elicitApprovals = opts.elicitApprovals === true;
198
202
  }
199
203
 
200
- /** Ask the authorizer, if any. Returns the denial; in audit mode the caller logs it and carries on. */
204
+ /** Ask each authorizer in turn. Returns the first denial; in audit mode the caller logs it and carries on. */
201
205
  private async authorize(request: Omit<AuthorizationRequest, "principal">): Promise<Denial | undefined> {
202
- return this.authorizer ? authorize(this.authorizer, { principal: this.principal, ...request }) : undefined;
206
+ for (const a of this.authorizers) {
207
+ const denied = await authorize(a, { principal: this.principal, ...request });
208
+ if (denied) return denied;
209
+ }
210
+ return undefined;
203
211
  }
204
212
 
205
213
  /** The downstream MCP server the agent connects to. */
@@ -299,7 +307,7 @@ export class AdtlGateway {
299
307
  * call: routes are kept, and a call to a hidden item is still authorized.
300
308
  */
301
309
  private async hideDenied<T>(items: T[], request: (item: T) => Omit<AuthorizationRequest, "principal"> | undefined): Promise<T[]> {
302
- if (!this.authorizer || this.invoker.modeOverride === "audit") return items;
310
+ if (!this.authorizers.length || this.invoker.modeOverride === "audit") return items;
303
311
  const shown = await Promise.all(
304
312
  items.map(async (item) => {
305
313
  const r = request(item);
@@ -446,6 +454,7 @@ export class AdtlGateway {
446
454
  this.audit?.write({
447
455
  ts: context.timestamp!,
448
456
  sessionId: this.sessionId,
457
+ ...this.agent,
449
458
  event,
450
459
  server: upstream,
451
460
  ...target,
@@ -497,7 +506,7 @@ export class AdtlGateway {
497
506
  }
498
507
  const label = infoOf(res, "adtl.label");
499
508
  finish({ outcome: "forwarded", ...audit, label: label.label, ...(label.ledger ? { ledger: label.ledger } : {}),
500
- ...(label.records ? { records: label.records } : {}), sessionLabel: label.sessionLabel, warnings });
509
+ ...(label.records ? { records: label.records } : {}), ...(label.recordIds ? { recordIds: label.recordIds } : {}), sessionLabel: label.sessionLabel, warnings });
501
510
  return (res.payload as { result: R }).result;
502
511
  }
503
512
 
@@ -521,7 +530,7 @@ export class AdtlGateway {
521
530
  server: route.upstream,
522
531
  ...(route.annotations ? { toolAnnotations: route.annotations } : {}),
523
532
  };
524
- const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
533
+ const base = { sessionId: this.sessionId, ...this.agent, event: "tools/call" as const, server: route.upstream, tool: route.tool };
525
534
  let denied: Denial | undefined;
526
535
  const ran: InterceptorRun[] = [];
527
536
  const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
@@ -626,7 +635,9 @@ export class AdtlGateway {
626
635
  // 2. Forward to the upstream server.
627
636
  let result: CallToolResult;
628
637
  try {
629
- result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments })) as CallToolResult;
638
+ // A mutator may add request _meta (e.g. fides-gateway labels); pass it on.
639
+ const meta = (forwarded as { _meta?: Record<string, unknown> })._meta;
640
+ result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments, ...(meta ? { _meta: meta } : {}) })) as CallToolResult;
630
641
  } catch (err) {
631
642
  const reason = (err as Error).message;
632
643
  finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), ...approved, reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
@@ -659,6 +670,7 @@ export class AdtlGateway {
659
670
  label: label.label,
660
671
  ...(label.ledger ? { ledger: label.ledger } : {}),
661
672
  ...(label.records ? { records: label.records } : {}),
673
+ ...(label.recordIds ? { recordIds: label.recordIds } : {}),
662
674
  sessionLabel: label.sessionLabel,
663
675
  warnings: [...req.warnings, ...res.warnings],
664
676
  });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Which session a Claude Code sub-agent belongs to.
3
+ *
4
+ * Claude Code's SubagentStart hook reports the parent's `session_id` with the
5
+ * new sub-agent's `agent_id`. Recording that link lets the sub-agent's later
6
+ * hook calls use the parent's label even if they ever report a session id of
7
+ * their own: the sub-agent starts from what its parent has seen, and what it
8
+ * reads taints the parent. Links live beside the session files, which the
9
+ * hooks already protect from the agent.
10
+ */
11
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { createHash } from "node:crypto";
13
+ import { join } from "node:path";
14
+
15
+ export interface AgentLinks {
16
+ /** The session `agentId` was started in, if known. */
17
+ parentOf(agentId: string): string | undefined;
18
+ link(agentId: string, sessionId: string): void;
19
+ }
20
+
21
+ const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
22
+
23
+ export class FileAgentLinks implements AgentLinks {
24
+ private readonly dir: string;
25
+
26
+ constructor(sessionsDir: string) {
27
+ this.dir = join(sessionsDir, "agents");
28
+ }
29
+
30
+ private pathOf(agentId: string): string {
31
+ const name = SAFE_ID.test(agentId) ? agentId : `h-${createHash("sha256").update(agentId).digest("hex")}`;
32
+ return join(this.dir, `${name}.parent`);
33
+ }
34
+
35
+ parentOf(agentId: string): string | undefined {
36
+ try {
37
+ return readFileSync(this.pathOf(agentId), "utf8").trim() || undefined;
38
+ } catch (err) {
39
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
40
+ throw err;
41
+ }
42
+ }
43
+
44
+ link(agentId: string, sessionId: string): void {
45
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
46
+ writeFileSync(this.pathOf(agentId), sessionId, { encoding: "utf8", mode: 0o600 });
47
+ }
48
+ }
49
+
50
+ export class InMemoryAgentLinks implements AgentLinks {
51
+ private readonly links = new Map<string, string>();
52
+ parentOf(agentId: string): string | undefined {
53
+ return this.links.get(agentId);
54
+ }
55
+ link(agentId: string, sessionId: string): void {
56
+ this.links.set(agentId, sessionId);
57
+ }
58
+ }
@@ -8,6 +8,8 @@
8
8
  * allow → no answer, so Claude Code's own permissions apply
9
9
  * PostToolUse labels the tool's output and adds it to the session label
10
10
  * PostToolUseFailure same: a failed tool's error output still reaches the model
11
+ * SubagentStart links the new sub-agent to its parent's session, so its
12
+ * calls start from the parent's label and taint it
11
13
  *
12
14
  * Built-in tools are labeled as the server `claude-code` (see preset.ts); MCP
13
15
  * tools `mcp__<server>__<tool>` as `<server>`. Tools of the gateway's own MCP
@@ -33,11 +35,13 @@ import {
33
35
  type AdtlConfig,
34
36
  type Decision,
35
37
  type PolicyDecision,
38
+ type Label,
36
39
  type SessionStore,
37
40
  join,
38
41
  } from "@adtl/labels";
39
42
  import { chunkHashes, valueHashes, type Ledger } from "@adtl/ledger";
40
43
  import type { AuditEntry, AuditSink } from "../audit.js";
44
+ import type { AgentLinks } from "./agentLinks.js";
41
45
  import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
42
46
 
43
47
  export interface HookInput {
@@ -64,6 +68,8 @@ export interface HookOptions {
64
68
  audit?: AuditSink;
65
69
  /** Label ledger shared with the gateway. */
66
70
  ledger?: Ledger;
71
+ /** Sub-agent → parent session links, from SubagentStart. */
72
+ agents?: AgentLinks;
67
73
  /** Files the agent must not change: the ADTL config, the session directory and the ledger. */
68
74
  protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
69
75
  }
@@ -172,19 +178,37 @@ export function addedLines(output: unknown): string[] {
172
178
  return out;
173
179
  }
174
180
 
181
+ /**
182
+ * The session whose label a hook call reads and adds to: a linked sub-agent's
183
+ * parent session, else the call's own. When the two differ, the sub-agent's own
184
+ * label is read too, so nothing it saw is lost.
185
+ */
186
+ function sessionFor(input: HookInput & { session_id: string }, opts: HookOptions): { id: string; get(): Label } {
187
+ const parent = typeof input.agent_id === "string" ? opts.agents?.parentOf(input.agent_id) : undefined;
188
+ if (!parent || parent === input.session_id) return { id: input.session_id, get: () => opts.sessions.get(input.session_id) };
189
+ return { id: parent, get: () => join(opts.sessions.get(parent), opts.sessions.get(input.session_id)) };
190
+ }
191
+
175
192
  export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
176
193
  const event = input.hook_event_name;
194
+ if (event === "SubagentStart") {
195
+ if (typeof input.agent_id === "string" && typeof input.session_id === "string" && input.session_id !== "") {
196
+ opts.agents?.link(input.agent_id, input.session_id);
197
+ }
198
+ return NOTHING;
199
+ }
177
200
  if (event !== "PreToolUse" && event !== "PostToolUse" && event !== "PostToolUseFailure") return NOTHING;
178
201
  if (typeof input.tool_name !== "string" || typeof input.session_id !== "string" || input.session_id === "") {
179
202
  return failure(event, "hook input has no tool_name or session_id");
180
203
  }
204
+ const session = sessionFor(input as HookInput & { session_id: string }, opts);
181
205
  const route = routeTool(input.tool_name, opts.gatewayServer);
182
206
  if (!route) return NOTHING;
183
207
  const config = withClaudeCodePreset(opts.config);
184
208
  const ts = new Date().toISOString();
185
209
  const agent =
186
210
  typeof input.agent_id === "string" ? { agent: { id: input.agent_id, ...(typeof input.agent_type === "string" ? { type: input.agent_type } : {}) } } : {};
187
- const base = { ts, sessionId: input.session_id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
211
+ const base = { ts, sessionId: session.id, event: "tools/call" as const, ...route, via: "claude-code-hook" as const, ...agent };
188
212
 
189
213
  if (event !== "PreToolUse") {
190
214
  const output = input.tool_response ?? input.error ?? null;
@@ -197,20 +221,20 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
197
221
  ledger = { chunks: match.chunks, writers: match.writers };
198
222
  }
199
223
  // What the tool was given came from the session as it was before this output.
200
- const before = opts.sessions.get(input.session_id);
224
+ const before = session.get();
201
225
  const written = [...valueHashes(input.tool_input ?? {}), ...addedLines(output).flatMap(chunkHashes)];
202
- opts.ledger.record(written, before, input.session_id, route);
226
+ opts.ledger.record(written, before, session.id, route);
203
227
  }
204
- const sessionLabel = opts.sessions.observe(input.session_id, label);
228
+ const sessionLabel = join(session.get(), opts.sessions.observe(session.id, label));
205
229
  opts.audit?.write({ ...base, outcome: "forwarded", label, ...(ledger ? { ledger } : {}), sessionLabel, durationMs: 0 });
206
230
  return NOTHING;
207
231
  }
208
232
 
209
233
  const hash = callHash(route.server, route.tool, input.tool_input ?? {});
210
- const session = opts.sessions.get(input.session_id);
234
+ const current = session.get();
211
235
  const guard = protectedTarget(input, route, opts.protect);
212
236
  const policy: PolicyDecision = evaluatePolicy(config.policy, {
213
- session,
237
+ session: current,
214
238
  ...route,
215
239
  egress: toolEgress(config, route.server, route.tool),
216
240
  });
@@ -229,7 +253,7 @@ export function handleHook(input: HookInput, opts: HookOptions): HookOutcome {
229
253
  reason: d.reason,
230
254
  matched: d.matched,
231
255
  callHash: hash,
232
- sessionLabel: session,
256
+ sessionLabel: current,
233
257
  durationMs: 0,
234
258
  };
235
259
  opts.audit?.write(entry);
@@ -1,3 +1,4 @@
1
1
  export * from "./claudeCode.js";
2
2
  export * from "./preset.js";
3
3
  export * from "./run.js";
4
+ export * from "./agentLinks.js";
package/src/hooks/run.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `adtl-gateway hook --config adtl.yaml`: the command Claude Code runs for
3
- * PreToolUse, PostToolUse and PostToolUseFailure. Reads the hook event from
3
+ * PreToolUse, PostToolUse, PostToolUseFailure and SubagentStart. Reads the hook event from
4
4
  * stdin and answers on stdout (JSON) or stderr (with exit code 2).
5
5
  *
6
6
  * The config needs no `gateway:` section; if there is one, its `audit` log and
@@ -13,6 +13,7 @@ import { Ledger, defaultLedgerPath } from "@adtl/ledger";
13
13
  import { parseConfigText } from "@adtl/interceptors";
14
14
  import { parse as parseYaml } from "yaml";
15
15
  import { JsonlAuditLog } from "../audit.js";
16
+ import { FileAgentLinks } from "./agentLinks.js";
16
17
  import { failure, handleHook, type HookInput, type HookOutcome } from "./claudeCode.js";
17
18
 
18
19
  export interface RunHookOptions {
@@ -46,6 +47,7 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
46
47
  return handleHook(input, {
47
48
  config,
48
49
  sessions: new FileSessionStore(sessionsDir),
50
+ agents: new FileAgentLinks(sessionsDir),
49
51
  ...(ledger ? { ledger } : {}),
50
52
  ...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
51
53
  mode: gw.mode === "audit" ? "audit" : "active",
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * from "./hooks/index.js";
12
12
  export * from "./ledgerCli.js";
13
13
  export * from "./auditCli.js";
14
14
  export * from "./roles.js";
15
+ export * from "./cedar.js";
15
16
  export * from "./elicitation.js";
16
17
  export * from "./receipt.js";
17
18
  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";