@adtl/gateway 0.5.1 → 0.7.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 (102) hide show
  1. package/README.md +152 -9
  2. package/dist/approvals.d.ts +22 -6
  3. package/dist/approvals.d.ts.map +1 -1
  4. package/dist/approvals.js +56 -26
  5. package/dist/approvals.js.map +1 -1
  6. package/dist/audit.d.ts +8 -0
  7. package/dist/audit.d.ts.map +1 -1
  8. package/dist/audit.js.map +1 -1
  9. package/dist/auditCli.d.ts +2 -0
  10. package/dist/auditCli.d.ts.map +1 -1
  11. package/dist/auditCli.js +13 -10
  12. package/dist/auditCli.js.map +1 -1
  13. package/dist/cedar.d.ts +27 -0
  14. package/dist/cedar.d.ts.map +1 -0
  15. package/dist/cedar.js +134 -0
  16. package/dist/cedar.js.map +1 -0
  17. package/dist/cli.js +17 -1
  18. package/dist/cli.js.map +1 -1
  19. package/dist/config.d.ts +330 -21
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +74 -1
  22. package/dist/config.js.map +1 -1
  23. package/dist/elicitation.d.ts +25 -0
  24. package/dist/elicitation.d.ts.map +1 -0
  25. package/dist/elicitation.js +46 -0
  26. package/dist/elicitation.js.map +1 -0
  27. package/dist/gateway.d.ts +30 -5
  28. package/dist/gateway.d.ts.map +1 -1
  29. package/dist/gateway.js +96 -14
  30. package/dist/gateway.js.map +1 -1
  31. package/dist/hooks/claudeCode.d.ts +4 -0
  32. package/dist/hooks/claudeCode.d.ts.map +1 -1
  33. package/dist/hooks/claudeCode.js +8 -1
  34. package/dist/hooks/claudeCode.js.map +1 -1
  35. package/dist/hooks/run.d.ts.map +1 -1
  36. package/dist/hooks/run.js +6 -3
  37. package/dist/hooks/run.js.map +1 -1
  38. package/dist/index.d.ts +6 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +6 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/init/presets.d.ts.map +1 -1
  43. package/dist/init/presets.js +54 -0
  44. package/dist/init/presets.js.map +1 -1
  45. package/dist/invoker.d.ts +21 -1
  46. package/dist/invoker.d.ts.map +1 -1
  47. package/dist/invoker.js +19 -2
  48. package/dist/invoker.js.map +1 -1
  49. package/dist/ledgerCli.d.ts +1 -1
  50. package/dist/ledgerCli.d.ts.map +1 -1
  51. package/dist/ledgerCli.js +9 -3
  52. package/dist/ledgerCli.js.map +1 -1
  53. package/dist/ntfy.d.ts +3 -1
  54. package/dist/ntfy.d.ts.map +1 -1
  55. package/dist/ntfy.js +2 -1
  56. package/dist/ntfy.js.map +1 -1
  57. package/dist/playgroundCli.d.ts +8 -0
  58. package/dist/playgroundCli.d.ts.map +1 -0
  59. package/dist/playgroundCli.js +57 -0
  60. package/dist/playgroundCli.js.map +1 -0
  61. package/dist/receipt.d.ts +62 -0
  62. package/dist/receipt.d.ts.map +1 -0
  63. package/dist/receipt.js +117 -0
  64. package/dist/receipt.js.map +1 -0
  65. package/dist/receiptCli.d.ts +8 -0
  66. package/dist/receiptCli.d.ts.map +1 -0
  67. package/dist/receiptCli.js +87 -0
  68. package/dist/receiptCli.js.map +1 -0
  69. package/dist/roles.d.ts +9 -0
  70. package/dist/roles.d.ts.map +1 -0
  71. package/dist/roles.js +41 -0
  72. package/dist/roles.js.map +1 -0
  73. package/dist/startup.d.ts +14 -0
  74. package/dist/startup.d.ts.map +1 -1
  75. package/dist/startup.js +92 -7
  76. package/dist/startup.js.map +1 -1
  77. package/package.json +16 -4
  78. package/playground/fake-github.mjs +98 -0
  79. package/playground/fake-memory.mjs +130 -0
  80. package/playground/index.html +411 -0
  81. package/playground/playground.yaml +40 -0
  82. package/playground/server.mjs +290 -0
  83. package/src/approvals.ts +63 -26
  84. package/src/audit.ts +5 -0
  85. package/src/auditCli.ts +11 -8
  86. package/src/cedar.ts +140 -0
  87. package/src/cli.ts +16 -1
  88. package/src/config.ts +80 -1
  89. package/src/elicitation.ts +69 -0
  90. package/src/gateway.ts +108 -17
  91. package/src/hooks/claudeCode.ts +13 -2
  92. package/src/hooks/run.ts +6 -3
  93. package/src/index.ts +6 -0
  94. package/src/init/presets.ts +54 -0
  95. package/src/invoker.ts +39 -2
  96. package/src/ledgerCli.ts +9 -3
  97. package/src/ntfy.ts +4 -1
  98. package/src/playgroundCli.ts +60 -0
  99. package/src/receipt.ts +148 -0
  100. package/src/receiptCli.ts +85 -0
  101. package/src/roles.ts +55 -0
  102. package/src/startup.ts +88 -6
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/cli.ts CHANGED
@@ -8,6 +8,9 @@
8
8
  * adtl-gateway hook --config adtl.yaml [--gateway-server adtl]
