@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.
- package/README.md +48 -4
- package/dist/approvals.d.ts +97 -0
- package/dist/approvals.d.ts.map +1 -0
- package/dist/approvals.js +151 -0
- package/dist/approvals.js.map +1 -0
- package/dist/audit.d.ts +10 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +8 -2
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +352 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +69 -2
- package/dist/config.js.map +1 -1
- package/dist/email.d.ts +35 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +63 -0
- package/dist/email.js.map +1 -0
- package/dist/gateway.d.ts +12 -2
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +91 -11
- package/dist/gateway.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/invoker.d.ts +10 -1
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +15 -6
- package/dist/invoker.js.map +1 -1
- package/dist/ntfy.d.ts +44 -0
- package/dist/ntfy.d.ts.map +1 -0
- package/dist/ntfy.js +133 -0
- package/dist/ntfy.js.map +1 -0
- package/dist/startup.d.ts +3 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +39 -4
- package/dist/startup.js.map +1 -1
- package/package.json +4 -3
- package/src/approvals.ts +219 -0
- package/src/audit.ts +5 -0
- package/src/cli.ts +15 -4
- package/src/config.ts +71 -2
- package/src/email.ts +79 -0
- package/src/gateway.ts +103 -11
- package/src/index.ts +3 -0
- package/src/invoker.ts +17 -5
- package/src/ntfy.ts +154 -0
- 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.
|
|
11
|
-
*
|
|
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.
|
|
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
|
-
`
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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:
|
|
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
|
-
|
|
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
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
|
|
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
|
|
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
|
|
166
|
-
|
|
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
|
|
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
|
|
153
|
-
const
|
|
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();
|