@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/dist/startup.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdtlConfig } from "@adtl/labels";
|
|
2
|
+
import { ApprovalBroker } from "./approvals.js";
|
|
2
3
|
import { type GatewayConfig } from "./config.js";
|
|
3
4
|
import { AdtlGateway, type GatewayLogger } from "./gateway.js";
|
|
4
5
|
export declare class GatewayConfigError extends Error {
|
|
@@ -12,6 +13,8 @@ export interface LoadedConfig {
|
|
|
12
13
|
export declare function loadGatewayConfig(path: string): Promise<LoadedConfig>;
|
|
13
14
|
/** Path of the bundled adtl-interceptors CLI. */
|
|
14
15
|
export declare function bundledInterceptorsCli(): string;
|
|
16
|
+
/** Build the approval broker from `gateway.approvals`, or undefined if not configured. */
|
|
17
|
+
export declare function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log: GatewayLogger): ApprovalBroker | undefined;
|
|
15
18
|
export interface StartedGateway {
|
|
16
19
|
gateway: AdtlGateway;
|
|
17
20
|
close(): Promise<void>;
|
package/dist/startup.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"startup.d.ts","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"startup.d.ts","sourceRoot":"","sources":["../src/startup.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAG/C,OAAO,EAAE,cAAc,EAAiB,MAAM,gBAAgB,CAAC;AAE/D,OAAO,EAKL,KAAK,aAAa,EAGnB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,WAAW,EAAmB,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAIhF,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,UAAU,CAAC;IACnB,OAAO,EAAE,aAAa,CAAC;CACxB;AAED,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CA2B3E;AAED,iDAAiD;AACjD,wBAAgB,sBAAsB,IAAI,MAAM,CAI/C;AAsBD,0FAA0F;AAC1F,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,EAAE,GAAG,EAAE,aAAa,GAAG,cAAc,GAAG,SAAS,CAiB1H;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,WAAW,CAAC;IACrB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,YAAY,EACpB,IAAI,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IAAC,MAAM,CAAC,EAAE,aAAa,CAAA;CAAO,GAC7D,OAAO,CAAC,cAAc,CAAC,CAgEzB"}
|
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);
|
|
@@ -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);
|
|
@@ -99,8 +124,11 @@ export async function startGateway(loaded, opts = {}) {
|
|
|
99
124
|
throw new GatewayConfigError(`The interceptor server does not offer ${required}; refusing to start without it`);
|
|
100
125
|
}
|
|
101
126
|
}
|
|
102
|
-
const
|
|
127
|
+
const audit = loaded.gateway.mode === "audit";
|
|
128
|
+
const invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, audit ? "audit" : undefined);
|
|
103
129
|
log.info(`interceptors: ${names.join(", ")}`);
|
|
130
|
+
if (audit)
|
|
131
|
+
log.warn("audit mode: calls are logged but never stopped or changed");
|
|
104
132
|
// 2. Upstream servers. One failing upstream does not stop the others.
|
|
105
133
|
const upstreams = new Map();
|
|
106
134
|
const ids = Object.keys(loaded.gateway.upstreams);
|
|
@@ -121,8 +149,15 @@ export async function startGateway(loaded, opts = {}) {
|
|
|
121
149
|
if (upstreams.size === 0)
|
|
122
150
|
throw new GatewayConfigError("No upstream server could be connected");
|
|
123
151
|
log.info(`upstreams: ${[...upstreams.keys()].join(", ")}`);
|
|
124
|
-
const
|
|
125
|
-
const
|
|
152
|
+
const auditLog = loaded.gateway.audit ? new JsonlAuditLog(resolve(baseDir, loaded.gateway.audit)) : undefined;
|
|
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
|
+
});
|
|
126
161
|
return { gateway, close: closeAll };
|
|
127
162
|
}
|
|
128
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.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
|
@@ -15,7 +15,12 @@ export interface AuditEntry {
|
|
|
15
15
|
tool: string;
|
|
16
16
|
/** Final outcome of the call. */
|
|
17
17
|
outcome: "forwarded" | "blocked" | "upstream_error";
|
|
18
|
+
/** The policy's decision. In audit mode a call can be forwarded with decision "ask" or "block". */
|
|
18
19
|
decision?: string;
|
|
20
|
+
/** Present when the gateway ran in audit mode: nothing was stopped or changed. */
|
|
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 };
|
|
19
24
|
reason?: string;
|
|
20
25
|
matched?: unknown;
|
|
21
26
|
callHash?: unknown;
|
package/src/cli.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* adtl-gateway: an MCP server for your agent that proxies to your real MCP
|
|
4
4
|
* servers through ADTL's interceptors.
|
|
5
5
|
*
|
|
6
|
-
* adtl-gateway --config adtl.yaml
|
|
6
|
+
* adtl-gateway --config adtl.yaml [--audit]
|
|
7
7
|
*
|
|
8
8
|
* Point your MCP client (e.g. Claude Code's .mcp.json) at this command.
|
|
9
9
|
* Logs go to stderr; stdout carries MCP JSON-RPC only.
|
|
@@ -13,24 +13,34 @@ import { EnvExpansionError } from "./config.js";
|
|
|
13
13
|
import { GATEWAY_VERSION } from "./gateway.js";
|
|
14
14
|
import { GatewayConfigError, loadGatewayConfig, startGateway } from "./startup.js";
|
|
15
15
|
|
|
16
|
-
const USAGE = `Usage: adtl-gateway --config <adtl.yaml>
|
|
16
|
+
const USAGE = `Usage: adtl-gateway --config <adtl.yaml> [--audit]
|
|
17
17
|
|
|
18
18
|
Runs an MCP server over stdio that forwards tool calls to the upstream
|
|
19
19
|
servers in the config, checking and labeling each call with ADTL.
|
|
20
20
|
|
|
21
21
|
Options:
|
|
22
22
|
-c, --config <path> ADTL config file with a gateway: section. Required.
|
|
23
|
+
--audit Log what would be stopped, but stop nothing
|
|
24
|
+
(same as gateway.mode: audit).
|
|
23
25
|
-v, --version Print the version and exit.
|
|
24
26
|
-h, --help Show this help.`;
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
interface Args {
|
|
29
|
+
config?: string;
|
|
30
|
+
audit?: boolean;
|
|
31
|
+
help?: boolean;
|
|
32
|
+
version?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv: string[]): Args {
|
|
36
|
+
const out: Args = {};
|
|
28
37
|
for (let i = 0; i < argv.length; i++) {
|
|
29
38
|
const a = argv[i];
|
|
30
39
|
if (a === "-h" || a === "--help") out.help = true;
|
|
31
40
|
else if (a === "-v" || a === "--version") out.version = true;
|
|
32
41
|
else if (a === "-c" || a === "--config") out.config = argv[++i];
|
|
33
42
|
else if (a?.startsWith("--config=")) out.config = a.slice("--config=".length);
|
|
43
|
+
else if (a === "--audit") out.audit = true;
|
|
34
44
|
else throw new GatewayConfigError(`Unknown argument: ${a}\n\n${USAGE}`);
|
|
35
45
|
}
|
|
36
46
|
return out;
|
|
@@ -44,6 +54,7 @@ async function main(): Promise<void> {
|
|
|
44
54
|
if (!args.config) throw new GatewayConfigError(`Missing --config\n\n${USAGE}`);
|
|
45
55
|
|
|
46
56
|
const loaded = await loadGatewayConfig(args.config);
|
|
57
|
+
if (args.audit) loaded.gateway.mode = "audit";
|
|
47
58
|
const started = await startGateway(loaded);
|
|
48
59
|
const server = started.gateway.createServer();
|
|
49
60
|
|
package/src/config.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* gateway:
|
|
5
5
|
* audit: ./adtl-audit.jsonl # optional JSONL audit log
|
|
6
|
+
* mode: audit # optional: log what would be stopped, stop nothing
|
|
6
7
|
* upstreams:
|
|
7
8
|
* github: # id must match `servers.github` in the label config
|
|
8
9
|
* command: npx
|
|
@@ -11,11 +12,19 @@
|
|
|
11
12
|
* docs:
|
|
12
13
|
* url: https://example.com/mcp
|
|
13
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
|
|
14
22
|
*
|
|
15
|
-
* `${VAR}` in command, args, env, cwd, url
|
|
16
|
-
* 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.
|
|
17
25
|
*/
|
|
18
26
|
import { z } from "zod";
|
|
27
|
+
import { EMAIL_EVENTS } from "./email.js";
|
|
19
28
|
|
|
20
29
|
const UPSTREAM_ID = /^[A-Za-z0-9-]+$/;
|
|
21
30
|
|
|
@@ -40,6 +49,48 @@ export type StdioUpstream = z.infer<typeof StdioUpstreamSchema>;
|
|
|
40
49
|
export type HttpUpstream = z.infer<typeof HttpUpstreamSchema>;
|
|
41
50
|
export type Upstream = z.infer<typeof UpstreamSchema>;
|
|
42
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
|
+
|
|
43
94
|
export const GatewayConfigSchema = z
|
|
44
95
|
.object({
|
|
45
96
|
upstreams: z
|
|
@@ -52,6 +103,13 @@ export const GatewayConfigSchema = z
|
|
|
52
103
|
interceptors: StdioUpstreamSchema.optional(),
|
|
53
104
|
/** Path of a JSONL audit log. Relative paths are resolved against the config file's directory. */
|
|
54
105
|
audit: z.string().min(1).optional(),
|
|
106
|
+
/**
|
|
107
|
+
* `active` (default) enforces the policy. `audit` stops and changes nothing, and
|
|
108
|
+
* logs what would have been stopped: for trying a policy out safely.
|
|
109
|
+
*/
|
|
110
|
+
mode: z.enum(["active", "audit"]).default("active"),
|
|
111
|
+
/** Human approvals for "ask" decisions. Without an answering channel, "ask" blocks. */
|
|
112
|
+
approvals: ApprovalsConfigSchema.optional(),
|
|
55
113
|
/** Timeout for each interceptor invocation, in milliseconds. */
|
|
56
114
|
interceptorTimeoutMs: z.number().int().positive().default(5000),
|
|
57
115
|
})
|
|
@@ -95,6 +153,17 @@ export function expandUpstream<T extends Upstream>(id: string, u: T, env: NodeJS
|
|
|
95
153
|
};
|
|
96
154
|
}
|
|
97
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
|
+
|
|
98
167
|
export function isHttpUpstream(u: Upstream): u is HttpUpstream {
|
|
99
168
|
return "url" in u;
|
|
100
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
|
+
}
|