@adtl/gateway 0.1.0 → 0.2.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 (49) hide show
  1. package/README.md +48 -4
  2. package/dist/approvals.d.ts +97 -0
  3. package/dist/approvals.d.ts.map +1 -0
  4. package/dist/approvals.js +151 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/audit.d.ts +10 -0
  7. package/dist/audit.d.ts.map +1 -1
  8. package/dist/audit.js.map +1 -1
  9. package/dist/cli.js +8 -2
  10. package/dist/cli.js.map +1 -1
  11. package/dist/config.d.ts +352 -2
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +69 -2
  14. package/dist/config.js.map +1 -1
  15. package/dist/email.d.ts +35 -0
  16. package/dist/email.d.ts.map +1 -0
  17. package/dist/email.js +63 -0
  18. package/dist/email.js.map +1 -0
  19. package/dist/gateway.d.ts +12 -2
  20. package/dist/gateway.d.ts.map +1 -1
  21. package/dist/gateway.js +91 -11
  22. package/dist/gateway.js.map +1 -1
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/invoker.d.ts +10 -1
  28. package/dist/invoker.d.ts.map +1 -1
  29. package/dist/invoker.js +15 -6
  30. package/dist/invoker.js.map +1 -1
  31. package/dist/ntfy.d.ts +44 -0
  32. package/dist/ntfy.d.ts.map +1 -0
  33. package/dist/ntfy.js +133 -0
  34. package/dist/ntfy.js.map +1 -0
  35. package/dist/startup.d.ts +3 -0
  36. package/dist/startup.d.ts.map +1 -1
  37. package/dist/startup.js +39 -4
  38. package/dist/startup.js.map +1 -1
  39. package/package.json +4 -3
  40. package/src/approvals.ts +219 -0
  41. package/src/audit.ts +5 -0
  42. package/src/cli.ts +15 -4
  43. package/src/config.ts +71 -2
  44. package/src/email.ts +79 -0
  45. package/src/gateway.ts +103 -11
  46. package/src/index.ts +3 -0
  47. package/src/invoker.ts +17 -5
  48. package/src/ntfy.ts +154 -0
  49. package/src/startup.ts +36 -3
package/src/gateway.ts CHANGED
@@ -7,8 +7,15 @@
7
7
  * interceptors (adtl.label) → agent
8
8
  *
9
9
  * When a call is stopped, the agent receives a normal tool result with
10
- * `isError: true` explaining why, so the model can tell the user. In v0.1 an
11
- * "ask" decision is treated as a block; approvals arrive in v0.2.
10
+ * `isError: true` explaining why, so the model can tell the user.
11
+ *
12
+ * An "ask" decision holds the call and asks a human through the approval
13
+ * broker (e.g. a phone notification). Approved calls run; denied, unanswered
14
+ * or undeliverable ones are stopped. Without an approval channel, "ask" blocks.
15
+ *
16
+ * In audit mode (the invoker runs every interceptor with `mode: "audit"`)
17
+ * nothing is stopped or changed: calls the policy would stop are forwarded,
18
+ * and the audit log records what would have happened.
12
19
  */
13
20
  import { randomUUID } from "node:crypto";
14
21
  import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -20,11 +27,12 @@ import {
20
27
  type Tool,
21
28
  } from "@modelcontextprotocol/sdk/types.js";
22
29
  import type { InvocationContext } from "@adtl/interceptors";
30
+ import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
23
31
  import type { AuditEntry, AuditSink } from "./audit.js";
24
32
  import { TOOL_SEPARATOR } from "./config.js";
25
33
  import type { Blocked, ChainOutcome, InterceptorInvoker } from "./invoker.js";
26
34
 
27
- export const GATEWAY_VERSION = "0.1.0";
35
+ export const GATEWAY_VERSION = "0.2.0";
28
36
 
29
37
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
30
38
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -52,8 +60,18 @@ export interface AdtlGatewayOptions {
52
60
  sessionId?: string;
53
61
  audit?: AuditSink;
54
62
  logger?: GatewayLogger;
63
+ /** Asks humans about "ask" decisions and sends notices. */
64
+ approvals?: ApprovalBroker;
65
+ }
66
+
67
+ export interface CallOptions {
68
+ /** Report progress to the client while a call waits for approval. */
69
+ progress?: (progress: number, message: string) => Promise<void>;
55
70
  }
