@adtl/gateway 0.1.1 → 0.3.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 +57 -5
- 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 +12 -2
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +46 -1
- package/dist/cli.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 +33 -3
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +263 -23
- package/dist/gateway.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/init/index.d.ts +4 -0
- package/dist/init/index.d.ts.map +1 -0
- package/dist/init/index.js +4 -0
- package/dist/init/index.js.map +1 -0
- package/dist/init/presets.d.ts +30 -0
- package/dist/init/presets.d.ts.map +1 -0
- package/dist/init/presets.js +34 -0
- package/dist/init/presets.js.map +1 -0
- package/dist/init/run.d.ts +18 -0
- package/dist/init/run.d.ts.map +1 -0
- package/dist/init/run.js +60 -0
- package/dist/init/run.js.map +1 -0
- package/dist/init/suggest.d.ts +50 -0
- package/dist/init/suggest.d.ts.map +1 -0
- package/dist/init/suggest.js +82 -0
- package/dist/init/suggest.js.map +1 -0
- 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 +6 -1
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +37 -5
- package/dist/startup.js.map +1 -1
- package/package.json +4 -3
- package/src/approvals.ts +219 -0
- package/src/audit.ts +8 -3
- package/src/cli.ts +41 -1
- package/src/config.ts +65 -2
- package/src/email.ts +79 -0
- package/src/gateway.ts +324 -22
- package/src/index.ts +4 -0
- package/src/init/index.ts +3 -0
- package/src/init/presets.ts +64 -0
- package/src/init/run.ts +79 -0
- package/src/init/suggest.ts +127 -0
- package/src/invoker.ts +3 -2
- package/src/ntfy.ts +154 -0
- package/src/startup.ts +35 -4
package/dist/startup.js
CHANGED
|
@@ -11,10 +11,13 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
11
11
|
import { INTERCEPTORS_EXTENSION, LABEL_INTERCEPTOR, POLICY_INTERCEPTOR, parseConfigText } from "@adtl/interceptors";
|
|
12
12
|
import { parse as parseYaml } from "yaml";
|
|
13
13
|
import { ZodError } from "zod";
|
|
14
|
+
import { ApprovalBroker } from "./approvals.js";
|
|
14
15
|
import { JsonlAuditLog } from "./audit.js";
|
|
15
|
-
import { GatewayConfigSchema, expandUpstream, isHttpUpstream, } from "./config.js";
|
|
16
|
+
import { GatewayConfigSchema, expandApprovals, expandUpstream, isHttpUpstream, } from "./config.js";
|
|
17
|
+
import { EmailNotifier } from "./email.js";
|
|
16
18
|
import { AdtlGateway, GATEWAY_VERSION } from "./gateway.js";
|
|
17
19
|
import { InterceptorInvoker } from "./invoker.js";
|
|
20
|
+
import { NtfyNotifier } from "./ntfy.js";
|
|
18
21
|
export class GatewayConfigError extends Error {
|
|
19
22
|
constructor(message) {
|
|
20
23
|
super(message);
|
|
@@ -68,7 +71,7 @@ function stdioTransport(u, baseDir) {
|
|
|
68
71
|
stderr: "inherit",
|
|
69
72
|
});
|
|
70
73
|
}
|
|
71
|
-
async function
|
|
74
|
+
export async function connectUpstream(id, u, baseDir) {
|
|
72
75
|
const client = new Client({ name: `adtl-gateway→${id}`, version: GATEWAY_VERSION });
|
|
73
76
|
const transport = isHttpUpstream(u)
|
|
74
77
|
? new StreamableHTTPClientTransport(new URL(u.url), { requestInit: { headers: u.headers } })
|
|
@@ -76,6 +79,28 @@ async function connect(id, u, baseDir) {
|
|
|
76
79
|
await client.connect(transport);
|
|
77
80
|
return client;
|
|
78
81
|
}
|
|
82
|
+
/** Build the approval broker from `gateway.approvals`, or undefined if not configured. */
|
|
83
|
+
export function createApprovals(cfg, env, log) {
|
|
84
|
+
if (!cfg.approvals)
|
|
85
|
+
return undefined;
|
|
86
|
+
const a = expandApprovals(cfg.approvals, env);
|
|
87
|
+
const notifiers = [];
|
|
88
|
+
if (a.ntfy) {
|
|
89
|
+
if (/^https:\/\/ntfy\.sh\/?$/.test(a.ntfy.server) && a.ntfy.topic.length < 20 && !a.ntfy.token) {
|
|
90
|
+
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.");
|
|
91
|
+
}
|
|
92
|
+
notifiers.push(new NtfyNotifier(a.ntfy));
|
|
93
|
+
}
|
|
94
|
+
if (a.email) {
|
|
95
|
+
notifiers.push(new EmailNotifier({ smtp: a.email.smtp, from: a.email.from, to: [a.email.to].flat(), on: a.email.on }));
|
|
96
|
+
}
|
|
97
|
+
if (notifiers.length === 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (!notifiers.some((n) => n.listen))
|
|
100
|
+
log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
|
|
101
|
+
log.info(`approvals: ${notifiers.map((n) => n.name).join(", ")} (timeout ${a.timeoutSeconds}s)`);
|
|
102
|
+
return new ApprovalBroker({ notifiers, timeoutMs: a.timeoutSeconds * 1000, log });
|
|
103
|
+
}
|
|
79
104
|
export async function startGateway(loaded, opts = {}) {
|
|
80
105
|
const env = opts.env ?? process.env;
|
|
81
106
|
const baseDir = dirname(loaded.path);
|
|
@@ -87,7 +112,7 @@ export async function startGateway(loaded, opts = {}) {
|
|
|
87
112
|
const icfg = loaded.gateway.interceptors
|
|
88
113
|
? expandUpstream("interceptors", loaded.gateway.interceptors, env)
|
|
89
114
|
: { command: process.execPath, args: [bundledInterceptorsCli(), "--config", loaded.path], env: {} };
|
|
90
|
-
const interceptors = await
|
|
115
|
+
const interceptors = await connectUpstream("interceptors", icfg, baseDir);
|
|
91
116
|
clients.push(interceptors);
|
|
92
117
|
if (!interceptors.getServerCapabilities()?.extensions?.[INTERCEPTORS_EXTENSION]) {
|
|
93
118
|
throw new GatewayConfigError(`The interceptor server does not declare the ${INTERCEPTORS_EXTENSION} extension`);
|
|
@@ -107,7 +132,7 @@ export async function startGateway(loaded, opts = {}) {
|
|
|
107
132
|
// 2. Upstream servers. One failing upstream does not stop the others.
|
|
108
133
|
const upstreams = new Map();
|
|
109
134
|
const ids = Object.keys(loaded.gateway.upstreams);
|
|
110
|
-
const settled = await Promise.allSettled(ids.map((id) =>
|
|
135
|
+
const settled = await Promise.allSettled(ids.map((id) => connectUpstream(id, expandUpstream(id, loaded.gateway.upstreams[id], env), baseDir)));
|
|
111
136
|
settled.forEach((s, i) => {
|
|
112
137
|
const id = ids[i];
|
|
113
138
|
if (s.status === "fulfilled") {
|
|
@@ -125,7 +150,14 @@ export async function startGateway(loaded, opts = {}) {
|
|
|
125
150
|
throw new GatewayConfigError("No upstream server could be connected");
|
|
126
151
|
log.info(`upstreams: ${[...upstreams.keys()].join(", ")}`);
|
|
127
152
|
const auditLog = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
|
|
128
|
-
const
|
|
153
|
+
const approvals = createApprovals(loaded.gateway, env, log);
|
|
154
|
+
const gateway = new AdtlGateway({
|
|
155
|
+
upstreams,
|
|
156
|
+
invoker,
|
|
157
|
+
...(auditLog ? { audit: auditLog } : {}),
|
|
158
|
+
...(approvals ? { approvals } : {}),
|
|
159
|
+
logger: log,
|
|
160
|
+
});
|
|
129
161
|
return { gateway, close: closeAll };
|
|
130
162
|
}
|
|
131
163
|
catch (err) {
|
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.3.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.3.0",
|
|
50
|
+
"@adtl/labels": "0.3.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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Append-only JSONL audit log. One line per tool call.
|
|
2
|
+
* Append-only JSONL audit log. One line per tool call, resource read or prompt get.
|
|
3
3
|
*
|
|
4
4
|
* Arguments and results are never written; the call is identified by its
|
|
5
5
|
* hash, so the log can be shared without leaking the data it describes.
|
|
@@ -10,15 +10,20 @@ import { dirname } from "node:path";
|
|
|
10
10
|
export interface AuditEntry {
|
|
11
11
|
ts: string;
|
|
12
12
|
sessionId: string;
|
|
13
|
-
event: "tools/call";
|
|
13
|
+
event: "tools/call" | "resources/read" | "prompts/get";
|
|
14
14
|
server: string;
|
|
15
|
-
tool
|
|
15
|
+
/** Exactly one of tool, resource or prompt is set, matching `event`. */
|
|
16
|
+
tool?: string;
|
|
17
|
+
resource?: string;
|
|
18
|
+
prompt?: string;
|
|
16
19
|
/** Final outcome of the call. */
|
|
17
20
|
outcome: "forwarded" | "blocked" | "upstream_error";
|
|
18
21
|
/** The policy's decision. In audit mode a call can be forwarded with decision "ask" or "block". */
|
|
19
22
|
decision?: string;
|
|
20
23
|
/** Present when the gateway ran in audit mode: nothing was stopped or changed. */
|
|
21
24
|
mode?: "audit";
|
|
25
|
+
/** Present when a human was asked: outcome, channel, approver (if known) and wait time. */
|
|
26
|
+
approval?: { outcome: string; channel?: string; approver?: string; waitedMs: number };
|
|
22
27
|
reason?: string;
|
|
23
28
|
matched?: unknown;
|
|
24
29
|
callHash?: unknown;
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* servers through ADTL's interceptors.
|
|
5
5
|
*
|
|
6
6
|
* adtl-gateway --config adtl.yaml [--audit]
|
|
7
|
+
* adtl-gateway init --config adtl.yaml [--write]
|
|
7
8
|
*
|
|
8
9
|
* Point your MCP client (e.g. Claude Code's .mcp.json) at this command.
|
|
9
10
|
* Logs go to stderr; stdout carries MCP JSON-RPC only.
|
|
@@ -11,9 +12,11 @@
|
|
|
11
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
13
|
import { EnvExpansionError } from "./config.js";
|
|
13
14
|
import { GATEWAY_VERSION } from "./gateway.js";
|
|
15
|
+
import { runInit, type InitResult } from "./init/run.js";
|
|
14
16
|
import { GatewayConfigError, loadGatewayConfig, startGateway } from "./startup.js";
|
|
15
17
|
|
|
16
18
|
const USAGE = `Usage: adtl-gateway --config <adtl.yaml> [--audit]
|
|
19
|
+
adtl-gateway init --config <adtl.yaml> [--write]
|
|
17
20
|
|
|
18
21
|
Runs an MCP server over stdio that forwards tool calls to the upstream
|
|
19
22
|
servers in the config, checking and labeling each call with ADTL.
|
|
@@ -23,9 +26,18 @@ Options:
|
|
|
23
26
|
--audit Log what would be stopped, but stop nothing
|
|
24
27
|
(same as gateway.mode: audit).
|
|
25
28
|
-v, --version Print the version and exit.
|
|
29
|
+
|
|
30
|
+
init:
|
|
31
|
+
Connects to every upstream, lists its tools, and drafts a labeling rule
|
|
32
|
+
for each tool the config doesn't cover yet (presets for GitHub,
|
|
33
|
+
filesystem and fetch; MCP tool hints otherwise). Existing entries are
|
|
34
|
+
never changed. Prints the updated config; review it before using it.
|
|
35
|
+
--write Update the config file in place (original kept as .bak).
|
|
26
36
|
-h, --help Show this help.`;
|
|
27
37
|
|
|
28
38
|
interface Args {
|
|
39
|
+
init?: boolean;
|
|
40
|
+
write?: boolean;
|
|
29
41
|
config?: string;
|
|
30
42
|
audit?: boolean;
|
|
31
43
|
help?: boolean;
|
|
@@ -36,11 +48,13 @@ function parseArgs(argv: string[]): Args {
|
|
|
36
48
|
const out: Args = {};
|
|
37
49
|
for (let i = 0; i < argv.length; i++) {
|
|
38
50
|
const a = argv[i];
|
|
39
|
-
if (
|
|
51
|
+
if (i === 0 && a === "init") out.init = true;
|
|
52
|
+
else if (a === "-h" || a === "--help") out.help = true;
|
|
40
53
|
else if (a === "-v" || a === "--version") out.version = true;
|
|
41
54
|
else if (a === "-c" || a === "--config") out.config = argv[++i];
|
|
42
55
|
else if (a?.startsWith("--config=")) out.config = a.slice("--config=".length);
|
|
43
56
|
else if (a === "--audit") out.audit = true;
|
|
57
|
+
else if (a === "--write" && out.init) out.write = true;
|
|
44
58
|
else throw new GatewayConfigError(`Unknown argument: ${a}\n\n${USAGE}`);
|
|
45
59
|
}
|
|
46
60
|
return out;
|
|
@@ -52,6 +66,7 @@ async function main(): Promise<void> {
|
|
|
52
66
|
if (args.help) return void console.log(USAGE);
|
|
53
67
|
if (args.version) return void console.log(GATEWAY_VERSION);
|
|
54
68
|
if (!args.config) throw new GatewayConfigError(`Missing --config\n\n${USAGE}`);
|
|
69
|
+
if (args.init) return init(args.config, args.write === true);
|
|
55
70
|
|
|
56
71
|
const loaded = await loadGatewayConfig(args.config);
|
|
57
72
|
if (args.audit) loaded.gateway.mode = "audit";
|
|
@@ -77,6 +92,31 @@ async function main(): Promise<void> {
|
|
|
77
92
|
console.error(`[adtl-gateway] ${GATEWAY_VERSION} ready (session ${started.gateway.sessionId})`);
|
|
78
93
|
}
|
|
79
94
|
|
|
95
|
+
function summarize(r: InitResult, path: string, write: boolean): string {
|
|
96
|
+
const lines = r.reports.map((s) => {
|
|
97
|
+
const parts = [`${s.added.length} tools added`];
|
|
98
|
+
if (s.kept.length) parts.push(`${s.kept.length} kept`);
|
|
99
|
+
if (s.addedDefaults) parts.push("defaults added");
|
|
100
|
+
if (s.stale.length) parts.push(`no longer offered: ${s.stale.join(", ")}`);
|
|
101
|
+
return ` ${s.id} (${s.preset ? `preset ${s.preset}` : "from tool hints, review"}): ${parts.join("; ")}`;
|
|
102
|
+
});
|
|
103
|
+
for (const f of r.failed) lines.push(` ${f.id}: could not connect, skipped (${f.error})`);
|
|
104
|
+
const changed = r.reports.some((s) => s.added.length || s.addedDefaults);
|
|
105
|
+
const tail = !changed
|
|
106
|
+
? "Nothing to add."
|
|
107
|
+
: write
|
|
108
|
+
? `Updated ${path} (original saved as ${path}.bak). Review every line marked "review".`
|
|
109
|
+
: "Nothing written. Review the config above, then re-run with --write.";
|
|
110
|
+
return `${lines.join("\n")}\n${tail}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function init(path: string, write: boolean): Promise<void> {
|
|
114
|
+
const result = await runInit({ path, write });
|
|
115
|
+
if (!write) process.stdout.write(result.text);
|
|
116
|
+
console.error(`[adtl-gateway] init\n${summarize(result, path, write)}`);
|
|
117
|
+
if (result.failed.length && !result.reports.length) process.exitCode = 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
80
120
|
main().catch((err: unknown) => {
|
|
81
121
|
const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError;
|
|
82
122
|
console.error(known ? `[adtl-gateway] ${(err as Error).message}` : err);
|
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
|
+
}
|