@adtl/gateway 0.5.0 → 0.6.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 +105 -12
- package/dist/approvals.d.ts +22 -6
- package/dist/approvals.d.ts.map +1 -1
- package/dist/approvals.js +56 -26
- package/dist/approvals.js.map +1 -1
- package/dist/audit.d.ts +8 -0
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js.map +1 -1
- package/dist/auditCli.d.ts +2 -0
- package/dist/auditCli.d.ts.map +1 -1
- package/dist/auditCli.js +13 -10
- package/dist/auditCli.js.map +1 -1
- package/dist/cli.js +17 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +285 -21
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +49 -1
- package/dist/config.js.map +1 -1
- package/dist/elicitation.d.ts +25 -0
- package/dist/elicitation.d.ts.map +1 -0
- package/dist/elicitation.js +46 -0
- package/dist/elicitation.js.map +1 -0
- package/dist/gateway.d.ts +26 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +85 -8
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/claudeCode.d.ts +4 -0
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +8 -1
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +6 -3
- package/dist/hooks/run.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/init/presets.d.ts.map +1 -1
- package/dist/init/presets.js +54 -0
- package/dist/init/presets.js.map +1 -1
- package/dist/invoker.d.ts +21 -1
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +19 -2
- package/dist/invoker.js.map +1 -1
- package/dist/ledgerCli.d.ts +1 -1
- package/dist/ledgerCli.d.ts.map +1 -1
- package/dist/ledgerCli.js +9 -3
- package/dist/ledgerCli.js.map +1 -1
- package/dist/ntfy.d.ts +3 -1
- package/dist/ntfy.d.ts.map +1 -1
- package/dist/ntfy.js +2 -1
- package/dist/ntfy.js.map +1 -1
- package/dist/playgroundCli.d.ts +8 -0
- package/dist/playgroundCli.d.ts.map +1 -0
- package/dist/playgroundCli.js +57 -0
- package/dist/playgroundCli.js.map +1 -0
- package/dist/receipt.d.ts +62 -0
- package/dist/receipt.d.ts.map +1 -0
- package/dist/receipt.js +117 -0
- package/dist/receipt.js.map +1 -0
- package/dist/receiptCli.d.ts +8 -0
- package/dist/receiptCli.d.ts.map +1 -0
- package/dist/receiptCli.js +87 -0
- package/dist/receiptCli.js.map +1 -0
- package/dist/roles.d.ts +9 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +41 -0
- package/dist/roles.js.map +1 -0
- package/dist/startup.d.ts +9 -0
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +69 -7
- package/dist/startup.js.map +1 -1
- package/package.json +5 -4
- package/playground/fake-github.mjs +98 -0
- package/playground/fake-memory.mjs +130 -0
- package/playground/index.html +411 -0
- package/playground/playground.yaml +40 -0
- package/playground/server.mjs +290 -0
- package/src/approvals.ts +63 -26
- package/src/audit.ts +5 -0
- package/src/auditCli.ts +11 -8
- package/src/cli.ts +16 -1
- package/src/config.ts +54 -1
- package/src/elicitation.ts +69 -0
- package/src/gateway.ts +96 -9
- package/src/hooks/claudeCode.ts +13 -2
- package/src/hooks/run.ts +6 -3
- package/src/index.ts +5 -0
- package/src/init/presets.ts +54 -0
- package/src/invoker.ts +39 -2
- package/src/ledgerCli.ts +9 -3
- package/src/ntfy.ts +4 -1
- package/src/playgroundCli.ts +60 -0
- package/src/receipt.ts +148 -0
- package/src/receiptCli.ts +85 -0
- package/src/roles.ts +55 -0
- package/src/startup.ts +68 -6
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// `adtl-gateway playground`: a local web playground for ADTL. It runs the real
|
|
2
|
+
// gateway in this process, in front of fake GitHub and fake memory servers,
|
|
3
|
+
// and shows every call: the label on its result, the session label, the
|
|
4
|
+
// policy decision, the audit log and what actually reached GitHub. "Ask"
|
|
5
|
+
// decisions come up in the page with Allow / Deny buttons (a notifier that
|
|
6
|
+
// answers like ntfy does).
|
|
7
|
+
//
|
|
8
|
+
// Listens on 127.0.0.1 only. Everything it writes (ledger, audit log, posted
|
|
9
|
+
// comments) goes to a temporary folder that is removed on reset and exit.
|
|
10
|
+
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
+
import { StdioClientTransport, getDefaultEnvironment } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
18
|
+
import { parseConfigText } from "@adtl/interceptors";
|
|
19
|
+
import {
|
|
20
|
+
AdtlGateway,
|
|
21
|
+
ApprovalBroker,
|
|
22
|
+
GATEWAY_DECISION_KEY,
|
|
23
|
+
InterceptorInvoker,
|
|
24
|
+
JsonlAuditLog,
|
|
25
|
+
bundledInterceptorsCli,
|
|
26
|
+
connectUpstream,
|
|
27
|
+
loadGatewayConfig,
|
|
28
|
+
} from "../dist/index.js";
|
|
29
|
+
|
|
30
|
+
const here = fileURLToPath(new URL(".", import.meta.url));
|
|
31
|
+
const FAKE_GITHUB = join(here, "fake-github.mjs");
|
|
32
|
+
const FAKE_MEMORY = join(here, "fake-memory.mjs");
|
|
33
|
+
export const DEFAULT_CONFIG = readFileSync(join(here, "playground.yaml"), "utf8");
|
|
34
|
+
export const DEFAULT_PORT = 4317;
|
|
35
|
+
const TRUST_KEY = "io.modelcontextprotocol/trust-annotations";
|
|
36
|
+
|
|
37
|
+
/** Answers "ask" decisions from the page, the way the ntfy notifier answers them from a phone. */
|
|
38
|
+
class PageNotifier {
|
|
39
|
+
name = "playground";
|
|
40
|
+
pending = new Map();
|
|
41
|
+
|
|
42
|
+
listen(request, answer, signal) {
|
|
43
|
+
this.pending.set(request.id, { request, answer });
|
|
44
|
+
return new Promise((resolve) => signal.addEventListener("abort", () => {
|
|
45
|
+
this.pending.delete(request.id);
|
|
46
|
+
resolve();
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async notify(event) {
|
|
51
|
+
if (event.kind === "pending") {
|
|
52
|
+
const p = this.pending.get(event.request.id);
|
|
53
|
+
if (p) p.tokens = event.tokens;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
answer(id, decision) {
|
|
58
|
+
const p = this.pending.get(id);
|
|
59
|
+
return !!p?.tokens && p.answer(p.tokens[decision], "you, in the playground");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
list() {
|
|
63
|
+
return [...this.pending.values()].filter((p) => p.tokens).map(({ request: r }) => ({
|
|
64
|
+
id: r.id, tool: `${r.server}__${r.tool}`, reason: r.reason, matched: r.matched, sessionLabel: r.sessionLabel,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function stdio(script, env = {}) {
|
|
70
|
+
const client = new Client({ name: "playground-agent", version: "0.0.0" });
|
|
71
|
+
await client.connect(new StdioClientTransport({ command: process.execPath, args: [script], env: { ...getDefaultEnvironment(), ...env }, stderr: "ignore" }));
|
|
72
|
+
return client;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readJsonl(path) {
|
|
76
|
+
return existsSync(path) ? readFileSync(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)) : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function close(s) {
|
|
80
|
+
if (!s) return;
|
|
81
|
+
await Promise.allSettled(s.closers.map((c) => c.close()));
|
|
82
|
+
rmSync(s.work, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fresh servers, ledger, audit log and session. Throws without side effects if the config is invalid. */
|
|
86
|
+
async function build({ adtl, config }) {
|
|
87
|
+
if (adtl) parseConfigText(config); // a broken adtl.yaml fails here, before anything starts
|
|
88
|
+
const work = mkdtempSync(join(tmpdir(), "adtl-playground-"));
|
|
89
|
+
const posted = join(work, "posted.jsonl");
|
|
90
|
+
const s = { adtl, config, work, posted, audit: join(work, "audit.jsonl"), timeline: [], sessionNo: 1, closers: [], auditSeen: 0 };
|
|
91
|
+
try {
|
|
92
|
+
if (adtl) {
|
|
93
|
+
const path = join(work, "adtl.yaml");
|
|
94
|
+
writeFileSync(
|
|
95
|
+
path,
|
|
96
|
+
config.replace(/^(ledger|gateway):[\s\S]*$/m, "") +
|
|
97
|
+
`\nledger:\n path: ${JSON.stringify(join(work, "ledger.db"))}\n` +
|
|
98
|
+
`gateway:\n audit: ${JSON.stringify(s.audit)}\n upstreams:\n` +
|
|
99
|
+
` github:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(FAKE_GITHUB)}]\n` +
|
|
100
|
+
` env: { FAKE_GITHUB_POSTED: ${JSON.stringify(posted)} }\n` +
|
|
101
|
+
` memory:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(FAKE_MEMORY)}]\n`,
|
|
102
|
+
);
|
|
103
|
+
const loaded = await loadGatewayConfig(path);
|
|
104
|
+
const interceptors = await connectUpstream("interceptors", {
|
|
105
|
+
command: process.execPath, args: [bundledInterceptorsCli(), "--config", path], env: {},
|
|
106
|
+
}, work);
|
|
107
|
+
s.closers.push(interceptors);
|
|
108
|
+
s.invoker = new InterceptorInvoker(await InterceptorInvoker.discover(interceptors), loaded.gateway.interceptorTimeoutMs);
|
|
109
|
+
s.upstreams = new Map();
|
|
110
|
+
for (const [id, u] of Object.entries(loaded.gateway.upstreams)) {
|
|
111
|
+
const c = await connectUpstream(id, u, work);
|
|
112
|
+
s.closers.push(c);
|
|
113
|
+
s.upstreams.set(id, c);
|
|
114
|
+
}
|
|
115
|
+
s.notifier = new PageNotifier();
|
|
116
|
+
s.approvals = new ApprovalBroker({ notifiers: [s.notifier], timeoutMs: 120_000 });
|
|
117
|
+
s.auditLog = new JsonlAuditLog(s.audit);
|
|
118
|
+
} else {
|
|
119
|
+
s.upstreams = new Map([
|
|
120
|
+
["github", await stdio(FAKE_GITHUB, { FAKE_GITHUB_POSTED: posted })],
|
|
121
|
+
["memory", await stdio(FAKE_MEMORY)],
|
|
122
|
+
]);
|
|
123
|
+
s.closers.push(...s.upstreams.values());
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
await close(s);
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
return s;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Start the playground. Resolves once it is listening, with its URL and a
|
|
134
|
+
* `close()` that stops the server and every child process. Port 0 picks a free port.
|
|
135
|
+
*/
|
|
136
|
+
export async function startPlayground({ port = DEFAULT_PORT, host = "127.0.0.1", warn = (m) => console.error(`[adtl] ${m}`) } = {}) {
|
|
137
|
+
let state;
|
|
138
|
+
|
|
139
|
+
function newSession(bump = true) {
|
|
140
|
+
const s = state;
|
|
141
|
+
if (bump) s.sessionNo++;
|
|
142
|
+
s.sessionId = `session-${s.sessionNo}`;
|
|
143
|
+
s.sessionLabel = undefined;
|
|
144
|
+
if (s.adtl) {
|
|
145
|
+
s.gateway = new AdtlGateway({
|
|
146
|
+
sessionId: s.sessionId, upstreams: s.upstreams, invoker: s.invoker, audit: s.auditLog, approvals: s.approvals,
|
|
147
|
+
logger: { info: () => {}, warn },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
s.timeline.push({ kind: "session", session: s.sessionId });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Start over. If the new setup fails (e.g. a broken adtl.yaml), the old one keeps running. */
|
|
154
|
+
async function reset({ adtl = true, config = state?.config ?? DEFAULT_CONFIG } = {}) {
|
|
155
|
+
const next = await build({ adtl, config });
|
|
156
|
+
const old = state;
|
|
157
|
+
state = next;
|
|
158
|
+
newSession(false);
|
|
159
|
+
await close(old);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function tools() {
|
|
163
|
+
const s = state;
|
|
164
|
+
if (s.adtl) return (await s.gateway.listTools()).map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }));
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const [id, c] of s.upstreams) {
|
|
167
|
+
for (const t of (await c.listTools()).tools) out.push({ name: `${id}__${t.name}`, description: t.description, inputSchema: t.inputSchema });
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function call(name, args) {
|
|
173
|
+
const s = state;
|
|
174
|
+
const entry = { kind: "call", session: s.sessionId, tool: name, args, pending: true };
|
|
175
|
+
s.timeline.push(entry);
|
|
176
|
+
try {
|
|
177
|
+
let r;
|
|
178
|
+
if (s.adtl) {
|
|
179
|
+
r = await s.gateway.callTool(name, args);
|
|
180
|
+
} else {
|
|
181
|
+
const [id, tool] = name.split("__");
|
|
182
|
+
r = await s.upstreams.get(id).callTool({ name: tool, arguments: args });
|
|
183
|
+
}
|
|
184
|
+
entry.text = r.content?.map((c) => c.text ?? "").join("") ?? "";
|
|
185
|
+
entry.isError = !!r.isError;
|
|
186
|
+
entry.label = r._meta?.[TRUST_KEY];
|
|
187
|
+
entry.decision = r._meta?.[GATEWAY_DECISION_KEY];
|
|
188
|
+
} catch (err) {
|
|
189
|
+
entry.text = err.message;
|
|
190
|
+
entry.isError = true;
|
|
191
|
+
}
|
|
192
|
+
if (s.adtl) {
|
|
193
|
+
const audit = readJsonl(s.audit);
|
|
194
|
+
const mine = audit.slice(s.auditSeen).filter((e) => e.sessionId === s.sessionId).at(-1);
|
|
195
|
+
s.auditSeen = audit.length;
|
|
196
|
+
if (mine) {
|
|
197
|
+
entry.audit = mine;
|
|
198
|
+
if (mine.sessionLabel) s.sessionLabel = mine.sessionLabel;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
entry.pending = false;
|
|
202
|
+
return entry;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function snapshot() {
|
|
206
|
+
const s = state;
|
|
207
|
+
return {
|
|
208
|
+
adtl: s.adtl,
|
|
209
|
+
config: s.config,
|
|
210
|
+
session: s.sessionId,
|
|
211
|
+
sessionLabel: s.sessionLabel,
|
|
212
|
+
timeline: s.timeline,
|
|
213
|
+
audit: s.adtl ? readJsonl(s.audit) : [],
|
|
214
|
+
posted: readJsonl(s.posted),
|
|
215
|
+
approvals: s.notifier?.list() ?? [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const page = () => readFileSync(join(here, "index.html"));
|
|
220
|
+
|
|
221
|
+
async function body(req) {
|
|
222
|
+
let raw = "";
|
|
223
|
+
for await (const chunk of req) raw += chunk;
|
|
224
|
+
return raw ? JSON.parse(raw) : {};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// One action at a time, except approvals: an "ask" holds its call open until the page answers.
|
|
228
|
+
let queue = Promise.resolve();
|
|
229
|
+
const serial = (fn) => (queue = queue.then(fn, fn));
|
|
230
|
+
|
|
231
|
+
const routes = {
|
|
232
|
+
"GET /": async (_req, res) => res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page()),
|
|
233
|
+
"GET /api/state": async () => snapshot(),
|
|
234
|
+
// Not queued: listing tools must not wait behind a call that is waiting for approval.
|
|
235
|
+
"GET /api/tools": async () => tools(),
|
|
236
|
+
"POST /api/reset": async (req) => {
|
|
237
|
+
const b = await body(req);
|
|
238
|
+
return serial(async () => {
|
|
239
|
+
await reset({ adtl: b.adtl ?? state.adtl, config: b.config ?? (b.defaults ? DEFAULT_CONFIG : state.config) });
|
|
240
|
+
return snapshot();
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
"POST /api/session": async () => serial(async () => (newSession(), snapshot())),
|
|
244
|
+
"POST /api/call": async (req) => {
|
|
245
|
+
const b = await body(req);
|
|
246
|
+
return serial(() => call(b.name, b.args ?? {}));
|
|
247
|
+
},
|
|
248
|
+
"POST /api/approve": async (req) => {
|
|
249
|
+
const b = await body(req);
|
|
250
|
+
return { ok: state.notifier?.answer(b.id, b.decision === "allow" ? "allow" : "deny") ?? false };
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
await reset();
|
|
255
|
+
|
|
256
|
+
const server = createServer(async (req, res) => {
|
|
257
|
+
const route = routes[`${req.method} ${req.url.split("?")[0]}`];
|
|
258
|
+
if (!route) return res.writeHead(404).end("not found");
|
|
259
|
+
try {
|
|
260
|
+
const out = await route(req, res);
|
|
261
|
+
if (!res.headersSent) res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(out ?? {}));
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (!res.headersSent) res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: err.message }));
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
await new Promise((resolve, reject) => {
|
|
269
|
+
server.once("error", reject);
|
|
270
|
+
server.listen(port, host, resolve);
|
|
271
|
+
});
|
|
272
|
+
} catch (err) {
|
|
273
|
+
await close(state);
|
|
274
|
+
if (err.code === "EADDRINUSE") {
|
|
275
|
+
throw new Error(`Port ${port} is in use (another playground running?). Try --port ${port + 1}`);
|
|
276
|
+
}
|
|
277
|
+
throw err;
|
|
278
|
+
}
|
|
279
|
+
const actual = server.address().port;
|
|
280
|
+
return {
|
|
281
|
+
url: `http://localhost:${actual}`,
|
|
282
|
+
port: actual,
|
|
283
|
+
async close() {
|
|
284
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
285
|
+
server.closeAllConnections?.();
|
|
286
|
+
await closed;
|
|
287
|
+
await close(state);
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
package/src/approvals.ts
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* decision and an expiry. A token only ever answers the one pending call it
|
|
11
11
|
* was made for, and only once. Anyone who can read the push channel can still
|
|
12
12
|
* use the tokens sent on it, so that channel must stay private.
|
|
13
|
+
*
|
|
14
|
+
* A request can name its approvers (policy rule `approvers:`). It then goes
|
|
15
|
+
* only to those people's own channels, each with tokens signed for that
|
|
16
|
+
* person, and an answer counts only if it comes back on the same person's
|
|
17
|
+
* channel. So the result names who approved, and nobody else can.
|
|
13
18
|
*/
|
|
14
19
|
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
15
20
|
|
|
@@ -26,6 +31,8 @@ export interface ApprovalRequest {
|
|
|
26
31
|
matched: string[];
|
|
27
32
|
/** The session label that triggered the rule (no arguments or results). */
|
|
28
33
|
sessionLabel?: unknown;
|
|
34
|
+
/** People who may answer, reached on their own channels. Absent: the shared channels. */
|
|
35
|
+
approvers?: string[];
|
|
29
36
|
createdAt: Date;
|
|
30
37
|
expiresAt: Date;
|
|
31
38
|
}
|
|
@@ -70,9 +77,10 @@ export function errorText(err: unknown): string {
|
|
|
70
77
|
export class ApprovalTokens {
|
|
71
78
|
constructor(private readonly secret: Buffer = randomBytes(32)) {}
|
|
72
79
|
|
|
73
|
-
|
|
80
|
+
/** A token answering `request` with `decision`; with `approver`, valid only as that person's answer. */
|
|
81
|
+
sign(request: Pick<ApprovalRequest, "id" | "callHash" | "expiresAt">, decision: ApprovalDecision, approver?: string): string {
|
|
74
82
|
const exp = Math.floor(request.expiresAt.getTime() / 1000);
|
|
75
|
-
return `${TOKEN_PREFIX}.${request.id}.${decision}.${exp}.${this.mac(request.id, request.callHash, decision, exp)}`;
|
|
83
|
+
return `${TOKEN_PREFIX}.${request.id}.${decision}.${exp}.${this.mac(request.id, request.callHash, decision, exp, approver)}`;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
/** Parse a token without verifying it. */
|
|
@@ -86,23 +94,26 @@ export class ApprovalTokens {
|
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
/** Verify a token against the pending request it claims to answer. */
|
|
89
|
-
verify(token: string, request: Pick<ApprovalRequest, "id" | "callHash">, now: Date): ApprovalDecision | undefined {
|
|
97
|
+
verify(token: string, request: Pick<ApprovalRequest, "id" | "callHash">, now: Date, approver?: string): ApprovalDecision | undefined {
|
|
90
98
|
const t = ApprovalTokens.parse(token);
|
|
91
99
|
if (!t || t.id !== request.id) return undefined;
|
|
92
100
|
if (now.getTime() > t.exp * 1000) return undefined;
|
|
93
|
-
const expected = Buffer.from(this.mac(t.id, request.callHash, t.decision, t.exp));
|
|
101
|
+
const expected = Buffer.from(this.mac(t.id, request.callHash, t.decision, t.exp, approver));
|
|
94
102
|
const given = Buffer.from(t.sig);
|
|
95
103
|
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined;
|
|
96
104
|
return t.decision;
|
|
97
105
|
}
|
|
98
106
|
|
|
99
|
-
private mac(id: string, callHash: string, decision: ApprovalDecision, exp: number): string {
|
|
100
|
-
|
|
107
|
+
private mac(id: string, callHash: string, decision: ApprovalDecision, exp: number, approver?: string): string {
|
|
108
|
+
const signed = `${id}\n${callHash}\n${decision}\n${exp}${approver === undefined ? "" : `\napprover:${approver}`}`;
|
|
109
|
+
return createHmac("sha256", this.secret).update(signed).digest("base64url");
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
export interface ApprovalBrokerOptions {
|
|
105
114
|
notifiers: Notifier[];
|
|
115
|
+
/** Each approver's own channels, by principal id. */
|
|
116
|
+
approverChannels?: Map<string, Notifier[]>;
|
|
106
117
|
timeoutMs: number;
|
|
107
118
|
tokens?: ApprovalTokens;
|
|
108
119
|
now?: () => Date;
|
|
@@ -114,8 +125,15 @@ interface Pending {
|
|
|
114
125
|
settle(decision: ApprovalDecision, channel: string, approver?: string): void;
|
|
115
126
|
}
|
|
116
127
|
|
|
128
|
+
/** A channel a request goes to, and whose answers it accepts on it (undefined: a shared channel). */
|
|
129
|
+
interface Target {
|
|
130
|
+
notifier: Notifier;
|
|
131
|
+
approver?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
export class ApprovalBroker {
|
|
118
135
|
private readonly notifiers: Notifier[];
|
|
136
|
+
private readonly approverChannels: Map<string, Notifier[]>;
|
|
119
137
|
private readonly timeoutMs: number;
|
|
120
138
|
private readonly tokens: ApprovalTokens;
|
|
121
139
|
private readonly now: () => Date;
|
|
@@ -124,19 +142,32 @@ export class ApprovalBroker {
|
|
|
124
142
|
|
|
125
143
|
constructor(opts: ApprovalBrokerOptions) {
|
|
126
144
|
this.notifiers = opts.notifiers;
|
|
145
|
+
this.approverChannels = opts.approverChannels ?? new Map();
|
|
127
146
|
this.timeoutMs = opts.timeoutMs;
|
|
128
147
|
this.tokens = opts.tokens ?? new ApprovalTokens();
|
|
129
148
|
this.now = opts.now ?? (() => new Date());
|
|
130
149
|
this.log = opts.log ?? { warn: () => {} };
|
|
131
150
|
}
|
|
132
151
|
|
|
133
|
-
/** True if at least one
|
|
152
|
+
/** True if at least one channel can carry answers. Without one, "ask" stays a block. */
|
|
134
153
|
get canApprove(): boolean {
|
|
135
|
-
return this.notifiers.some((n) => n.listen);
|
|
154
|
+
return this.notifiers.some((n) => n.listen) || [...this.approverChannels.values()].some((ns) => ns.some((n) => n.listen));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Where a request goes: the named approvers' own channels plus notice-only
|
|
159
|
+
* ones, or else every shared channel plus `extra` (channels for this request
|
|
160
|
+
* only, such as the client's elicitation).
|
|
161
|
+
*/
|
|
162
|
+
private targets(request: ApprovalRequest, extra: Notifier[]): Target[] {
|
|
163
|
+
if (!request.approvers) return [...this.notifiers, ...extra].map((notifier) => ({ notifier }));
|
|
164
|
+
const own = request.approvers.flatMap((approver) => (this.approverChannels.get(approver) ?? []).map((notifier) => ({ notifier, approver })));
|
|
165
|
+
// Shared channels that can answer would let anyone who reads them approve; notice-only ones (email) are fine.
|
|
166
|
+
return [...own, ...this.notifiers.filter((n) => !n.listen).map((notifier) => ({ notifier }))];
|
|
136
167
|
}
|
|
137
168
|
|
|
138
169
|
/** 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"
|
|
170
|
+
async request(input: Omit<ApprovalRequest, "id" | "createdAt" | "expiresAt">, extra: Notifier[] = []): Promise<ApprovalResult> {
|
|
140
171
|
const createdAt = this.now();
|
|
141
172
|
const request: ApprovalRequest = {
|
|
142
173
|
...input,
|
|
@@ -164,45 +195,51 @@ export class ApprovalBroker {
|
|
|
164
195
|
finish({ outcome: decision === "allow" ? "approved" : "denied", channel, ...(approver ? { approver } : {}), waitedMs: waited() }),
|
|
165
196
|
});
|
|
166
197
|
|
|
167
|
-
// Start listening before anyone can answer, then send the request
|
|
168
|
-
const
|
|
169
|
-
for (const n of
|
|
170
|
-
n.listen!(request, (token,
|
|
198
|
+
// Start listening before anyone can answer, then send the request to every target.
|
|
199
|
+
const targets = this.targets(request, extra);
|
|
200
|
+
for (const { notifier: n, approver } of targets.filter((t) => t.notifier.listen)) {
|
|
201
|
+
n.listen!(request, (token, reportedBy) => this.answer(token, n.name, approver, reportedBy), abort.signal).catch((err: unknown) =>
|
|
171
202
|
this.log.warn(`${n.name}: stopped listening for approvals: ${errorText(err)}`),
|
|
172
203
|
);
|
|
173
204
|
}
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
n.notify({ kind: "pending", request, tokens }).then(
|
|
205
|
+
const sent = targets.map(({ notifier: n, approver }) => {
|
|
206
|
+
const tokens = { allow: this.tokens.sign(request, "allow", approver), deny: this.tokens.sign(request, "deny", approver) };
|
|
207
|
+
return n.notify({ kind: "pending", request, tokens }).then(
|
|
177
208
|
() => true,
|
|
178
209
|
(err: unknown) => {
|
|
179
210
|
this.log.warn(`${n.name}: could not send approval request: ${errorText(err)}`);
|
|
180
211
|
return false;
|
|
181
212
|
},
|
|
182
|
-
)
|
|
183
|
-
);
|
|
213
|
+
);
|
|
214
|
+
});
|
|
184
215
|
// Nobody who could answer was reached: deny now rather than wait out the timeout.
|
|
185
216
|
// Notify-only channels (email) are not waited for.
|
|
186
|
-
void Promise.all(sent.filter((_, i) =>
|
|
217
|
+
void Promise.all(sent.filter((_, i) => targets[i]!.notifier.listen)).then((ok) => {
|
|
187
218
|
if (!ok.includes(true)) finish({ outcome: "undeliverable", waitedMs: waited() });
|
|
188
219
|
});
|
|
189
220
|
|
|
190
221
|
const r = await result;
|
|
191
|
-
this.broadcast({ kind: "resolved", request, result: r });
|
|
222
|
+
this.broadcast({ kind: "resolved", request, result: r }, [...new Set(targets.map((t) => t.notifier))]);
|
|
192
223
|
return r;
|
|
193
224
|
}
|
|
194
225
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Handle an answer token from a channel; `approver` is whose own channel it
|
|
228
|
+
* came on, `reportedBy` who a shared channel says answered (recorded, not
|
|
229
|
+
* trusted for anything). Returns true if it settled a pending approval.
|
|
230
|
+
*/
|
|
231
|
+
answer(token: string, channel: string, approver?: string, reportedBy?: string): boolean {
|
|
197
232
|
const parsed = ApprovalTokens.parse(token);
|
|
198
233
|
const p = parsed && this.pending.get(parsed.id);
|
|
199
234
|
if (!p) return false; // not ours, already answered, or garbage
|
|
200
|
-
|
|
235
|
+
// A request with approvers takes answers only on their own channels.
|
|
236
|
+
if (p.request.approvers && (approver === undefined || !p.request.approvers.includes(approver))) return false;
|
|
237
|
+
const decision = this.tokens.verify(token, p.request, this.now(), approver);
|
|
201
238
|
if (!decision) {
|
|
202
239
|
this.log.warn(`${channel}: ignored an approval answer with a bad or expired signature`);
|
|
203
240
|
return false;
|
|
204
241
|
}
|
|
205
|
-
p.settle(decision, channel, approver);
|
|
242
|
+
p.settle(decision, channel, approver ?? reportedBy);
|
|
206
243
|
return true;
|
|
207
244
|
}
|
|
208
245
|
|
|
@@ -211,8 +248,8 @@ export class ApprovalBroker {
|
|
|
211
248
|
this.broadcast({ kind: "blocked", ...event });
|
|
212
249
|
}
|
|
213
250
|
|
|
214
|
-
private broadcast(event: ApprovalEvent): void {
|
|
215
|
-
for (const n of
|
|
251
|
+
private broadcast(event: ApprovalEvent, notifiers: Notifier[] = this.notifiers): void {
|
|
252
|
+
for (const n of notifiers) {
|
|
216
253
|
n.notify(event).catch((err: unknown) => this.log.warn(`${n.name}: could not send ${event.kind} notice: ${errorText(err)}`));
|
|
217
254
|
}
|
|
218
255
|
}
|
package/src/audit.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
19
19
|
import { dirname } from "node:path";
|
|
20
20
|
import { digest, sha256Hex } from "@adtl/labels";
|
|
21
|
+
import type { InterceptorRun } from "./invoker.js";
|
|
21
22
|
|
|
22
23
|
export interface AuditEntry {
|
|
23
24
|
ts: string;
|
|
@@ -32,6 +33,8 @@ export interface AuditEntry {
|
|
|
32
33
|
outcome: "forwarded" | "blocked" | "asked" | "upstream_error";
|
|
33
34
|
/** Present when the entry was written by the Claude Code hook rather than the gateway. */
|
|
34
35
|
via?: "claude-code-hook";
|
|
36
|
+
/** Present when a Claude Code subagent made the call: its id and type (e.g. "Explore"). */
|
|
37
|
+
agent?: { id: string; type?: string };
|
|
35
38
|
/** The policy's decision. In audit mode a call can be forwarded with decision "ask" or "block". */
|
|
36
39
|
decision?: string;
|
|
37
40
|
/** Present when the gateway ran in audit mode: nothing was stopped or changed. */
|
|
@@ -49,6 +52,8 @@ export interface AuditEntry {
|
|
|
49
52
|
/** Present when the result carried record label stamps: how many, and which sessions wrote them. */
|
|
50
53
|
records?: unknown;
|
|
51
54
|
sessionLabel?: unknown;
|
|
55
|
+
/** Every interceptor invocation for this call, with its resolved mode and failOpen and where each came from (SEP-2624). */
|
|
56
|
+
interceptors?: InterceptorRun[];
|
|
52
57
|
warnings?: string[];
|
|
53
58
|
durationMs: number;
|
|
54
59
|
}
|
package/src/auditCli.ts
CHANGED
|
@@ -23,6 +23,16 @@ export interface AuditCommandResult {
|
|
|
23
23
|
exitCode: number;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** The audit log: `file` if given, else `gateway.audit` from `config`. */
|
|
27
|
+
export async function resolveAuditPath(file: string | undefined, config: string | undefined, usage: string): Promise<string> {
|
|
28
|
+
if (file) return resolve(file);
|
|
29
|
+
if (!config) throw new Error(`Give the audit log file, or -c <adtl.yaml> with gateway.audit set\n\n${usage}`);
|
|
30
|
+
const abs = resolve(config);
|
|
31
|
+
const gw = ((parseYaml(await readFile(abs, "utf8")) ?? {}) as { gateway?: { audit?: unknown } }).gateway ?? {};
|
|
32
|
+
if (typeof gw.audit !== "string") throw new Error(`${config} has no gateway.audit`);
|
|
33
|
+
return resolve(dirname(abs), gw.audit);
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
export async function runAuditCommand(argv: string[]): Promise<AuditCommandResult> {
|
|
27
37
|
const [cmd, ...rest] = argv;
|
|
28
38
|
let config: string | undefined;
|
|
@@ -37,14 +47,7 @@ export async function runAuditCommand(argv: string[]): Promise<AuditCommandResul
|
|
|
37
47
|
if (cmd !== "verify") throw new Error(`Unknown audit command: ${cmd}\n\n${AUDIT_USAGE}`);
|
|
38
48
|
if (files.length > 1) throw new Error(`Unexpected argument: ${files[1]}\n\n${AUDIT_USAGE}`);
|
|
39
49
|
|
|
40
|
-
|
|
41
|
-
if (!path) {
|
|
42
|
-
if (!config) throw new Error(`Give the audit log file, or -c <adtl.yaml> with gateway.audit set\n\n${AUDIT_USAGE}`);
|
|
43
|
-
const abs = resolve(config);
|
|
44
|
-
const gw = ((parseYaml(await readFile(abs, "utf8")) ?? {}) as { gateway?: { audit?: unknown } }).gateway ?? {};
|
|
45
|
-
if (typeof gw.audit !== "string") throw new Error(`${config} has no gateway.audit`);
|
|
46
|
-
path = resolve(dirname(abs), gw.audit);
|
|
47
|
-
}
|
|
50
|
+
const path = await resolveAuditPath(files[0], config, AUDIT_USAGE);
|
|
48
51
|
|
|
49
52
|
const v = verifyAuditLog(path);
|
|
50
53
|
if (!v.ok) return { stdout: `${path}: TAMPERED: ${v.problem}`, exitCode: 1 };
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* adtl-gateway hook --config adtl.yaml [--gateway-server adtl]
|
|
9
9
|
* adtl-gateway ledger stats|verify|declassify|declassified
|
|
10
10
|
* adtl-gateway audit verify [<file>]
|
|
11
|
+
* adtl-gateway receipt list|<session>|verify <file>
|
|
12
|
+
* adtl-gateway key
|
|
13
|
+
* adtl-gateway playground [--port 4317] [--no-open]
|
|
11
14
|
*
|
|
12
15
|
* Point your MCP client (e.g. Claude Code's .mcp.json) at this command.
|
|
13
16
|
* Logs go to stderr; stdout carries MCP JSON-RPC only.
|
|
@@ -16,6 +19,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
16
19
|
import { EnvExpansionError } from "./config.js";
|
|
17
20
|
import { GATEWAY_VERSION } from "./gateway.js";
|
|
18
21
|
import { runAuditCommand } from "./auditCli.js";
|
|
22
|
+
import { runKeyCommand, runReceiptCommand } from "./receiptCli.js";
|
|
23
|
+
import { runPlayground } from "./playgroundCli.js";
|
|
19
24
|
import { runHook } from "./hooks/run.js";
|
|
20
25
|
import { runLedgerCommand } from "./ledgerCli.js";
|
|
21
26
|
import { runInit, type InitResult } from "./init/run.js";
|
|
@@ -26,6 +31,9 @@ const USAGE = `Usage: adtl-gateway --config <adtl.yaml> [--audit]
|
|
|
26
31
|
adtl-gateway hook --config <adtl.yaml> [--gateway-server <name>]
|
|
27
32
|
adtl-gateway ledger stats|verify|declassify|declassified (see: adtl-gateway ledger --help)
|
|
28
33
|
adtl-gateway audit verify [<file>] (see: adtl-gateway audit --help)
|
|
34
|
+
adtl-gateway receipt list|<session>|verify <file> (see: adtl-gateway receipt --help)
|
|
35
|
+
adtl-gateway key
|
|
36
|
+
adtl-gateway playground [--port <n>] [--no-open] (a local web UI with fake servers)
|
|
29
37
|
|
|
30
38
|
Runs an MCP server over stdio that forwards tool calls to the upstream
|
|
31
39
|
servers in the config, checking and labeling each call with ADTL.
|
|
@@ -104,6 +112,13 @@ async function main(): Promise<void> {
|
|
|
104
112
|
process.exitCode = r.exitCode;
|
|
105
113
|
return;
|
|
106
114
|
}
|
|
115
|
+
if (process.argv[2] === "playground") return runPlayground(process.argv.slice(3));
|
|
116
|
+
if (process.argv[2] === "receipt" || process.argv[2] === "key") {
|
|
117
|
+
const r = process.argv[2] === "key" ? await runKeyCommand() : await runReceiptCommand(process.argv.slice(3));
|
|
118
|
+
console.log(r.stdout);
|
|
119
|
+
process.exitCode = r.exitCode;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
107
122
|
const args = parseArgs(process.argv.slice(2));
|
|
108
123
|
// --help and --version print to stdout and exit; MCP is never served in these modes.
|
|
109
124
|
if (args.help) return void console.log(USAGE);
|
|
@@ -190,7 +205,7 @@ async function init(path: string, write: boolean, from?: string): Promise<void>
|
|
|
190
205
|
}
|
|
191
206
|
|
|
192
207
|
main().catch((err: unknown) => {
|
|
193
|
-
const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit"].includes(process.argv[2] ?? "");
|
|
208
|
+
const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit", "receipt", "key", "playground"].includes(process.argv[2] ?? "");
|
|
194
209
|
console.error(known ? `[adtl-gateway] ${(err as Error).message}` : err);
|
|
195
210
|
// For Claude Code hooks only exit code 2 stops the call; any other code lets it run.
|
|
196
211
|
process.exit(process.argv[2] === "hook" ? 2 : 1);
|