56
71
 
72
+ /** How often a waiting call reports progress, so clients that reset their timeout on progress keep waiting. */
73
+ const PROGRESS_INTERVAL_MS = 10_000;
74
+
57
75
  function textError(text: string, meta: Record<string, unknown>): CallToolResult {
58
76
  return { content: [{ type: "text", text }], isError: true, _meta: { [GATEWAY_DECISION_KEY]: meta } };
59
77
  }
@@ -62,12 +80,24 @@ function blockedMessage(b: Blocked): string {
62
80
  if (b.decision === "ask") {
63
81
  return (
64
82
  `ADTL stopped this call because it needs a human's approval: ${b.reason}. ` +
65
- `This version of ADTL cannot ask for approval yet, so the call was not run. Tell the user what you were trying to do.`
83
+ `No approval channel is configured, so the call was not run. Tell the user what you were trying to do.`
66
84
  );
67
85
  }
68
86
  return `ADTL blocked this call: ${b.reason}. The call was not run.`;
69
87
  }
70
88
 
89
+ function approvalMessage(b: Blocked, a: ApprovalResult): string {
90
+ const why = `ADTL asked a human to approve this call because: ${b.reason}.`;
91
+ switch (a.outcome) {
92
+ case "denied":
93
+ return `${why} The request was denied, so the call was not run. Do not retry it; tell the user what you were trying to do.`;
94
+ case "timeout":
95
+ return `${why} Nobody answered in time, so the call was not run. Tell the user, who can approve it if you try again.`;
96
+ default:
97
+ return `${why} ADTL could not reach anyone to approve it, so the call was not run. Tell the user what you were trying to do.`;
98
+ }
99
+ }
100
+
71
101
  function infoOf(outcome: ChainOutcome, interceptor: string): Record<string, unknown> {
72
102
  return outcome.results.find((r) => r.interceptor === interceptor)?.info ?? {};
73
103
  }
@@ -78,6 +108,7 @@ export class AdtlGateway {
78
108
  private readonly invoker: InterceptorInvoker;
79
109
  private readonly audit: AuditSink | undefined;
80
110
  private readonly log: GatewayLogger;
111
+ private readonly approvals: ApprovalBroker | undefined;
81
112
  private routes = new Map<string, Route>();
82
113
 
83
114
  constructor(opts: AdtlGatewayOptions) {
@@ -86,6 +117,7 @@ export class AdtlGateway {
86
117
  this.sessionId = opts.sessionId ?? randomUUID();
87
118
  this.audit = opts.audit;
88
119
  this.log = opts.logger ?? stderrLogger;
120
+ this.approvals = opts.approvals;
89
121
  }
90
122
 
91
123
  /** The downstream MCP server the agent connects to. */
@@ -100,7 +132,15 @@ export class AdtlGateway {
100
132
  },
101
133
  );
102
134
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.listTools() }));
103
- server.setRequestHandler(CallToolRequestSchema, async (req) => this.callTool(req.params.name, req.params.arguments ?? {}));
135
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
136
+ const progressToken = req.params._meta?.progressToken;
137
+ const progress =
138
+ progressToken === undefined
139
+ ? undefined
140
+ : (progress: number, message: string) =>
141
+ extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress, message } });
142
+ return this.callTool(req.params.name, req.params.arguments ?? {}, progress ? { progress } : {});
143
+ });
104
144
  return server;
105
145
  }
106
146
 
@@ -127,7 +167,7 @@ export class AdtlGateway {
127
167
  return tools;
128
168
  }
129
169
 