9
9
  * adtl-gateway ledger stats|verify|declassify|declassified
10
10
  * adtl-gateway audit verify [<file>]
11
+ * adtl-gateway receipt list|<session>|verify <file>
12
+ * adtl-gateway key
13
+ * adtl-gateway playground [--port 4317] [--no-open]
11
14
  *
12
15
  * Point your MCP client (e.g. Claude Code's .mcp.json) at this command.
13
16
  * Logs go to stderr; stdout carries MCP JSON-RPC only.
@@ -16,6 +19,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
16
19
  import { EnvExpansionError } from "./config.js";
17
20
  import { GATEWAY_VERSION } from "./gateway.js";
18
21
  import { runAuditCommand } from "./auditCli.js";
22
+ import { runKeyCommand, runReceiptCommand } from "./receiptCli.js";
23
+ import { runPlayground } from "./playgroundCli.js";
19
24
  import { runHook } from "./hooks/run.js";
20
25
  import { runLedgerCommand } from "./ledgerCli.js";
21
26
  import { runInit, type InitResult } from "./init/run.js";
@@ -26,6 +31,9 @@ const USAGE = `Usage: adtl-gateway --config <adtl.yaml> [--audit]
26
31
  adtl-gateway hook --config <adtl.yaml> [--gateway-server <name>]
27
32
  adtl-gateway ledger stats|verify|declassify|declassified (see: adtl-gateway ledger --help)
28
33
  adtl-gateway audit verify [<file>] (see: adtl-gateway audit --help)
34
+ adtl-gateway receipt list|<session>|verify <file> (see: adtl-gateway receipt --help)
35
+ adtl-gateway key
36
+ adtl-gateway playground [--port <n>] [--no-open] (a local web UI with fake servers)
29
37
 
30
38
  Runs an MCP server over stdio that forwards tool calls to the upstream
31
39
  servers in the config, checking and labeling each call with ADTL.
@@ -104,6 +112,13 @@ async function main(): Promise<void> {
104
112
  process.exitCode = r.exitCode;
105
113
  return;
106
114
  }
115
+ if (process.argv[2] === "playground") return runPlayground(process.argv.slice(3));
116
+ if (process.argv[2] === "receipt" || process.argv[2] === "key") {
117
+ const r = process.argv[2] === "key" ? await runKeyCommand() : await runReceiptCommand(process.argv.slice(3));
118
+ console.log(r.stdout);
119
+ process.exitCode = r.exitCode;
120
+ return;
121
+ }
107
122
  const args = parseArgs(process.argv.slice(2));
108
123
  // --help and --version print to stdout and exit; MCP is never served in these modes.
109
124
  if (args.help) return void console.log(USAGE);
@@ -190,7 +205,7 @@ async function init(path: string, write: boolean, from?: string): Promise<void>
190
205
  }
191
206
 
192
207
  main().catch((err: unknown) => {
193
- const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit"].includes(process.argv[2] ?? "");
208
+ const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit", "receipt", "key", "playground"].includes(process.argv[2] ?? "");
194
209
  console.error(known ? `[adtl-gateway] ${(err as Error).message}` : err);
195
210
  // For Claude Code hooks only exit code 2 stops the call; any other code lets it run.
196
211
  process.exit(process.argv[2] === "hook" ? 2 : 1);
package/src/config.ts CHANGED
@@ -12,15 +12,22 @@
12
12
  * docs:
13
13
  * url: https://example.com/mcp
14
14
  * headers: { Authorization: "Bearer ${DOCS_TOKEN}" }
15
+ * principal: { id: "${USER}" } # optional: who runs this gateway (default: anonymous)
16
+ * roles: # optional: who may use which tools (deny by default)
17
+ * maintainers: { members: [alice], tools: ["github.*"] }
18
+ * readers: { members: ["*"], tools: ["github.read_*", "github.get_*"] }
19
+ * cedar: { policies: ./adtl.cedar } # optional: attribute-based rules (see cedar.ts)
15
20
  * approvals: # optional: make "ask" really ask
16
21
  * timeoutSeconds: 50
17
22
  * ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
23
+ * approvers: # optional: each approver's own channel (policy rule `approvers:`)
24
+ * alice: { ntfy: { topic: "${ALICE_NTFY_TOPIC}" } }
18
25
  * email:
19
26
  * smtp: { host: smtp.example.com, port: 465, user: "${SMTP_USER}", pass: "${SMTP_PASS}" }
20
27
  * from: adtl@example.com
21
28
  * to: you@example.com
22
29
  *
23
- * `${VAR}` in command, args, env, cwd, url, headers and approval settings is
30
+ * `${VAR}` in command, args, env, cwd, url, headers, approval settings and principal.id is
24
31
  * replaced from the gateway's environment. A missing variable is a config error,
25
32
  * unless a default is given: `${VAR:-default}`.
26
33
  */
@@ -88,10 +95,73 @@ export const ApprovalsConfigSchema = z
88
95
  timeoutSeconds: z.number().int().positive().max(3600).default(50),
89
96
  ntfy: NtfyConfigSchema.optional(),
90
97
  email: EmailConfigSchema.optional(),
98
+ /**
99
+ * Also ask in the MCP client (elicitation), when it supports it. Off by
100
+ * default: a client that accepts elicitations by itself would approve calls.
101
+ */
102
+ elicitation: z.boolean().default(false),
103
+ /**
104
+ * Each approver's own channel, by principal id. Policy rules with
105
+ * `approvers: [role]` ask only these people, so the answer names who approved.
106
+ */
107
+ approvers: z.record(z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
91
108
  })
92
109
  .strict();
93
110
  export type ApprovalsConfig = z.infer<typeof ApprovalsConfigSchema>;
94
111
 
112
+ /** `server.tool` (or `server.prompt`), with `*` wildcards, as in policy rules. */
113
+ const QualifiedPattern = z
114
+ .string()
115
+ .min(1)
116
+ .refine((p) => /^[^.]+\..+$/.test(p), { message: 'Use "server.name" with optional * wildcards, e.g. "github.read_*" or "github.*"' });
117
+
118
+ export const PrincipalConfigSchema = z
119
+ .object({
120
+ type: z.enum(["user", "service"]).default("user"),
121
+ id: z.string().min(1),
122
+ claims: z.record(z.unknown()).optional(),
123
+ })
124
+ .strict();
125
+ export type PrincipalConfig = z.infer<typeof PrincipalConfigSchema>;
126
+
127
+ export const RoleSchema = z
128
+ .object({
129
+ /** Principal ids; "*" means everyone, including an anonymous caller. */
130
+ members: z.array(z.string().min(1)).nonempty("A role needs at least one member (\"*\" for everyone)"),
131
+ tools: z.array(QualifiedPattern).default([]),
132
+ prompts: z.array(QualifiedPattern).default([]),
133
+ /** Resource URI patterns, e.g. "github://acme/*". */
134
+ resources: z.array(z.string().min(1)).default([]),
135
+ })
136
+ .strict()
137
+ .refine((r) => r.tools.length + r.prompts.length + r.resources.length > 0, {
138
+ message: "A role must grant at least one of tools, prompts or resources",
139
+ });
140
+ export type RoleConfig = z.infer<typeof RoleSchema>;
141
+
142
+ export const CedarConfigSchema = z
143
+ .object({
144
+ /** Cedar policy file. Relative paths are resolved against the config file's directory. */
145
+ policies: z.string().min(1),
146
+ /** Optional Cedar entities JSON file: groups (`parents`) and attributes. */
147
+ entities: z.string().min(1).optional(),
148
+ /** IANA time zone for `context.time`, e.g. "Europe/Berlin". Default: the gateway's. */
149
+ timeZone: z
150
+ .string()
151
+ .min(1)
152
+ .refine((tz) => {
153
+ try {
154
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
155
+ return true;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }, "Unknown time zone")
160
+ .optional(),
161
+ })
162
+ .strict();
163
+ export type CedarConfig = z.infer<typeof CedarConfigSchema>;
164
+
95
165
  export const GatewayConfigSchema = z
96
166
  .object({
97
167
  upstreams: z
@@ -113,6 +183,15 @@ export const GatewayConfigSchema = z
113
183
  approvals: ApprovalsConfigSchema.optional(),
114
184
  /** Timeout for each interceptor invocation, in milliseconds. */
115
185
  interceptorTimeoutMs: z.number().int().positive().default(5000),
186
+ /** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
187
+ principal: PrincipalConfigSchema.optional(),
188
+ /** Role-based authorization: when present, a call no role grants is denied. */
189
+ roles: z
190
+ .record(RoleSchema)
191
+ .refine((r) => Object.keys(r).length > 0, "roles: needs at least one role (remove it to allow everyone)")
192
+ .optional(),
193
+ /** Cedar authorization. With roles as well, both must allow. */
194
+ cedar: CedarConfigSchema.optional(),
116
195
  })
117
196
  .strict();
118
197
  export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Approvals inside the MCP client, through `elicitation/create`: the person
3
+ * using the client (for example Claude Code) sees the request and answers
4
+ * Allow or Deny there, no phone needed.
5
+ *
6
+ * The answer is turned into the same signed token a push button would send,
7
+ * so it answers only the one pending call it was asked about. Declining or
8
+ * dismissing the prompt counts as Deny. The gateway uses this channel only
9
+ * when `gateway.approvals.elicitation` is on and the client declares the
10
+ * elicitation capability, and never for rules with named approvers: the
11
+ * client can't say who answered.
12
+ */
13
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
+ import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js";
15
+ import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
16
+
17
+ interface Listening {
18
+ answer: AnswerFn;
19
+ signal: AbortSignal;
20
+ }
21
+
22
+ /** True if the connected client can answer elicitation requests. */
23
+ export function supportsElicitation(server: Server): boolean {
24
+ return server.getClientCapabilities()?.elicitation !== undefined;
25
+ }
26
+
27
+ function message(request: ApprovalRequest): string {
28
+ const rule = request.matched.join(", ") || "default";
29
+ 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
+ }
31
+
32
+ export class ElicitationChannel implements Notifier {
33
+ readonly name = "elicitation";
34
+ private readonly listening = new Map<string, Listening>();
35
+
36
+ constructor(private readonly server: Server) {}
37
+
38
+ async listen(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void> {
39
+ this.listening.set(request.id, { answer, signal });
40
+ await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
41
+ this.listening.delete(request.id);
42
+ }
43
+
44
+ async notify(event: ApprovalEvent): Promise<void> {
45
+ if (event.kind !== "pending") return; // the client sees the call's result; no other notices
46
+ const l = this.listening.get(event.request.id);
47
+ if (!l) throw new Error("not listening for this request");
48
+ // 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
+ );
66
+ const allow = result.action === "accept" && result.content?.decision === "allow";
67
+ l.answer(allow ? event.tokens.allow : event.tokens.deny);
68
+ }
69
+ }
package/src/gateway.ts CHANGED
@@ -21,8 +21,10 @@
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.
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
+ * agent doesn't try it; hiding is a convenience, and every call is still checked.
26
28
  *
27
29
  * In audit mode (the invoker runs every interceptor with `mode: "audit"`)
28
30
  * nothing is stopped or changed: calls the policy would stop are forwarded,
@@ -54,12 +56,13 @@ import {
54
56
  import type { InvocationContext } from "@adtl/interceptors";
55
57
  import { callBindingHash } from "@adtl/labels";
56
58
  import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
59
+ import { ElicitationChannel, supportsElicitation } from "./elicitation.js";
57
60
  import type { AuditEntry, AuditSink } from "./audit.js";
58
61
  import { authorize, type AuthorizationRequest, type Authorizer, type Denial, type Principal } from "./authorizer.js";
59
62
  import { TOOL_SEPARATOR } from "./config.js";
60
- import type { Blocked, PhaseOutcome, InterceptorInvoker } from "./invoker.js";
63
+ import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
61
64
 
62
- export const GATEWAY_VERSION = "0.5.1";
65
+ export const GATEWAY_VERSION = "0.7.0";
63
66
 
64
67
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
65
68
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -104,10 +107,24 @@ export interface AdtlGatewayOptions {
104
107
  logger?: GatewayLogger;
105
108
  /** Asks humans about "ask" decisions and sends notices. */
106
109
  approvals?: ApprovalBroker;
107
- /** Decides whether the caller may use a tool, resource or prompt at all, before the policy. */
108
- 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[];
109
112
  /** The caller, passed to the authorizer and the interceptors. Default: anonymous. */
110
113
  principal?: Principal;
114
+ /** Members of each role that policy rules name as `approvers:`. */
115
+ approverRoles?: Map<string, string[]>;
116
+ /** Also ask for approval in the MCP client, when it supports elicitation. */
117
+ elicitApprovals?: boolean;
118
+ }
119
+
120
+ /**
121
+ * Who may answer an "ask" whose rules name approver roles: someone in one of
122
+ * the roles of every list. Sorted, for stable notifications and tests.
123
+ */
124
+ export function eligibleApprovers(lists: string[][], roles: Map<string, string[]>): string[] {
125
+ const sets = lists.map((list) => new Set(list.flatMap((role) => roles.get(role) ?? [])));
126
+ const [first, ...rest] = sets;
127
+ return [...(first ?? [])].filter((id) => id !== "*" && rest.every((s) => s.has(id))).sort();
111
128
  }
112
129
 
113
130
  export interface CallOptions {
@@ -155,8 +172,12 @@ export class AdtlGateway {
155
172
  private readonly audit: AuditSink | undefined;
156
173
  private readonly log: GatewayLogger;
157
174
  private readonly approvals: ApprovalBroker | undefined;
158
- private readonly authorizer: Authorizer | undefined;
175
+ private readonly authorizers: Authorizer[];
159
176
  private readonly principal: Principal;
177
+ private readonly approverRoles: Map<string, string[]>;
178
+ private readonly elicitApprovals: boolean;
179
+ /** The server the agent is connected to, for elicitation. */
180
+ private server: Server | undefined;
160
181
  private routes = new Map<string, Route>();
161
182
  private promptRoutes = new Map<string, PromptRoute>();
162
183
  /** Resource URI → the upstreams that list it. */
@@ -170,13 +191,19 @@ export class AdtlGateway {
170
191
  this.audit = opts.audit;
171
192
  this.log = opts.logger ?? stderrLogger;
172
193
  this.approvals = opts.approvals;
173
- this.authorizer = opts.authorizer;
194
+ this.authorizers = opts.authorizer === undefined ? [] : [opts.authorizer].flat();
174
195
  this.principal = opts.principal ?? { type: "anonymous" };
196
+ this.approverRoles = opts.approverRoles ?? new Map();
197
+ this.elicitApprovals = opts.elicitApprovals === true;
175
198
  }
176
199
 
177
- /** Ask the authorizer, if any. Returns the denial; in audit mode the caller logs it and carries on. */
200
+ /** Ask each authorizer in turn. Returns the first denial; in audit mode the caller logs it and carries on. */
178
201
  private async authorize(request: Omit<AuthorizationRequest, "principal">): Promise<Denial | undefined> {
179
- return this.authorizer ? authorize(this.authorizer, { principal: this.principal, ...request }) : undefined;
202
+ for (const a of this.authorizers) {
203
+ const denied = await authorize(a, { principal: this.principal, ...request });
204
+ if (denied) return denied;
205
+ }
206
+ return undefined;
180
207
  }
181
208
 
182
209
  /** The downstream MCP server the agent connects to. */
@@ -195,7 +222,7 @@ export class AdtlGateway {
195
222
  "if a call is stopped, the result explains why.",
196
223
  },
197
224
  );
198
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.listTools() }));
225
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.visibleTools() }));
199
226
  server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
200
227
  const progressToken = req.params._meta?.progressToken;
201
228
  const progress =
@@ -206,16 +233,17 @@ export class AdtlGateway {
206
233
  return this.callTool(req.params.name, req.params.arguments ?? {}, progress ? { progress } : {});
207
234
  });
208
235
  if (capabilities.resources) {
209
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await this.listResources() }));
236
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: await this.visibleResources() }));
210
237
  server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
211
238
  resourceTemplates: await this.listResourceTemplates(),
212
239
  }));
213
240
  server.setRequestHandler(ReadResourceRequestSchema, async (req) => this.readResource(req.params.uri));
214
241
  }
215
242
  if (capabilities.prompts) {
216
- server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await this.listPrompts() }));
243
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: await this.visiblePrompts() }));
217
244
  server.setRequestHandler(GetPromptRequestSchema, async (req) => this.getPrompt(req.params.name, req.params.arguments));
218
245
  }
246
+ this.server = server;
219
247
  return server;
220
248
  }
221
249
 
@@ -242,6 +270,49 @@ export class AdtlGateway {
242
270
  return out;
243
271
  }
244
272
 
273
+ /** tools/list as the caller sees it: without the tools the authorizer would deny. */
274
+ async visibleTools(): Promise<Tool[]> {
275
+ const tools = await this.listTools();
276
+ return this.hideDenied(tools, (t) => {
277
+ const r = this.routes.get(t.name)!;
278
+ return { event: "tools/call", server: r.upstream, tool: r.tool };
279
+ });
280
+ }
281
+
282
+ /** prompts/list as the caller sees it. */
283
+ async visiblePrompts(): Promise<Prompt[]> {
284
+ const prompts = await this.listPrompts();
285
+ return this.hideDenied(prompts, (p) => {
286
+ const r = this.promptRoutes.get(p.name)!;
287
+ return { event: "prompts/get", server: r.upstream, prompt: r.prompt };
288
+ });
289
+ }
290
+
291
+ /** resources/list as the caller sees it. A URI several upstreams offer is listed as before (reading it is refused). */
292
+ async visibleResources(): Promise<Resource[]> {
293
+ const resources = await this.listResources();
294
+ return this.hideDenied(resources, (r) => {
295
+ const ups = this.resourceRoutes.get(r.uri);
296
+ return ups?.size === 1 ? { event: "resources/read", server: [...ups][0]!, resource: r.uri } : undefined;
297
+ });
298
+ }
299
+
300
+ /**
301
+ * Leave out items the authorizer would deny, asked without arguments. Not in
302
+ * audit mode, which changes nothing. Hiding only saves the agent a denied
303
+ * call: routes are kept, and a call to a hidden item is still authorized.
304
+ */
305
+ private async hideDenied<T>(items: T[], request: (item: T) => Omit<AuthorizationRequest, "principal"> | undefined): Promise<T[]> {
306
+ if (!this.authorizers.length || this.invoker.modeOverride === "audit") return items;
307
+ const shown = await Promise.all(
308
+ items.map(async (item) => {
309
+ const r = request(item);
310
+ return r === undefined || (await this.authorize(r)) === undefined;
311
+ }),
312
+ );
313
+ return items.filter((_, i) => shown[i]);
314
+ }
315
+
245
316
  async listTools(): Promise<Tool[]> {
246
317
  const all = await this.listAll("tools/list", "tools", async (c, cursor) => {
247
318
  const p = await c.listTools(cursor ? { cursor } : {});
@@ -374,6 +445,7 @@ export class AdtlGateway {
374
445
  const config = { server: upstream };
375
446
  const audit = this.invoker.modeOverride === "audit" ? { mode: "audit" as const } : {};
376
447
  let denied: Denial | undefined;
448
+ const ran: InterceptorRun[] = [];
377
449
  const finish = (entry: Pick<AuditEntry, "outcome"> & Partial<AuditEntry>) =>
378
450
  this.audit?.write({
379
451
  ts: context.timestamp!,
@@ -383,6 +455,7 @@ export class AdtlGateway {
383
455
  ...target,
384
456
  ...(denied ? { denied } : {}),
385
457
  ...entry,
458
+ ...(ran.length ? { interceptors: [...ran] } : {}),
386
459
  durationMs: Math.round(performance.now() - started),
387
460
  });
388
461
  const what = "resource" in target ? `resource ${target.resource}` : `prompt ${upstream}${TOOL_SEPARATOR}${target.prompt}`;
@@ -401,6 +474,7 @@ export class AdtlGateway {
401
474
  if (denied) this.log.info(`audit: would deny ${what}: ${denied.reason}`);
402
475
 
403
476
  const req = await this.invoker.runRequest({ event, payload: { method: event, params }, config, context });
477
+ ran.push(...req.ran);
404
478
  if (req.blocked) {
405
479
  finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, warnings: req.warnings });
406
480
  this.log.info(`${req.blocked.decision} ${what}: ${req.blocked.reason}`);
@@ -417,6 +491,7 @@ export class AdtlGateway {
417
491
  }
418
492
 
419
493
  const res = await this.invoker.runResponse({ event, payload: { method: event, params: forwarded, result }, config, context });
494
+ ran.push(...res.ran);
420
495
  const warnings = [...req.warnings, ...res.warnings];
421
496
  if (res.blocked) {
422
497
  // Fail closed: unlabeled content must not reach the agent.
@@ -452,8 +527,16 @@ export class AdtlGateway {
452
527
  };
453
528
  const base = { sessionId: this.sessionId, event: "tools/call" as const, server: route.upstream, tool: route.tool };
454
529
  let denied: Denial | undefined;
530
+ const ran: InterceptorRun[] = [];
455
531
  const finish = (entry: Omit<AuditEntry, keyof typeof base | "ts" | "durationMs">) =>
456
- this.audit?.write({ ts: context.timestamp!, ...base, ...(denied ? { denied } : {}), ...entry, durationMs: Math.round(performance.now() - started) });
532
+ this.audit?.write({
533
+ ts: context.timestamp!,
534
+ ...base,
535
+ ...(denied ? { denied } : {}),
536
+ ...entry,
537
+ ...(ran.length ? { interceptors: [...ran] } : {}),
538
+ durationMs: Math.round(performance.now() - started),
539
+ });
457
540
 
458
541
  // 0. Authorization: may this caller use this tool at all? A denial wins over any policy decision.
459
542
  denied = await this.authorize({ event: "tools/call", server: route.upstream, tool: route.tool, arguments: args });
@@ -472,6 +555,7 @@ export class AdtlGateway {
472
555
  // 1. Request phase: adtl.policy decides whether the call may run.
473
556
  const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
474
557
  let req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
558
+ ran.push(...req.ran);
475
559
  const policy = infoOf(req, "adtl.policy");
476
560
  let approval: ApprovalResult | undefined;
477
561
  if (req.blocked) {
@@ -481,8 +565,12 @@ export class AdtlGateway {
481
565
  const stopped = { decision: b.decision, reason: b.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel };
482
566
  const matched = Array.isArray(policy.matched) ? (policy.matched as string[]) : [];
483
567
  const hash = b.confirmation?.callHash ?? policy.callHash;
484
- if (b.decision === "ask" && this.approvals?.canApprove && typeof hash === "string") {
485
- this.log.info(`ask ${name}: waiting for approval (${b.reason})`);
568
+ const lists = Array.isArray(policy.approvers) ? (policy.approvers as string[][]) : undefined;
569
+ const approvers = lists ? eligibleApprovers(lists, this.approverRoles) : undefined;
570
+ // 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)] : [];
572
+ if (b.decision === "ask" && this.approvals && (this.approvals.canApprove || elicit.length > 0) && typeof hash === "string") {
573
+ this.log.info(`ask ${name}: waiting for approval${approvers ? ` from ${approvers.join(", ") || "nobody"}` : ""} (${b.reason})`);
486
574
  approval = await this.waitForApproval(opts, () =>
487
575
  this.approvals!.request({
488
576
  sessionId: this.sessionId,
@@ -492,7 +580,8 @@ export class AdtlGateway {
492
580
  reason: b.reason,
493
581
  matched,
494
582
  ...(policy.sessionLabel !== undefined ? { sessionLabel: policy.sessionLabel } : {}),
495
- }),
583
+ ...(approvers ? { approvers } : {}),
584
+ }, elicit),
496
585
  );
497
586
  this.log.info(`ask ${name}: ${approval.outcome}`);
498
587
  if (approval.outcome !== "approved") {
@@ -511,6 +600,7 @@ export class AdtlGateway {
511
600
  ...(approval.approver ? { principal: { type: "user" as const, id: approval.approver } } : {}),
512
601
  }];
513
602
  req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context: { ...context, confirmations } });
603
+ ran.push(...req.ran);
514
604
  const sent = callBindingHash({ event: "tools/call", phase: "request", target: route.upstream, payload: req.payload });
515
605
  const recheck = req.blocked?.reason ?? (sent !== c.callHash ? "the call changed after it was approved" : undefined);
516
606
  if (recheck !== undefined) {
@@ -554,6 +644,7 @@ export class AdtlGateway {
554
644
  config,
555
645
  context,
556
646
  });
647
+ ran.push(...res.ran);
557
648
  if (res.blocked) {
558
649
  // Fail closed: an unlabeled result must not reach the agent.
559
650
  finish({ outcome: "blocked", decision: res.blocked.decision, reason: res.blocked.reason, callHash: policy.callHash, warnings: [...req.warnings, ...res.warnings] });