@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/startup.ts CHANGED
@@ -9,14 +9,17 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
9
9
  import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
10
10
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
11
  import { INTERCEPTORS_EXTENSION, LABEL_INTERCEPTOR, POLICY_INTERCEPTOR, parseConfigText } from "@adtl/interceptors";
12
- import { defaultSessionsDir, type AdtlConfig } from "@adtl/labels";
12
+ import { SIGNING_KEY_ENV, defaultSessionsDir, type AdtlConfig } from "@adtl/labels";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { ZodError } from "zod";
15
15
  import { ApprovalBroker, type Notifier } from "./approvals.js";
16
16
  import { JsonlAuditLog } from "./audit.js";
17
+ import type { Authorizer, Principal } from "./authorizer.js";
18
+ import { CedarAuthorizer } from "./cedar.js";
17
19
  import {
18
20
  GatewayConfigSchema,
19
21
  expandApprovals,
22
+ expandEnv,
20
23
  expandUpstream,
21
24
  isHttpUpstream,
22
25
  type GatewayConfig,
@@ -27,6 +30,7 @@ import { EmailNotifier } from "./email.js";
27
30
  import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
28
31
  import { InterceptorInvoker } from "./invoker.js";
29
32
  import { NtfyNotifier } from "./ntfy.js";
33
+ import { RoleAuthorizer } from "./roles.js";
30
34
 
31
35
  export class GatewayConfigError extends Error {
32
36
  constructor(message: string) {
@@ -117,10 +121,71 @@ export function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log:
117
121
  if (a.email) {
118
122
  notifiers.push(new EmailNotifier({ smtp: a.email.smtp, from: a.email.from, to: [a.email.to].flat(), on: a.email.on }));
119
123
  }
120
- if (notifiers.length === 0) return undefined;
121
- if (!notifiers.some((n) => n.listen)) log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
122
- log.info(`approvals: ${notifiers.map((n) => n.name).join(", ")} (timeout ${a.timeoutSeconds}s)`);
123
- return new ApprovalBroker({ notifiers, timeoutMs: a.timeoutSeconds * 1000, log });
124
+ const approverChannels = new Map<string, Notifier[]>(
125
+ Object.entries(a.approvers ?? {}).map(([id, c]) => [id, [new NtfyNotifier({ ...c.ntfy, name: `ntfy:${id}` })]]),
126
+ );
127
+ if (notifiers.length === 0 && approverChannels.size === 0 && !a.elicitation) return undefined;
128
+ if (!notifiers.some((n) => n.listen) && approverChannels.size === 0 && !a.elicitation) {
129
+ log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
130
+ }
131
+ const approvers = approverChannels.size ? `; approvers: ${[...approverChannels.keys()].join(", ")}` : "";
132
+ const shared = [...notifiers.map((n) => n.name), ...(a.elicitation ? ["elicitation (if the client supports it)"] : [])];
133
+ log.info(`approvals: ${shared.join(", ") || "none shared"}${approvers} (timeout ${a.timeoutSeconds}s)`);
134
+ return new ApprovalBroker({ notifiers, approverChannels, timeoutMs: a.timeoutSeconds * 1000, log });
135
+ }
136
+
137
+ /**
138
+ * Check that every role a policy rule names under `approvers:` exists, has
139
+ * named members, and that each member has a channel in
140
+ * `gateway.approvals.approvers`. Returns each such role's members.
141
+ */
142
+ export function approverRoles(labels: AdtlConfig, cfg: GatewayConfig): Map<string, string[]> {
143
+ const out = new Map<string, string[]>();
144
+ const problems: string[] = [];
145
+ for (const rule of labels.policy.rules) {
146
+ for (const role of rule.approvers ?? []) {
147
+ if (out.has(role)) continue;
148
+ const r = cfg.roles?.[role];
149
+ if (!r) {
150
+ problems.push(`policy rule "${rule.name}" names approver role "${role}", which is not under gateway.roles`);
151
+ continue;
152
+ }
153
+ const members = [...r.members];
154
+ if (members.includes("*")) problems.push(`approver role "${role}" has member "*": approvers must be named, so the answer says who approved`);
155
+ for (const m of members.filter((x) => x !== "*")) {
156
+ if (!cfg.approvals?.approvers?.[m]) problems.push(`approver "${m}" (role "${role}") has no channel under gateway.approvals.approvers`);
157
+ }
158
+ out.set(role, members);
159
+ }
160
+ }
161
+ if (problems.length) throw new GatewayConfigError(`approvers:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
162
+ return out;
163
+ }
164
+
165
+ /** `gateway.principal`, with `${VAR}` in its id expanded. */
166
+ export function gatewayPrincipal(cfg: GatewayConfig, env: NodeJS.ProcessEnv): Principal | undefined {
167
+ const p = cfg.principal;
168
+ if (!p) return undefined;
169
+ const id = expandEnv(p.id, env, "gateway.principal.id").trim();
170
+ if (!id) throw new GatewayConfigError("gateway.principal.id is empty after expanding environment variables");
171
+ return { type: p.type, id, ...(p.claims ? { claims: p.claims } : {}) };
172
+ }
173
+
174
+ /**
175
+ * The configured authorizers, in the order they are asked: roles, then cedar.
176
+ * Cedar files are read and parsed here, so a bad policy fails startup.
177
+ */
178
+ export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): Promise<Authorizer[]> {
179
+ const out: Authorizer[] = [];
180
+ if (cfg.roles) out.push(new RoleAuthorizer(cfg.roles));
181
+ if (cfg.cedar) {
182
+ try {
183
+ out.push(await CedarAuthorizer.load(cfg.cedar, (p) => resolve(baseDir, p)));
184
+ } catch (err) {
185
+ throw new GatewayConfigError((err as Error).message);
186
+ }
187
+ }
188
+ return out;
124
189
  }
125
190
 
126
191
  /**
@@ -143,7 +208,10 @@ export async function startGateway(
143
208
  opts: { env?: NodeJS.ProcessEnv; logger?: GatewayLogger } = {},
144
209
  ): Promise<StartedGateway> {
145
210
  const env = opts.env ?? process.env;
211
+ // Config problems that need no process: fail before starting any.
212
+ const approverMembers = approverRoles(loaded.labels, loaded.gateway);
146
213
  const baseDir = dirname(loaded.path);
214
+ const authorizers = await gatewayAuthorizers(loaded.gateway, baseDir);
147
215
  const log = opts.logger ?? { info: (m: string) => console.error(`[adtl-gateway] ${m}`), warn: (m: string) => console.error(`[adtl-gateway] warning: ${m}`) };
148
216
  const clients: Client[] = [];
149
217
  const closeAll = () => Promise.allSettled(clients.map((c) => c.close())).then(() => undefined);
@@ -153,7 +221,10 @@ export async function startGateway(
153
221
  const shared = sharedSession(env);
154
222
  const custom = loaded.gateway.interceptors && expandUpstream("interceptors", loaded.gateway.interceptors, env);
155
223
  // The interceptor process starts with a minimal environment; pass on where the ledger lives.
156
- const ledgerEnv: Record<string, string> = env.ADTL_LEDGER ? { ADTL_LEDGER: env.ADTL_LEDGER } : {};
224
+ const ledgerEnv: Record<string, string> = {
225
+ ...(env.ADTL_LEDGER ? { ADTL_LEDGER: env.ADTL_LEDGER } : {}),
226
+ ...(env[SIGNING_KEY_ENV] ? { [SIGNING_KEY_ENV]: env[SIGNING_KEY_ENV]! } : {}),
227
+ };
157
228
  const icfg: StdioUpstream = custom
158
229
  ? { ...custom, env: { ...ledgerEnv, ...custom.env, ...(shared ? { ADTL_SESSIONS_DIR: shared.dir } : {}) } }
159
230
  : {
@@ -202,12 +273,23 @@ export async function startGateway(
202
273
 
203
274
  const auditLog = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
204
275
  const approvals = createApprovals(loaded.gateway, env, log);
276
+ const principal = gatewayPrincipal(loaded.gateway, env);
277
+ const roles = loaded.gateway.roles;
278
+ if (roles) {
279
+ log.info(`roles: ${Object.keys(roles).join(", ")} (caller: ${principal?.id ?? "anonymous"})`);
280
+ if (!principal) log.warn('roles are set but gateway.principal is not: only roles with members ["*"] apply');
281
+ }
282
+ for (const a of authorizers) if (a.name !== "roles") log.info(`authorizer: ${a.name}`);
205
283
  const gateway = new AdtlGateway({
206
284
  ...(shared ? { sessionId: shared.sessionId } : {}),
207
285
  upstreams,
208
286
  invoker,
209
287
  ...(auditLog ? { audit: auditLog } : {}),
210
288
  ...(approvals ? { approvals } : {}),
289
+ ...(authorizers.length ? { authorizer: authorizers } : {}),
290
+ ...(principal ? { principal } : {}),
291
+ ...(approverMembers.size ? { approverRoles: approverMembers } : {}),
292
+ ...(loaded.gateway.approvals?.elicitation ? { elicitApprovals: true } : {}),
211
293
  logger: log,
212
294
  });
213
295
  return { gateway, close: closeAll };