130
- async callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
170
+ async callTool(name: string, args: Record<string, unknown>, opts: CallOptions = {}): Promise<CallToolResult> {
131
171
  const started = performance.now();
132
172
  let route = this.routes.get(name);
133
173
  if (!route) {
@@ -154,12 +194,46 @@ export class AdtlGateway {
154
194
  const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
155
195
  const req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
156
196
  const policy = infoOf(req, "adtl.policy");
197
+ let approval: ApprovalResult | undefined;
157
198
  if (req.blocked) {
158
- finish({ outcome: "blocked", decision: req.blocked.decision, reason: req.blocked.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel, warnings: req.warnings });
159
- this.log.info(`${req.blocked.decision} ${name}: ${req.blocked.reason}`);
160
- return textError(blockedMessage(req.blocked), { ...req.blocked, callHash: policy.callHash });
199
+ const b = req.blocked;
200
+ const stopped = { decision: b.decision, reason: b.reason, matched: policy.matched, callHash: policy.callHash, sessionLabel: policy.sessionLabel };
201
+ const matched = Array.isArray(policy.matched) ? (policy.matched as string[]) : [];
202
+ if (b.decision === "ask" && this.approvals?.canApprove && typeof policy.callHash === "string") {
203
+ this.log.info(`ask ${name}: waiting for approval (${b.reason})`);
204
+ approval = await this.waitForApproval(opts, () =>
205
+ this.approvals!.request({
206
+ sessionId: this.sessionId,
207
+ server: route.upstream,
208
+ tool: route.tool,
209
+ callHash: policy.callHash as string,
210
+ reason: b.reason,
211
+ matched,
212
+ ...(policy.sessionLabel !== undefined ? { sessionLabel: policy.sessionLabel } : {}),
213
+ }),
214
+ );
215
+ this.log.info(`ask ${name}: ${approval.outcome}`);
216
+ if (approval.outcome !== "approved") {
217
+ finish({ outcome: "blocked", ...stopped, approval, warnings: req.warnings });
218
+ return textError(approvalMessage(b, approval), { ...b, callHash: policy.callHash, approval });
219
+ }
220
+ } else {
221
+ finish({ outcome: "blocked", ...stopped, warnings: req.warnings });
222
+ this.log.info(`${b.decision} ${name}: ${b.reason}`);
223
+ this.approvals?.blocked({ sessionId: this.sessionId, server: route.upstream, tool: route.tool, reason: b.reason, matched });
224
+ return textError(blockedMessage(b), { ...b, callHash: policy.callHash });
225
+ }
161
226
  }
162
227
  const forwarded = (req.payload as typeof requestPayload).params;
228
+ const decision = policy["adtl/decision"] as string | undefined;
229
+ const approved = approval ? { approval } : {};
230
+ const audit = this.invoker.modeOverride === "audit";
231
+ // Only set in audit mode, for calls the policy would have stopped.
232
+ const wouldStop =
233
+ audit && decision !== undefined && decision !== "allow"
234
+ ? { reason: policy.reason as string | undefined, matched: policy.matched }
235
+ : undefined;
236
+ if (wouldStop) this.log.info(`audit: would ${decision} ${name}: ${wouldStop.reason}`);
163
237
 
164
238
  // 2. Forward to the upstream server.
165
239
  let result: CallToolResult;
@@ -167,7 +241,7 @@ export class AdtlGateway {
167
241
  result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments })) as CallToolResult;
168
242
  } catch (err) {
169
243
  const reason = (err as Error).message;
170
- finish({ outcome: "upstream_error", decision: "allow", reason, callHash: policy.callHash, warnings: req.warnings });
244
+ finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), ...approved, reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
171
245
  return textError(`The ${route.upstream} server returned an error: ${reason}`, { reason: "upstream_error" });
172
246
  }
173
247
 
