@adtl/gateway 0.1.1 → 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 +36 -3
- 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 +7 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/config.d.ts +346 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +63 -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 +75 -10
- 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.map +1 -1
- package/dist/invoker.js +4 -2
- 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 +34 -2
- package/dist/startup.js.map +1 -1
- package/package.json +4 -3
- package/src/approvals.ts +219 -0
- package/src/audit.ts +2 -0
- package/src/config.ts +65 -2
- package/src/email.ts +79 -0
- package/src/gateway.ts +87 -10
- package/src/index.ts +3 -0
- package/src/invoker.ts +3 -2
- package/src/ntfy.ts +154 -0
- package/src/startup.ts +32 -1
package/dist/startup.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"startup.js","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,2CAA2C,CAAC;AACxG,OAAO,EAAE,6BAA6B,EAAE,MAAM,oDAAoD,CAAC;AACnG,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAEpH,OAAO,EAAE,KAAK,IAAI,SAAS,EAAE,MAAM,MAAM,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAE,MAAM,KAAK,CAAC;AAC/B,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,cAAc,GAIf,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,eAAe,EAAsB,MAAM,cAAc,CAAC;AAChF,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"startup.js","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adtl/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Experimental: MCP gateway that labels every tool result and blocks risky tool calls using ADTL trust labels",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -46,9 +46,10 @@
|
|
|
46
46
|
"url": "https://github.com/probita-ai/adtl/issues"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adtl/interceptors": "0.
|
|
50
|
-
"@adtl/labels": "0.
|
|
49
|
+
"@adtl/interceptors": "0.2.0",
|
|
50
|
+
"@adtl/labels": "0.2.0",
|
|
51
51
|
"@modelcontextprotocol/sdk": "1.31.0",
|
|
52
|
+
"nodemailer": "10.0.12",
|
|
52
53
|
"yaml": "2.9.1",
|
|
53
54
|
"zod": "3.25.76"
|
|
54
55
|
},
|
package/src/approvals.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human approvals for "ask" decisions.
|
|
3
|
+
*
|
|
4
|
+
* When adtl.policy answers `ask`, the gateway holds the call and asks the
|
|
5
|
+
* broker. The broker sends the request to every notifier and waits for an
|
|
6
|
+
* answer from a notifier that can carry one (e.g. ntfy). No answer before the
|
|
7
|
+
* timeout means deny.
|
|
8
|
+
*
|
|
9
|
+
* Answers are signed tokens: HMAC over the approval id, the call's hash, the
|
|
10
|
+
* decision and an expiry. A token only ever answers the one pending call it
|
|
11
|
+
* was made for, and only once. Anyone who can read the push channel can still
|
|
12
|
+
* use the tokens sent on it, so that channel must stay private.
|
|
13
|
+
*/
|
|
14
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
export type ApprovalDecision = "allow" | "deny";
|
|
17
|
+
export type ApprovalOutcome = "approved" | "denied" | "timeout" | "undeliverable";
|
|
18
|
+
|
|
19
|
+
export interface ApprovalRequest {
|
|
20
|
+
id: string;
|
|
21
|
+
sessionId: string;
|
|
22
|
+
server: string;
|
|
23
|
+
tool: string;
|
|
24
|
+
callHash: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
matched: string[];
|
|
27
|
+
/** The session label that triggered the rule (no arguments or results). */
|
|
28
|
+
sessionLabel?: unknown;
|
|
29
|
+
createdAt: Date;
|
|
30
|
+
expiresAt: Date;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ApprovalResult {
|
|
34
|
+
outcome: ApprovalOutcome;
|
|
35
|
+
/** Notifier that delivered the answer. */
|
|
36
|
+
channel?: string;
|
|
37
|
+
approver?: string;
|
|
38
|
+
waitedMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ApprovalEvent =
|
|
42
|
+
| { kind: "pending"; request: ApprovalRequest; tokens: Record<ApprovalDecision, string> }
|
|
43
|
+
| { kind: "resolved"; request: ApprovalRequest; result: ApprovalResult }
|
|
44
|
+
| { kind: "blocked"; sessionId: string; server: string; tool: string; reason: string; matched: string[] };
|
|
45
|
+
|
|
46
|
+
/** Called by a notifier with every answer it receives. Returns true if the answer was accepted. */
|
|
47
|
+
export type AnswerFn = (token: string, approver?: string) => boolean;
|
|
48
|
+
|
|
49
|
+
export interface Notifier {
|
|
50
|
+
readonly name: string;
|
|
51
|
+
/** Deliver an event. Throwing marks the delivery as failed; it never stops the call by itself. */
|
|
52
|
+
notify(event: ApprovalEvent): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Notifiers that can carry answers: pass every answer for `request` to
|
|
55
|
+
* `answer` until `signal` aborts. Called before the pending event is sent.
|
|
56
|
+
*/
|
|
57
|
+
listen?(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TOKEN_PREFIX = "adtl1";
|
|
61
|
+
|
|
62
|
+
/** Error message plus its cause, e.g. "fetch failed (ECONNRESET)". */
|
|
63
|
+
export function errorText(err: unknown): string {
|
|
64
|
+
if (!(err instanceof Error)) return String(err);
|
|
65
|
+
const cause = err.cause as { code?: string; message?: string } | undefined;
|
|
66
|
+
const detail = cause?.code ?? cause?.message;
|
|
67
|
+
return detail && !err.message.includes(detail) ? `${err.message} (${detail})` : err.message;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ApprovalTokens {
|
|
71
|
+
constructor(private readonly secret: Buffer = randomBytes(32)) {}
|
|
72
|
+
|
|
73
|
+
sign(request: Pick<ApprovalRequest, "id" | "callHash" | "expiresAt">, decision: ApprovalDecision): string {
|
|
74
|
+
const exp = Math.floor(request.expiresAt.getTime() / 1000);
|
|
75
|
+
return `${TOKEN_PREFIX}.${request.id}.${decision}.${exp}.${this.mac(request.id, request.callHash, decision, exp)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse a token without verifying it. */
|
|
79
|
+
static parse(token: string): { id: string; decision: ApprovalDecision; exp: number; sig: string } | undefined {
|
|
80
|
+
const parts = token.trim().split(".");
|
|
81
|
+
if (parts.length !== 5 || parts[0] !== TOKEN_PREFIX) return undefined;
|
|
82
|
+
const [, id, decision, exp, sig] = parts as [string, string, string, string, string];
|
|
83
|
+
if (decision !== "allow" && decision !== "deny") return undefined;
|
|
84
|
+
if (!/^\d+$/.test(exp)) return undefined;
|
|
85
|
+
return { id, decision, exp: Number(exp), sig };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Verify a token against the pending request it claims to answer. */
|
|
89
|
+
verify(token: string, request: Pick<ApprovalRequest, "id" | "callHash">, now: Date): ApprovalDecision | undefined {
|
|
90
|
+
const t = ApprovalTokens.parse(token);
|
|
91
|
+
if (!t || t.id !== request.id) return undefined;
|
|
92
|
+
if (now.getTime() > t.exp * 1000) return undefined;
|
|
93
|
+
const expected = Buffer.from(this.mac(t.id, request.callHash, t.decision, t.exp));
|
|
94
|
+
const given = Buffer.from(t.sig);
|
|
95
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined;
|
|
96
|
+
return t.decision;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private mac(id: string, callHash: string, decision: ApprovalDecision, exp: number): string {
|
|
100
|
+
return createHmac("sha256", this.secret).update(`${id}\n${callHash}\n${decision}\n${exp}`).digest("base64url");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ApprovalBrokerOptions {
|
|
105
|
+
notifiers: Notifier[];
|
|
106
|
+
timeoutMs: number;
|
|
107
|
+
tokens?: ApprovalTokens;
|
|
108
|
+
now?: () => Date;
|
|
109
|
+
log?: { warn(msg: string): void };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface Pending {
|
|
113
|
+
request: ApprovalRequest;
|
|
114
|
+
settle(decision: ApprovalDecision, channel: string, approver?: string): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class ApprovalBroker {
|
|
118
|
+
private readonly notifiers: Notifier[];
|
|
119
|
+
private readonly timeoutMs: number;
|
|
120
|
+
private readonly tokens: ApprovalTokens;
|
|
121
|
+
private readonly now: () => Date;
|
|
122
|
+
private readonly log: { warn(msg: string): void };
|
|
123
|
+
private readonly pending = new Map<string, Pending>();
|
|
124
|
+
|
|
125
|
+
constructor(opts: ApprovalBrokerOptions) {
|
|
126
|
+
this.notifiers = opts.notifiers;
|
|
127
|
+
this.timeoutMs = opts.timeoutMs;
|
|
128
|
+
this.tokens = opts.tokens ?? new ApprovalTokens();
|
|
129
|
+
this.now = opts.now ?? (() => new Date());
|
|
130
|
+
this.log = opts.log ?? { warn: () => {} };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True if at least one notifier can carry answers. Without one, "ask" stays a block. */
|
|
134
|
+
get canApprove(): boolean {
|
|
135
|
+
return this.notifiers.some((n) => n.listen);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Ask for approval and wait for the answer, the timeout, or a failure to reach anyone. */
|
|
139
|
+
async request(input: Omit<ApprovalRequest, "id" | "createdAt" | "expiresAt">): Promise<ApprovalResult> {
|
|
140
|
+
const createdAt = this.now();
|
|
141
|
+
const request: ApprovalRequest = {
|
|
142
|
+
...input,
|
|
143
|
+
id: randomUUID(),
|
|
144
|
+
createdAt,
|
|
145
|
+
expiresAt: new Date(createdAt.getTime() + this.timeoutMs),
|
|
146
|
+
};
|
|
147
|
+
const started = performance.now();
|
|
148
|
+
const waited = () => Math.round(performance.now() - started);
|
|
149
|
+
const abort = new AbortController();
|
|
150
|
+
|
|
151
|
+
let finish!: (r: ApprovalResult) => void;
|
|
152
|
+
const result = new Promise<ApprovalResult>((resolve) => {
|
|
153
|
+
const timer = setTimeout(() => finish({ outcome: "timeout", waitedMs: waited() }), this.timeoutMs);
|
|
154
|
+
finish = (r) => {
|
|
155
|
+
if (!this.pending.delete(request.id)) return; // already settled
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
abort.abort();
|
|
158
|
+
resolve(r);
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
this.pending.set(request.id, {
|
|
162
|
+
request,
|
|
163
|
+
settle: (decision, channel, approver) =>
|
|
164
|
+
finish({ outcome: decision === "allow" ? "approved" : "denied", channel, ...(approver ? { approver } : {}), waitedMs: waited() }),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Start listening before anyone can answer, then send the request everywhere.
|
|
168
|
+
const listeners = this.notifiers.filter((n) => n.listen);
|
|
169
|
+
for (const n of listeners) {
|
|
170
|
+
n.listen!(request, (token, approver) => this.answer(token, n.name, approver), abort.signal).catch((err: unknown) =>
|
|
171
|
+
this.log.warn(`${n.name}: stopped listening for approvals: ${errorText(err)}`),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const tokens = { allow: this.tokens.sign(request, "allow"), deny: this.tokens.sign(request, "deny") };
|
|
175
|
+
const sent = this.notifiers.map((n) =>
|
|
176
|
+
n.notify({ kind: "pending", request, tokens }).then(
|
|
177
|
+
() => true,
|
|
178
|
+
(err: unknown) => {
|
|
179
|
+
this.log.warn(`${n.name}: could not send approval request: ${errorText(err)}`);
|
|
180
|
+
return false;
|
|
181
|
+
},
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
// Nobody who could answer was reached: deny now rather than wait out the timeout.
|
|
185
|
+
// Notify-only channels (email) are not waited for.
|
|
186
|
+
void Promise.all(sent.filter((_, i) => this.notifiers[i]!.listen)).then((ok) => {
|
|
187
|
+
if (!ok.includes(true)) finish({ outcome: "undeliverable", waitedMs: waited() });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const r = await result;
|
|
191
|
+
this.broadcast({ kind: "resolved", request, result: r });
|
|
192
|
+
return r;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Handle an answer token from a channel. Returns true if it settled a pending approval. */
|
|
196
|
+
answer(token: string, channel: string, approver?: string): boolean {
|
|
197
|
+
const parsed = ApprovalTokens.parse(token);
|
|
198
|
+
const p = parsed && this.pending.get(parsed.id);
|
|
199
|
+
if (!p) return false; // not ours, already answered, or garbage
|
|
200
|
+
const decision = this.tokens.verify(token, p.request, this.now());
|
|
201
|
+
if (!decision) {
|
|
202
|
+
this.log.warn(`${channel}: ignored an approval answer with a bad or expired signature`);
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
p.settle(decision, channel, approver);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Tell notifiers a call was blocked. Fire and forget. */
|
|
210
|
+
blocked(event: Omit<Extract<ApprovalEvent, { kind: "blocked" }>, "kind">): void {
|
|
211
|
+
this.broadcast({ kind: "blocked", ...event });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private broadcast(event: ApprovalEvent): void {
|
|
215
|
+
for (const n of this.notifiers) {
|
|
216
|
+
n.notify(event).catch((err: unknown) => this.log.warn(`${n.name}: could not send ${event.kind} notice: ${errorText(err)}`));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/audit.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface AuditEntry {
|
|
|
19
19
|
decision?: string;
|
|
20
20
|
/** Present when the gateway ran in audit mode: nothing was stopped or changed. */
|
|
21
21
|
mode?: "audit";
|
|
22
|
+
/** Present when a human was asked: outcome, channel, approver (if known) and wait time. */
|
|
23
|
+
approval?: { outcome: string; channel?: string; approver?: string; waitedMs: number };
|
|
22
24
|
reason?: string;
|
|
23
25
|
matched?: unknown;
|
|
24
26
|
callHash?: unknown;
|
package/src/config.ts
CHANGED
|
@@ -12,11 +12,19 @@
|
|
|
12
12
|
* docs:
|
|
13
13
|
* url: https://example.com/mcp
|
|
14
14
|
* headers: { Authorization: "Bearer ${DOCS_TOKEN}" }
|
|
15
|
+
* approvals: # optional: make "ask" really ask
|
|
16
|
+
* timeoutSeconds: 50
|
|
17
|
+
* ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
|
|
18
|
+
* email:
|
|
19
|
+
* smtp: { host: smtp.example.com, port: 465, user: "${SMTP_USER}", pass: "${SMTP_PASS}" }
|
|
20
|
+
* from: adtl@example.com
|
|
21
|
+
* to: you@example.com
|
|
15
22
|
*
|
|
16
|
-
* `${VAR}` in command, args, env, cwd, url
|
|
17
|
-
* gateway's environment. A missing variable is a config error.
|
|
23
|
+
* `${VAR}` in command, args, env, cwd, url, headers and approval settings is
|
|
24
|
+
* replaced from the gateway's environment. A missing variable is a config error.
|
|
18
25
|
*/
|
|
19
26
|
import { z } from "zod";
|
|
27
|
+
import { EMAIL_EVENTS } from "./email.js";
|
|
20
28
|
|
|
21
29
|
const UPSTREAM_ID = /^[A-Za-z0-9-]+$/;
|
|
22
30
|
|
|
@@ -41,6 +49,48 @@ export type StdioUpstream = z.infer<typeof StdioUpstreamSchema>;
|
|
|
41
49
|
export type HttpUpstream = z.infer<typeof HttpUpstreamSchema>;
|
|
42
50
|
export type Upstream = z.infer<typeof UpstreamSchema>;
|
|
43
51
|
|
|
52
|
+
export const NtfyConfigSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
server: z.string().min(1).default("https://ntfy.sh"),
|
|
55
|
+
/** Topic approval requests are sent to. Anyone who can read it can approve: keep it secret. */
|
|
56
|
+
topic: z.string().min(1),
|
|
57
|
+
/** Topic answers are posted to. Default: `<topic>-reply`. */
|
|
58
|
+
replyTopic: z.string().min(1).optional(),
|
|
59
|
+
token: z.string().min(1).optional(),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
|
62
|
+
|
|
63
|
+
export const EmailConfigSchema = z
|
|
64
|
+
.object({
|
|
65
|
+
smtp: z
|
|
66
|
+
.object({
|
|
67
|
+
host: z.string().min(1),
|
|
68
|
+
port: z.number().int().positive().default(587),
|
|
69
|
+
/** TLS from the start. Default: true on port 465, otherwise STARTTLS. */
|
|
70
|
+
secure: z.boolean().optional(),
|
|
71
|
+
user: z.string().min(1).optional(),
|
|
72
|
+
pass: z.string().optional(),
|
|
73
|
+
})
|
|
74
|
+
.strict(),
|
|
75
|
+
from: z.string().min(1),
|
|
76
|
+
to: z.union([z.string().min(1), z.array(z.string().min(1)).nonempty()]),
|
|
77
|
+
on: z.array(z.enum(EMAIL_EVENTS)).nonempty().default(["pending", "blocked"]),
|
|
78
|
+
})
|
|
79
|
+
.strict();
|
|
80
|
+
|
|
81
|
+
export const ApprovalsConfigSchema = z
|
|
82
|
+
.object({
|
|
83
|
+
/**
|
|
84
|
+
* How long a call waits for an answer; no answer means deny. Many MCP
|
|
85
|
+
* clients give up on a request after 60 seconds, hence the default.
|
|
86
|
+
*/
|
|
87
|
+
timeoutSeconds: z.number().int().positive().max(3600).default(50),
|
|
88
|
+
ntfy: NtfyConfigSchema.optional(),
|
|
89
|
+
email: EmailConfigSchema.optional(),
|
|
90
|
+
})
|
|
91
|
+
.strict();
|
|
92
|
+
export type ApprovalsConfig = z.infer<typeof ApprovalsConfigSchema>;
|
|
93
|
+
|
|
44
94
|
export const GatewayConfigSchema = z
|
|
45
95
|
.object({
|
|
46
96
|
upstreams: z
|
|
@@ -58,6 +108,8 @@ export const GatewayConfigSchema = z
|
|
|
58
108
|
* logs what would have been stopped: for trying a policy out safely.
|
|
59
109
|
*/
|
|
60
110
|
mode: z.enum(["active", "audit"]).default("active"),
|
|
111
|
+
/** Human approvals for "ask" decisions. Without an answering channel, "ask" blocks. */
|
|
112
|
+
approvals: ApprovalsConfigSchema.optional(),
|
|
61
113
|
/** Timeout for each interceptor invocation, in milliseconds. */
|
|
62
114
|
interceptorTimeoutMs: z.number().int().positive().default(5000),
|
|
63
115
|
})
|
|
@@ -101,6 +153,17 @@ export function expandUpstream<T extends Upstream>(id: string, u: T, env: NodeJS
|
|
|
101
153
|
};
|
|
102
154
|
}
|
|
103
155
|
|
|
156
|
+
/** Expand `${VAR}` in every string of the approvals section. */
|
|
157
|
+
export function expandApprovals(a: ApprovalsConfig, env: NodeJS.ProcessEnv): ApprovalsConfig {
|
|
158
|
+
const walk = (v: unknown, where: string): unknown => {
|
|
159
|
+
if (typeof v === "string") return expandEnv(v, env, where);
|
|
160
|
+
if (Array.isArray(v)) return v.map((x, i) => walk(x, `${where}[${i}]`));
|
|
161
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, `${where}.${k}`)]));
|
|
162
|
+
return v;
|
|
163
|
+
};
|
|
164
|
+
return walk(a, "gateway.approvals") as ApprovalsConfig;
|
|
165
|
+
}
|
|
166
|
+
|
|
104
167
|
export function isHttpUpstream(u: Upstream): u is HttpUpstream {
|
|
105
168
|
return "url" in u;
|
|
106
169
|
}
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email notices over SMTP. Notify only, on purpose: emails never contain
|
|
3
|
+
* approve links, because mail security scanners open links automatically and
|
|
4
|
+
* would approve calls by themselves. Approving happens on the push channel.
|
|
5
|
+
*
|
|
6
|
+
* Emails name the tool, server and reason, never arguments or results.
|
|
7
|
+
*/
|
|
8
|
+
import nodemailer from "nodemailer";
|
|
9
|
+
import type { ApprovalEvent, Notifier } from "./approvals.js";
|
|
10
|
+
|
|
11
|
+
export const EMAIL_EVENTS = ["pending", "blocked", "approved", "denied", "timeout", "undeliverable"] as const;
|
|
12
|
+
export type EmailEvent = (typeof EMAIL_EVENTS)[number];
|
|
13
|
+
|
|
14
|
+
export interface MailSender {
|
|
15
|
+
sendMail(mail: { from: string; to: string[]; subject: string; text: string }): Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EmailOptions {
|
|
19
|
+
from: string;
|
|
20
|
+
to: string[];
|
|
21
|
+
/** Which events send an email. */
|
|
22
|
+
on: EmailEvent[];
|
|
23
|
+
/** SMTP settings, used unless `sender` is given. */
|
|
24
|
+
smtp?: { host: string; port: number; secure?: boolean; user?: string; pass?: string };
|
|
25
|
+
sender?: MailSender;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class EmailNotifier implements Notifier {
|
|
29
|
+
readonly name = "email";
|
|
30
|
+
private readonly sender: MailSender;
|
|
31
|
+
|
|
32
|
+
constructor(private readonly opts: EmailOptions) {
|
|
33
|
+
if (opts.sender) {
|
|
34
|
+
this.sender = opts.sender;
|
|
35
|
+
} else if (opts.smtp) {
|
|
36
|
+
const { host, port, secure, user, pass } = opts.smtp;
|
|
37
|
+
this.sender = nodemailer.createTransport({
|
|
38
|
+
host,
|
|
39
|
+
port,
|
|
40
|
+
secure: secure ?? port === 465,
|
|
41
|
+
...(user !== undefined ? { auth: { user, pass } } : {}),
|
|
42
|
+
});
|
|
43
|
+
} else {
|
|
44
|
+
throw new Error("EmailNotifier needs smtp settings or a sender");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async notify(event: ApprovalEvent): Promise<void> {
|
|
49
|
+
const kind: EmailEvent = event.kind === "resolved" ? event.result.outcome : event.kind;
|
|
50
|
+
if (!this.opts.on.includes(kind)) return;
|
|
51
|
+
const { subject, text } = this.compose(event);
|
|
52
|
+
await this.sender.sendMail({ from: this.opts.from, to: this.opts.to, subject, text });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private compose(event: ApprovalEvent): { subject: string; text: string } {
|
|
56
|
+
const footer = "\n\n-- \nSent by ADTL (adtl-gateway). This email never contains approve links; approve on your push channel.";
|
|
57
|
+
if (event.kind === "blocked") {
|
|
58
|
+
return {
|
|
59
|
+
subject: `ADTL blocked ${event.server}.${event.tool}`,
|
|
60
|
+
text: `ADTL blocked a call to ${event.server}.${event.tool}.\n\nReason: ${event.reason}\nRule: ${event.matched.join(", ") || "default"}\nSession: ${event.sessionId}${footer}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const r = event.request;
|
|
64
|
+
const subject = `${r.server}.${r.tool}`;
|
|
65
|
+
if (event.kind === "pending") {
|
|
66
|
+
return {
|
|
67
|
+
subject: `ADTL: approval needed for ${subject}`,
|
|
68
|
+
text:
|
|
69
|
+
`An agent wants to call ${subject} and ADTL needs your approval.\n\nReason: ${r.reason}\nRule: ${r.matched.join(", ") || "default"}\n` +
|
|
70
|
+
`Session: ${r.sessionId}\nExpires: ${r.expiresAt.toISOString()} (no answer means deny)${footer}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const res = event.result;
|
|
74
|
+
return {
|
|
75
|
+
subject: `ADTL: ${subject} ${res.outcome}`,
|
|
76
|
+
text: `The approval request for ${subject} was ${res.outcome}${res.channel ? ` via ${res.channel}` : ""} after ${Math.round(res.waitedMs / 1000)}s.\n\nReason it was asked: ${r.reason}\nSession: ${r.sessionId}${footer}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/gateway.ts
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
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.
|
|
12
15
|
*
|
|
13
16
|
* In audit mode (the invoker runs every interceptor with `mode: "audit"`)
|
|
14
17
|
* nothing is stopped or changed: calls the policy would stop are forwarded,
|
|
@@ -24,11 +27,12 @@ import {
|
|
|
24
27
|
type Tool,
|
|
25
28
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
26
29
|
import type { InvocationContext } from "@adtl/interceptors";
|
|
30
|
+
import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
|
|
27
31
|
import type { AuditEntry, AuditSink } from "./audit.js";
|
|
28
32
|
import { TOOL_SEPARATOR } from "./config.js";
|
|
29
33
|
import type { Blocked, ChainOutcome, InterceptorInvoker } from "./invoker.js";
|
|
30
34
|
|
|
31
|
-
export const GATEWAY_VERSION = "0.
|
|
35
|
+
export const GATEWAY_VERSION = "0.2.0";
|
|
32
36
|
|
|
33
37
|
/** `_meta` key on gateway-generated error results describing why a call was stopped. */
|
|
34
38
|
export const GATEWAY_DECISION_KEY = "adtl/gateway";
|
|
@@ -56,8 +60,18 @@ export interface AdtlGatewayOptions {
|
|
|
56
60
|
sessionId?: string;
|
|
57
61
|
audit?: AuditSink;
|
|
58
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>;
|
|
59
70
|
}
|
|
60
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
|
+
|
|
61
75
|
function textError(text: string, meta: Record<string, unknown>): CallToolResult {
|
|
62
76
|
return { content: [{ type: "text", text }], isError: true, _meta: { [GATEWAY_DECISION_KEY]: meta } };
|
|
63
77
|
}
|
|
@@ -66,12 +80,24 @@ function blockedMessage(b: Blocked): string {
|
|
|
66
80
|
if (b.decision === "ask") {
|
|
67
81
|
return (
|
|
68
82
|
`ADTL stopped this call because it needs a human's approval: ${b.reason}. ` +
|
|
69
|
-
`
|
|
83
|
+
`No approval channel is configured, so the call was not run. Tell the user what you were trying to do.`
|
|
70
84
|
);
|
|
71
85
|
}
|
|
72
86
|
return `ADTL blocked this call: ${b.reason}. The call was not run.`;
|
|
73
87
|
}
|
|
74
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
|
+
|
|
75
101
|
function infoOf(outcome: ChainOutcome, interceptor: string): Record<string, unknown> {
|
|
76
102
|
return outcome.results.find((r) => r.interceptor === interceptor)?.info ?? {};
|
|
77
103
|
}
|
|
@@ -82,6 +108,7 @@ export class AdtlGateway {
|
|
|
82
108
|
private readonly invoker: InterceptorInvoker;
|
|
83
109
|
private readonly audit: AuditSink | undefined;
|
|
84
110
|
private readonly log: GatewayLogger;
|
|
111
|
+
private readonly approvals: ApprovalBroker | undefined;
|
|
85
112
|
private routes = new Map<string, Route>();
|
|
86
113
|
|
|
87
114
|
constructor(opts: AdtlGatewayOptions) {
|
|
@@ -90,6 +117,7 @@ export class AdtlGateway {
|
|
|
90
117
|
this.sessionId = opts.sessionId ?? randomUUID();
|
|
91
118
|
this.audit = opts.audit;
|
|
92
119
|
this.log = opts.logger ?? stderrLogger;
|
|
120
|
+
this.approvals = opts.approvals;
|
|
93
121
|
}
|
|
94
122
|
|
|
95
123
|
/** The downstream MCP server the agent connects to. */
|
|
@@ -104,7 +132,15 @@ export class AdtlGateway {
|
|
|
104
132
|
},
|
|
105
133
|
);
|
|
106
134
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: await this.listTools() }));
|
|
107
|
-
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
|
+
});
|
|
108
144
|
return server;
|
|
109
145
|
}
|
|
110
146
|
|
|
@@ -131,7 +167,7 @@ export class AdtlGateway {
|
|
|
131
167
|
return tools;
|
|
132
168
|
}
|
|
133
169
|
|
|
134
|
-
async callTool(name: string, args: Record<string, unknown
|
|
170
|
+
async callTool(name: string, args: Record<string, unknown>, opts: CallOptions = {}): Promise<CallToolResult> {
|
|
135
171
|
const started = performance.now();
|
|
136
172
|
let route = this.routes.get(name);
|
|
137
173
|
if (!route) {
|
|
@@ -158,13 +194,39 @@ export class AdtlGateway {
|
|
|
158
194
|
const requestPayload = { method: "tools/call", params: { name: route.tool, arguments: args } };
|
|
159
195
|
const req = await this.invoker.runRequest({ event: "tools/call", payload: requestPayload, config, context });
|
|
160
196
|
const policy = infoOf(req, "adtl.policy");
|
|
197
|
+
let approval: ApprovalResult | undefined;
|
|
161
198
|
if (req.blocked) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
}
|
|
165
226
|
}
|
|
166
227
|
const forwarded = (req.payload as typeof requestPayload).params;
|
|
167
228
|
const decision = policy["adtl/decision"] as string | undefined;
|
|
229
|
+
const approved = approval ? { approval } : {};
|
|
168
230
|
const audit = this.invoker.modeOverride === "audit";
|
|
169
231
|
// Only set in audit mode, for calls the policy would have stopped.
|
|
170
232
|
const wouldStop =
|
|
@@ -179,7 +241,7 @@ export class AdtlGateway {
|
|
|
179
241
|
result = (await client.callTool({ name: forwarded.name, arguments: forwarded.arguments })) as CallToolResult;
|
|
180
242
|
} catch (err) {
|
|
181
243
|
const reason = (err as Error).message;
|
|
182
|
-
finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
|
|
244
|
+
finish({ outcome: "upstream_error", ...(decision ? { decision } : {}), ...approved, reason, callHash: policy.callHash, ...(audit ? { mode: "audit" as const } : {}), warnings: req.warnings });
|
|
183
245
|
return textError(`The ${route.upstream} server returned an error: ${reason}`, { reason: "upstream_error" });
|
|
184
246
|
}
|
|
185
247
|
|
|
@@ -202,6 +264,7 @@ export class AdtlGateway {
|
|
|
202
264
|
// Missing only if adtl.policy failed and was allowed to (audit mode); see warnings.
|
|
203
265
|
...(decision ? { decision } : {}),
|
|
204
266
|
...(wouldStop ?? {}),
|
|
267
|
+
...approved,
|
|
205
268
|
...(audit ? { mode: "audit" as const } : {}),
|
|
206
269
|
callHash: policy.callHash,
|
|
207
270
|
label: label.label,
|
|
@@ -210,4 +273,18 @@ export class AdtlGateway {
|
|
|
210
273
|
});
|
|
211
274
|
return (res.payload as { result: CallToolResult }).result;
|
|
212
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
|
+
}
|
|
213
290
|
}
|
package/src/index.ts
CHANGED