@@ -187,7 +261,11 @@ export class AdtlGateway {
187
261
  const label = infoOf(res, "adtl.label");
188
262
  finish({
189
263
  outcome: "forwarded",
190
- decision: (policy["adtl/decision"] as string | undefined) ?? "allow",
264
+ // Missing only if adtl.policy failed and was allowed to (audit mode); see warnings.
265
+ ...(decision ? { decision } : {}),
266
+ ...(wouldStop ?? {}),
267
+ ...approved,
268
+ ...(audit ? { mode: "audit" as const } : {}),
191
269
  callHash: policy.callHash,
192
270
  label: label.label,
193
271
  sessionLabel: label.sessionLabel,
@@ -195,4 +273,18 @@ export class AdtlGateway {
195
273
  });
196
274
  return (res.payload as { result: CallToolResult }).result;
197
275
  }
276
+
277
+ /** Run an approval request, reporting progress to the client while it waits. */
278
+ private async waitForApproval(opts: CallOptions, ask: () => Promise<ApprovalResult>): Promise<ApprovalResult> {
279
+ if (!opts.progress) return ask();
280
+ let n = 0;
281
+ const report = () => void opts.progress!(++n, "Waiting for a human to approve this call").catch(() => undefined);
282
+ report();
283
+ const timer = setInterval(report, PROGRESS_INTERVAL_MS);
284
+ try {
285
+ return await ask();
286
+ } finally {
287
+ clearInterval(timer);
288
+ }
289
+ }
198
290
  }
package/src/index.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export * from "./config.js";
2
2
  export * from "./invoker.js";
3
3
  export * from "./audit.js";
4
+ export * from "./approvals.js";
5
+ export * from "./ntfy.js";
6
+ export * from "./email.js";
4
7
  export * from "./gateway.js";
5
8
  export * from "./startup.js";
package/src/invoker.ts CHANGED
@@ -9,6 +9,9 @@
9
9
  * Only `severity: "error"` blocks. Audit-mode interceptors never block, and
10
10
  * their mutations are not applied. An interceptor that fails (error or
11
11
  * timeout) blocks the message unless it declares `failOpen: true`.
12
+ *
13
+ * The invoker may override every interceptor's `mode`, as SEP-2624 allows
14
+ * (e.g. audit in staging, active in production).
12
15
  */
13
16
  import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
17
  import {
@@ -35,6 +38,8 @@ function request(client: Client, method: string, params: unknown, timeout: numbe
35
38
  return r.call(client, { method, params }, AnyResult, { timeout });
36
39
  }
37
40
 
41
+ export type InterceptorMode = NonNullable<InterceptorDefinition["mode"]>;
42
+
38
43
  export interface BoundInterceptor {
39
44
  client: Client;
40
45
  definition: InterceptorDefinition;
@@ -76,6 +81,8 @@ export class InterceptorInvoker {
76
81
  constructor(
77
82
  private readonly interceptors: BoundInterceptor[],
78
83
  private readonly timeoutMs = 5000,
84
+ /** Overrides the mode every interceptor declares. */
85
+ readonly modeOverride?: InterceptorMode,
79
86
  ) {}
80
87
 
81
88
  /** Discover the interceptors an interceptor server offers. */
@@ -103,6 +110,10 @@ export class InterceptorInvoker {
103
110
  return out;
104
111
  }
105
112
 
113
+ private isAudit(i: BoundInterceptor): boolean {
114
+ return (this.modeOverride ?? i.definition.mode) === "audit";
115
+ }
116
+
106
117
  private select(type: InterceptorDefinition["type"], event: string, phase: Phase): BoundInterceptor[] {
107
118
  return this.interceptors.filter((i) => i.definition.type === type && hooks(i.definition, event, phase));
108
119
  }
@@ -120,7 +131,7 @@ export class InterceptorInvoker {
120
131
  /** Handle an interceptor failure. Returns false if the chain must stop. */
121
132
  private failed(i: BoundInterceptor, err: unknown, out: ChainOutcome): boolean {
122
133
  const msg = `${i.definition.name} failed: ${err instanceof Error ? err.message : String(err)}`;
123
- if (i.definition.mode === "audit" || i.definition.failOpen === true) {
134
+ if (this.isAudit(i) || i.definition.failOpen === true) {
124
135
  out.warnings.push(msg);
125
136
  return true;
126
137
  }
@@ -143,7 +154,7 @@ export class InterceptorInvoker {
143
154
  continue;
144
155
  }
145
156
  out.results.push(r);
146
- if (i.definition.mode !== "audit" && r.modified) payload = r.payload;
157
+ if (!this.isAudit(i) && r.modified) payload = r.payload;
147
158
  }
148
159
  out.payload = payload;
149
160
  return true;
@@ -162,11 +173,12 @@ export class InterceptorInvoker {
162
173
  }
163
174
  const r = s.value as ValidationResult;
164
175
  out.results.push(r);
165
- if (r.valid || r.severity !== "error" || i.definition.mode === "audit") return;
166
- if (!out.blocked) {
176
+ if (r.valid || r.severity !== "error" || this.isAudit(i)) return;
177
+ // Keep the strictest rejection, so an "ask" never hides another validator's "block".
178
+ if (!out.blocked || out.blocked.decision === "ask") {
167
179
  const decision = (r.info?.[DECISION_INFO_KEY] as Decision | undefined) ?? "block";
168
180
  const reason = r.messages?.map((m) => m.message).join("; ") || `${i.definition.name} rejected the call`;
169
- out.blocked = { interceptor: i.definition.name, decision, reason };
181
+ if (!out.blocked || decision === "block") out.blocked = { interceptor: i.definition.name, decision, reason };
170
182
  }
171
183
  ok = false;
172
184
  });
package/src/ntfy.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * ntfy (https://ntfy.sh) push notifications with Allow / Deny buttons.
3
+ *
4
+ * The buttons make the phone POST a signed answer token to a reply topic on
5
+ * the same ntfy server, and the gateway subscribes to that topic. So answers
6
+ * reach the gateway without it ever accepting inbound connections.
7
+ *
8
+ * Messages name the tool, server and reason, never arguments or results.
9
+ * Anyone who can read the topic can press the buttons, so use a long random
10
+ * topic name or an access-controlled topic on your own server.
11
+ */
12
+ import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
13
+
14
+ export interface NtfyOptions {
15
+ /** Server base URL, e.g. https://ntfy.sh */
16
+ server: string;
17
+ topic: string;
18
+ /** Topic answers are posted to. Default: `<topic>-reply`. */
19
+ replyTopic?: string;
20
+ /** Access token for protected topics. */
21
+ token?: string;
22
+ fetch?: typeof fetch;
23
+ /** Delay before re-subscribing after the stream drops, and the base delay between publish retries. */
24
+ reconnectMs?: number;
25
+ /** Publish attempts before giving up (network errors, 429 and 5xx are retried). */
26
+ publishAttempts?: number;
27
+ }
28
+
29
+ function describe(request: ApprovalRequest): string {
30
+ const secs = Math.max(0, Math.round((request.expiresAt.getTime() - request.createdAt.getTime()) / 1000));
31
+ return `${request.reason}\n\nRule: ${request.matched.join(", ") || "default"}\nExpires in ${secs}s; no answer means deny.`;
32
+ }
33
+
34
+ export class NtfyNotifier implements Notifier {
35
+ readonly name = "ntfy";
36
+ private readonly server: string;
37
+ private readonly topic: string;
38
+ private readonly replyTopic: string;
39
+ private readonly token: string | undefined;
40
+ private readonly fetch: typeof fetch;
41
+ private readonly reconnectMs: number;
42
+ private readonly publishAttempts: number;
43
+
44
+ constructor(opts: NtfyOptions) {
45
+ this.server = opts.server.replace(/\/+$/, "");
46
+ this.topic = opts.topic;
47
+ this.replyTopic = opts.replyTopic ?? `${opts.topic}-reply`;
48
+ this.token = opts.token;
49
+ this.fetch = opts.fetch ?? globalThis.fetch;
50
+ this.reconnectMs = opts.reconnectMs ?? 1000;
51
+ this.publishAttempts = opts.publishAttempts ?? 3;
52
+ }
53
+
54
+ private get auth(): Record<string, string> {
55
+ return this.token ? { Authorization: `Bearer ${this.token}` } : {};
56
+ }
57
+
58
+ async notify(event: ApprovalEvent): Promise<void> {
59
+ if (event.kind === "blocked") return; // push is for decisions; email covers blocked notices
60
+ const { request } = event;
61
+ const subject = `${request.server}.${request.tool}`;
62
+ let message: Record<string, unknown>;
63
+ if (event.kind === "pending") {
64
+ const reply = `${this.server}/${encodeURIComponent(this.replyTopic)}`;
65
+ const button = (label: string, body: string) => ({
66
+ action: "http",
67
+ label,
68
+ url: reply,
69
+ method: "POST",
70
+ body,
71
+ clear: true,
72
+ ...(this.token ? { headers: this.auth } : {}),
73
+ });
74
+ message = {
75
+ title: `ADTL: allow ${subject}?`,
76
+ message: describe(request),
77
+ priority: 4,
78
+ tags: ["warning"],
79
+ actions: [button("Allow", event.tokens.allow), button("Deny", event.tokens.deny)],
80
+ };
81
+ } else {
82
+ message = {
83
+ title: `ADTL: ${subject} ${event.result.outcome}`,
84
+ message: `Approval ${event.result.outcome} after ${Math.round(event.result.waitedMs / 1000)}s.`,
85
+ priority: 2,
86
+ tags: [event.result.outcome === "approved" ? "white_check_mark" : "x"],
87
+ };
88
+ }
89
+ await this.publish(JSON.stringify({ topic: this.topic, ...message }));
90
+ }
91
+
92
+ /** POST with retries: flaky networks should not turn an approval request into a denial. */
93
+ private async publish(body: string): Promise<void> {
94
+ for (let attempt = 1; ; attempt++) {
95
+ let res: Response | undefined;
96
+ try {
97
+ res = await this.fetch(this.server, { method: "POST", headers: { "Content-Type": "application/json", ...this.auth }, body });
98
+ } catch (err) {
99
+ if (attempt >= this.publishAttempts) throw err;
100
+ }
101
+ if (res?.ok) return;
102
+ if (res && ((res.status !== 429 && res.status < 500) || attempt >= this.publishAttempts)) {
103
+ throw new Error(`ntfy publish failed: HTTP ${res.status}`);
104
+ }
105
+ await new Promise((r) => setTimeout(r, this.reconnectMs * attempt));
106
+ }
107
+ }
108
+
109
+ async listen(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void> {
110
+ // `since` replays anything posted after the request was created, so a fast
111
+ // answer is not lost while (re)connecting.
112
+ const since = Math.floor(request.createdAt.getTime() / 1000) - 1;
113
+ const url = `${this.server}/${encodeURIComponent(this.replyTopic)}/json?since=${since}`;
114
+ while (!signal.aborted) {
115
+ try {
116
+ const res = await this.fetch(url, { headers: this.auth, signal });
117
+ if (!res.ok || !res.body) throw new Error(`ntfy subscribe failed: HTTP ${res.status}`);
118
+ await this.read(res.body, answer, signal);
119
+ } catch (err) {
120
+ if (signal.aborted) return;
121
+ if ((err as Error).message.includes("HTTP 401") || (err as Error).message.includes("HTTP 403")) throw err;
122
+ }
123
+ if (!signal.aborted) await sleep(this.reconnectMs, signal);
124
+ }
125
+ }
126
+
127
+ private async read(body: ReadableStream<Uint8Array>, answer: AnswerFn, signal: AbortSignal): Promise<void> {
128
+ const decoder = new TextDecoder();
129
+ let buf = "";
130
+ for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
131
+ if (signal.aborted) return;
132
+ buf += decoder.decode(chunk, { stream: true });
133
+ let nl: number;
134
+ while ((nl = buf.indexOf("\n")) >= 0) {
135
+ const line = buf.slice(0, nl).trim();
136
+ buf = buf.slice(nl + 1);
137
+ if (!line) continue;
138
+ try {
139
+ const msg = JSON.parse(line) as { event?: string; message?: string };
140
+ if (msg.event === "message" && typeof msg.message === "string") answer(msg.message);
141
+ } catch {
142
+ // Not JSON: ignore.
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
150
+ return new Promise((resolve) => {
151
+ const t = setTimeout(resolve, ms);
152
+ signal.addEventListener("abort", () => (clearTimeout(t), resolve()), { once: true });
153
+ });
154
+ }
package/src/startup.ts CHANGED
@@ -12,17 +12,21 @@ import { INTERCEPTORS_EXTENSION, LABEL_INTERCEPTOR, POLICY_INTERCEPTOR, parseCon
12
12
  import type { AdtlConfig } from "@adtl/labels";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { ZodError } from "zod";
15
+ import { ApprovalBroker, type Notifier } from "./approvals.js";
15
16
  import { JsonlAuditLog } from "./audit.js";
16
17
  import {
17
18
  GatewayConfigSchema,
19
+ expandApprovals,
18
20
  expandUpstream,
19
21
  isHttpUpstream,
20
22
  type GatewayConfig,
21
23
  type StdioUpstream,
22
24
  type Upstream,
23
25
  } from "./config.js";
26
+ import { EmailNotifier } from "./email.js";
24
27
  import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
25
28
  import { InterceptorInvoker } from "./invoker.js";
29
+ import { NtfyNotifier } from "./ntfy.js";
26
30
 
27
31
  export class GatewayConfigError extends Error {
28
32
  constructor(message: string) {
@@ -93,6 +97,26 @@ async function connect(id: string, u: Upstream, baseDir: string): Promise<Client
93
97
  return client;
94
98
  }
95
99
 
100
+ /** Build the approval broker from `gateway.approvals`, or undefined if not configured. */
101
+ export function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log: GatewayLogger): ApprovalBroker | undefined {
102
+ if (!cfg.approvals) return undefined;
103
+ const a = expandApprovals(cfg.approvals, env);
104
+ const notifiers: Notifier[] = [];
105
+ if (a.ntfy) {
106
+ if (/^https:\/\/ntfy\.sh\/?$/.test(a.ntfy.server) && a.ntfy.topic.length < 20 && !a.ntfy.token) {
107
+ log.warn("gateway.approvals.ntfy.topic is short and on the public ntfy.sh server: anyone who guesses it can approve calls. Use a long random topic.");
108
+ }
109
+ notifiers.push(new NtfyNotifier(a.ntfy));
110
+ }
111
+ if (a.email) {
112
+ notifiers.push(new EmailNotifier({ smtp: a.email.smtp, from: a.email.from, to: [a.email.to].flat(), on: a.email.on }));
113
+ }
114
+ if (notifiers.length === 0) return undefined;
115
+ if (!notifiers.some((n) => n.listen)) log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
116
+ log.info(`approvals: ${notifiers.map((n) => n.name).join(", ")} (timeout ${a.timeoutSeconds}s)`);
117
+ return new ApprovalBroker({ notifiers, timeoutMs: a.timeoutSeconds * 1000, log });
118
+ }
119
+
96
120
  export interface StartedGateway {
97
121
  gateway: AdtlGateway;
98
122
  close(): Promise<void>;
@@ -125,8 +149,10 @@ export async function startGateway(
125
149
  throw new GatewayConfigError(`The interceptor server does not offer ${required}; refusing to start without it`);
126
150
  }
127
151
  }
128
- const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs);
152
+ const audit = loaded.gateway.mode === "audit";
153
+ const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined);
129
154
  log.info(`interceptors: ${names.join(", ")}`);
155
+ if (audit) log.warn("audit mode: calls are logged but never stopped or changed");
130
156
 
131
157
  // 2. Upstream servers. One failing upstream does not stop the others.
132
158
  const upstreams = new Map<string, Client>();
@@ -149,8 +175,15 @@ export async function startGateway(
149
175
  if (upstreams.size === 0) throw new GatewayConfigError("No upstream server could be connected");
150
176
  log.info(`upstreams: ${[...upstreams.keys()].join(", ")}`);
151
177
 
152
- const audit = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
153
- const gateway = new AdtlGateway({ upstreams, invoker, ...(audit ? { audit } : {}), logger: log });
178
+ const auditLog = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
179
+ const approvals = createApprovals(loaded.gateway, env, log);
180
+ const gateway = new AdtlGateway({
181
+ upstreams,
182
+ invoker,
183
+ ...(auditLog ? { audit: auditLog } : {}),
184
+ ...(approvals ? { approvals } : {}),
185
+ logger: log,
186
+ });
154
187
  return { gateway, close: closeAll };
155
188
  } catch (err) {
156
189
  await closeAll();