@adtl/gateway 0.8.0 → 0.8.2

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.
Files changed (61) hide show
  1. package/README.md +74 -3
  2. package/dist/cli.js +10 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +145 -881
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +37 -9
  7. package/dist/config.js.map +1 -1
  8. package/dist/elicitation.d.ts +7 -1
  9. package/dist/elicitation.d.ts.map +1 -1
  10. package/dist/elicitation.js +21 -14
  11. package/dist/elicitation.js.map +1 -1
  12. package/dist/gateway.d.ts +5 -1
  13. package/dist/gateway.d.ts.map +1 -1
  14. package/dist/gateway.js +4 -2
  15. package/dist/gateway.js.map +1 -1
  16. package/dist/hooks/claudeCode.d.ts +1 -0
  17. package/dist/hooks/claudeCode.d.ts.map +1 -1
  18. package/dist/hooks/claudeCode.js +23 -4
  19. package/dist/hooks/claudeCode.js.map +1 -1
  20. package/dist/hooks/run.d.ts.map +1 -1
  21. package/dist/hooks/run.js +2 -1
  22. package/dist/hooks/run.js.map +1 -1
  23. package/dist/index.d.ts +3 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/invoker.d.ts +2 -0
  28. package/dist/invoker.d.ts.map +1 -1
  29. package/dist/invoker.js +6 -0
  30. package/dist/invoker.js.map +1 -1
  31. package/dist/openfga.d.ts +13 -0
  32. package/dist/openfga.d.ts.map +1 -0
  33. package/dist/openfga.js +101 -0
  34. package/dist/openfga.js.map +1 -0
  35. package/dist/startup.d.ts +1 -1
  36. package/dist/startup.d.ts.map +1 -1
  37. package/dist/startup.js +27 -6
  38. package/dist/startup.js.map +1 -1
  39. package/dist/totp.d.ts +42 -0
  40. package/dist/totp.d.ts.map +1 -0
  41. package/dist/totp.js +176 -0
  42. package/dist/totp.js.map +1 -0
  43. package/dist/totpCli.d.ts +11 -0
  44. package/dist/totpCli.d.ts.map +1 -0
  45. package/dist/totpCli.js +46 -0
  46. package/dist/totpCli.js.map +1 -0
  47. package/package.json +8 -7
  48. package/playground/server.mjs +13 -3
  49. package/src/cli.ts +10 -1
  50. package/src/config.ts +42 -9
  51. package/src/elicitation.ts +35 -17
  52. package/src/gateway.ts +7 -2
  53. package/src/hooks/claudeCode.ts +25 -5
  54. package/src/hooks/run.ts +2 -1
  55. package/src/index.ts +3 -0
  56. package/src/invoker.ts +7 -0
  57. package/src/openfga.ts +101 -0
  58. package/src/startup.ts +27 -6
  59. package/src/totp.ts +191 -0
  60. package/src/totpCli.ts +52 -0
  61. package/src/types/qrcode-terminal.d.ts +4 -0
package/dist/totp.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Approval codes from an authenticator app (TOTP, RFC 6238: SHA-1, 6 digits,
3
+ * 30 seconds), entered in the MCP client's prompt. Codes are made offline on
4
+ * the phone, and a client that accepts prompts by itself can't produce one.
5
+ *
6
+ * The secret lives in the macOS Keychain, or elsewhere in a file only its
7
+ * owner can read (`ADTL_TOTP_FILE`, or `~/.adtl/totp-secret`). An agent with
8
+ * shell access as the same user may still reach either: the hooks ask before
9
+ * commands that mention them, but only a gateway on another machine rules it out.
10
+ *
11
+ * A code is accepted within one time step either way, and once: the last used
12
+ * step is kept in a file, so a code can't be replayed after a restart either.
13
+ */
14
+ import { execFileSync } from "node:child_process";
15
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
16
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ const STEP_SECONDS = 30;
20
+ const DIGITS = 6;
21
+ const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
22
+ /** The Keychain service name, also how the hooks recognize commands that read it. */
23
+ export const TOTP_KEYCHAIN_SERVICE = "adtl-totp";
24
+ export function base32Encode(bytes) {
25
+ let bits = 0;
26
+ let value = 0;
27
+ let out = "";
28
+ for (const b of bytes) {
29
+ value = (value << 8) | b;
30
+ bits += 8;
31
+ while (bits >= 5) {
32
+ out += BASE32[(value >>> (bits - 5)) & 31];
33
+ bits -= 5;
34
+ }
35
+ value &= (1 << bits) - 1;
36
+ }
37
+ return bits > 0 ? out + BASE32[(value << (5 - bits)) & 31] : out;
38
+ }
39
+ export function base32Decode(text) {
40
+ let bits = 0;
41
+ let value = 0;
42
+ const out = [];
43
+ for (const c of text.toUpperCase().replace(/[\s=]/g, "")) {
44
+ const i = BASE32.indexOf(c);
45
+ if (i < 0)
46
+ throw new Error("The TOTP secret is not valid base32");
47
+ value = (value << 5) | i;
48
+ bits += 5;
49
+ if (bits >= 8) {
50
+ out.push((value >>> (bits - 8)) & 255);
51
+ bits -= 8;
52
+ }
53
+ value &= (1 << bits) - 1;
54
+ }
55
+ return Buffer.from(out);
56
+ }
57
+ /** The code for one time step (RFC 4226's HOTP with the step as the counter). */
58
+ export function totpCode(secret, step, digits = DIGITS) {
59
+ const counter = Buffer.alloc(8);
60
+ counter.writeBigUInt64BE(BigInt(step));
61
+ const mac = createHmac("sha1", secret).update(counter).digest();
62
+ const offset = mac[mac.length - 1] & 15;
63
+ const n = mac.readUInt32BE(offset) & 0x7fffffff;
64
+ return String(n % 10 ** digits).padStart(digits, "0");
65
+ }
66
+ export function totpStep(ms) {
67
+ return Math.floor(ms / 1000 / STEP_SECONDS);
68
+ }
69
+ /** The `otpauth://` URI authenticator apps scan. */
70
+ export function totpUri(secretBase32, account) {
71
+ const label = encodeURIComponent(`ADTL:${account}`);
72
+ return `otpauth://totp/${label}?secret=${secretBase32}&issuer=ADTL&algorithm=SHA1&digits=${DIGITS}&period=${STEP_SECONDS}`;
73
+ }
74
+ export class FileTotpState {
75
+ path;
76
+ constructor(path) {
77
+ this.path = path;
78
+ }
79
+ lastStep() {
80
+ try {
81
+ const n = Number(readFileSync(this.path, "utf8").trim());
82
+ return Number.isSafeInteger(n) ? n : Number.MAX_SAFE_INTEGER; // damaged: accept nothing
83
+ }
84
+ catch (err) {
85
+ if (err.code === "ENOENT")
86
+ return 0;
87
+ throw err;
88
+ }
89
+ }
90
+ setLastStep(step) {
91
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
92
+ writeFileSync(this.path, String(step), { encoding: "utf8", mode: 0o600 });
93
+ }
94
+ }
95
+ export class TotpVerifier {
96
+ secret;
97
+ state;
98
+ now;
99
+ constructor(secret, state, now = Date.now) {
100
+ this.secret = secret;
101
+ this.state = state;
102
+ this.now = now;
103
+ }
104
+ /** True if `code` is right for now, give or take one step, and that step or a later one wasn't used yet. */
105
+ verify(code) {
106
+ const given = Buffer.from(code.trim());
107
+ const current = totpStep(this.now());
108
+ const last = this.state.lastStep();
109
+ let matched;
110
+ for (const step of [current - 1, current, current + 1]) {
111
+ const expected = Buffer.from(totpCode(this.secret, step));
112
+ if (given.length === expected.length && timingSafeEqual(given, expected) && step > last)
113
+ matched = step;
114
+ }
115
+ if (matched === undefined)
116
+ return false;
117
+ this.state.setLastStep(matched);
118
+ return true;
119
+ }
120
+ }
121
+ /** `ADTL_TOTP_FILE`, or `~/.adtl/totp-secret`. */
122
+ export function totpSecretPath(env = process.env) {
123
+ return env.ADTL_TOTP_FILE?.trim() || join(homedir(), ".adtl", "totp-secret");
124
+ }
125
+ function totpStatePath(env) {
126
+ return join(dirname(totpSecretPath(env)), "totp-last-step");
127
+ }
128
+ class FileSecretStore {
129
+ path;
130
+ where;
131
+ constructor(path) {
132
+ this.path = path;
133
+ this.where = path;
134
+ }
135
+ read() {
136
+ try {
137
+ return readFileSync(this.path, "utf8").trim() || undefined;
138
+ }
139
+ catch (err) {
140
+ if (err.code === "ENOENT")
141
+ return undefined;
142
+ throw err;
143
+ }
144
+ }
145
+ write(secretBase32) {
146
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
147
+ writeFileSync(this.path, `${secretBase32}\n`, { encoding: "utf8", mode: 0o600 });
148
+ }
149
+ }
150
+ class KeychainSecretStore {
151
+ where = "the macOS Keychain";
152
+ read() {
153
+ try {
154
+ return execFileSync("security", ["find-generic-password", "-s", TOTP_KEYCHAIN_SERVICE, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
155
+ }
156
+ catch {
157
+ return undefined; // no such item
158
+ }
159
+ }
160
+ write(secretBase32) {
161
+ execFileSync("security", ["add-generic-password", "-U", "-a", "adtl", "-s", TOTP_KEYCHAIN_SERVICE, "-w", secretBase32], { stdio: "ignore" });
162
+ }
163
+ }
164
+ /** The macOS Keychain, unless `ADTL_TOTP_FILE` is set; a file on other systems. */
165
+ export function totpSecretStore(env = process.env, platform = process.platform) {
166
+ return platform === "darwin" && !env.ADTL_TOTP_FILE?.trim() ? new KeychainSecretStore() : new FileSecretStore(totpSecretPath(env));
167
+ }
168
+ export function newTotpSecret() {
169
+ return base32Encode(randomBytes(20));
170
+ }
171
+ /** The verifier for `gateway.approvals.totp`, or undefined if no secret was set up. */
172
+ export function loadTotpVerifier(env = process.env, store = totpSecretStore(env)) {
173
+ const secret = store.read();
174
+ return secret ? new TotpVerifier(base32Decode(secret), new FileTotpState(totpStatePath(env))) : undefined;
175
+ }
176
+ //# sourceMappingURL=totp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totp.js","sourceRoot":"","sources":["../src/totp.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ import { type TotpSecretStore } from "./totp.js";
2
+ export declare const TOTP_USAGE = "Usage: adtl-gateway totp setup [--force]\n adtl-gateway totp check <code>\n\nsetup makes a secret, stores it, and shows a QR code to scan into an\nauthenticator app. --force replaces an existing secret. check tells you\nwhether a code matches right now, without using it up. Then set\ngateway.approvals.totp: true.";
3
+ export interface TotpCommandResult {
4
+ stdout: string;
5
+ exitCode: number;
6
+ }
7
+ export declare function runTotpCommand(argv: string[], opts?: {
8
+ store?: TotpSecretStore;
9
+ now?: () => number;
10
+ }): Promise<TotpCommandResult>;
11
+ //# sourceMappingURL=totpCli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totpCli.d.ts","sourceRoot":"","sources":["../src/totpCli.ts"],"names":[],"mappings":"AAQA,OAAO,EAA6E,KAAK,eAAe,EAAE,MAAM,WAAW,CAAC;AAE5H,eAAO,MAAM,UAAU,qUAMO,CAAC;AAE/B,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAMD,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,eAAe,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAwB3I"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `adtl-gateway totp`: set up authenticator-app approvals.
3
+ *
4
+ * adtl-gateway totp setup [--force]
5
+ * adtl-gateway totp check <code>
6
+ */
7
+ import { userInfo } from "node:os";
8
+ import qrcode from "qrcode-terminal";
9
+ import { base32Decode, newTotpSecret, totpCode, totpSecretStore, totpStep, totpUri } from "./totp.js";
10
+ export const TOTP_USAGE = `Usage: adtl-gateway totp setup [--force]
11
+ adtl-gateway totp check <code>
12
+
13
+ setup makes a secret, stores it, and shows a QR code to scan into an
14
+ authenticator app. --force replaces an existing secret. check tells you
15
+ whether a code matches right now, without using it up. Then set
16
+ gateway.approvals.totp: true.`;
17
+ function qr(text) {
18
+ return new Promise((resolve) => qrcode.generate(text, { small: true }, resolve));
19
+ }
20
+ export async function runTotpCommand(argv, opts = {}) {
21
+ const store = opts.store ?? totpSecretStore();
22
+ const [cmd, ...rest] = argv;
23
+ if (cmd === "setup" && rest.every((a) => a === "--force")) {
24
+ if (store.read() && !rest.includes("--force")) {
25
+ return { stdout: `A secret is already stored in ${store.where}. Use --force to replace it; codes from the old one stop working.`, exitCode: 1 };
26
+ }
27
+ const secret = newTotpSecret();
28
+ store.write(secret);
29
+ const uri = totpUri(secret, userInfo().username);
30
+ return {
31
+ stdout: `${await qr(uri)}\nScan this with your authenticator app, or enter the key by hand:\n\n ${secret}\n\nStored in ${store.where}. Clear your terminal afterwards: this screen shows the secret.\nThen set gateway.approvals.totp: true, and test a code with: adtl-gateway totp check <code>`,
32
+ exitCode: 0,
33
+ };
34
+ }
35
+ if (cmd === "check" && rest.length === 1) {
36
+ const secret = store.read();
37
+ if (!secret)
38
+ return { stdout: "No secret yet: run adtl-gateway totp setup", exitCode: 1 };
39
+ const key = base32Decode(secret);
40
+ const step = totpStep((opts.now ?? Date.now)());
41
+ const ok = [step - 1, step, step + 1].some((s) => totpCode(key, s) === rest[0]);
42
+ return { stdout: ok ? "The code matches." : "The code doesn't match. Check the time on your phone.", exitCode: ok ? 0 : 1 };
43
+ }
44
+ return { stdout: TOTP_USAGE, exitCode: cmd === undefined || cmd === "--help" || cmd === "-h" ? 0 : 1 };
45
+ }
46
+ //# sourceMappingURL=totpCli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"totpCli.js","sourceRoot":"","sources":["../src/totpCli.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACnC,OAAO,MAAM,MAAM,iBAAiB,CAAC;AACrC,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,QAAQ,EAAE,eAAe,EAAE,QAAQ,EAAE,OAAO,EAAwB,MAAM,WAAW,CAAC;AAE5H,MAAM,CAAC,MAAM,UAAU,GAAG;;;;;;8BAMI,CAAC;AAO/B,SAAS,EAAE,CAAC,IAAY;IACtB,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,OAAO,CAAC,CAAC,CAAC;AACnF,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAc,EAAE,OAAwD,EAAE;IAC7G,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,eAAe,EAAE,CAAC;IAC9C,MAAM,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;IAC5B,IAAI,GAAG,KAAK,OAAO,IAAI,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,EAAE,CAAC;QAC1D,IAAI,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;YAC9C,OAAO,EAAE,MAAM,EAAE,iCAAiC,KAAK,CAAC,KAAK,mEAAmE,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;QAClJ,CAAC;QACD,MAAM,MAAM,GAAG,aAAa,EAAE,CAAC;QAC/B,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QACpB,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,EAAE,QAAQ,EAAE,CAAC,QAAQ,CAAC,CAAC;QACjD,OAAO;YACL,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,2EAA2E,MAAM,iBAAiB,KAAK,CAAC,KAAK,8JAA8J;YACnS,QAAQ,EAAE,CAAC;SACZ,CAAC;IACJ,CAAC;IACD,IAAI,GAAG,KAAK,OAAO,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzC,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC;QAC5B,IAAI,CAAC,MAAM;YAAE,OAAO,EAAE,MAAM,EAAE,4CAA4C,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;QAC1F,MAAM,GAAG,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;QACjC,MAAM,IAAI,GAAG,QAAQ,CAAC,CAAC,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAChD,MAAM,EAAE,GAAG,CAAC,IAAI,GAAG,CAAC,EAAE,IAAI,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAChF,OAAO,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,CAAC,mBAAmB,CAAC,CAAC,CAAC,uDAAuD,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;IAC9H,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,GAAG,KAAK,SAAS,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AACzG,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adtl/gateway",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",
@@ -47,13 +47,14 @@
47
47
  "url": "https://github.com/probita-ai/adtl/issues"
48
48
  },
49
49
  "dependencies": {
50
- "@adtl/interceptors": "0.8.0",
51
- "@adtl/labels": "0.8.0",
52
- "@adtl/ledger": "0.8.0",
53
- "@modelcontextprotocol/sdk": "1.31.0",
54
- "nodemailer": "10.0.12",
50
+ "@adtl/interceptors": "0.8.2",
51
+ "@adtl/labels": "0.8.2",
52
+ "@adtl/ledger": "0.8.2",
53
+ "@modelcontextprotocol/sdk": "1.32.2",
54
+ "nodemailer": "10.3.1",
55
+ "qrcode-terminal": "0.12.0",
55
56
  "yaml": "2.9.1",
56
- "zod": "3.25.76"
57
+ "zod": "4.6.7"
57
58
  },
58
59
  "peerDependencies": {
59
60
  "@cedar-policy/cedar-wasm": "^4.13.0"
@@ -23,6 +23,7 @@ import {
23
23
  InterceptorInvoker,
24
24
  JsonlAuditLog,
25
25
  bundledInterceptorsCli,
26
+ checkOverrides,
26
27
  connectUpstream,
27
28
  loadGatewayConfig,
28
29
  } from "../dist/index.js";
@@ -84,7 +85,11 @@ async function close(s) {
84
85
 
85
86
  /** Fresh servers, ledger, audit log and session. Throws without side effects if the config is invalid. */
86
87
  async function build({ adtl, config }) {
87
- if (adtl) parseConfigText(config); // a broken adtl.yaml fails here, before anything starts
88
+ // A broken adtl.yaml fails here, before anything starts.
89
+ const gateway = adtl ? parseConfigText(config).gateway : undefined;
90
+ // The playground supplies its own gateway section; only overrides carry over. Interceptor
91
+ // servers don't: a web page must not be able to start commands.
92
+ const overrides = gateway?.interceptorOverrides;
88
93
  const work = mkdtempSync(join(tmpdir(), "adtl-playground-"));
89
94
  const posted = join(work, "posted.jsonl");
90
95
  const s = { adtl, config, work, posted, audit: join(work, "audit.jsonl"), timeline: [], sessionNo: 1, closers: [], auditSeen: 0 };
@@ -95,7 +100,9 @@ async function build({ adtl, config }) {
95
100
  path,
96
101
  config.replace(/^(ledger|gateway):[\s\S]*$/m, "") +
97
102
  `\nledger:\n path: ${JSON.stringify(join(work, "ledger.db"))}\n` +
98
- `gateway:\n audit: ${JSON.stringify(s.audit)}\n upstreams:\n` +
103
+ `gateway:\n audit: ${JSON.stringify(s.audit)}\n` +
104
+ (overrides ? ` interceptorOverrides: ${JSON.stringify(overrides)}\n` : "") +
105
+ ` upstreams:\n` +
99
106
  ` github:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(FAKE_GITHUB)}]\n` +
100
107
  ` env: { FAKE_GITHUB_POSTED: ${JSON.stringify(posted)} }\n` +
101
108
  ` memory:\n command: ${JSON.stringify(process.execPath)}\n args: [${JSON.stringify(FAKE_MEMORY)}]\n`,
@@ -105,7 +112,10 @@ async function build({ adtl, config }) {
105
112
  command: process.execPath, args: [bundledInterceptorsCli(), "--config", path], env: {},
106
113
  }, work);
107
114
  s.closers.push(interceptors);
108
- s.invoker = new InterceptorInvoker(await InterceptorInvoker.discover(interceptors), loaded.gateway.interceptorTimeoutMs);
115
+ const bound = await InterceptorInvoker.discover(interceptors);
116
+ const problems = checkOverrides(bound.map((b) => b.definition), loaded.gateway.interceptorOverrides);
117
+ if (problems.length) throw new Error(`gateway.interceptorOverrides: ${problems.join("; ")}`);
118
+ s.invoker = new InterceptorInvoker(bound, loaded.gateway.interceptorTimeoutMs, undefined, loaded.gateway.interceptorOverrides);
109
119
  s.upstreams = new Map();
110
120
  for (const [id, u] of Object.entries(loaded.gateway.upstreams)) {
111
121
  const c = await connectUpstream(id, u, work);
package/src/cli.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * adtl-gateway receipt list|<session>|verify <file>
12
12
  * adtl-gateway key
13
13
  * adtl-gateway playground [--port 4317] [--no-open]
14
+ * adtl-gateway totp setup [--force]
14
15
  *
15
16
  * Point your MCP client (e.g. Claude Code's .mcp.json) at this command.
16
17
  * Logs go to stderr; stdout carries MCP JSON-RPC only.
@@ -21,6 +22,7 @@ import { GATEWAY_VERSION } from "./gateway.js";
21
22
  import { runAuditCommand } from "./auditCli.js";
22
23
  import { runKeyCommand, runReceiptCommand } from "./receiptCli.js";
23
24
  import { runPlayground } from "./playgroundCli.js";
25
+ import { runTotpCommand } from "./totpCli.js";
24
26
  import { runHook } from "./hooks/run.js";
25
27
  import { runLedgerCommand } from "./ledgerCli.js";
26
28
  import { runInit, type InitResult } from "./init/run.js";
@@ -34,6 +36,7 @@ const USAGE = `Usage: adtl-gateway --config <adtl.yaml> [--audit]
34
36
  adtl-gateway receipt list|<session>|verify <file> (see: adtl-gateway receipt --help)
35
37
  adtl-gateway key
36
38
  adtl-gateway playground [--port <n>] [--no-open] (a local web UI with fake servers)
39
+ adtl-gateway totp setup [--force] (authenticator-app approvals; see: adtl-gateway totp --help)
37
40
 
38
41
  Runs an MCP server over stdio that forwards tool calls to the upstream
39
42
  servers in the config, checking and labeling each call with ADTL.
@@ -113,6 +116,12 @@ async function main(): Promise<void> {
113
116
  return;
114
117
  }
115
118
  if (process.argv[2] === "playground") return runPlayground(process.argv.slice(3));
119
+ if (process.argv[2] === "totp") {
120
+ const r = await runTotpCommand(process.argv.slice(3));
121
+ console.log(r.stdout);
122
+ process.exitCode = r.exitCode;
123
+ return;
124
+ }
116
125
  if (process.argv[2] === "receipt" || process.argv[2] === "key") {
117
126
  const r = process.argv[2] === "key" ? await runKeyCommand() : await runReceiptCommand(process.argv.slice(3));
118
127
  console.log(r.stdout);
@@ -205,7 +214,7 @@ async function init(path: string, write: boolean, from?: string): Promise<void>
205
214
  }
206
215
 
207
216
  main().catch((err: unknown) => {
208
- const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit", "receipt", "key", "playground"].includes(process.argv[2] ?? "");
217
+ const known = err instanceof GatewayConfigError || err instanceof EnvExpansionError || ["ledger", "audit", "receipt", "key", "playground", "totp"].includes(process.argv[2] ?? "");
209
218
  console.error(known ? `[adtl-gateway] ${(err as Error).message}` : err);
210
219
  // For Claude Code hooks only exit code 2 stops the call; any other code lets it run.
211
220
  process.exit(process.argv[2] === "hook" ? 2 : 1);
package/src/config.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  * maintainers: { members: [alice], tools: ["github.*"] }
22
22
  * readers: { members: ["*"], tools: ["github.read_*", "github.get_*"] }
23
23
  * cedar: { policies: ./adtl.cedar } # optional: attribute-based rules (see cedar.ts)
24
+ * openfga: { url: http://localhost:8080, storeId: "${FGA_STORE_ID}" } # optional: relationship rules (see openfga.ts)
24
25
  * approvals: # optional: make "ask" really ask
25
26
  * timeoutSeconds: 50
26
27
  * ntfy: { topic: "${ADTL_NTFY_TOPIC}" }
@@ -31,7 +32,7 @@
31
32
  * from: adtl@example.com
32
33
  * to: you@example.com
33
34
  *
34
- * `${VAR}` in command, args, env, cwd, url, headers, approval settings and principal.id is
35
+ * `${VAR}` in command, args, env, cwd, url, headers, approval settings, openfga settings and principal.id is
35
36
  * replaced from the gateway's environment. A missing variable is a config error,
36
37
  * unless a default is given: `${VAR:-default}`.
37
38
  */
@@ -44,7 +45,7 @@ export const StdioUpstreamSchema = z
44
45
  .object({
45
46
  command: z.string().min(1),
46
47
  args: z.array(z.string()).default([]),
47
- env: z.record(z.string()).default({}),
48
+ env: z.record(z.string(), z.string()).default({}),
48
49
  cwd: z.string().optional(),
49
50
  })
50
51
  .strict();
@@ -52,7 +53,7 @@ export const StdioUpstreamSchema = z
52
53
  export const HttpUpstreamSchema = z
53
54
  .object({
54
55
  url: z.string().min(1),
55
- headers: z.record(z.string()).default({}),
56
+ headers: z.record(z.string(), z.string()).default({}),
56
57
  })
57
58
  .strict();
58
59
 
@@ -104,11 +105,16 @@ export const ApprovalsConfigSchema = z
104
105
  * default: a client that accepts elicitations by itself would approve calls.
105
106
  */
106
107
  elicitation: z.boolean().default(false),
108
+ /**
109
+ * Ask in the MCP client for a code from an authenticator app (`adtl-gateway
110
+ * totp setup`), instead of Allow or Deny. Wins over `elicitation`.
111
+ */
112
+ totp: z.boolean().default(false),
107
113
  /**
108
114
  * Each approver's own channel, by principal id. Policy rules with
109
115
  * `approvers: [role]` ask only these people, so the answer names who approved.
110
116
  */
111
- approvers: z.record(z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
117
+ approvers: z.record(z.string(), z.object({ ntfy: NtfyConfigSchema }).strict()).optional(),
112
118
  })
113
119
  .strict();
114
120
  export type ApprovalsConfig = z.infer<typeof ApprovalsConfigSchema>;
@@ -123,7 +129,7 @@ export const PrincipalConfigSchema = z
123
129
  .object({
124
130
  type: z.enum(["user", "service"]).default("user"),
125
131
  id: z.string().min(1),
126
- claims: z.record(z.unknown()).optional(),
132
+ claims: z.record(z.string(), z.unknown()).optional(),
127
133
  })
128
134
  .strict();
129
135
  export type PrincipalConfig = z.infer<typeof PrincipalConfigSchema>;
@@ -166,6 +172,31 @@ export const CedarConfigSchema = z
166
172
  .strict();
167
173
  export type CedarConfig = z.infer<typeof CedarConfigSchema>;
168
174
 
175
+ export const OpenFgaConfigSchema = z
176
+ .object({
177
+ url: z.string().min(1),
178
+ storeId: z.string().min(1),
179
+ modelId: z.string().min(1).optional(),
180
+ token: z.string().min(1).optional(),
181
+ /** Relation checked when a tool has no rule of its own. */
182
+ relation: z.string().min(1).default("can_use"),
183
+ timeoutMs: z.number().int().positive().max(60_000).default(2000),
184
+ /** Per-tool rules, keyed `server.tool`. `object` is `type:id`, and `{argument}` is taken from the call. */
185
+ tools: z
186
+ .record(
187
+ z.string(),
188
+ z
189
+ .object({
190
+ relation: z.string().min(1).optional(),
191
+ object: z.string().regex(/^[^:\s]+:.+$/, 'Use "type:id", e.g. "repo:{owner}/{repo}"').optional(),
192
+ })
193
+ .strict(),
194
+ )
195
+ .default({}),
196
+ })
197
+ .strict();
198
+ export type OpenFgaConfig = z.infer<typeof OpenFgaConfigSchema>;
199
+
169
200
  const PhaseSchema = z.enum(["request", "response"]);
170
201
 
171
202
  /** SEP-2624 InterceptorOverrides, set by the gateway owner for one interceptor. */
@@ -187,7 +218,7 @@ export const InterceptorOverridesSchema = z
187
218
  export const GatewayConfigSchema = z
188
219
  .object({
189
220
  upstreams: z
190
- .record(UpstreamSchema)
221
+ .record(z.string(), UpstreamSchema)
191
222
  .refine((u) => Object.keys(u).length > 0, "At least one upstream server is required")
192
223
  .refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
193
224
  message: "Upstream ids may only contain letters, digits and '-' (they prefix tool names as <id>__<tool>)",
@@ -196,7 +227,7 @@ export const GatewayConfigSchema = z
196
227
  interceptors: StdioUpstreamSchema.optional(),
197
228
  /** More interceptor servers (stdio or Streamable HTTP) whose interceptors run beside ADTL's. */
198
229
  interceptorServers: z
199
- .record(UpstreamSchema)
230
+ .record(z.string(), UpstreamSchema)
200
231
  .refine((u) => Object.keys(u).every((id) => UPSTREAM_ID.test(id)), {
201
232
  message: "Interceptor server ids may only contain letters, digits and '-'",
202
233
  })
@@ -213,16 +244,18 @@ export const GatewayConfigSchema = z
213
244
  /** Timeout for each interceptor invocation, in milliseconds. */
214
245
  interceptorTimeoutMs: z.number().int().positive().default(5000),
215
246
  /** Per-interceptor overrides of failOpen, priorityHint, mode, timeoutMs and hooks, by interceptor name. */
216
- interceptorOverrides: z.record(InterceptorOverridesSchema).default({}),
247
+ interceptorOverrides: z.record(z.string(), InterceptorOverridesSchema).default({}),
217
248
  /** Who runs this gateway. Passed to the authorizer and the interceptors. Default: anonymous. */
218
249
  principal: PrincipalConfigSchema.optional(),
219
250
  /** Role-based authorization: when present, a call no role grants is denied. */
220
251
  roles: z
221
- .record(RoleSchema)
252
+ .record(z.string(), RoleSchema)
222
253
  .refine((r) => Object.keys(r).length > 0, "roles: needs at least one role (remove it to allow everyone)")
223
254
  .optional(),
224
255
  /** Cedar authorization. With roles as well, both must allow. */
225
256
  cedar: CedarConfigSchema.optional(),
257
+ /** OpenFGA authorization. With roles or cedar as well, all must allow. */
258
+ openfga: OpenFgaConfigSchema.optional(),
226
259
  })
227
260
  .strict();
228
261
  export type GatewayConfig = z.infer<typeof GatewayConfigSchema>;
@@ -9,10 +9,15 @@
9
9
  * when `gateway.approvals.elicitation` is on and the client declares the
10
10
  * elicitation capability, and never for rules with named approvers: the
11
11
  * client can't say who answered.
12
+ *
13
+ * With `gateway.approvals.totp`, the prompt asks for a code from the person's
14
+ * authenticator app instead of Allow or Deny, so a client that accepts
15
+ * prompts by itself can't approve anything. Three wrong codes count as Deny.
12
16
  */
13
17
  import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
14
18
  import { ElicitResultSchema } from "@modelcontextprotocol/sdk/types.js";
15
19
  import type { AnswerFn, ApprovalEvent, ApprovalRequest, Notifier } from "./approvals.js";
20
+ import type { TotpVerifier } from "./totp.js";
16
21
 
17
22
  interface Listening {
18
23
  answer: AnswerFn;
@@ -29,11 +34,22 @@ function message(request: ApprovalRequest): string {
29
34
  return `ADTL: allow ${request.server}.${request.tool}?\n\n${request.reason}\n\nRule: ${rule}. Allow runs this one call; Deny or no answer stops it.`;
30
35
  }
31
36
 
37
+ function codeMessage(request: ApprovalRequest, wrong: number): string {
38
+ const rule = request.matched.join(", ") || "default";
39
+ const again = wrong > 0 ? "That code was not right. " : "";
40
+ return `ADTL: allow ${request.server}.${request.tool}?\n\n${request.reason}\n\nRule: ${rule}. ${again}Enter the code from your authenticator app to run this one call; decline or no answer stops it.`;
41
+ }
42
+
43
+ const MAX_CODE_ATTEMPTS = 3;
44
+
32
45
  export class ElicitationChannel implements Notifier {
33
46
  readonly name = "elicitation";
34
47
  private readonly listening = new Map<string, Listening>();
35
48
 
36
- constructor(private readonly server: Server) {}
49
+ constructor(
50
+ private readonly server: Server,
51
+ private readonly totp?: TotpVerifier,
52
+ ) {}
37
53
 
38
54
  async listen(request: ApprovalRequest, answer: AnswerFn, signal: AbortSignal): Promise<void> {
39
55
  this.listening.set(request.id, { answer, signal });
@@ -46,22 +62,24 @@ export class ElicitationChannel implements Notifier {
46
62
  const l = this.listening.get(event.request.id);
47
63
  if (!l) throw new Error("not listening for this request");
48
64
  // A plain request, not Server.elicitInput(): clients that declare only `elicitation: {}` support forms too.
49
- const result = await this.server.request(
50
- {
51
- method: "elicitation/create",
52
- params: {
53
- message: message(event.request),
54
- requestedSchema: {
55
- type: "object",
56
- properties: {
57
- decision: { type: "string", title: "Decision", enum: ["allow", "deny"], description: "allow runs this one call; deny stops it" },
58
- },
59
- required: ["decision"],
60
- },
61
- },
62
- },
63
- ElicitResultSchema,
64
- { signal: l.signal, timeout: Math.max(1, event.request.expiresAt.getTime() - Date.now()) },
65
+ const ask = (text: string, properties: Record<string, unknown>, required: string) =>
66
+ this.server.request(
67
+ { method: "elicitation/create", params: { message: text, requestedSchema: { type: "object", properties, required: [required] } } } as never,
68
+ ElicitResultSchema,
69
+ { signal: l.signal, timeout: Math.max(1, event.request.expiresAt.getTime() - Date.now()) },
70
+ );
71
+ if (this.totp) {
72
+ for (let wrong = 0; wrong < MAX_CODE_ATTEMPTS; wrong++) {
73
+ const r = await ask(codeMessage(event.request, wrong), { code: { type: "string", title: "Code", description: "6 digits from your authenticator app" } }, "code");
74
+ if (r.action !== "accept") break;
75
+ if (this.totp.verify(String(r.content?.code ?? ""))) return void l.answer(event.tokens.allow);
76
+ }
77
+ return void l.answer(event.tokens.deny);
78
+ }
79
+ const result = await ask(
80
+ message(event.request),
81
+ { decision: { type: "string", title: "Decision", enum: ["allow", "deny"], description: "allow runs this one call; deny stops it" } },
82
+ "decision",
65
83
  );
66
84
  const allow = result.action === "accept" && result.content?.decision === "allow";
67
85
  l.answer(allow ? event.tokens.allow : event.tokens.deny);
package/src/gateway.ts CHANGED
@@ -57,12 +57,13 @@ import type { InvocationContext } from "@adtl/interceptors";
57
57
  import { callBindingHash } from "@adtl/labels";
58
58
  import type { ApprovalBroker, ApprovalResult } from "./approvals.js";
59
59
  import { ElicitationChannel, supportsElicitation } from "./elicitation.js";
60
+ import type { TotpVerifier } from "./totp.js";
60
61
  import type { AuditEntry, AuditSink } from "./audit.js";
61
62
  import { authorize, type AuthorizationRequest, type Authorizer, type Denial, type Principal } from "./authorizer.js";
62
63
  import { TOOL_SEPARATOR } from "./config.js";
63
64
  import type { Blocked, InterceptorInvoker, InterceptorRun, PhaseOutcome } from "./invoker.js";
64
65
 
65
- export const GATEWAY_VERSION = "0.8.0";
66
+ export const GATEWAY_VERSION = "0.8.2";
66
67
 
67
68
  /** `_meta` key on gateway-generated error results describing why a call was stopped. */
68
69
  export const GATEWAY_DECISION_KEY = "adtl/gateway";
@@ -115,6 +116,8 @@ export interface AdtlGatewayOptions {
115
116
  approverRoles?: Map<string, string[]>;
116
117
  /** Also ask for approval in the MCP client, when it supports elicitation. */
117
118
  elicitApprovals?: boolean;
119
+ /** Ask for a code from an authenticator app in the MCP client, instead of Allow or Deny. */
120
+ totp?: TotpVerifier;
118
121
  /** The (sub-)agent this gateway serves, recorded on every audit entry. */
119
122
  agent?: { id: string; type?: string };
120
123
  }
@@ -179,6 +182,7 @@ export class AdtlGateway {
179
182
  private readonly agent: { agent: { id: string; type?: string } } | Record<string, never>;
180
183
  private readonly approverRoles: Map<string, string[]>;
181
184
  private readonly elicitApprovals: boolean;
185
+ private readonly totp: TotpVerifier | undefined;
182
186
  /** The server the agent is connected to, for elicitation. */
183
187
  private server: Server | undefined;
184
188
  private routes = new Map<string, Route>();
@@ -199,6 +203,7 @@ export class AdtlGateway {
199
203
  this.agent = opts.agent ? { agent: opts.agent } : {};
200
204
  this.approverRoles = opts.approverRoles ?? new Map();
201
205
  this.elicitApprovals = opts.elicitApprovals === true;
206
+ this.totp = opts.totp;
202
207
  }
203
208
 
204
209
  /** Ask each authorizer in turn. Returns the first denial; in audit mode the caller logs it and carries on. */
@@ -573,7 +578,7 @@ export class AdtlGateway {
573
578
  const lists = Array.isArray(policy.approvers) ? (policy.approvers as string[][]) : undefined;
574
579
  const approvers = lists ? eligibleApprovers(lists, this.approverRoles) : undefined;
575
580
  // The client's own prompt, if enabled and supported; never for named approvers (it can't say who answered).
576
- const elicit = this.elicitApprovals && !approvers && this.server && supportsElicitation(this.server) ? [new ElicitationChannel(this.server)] : [];
581
+ const elicit = (this.elicitApprovals || this.totp) && !approvers && this.server && supportsElicitation(this.server) ? [new ElicitationChannel(this.server, this.totp)] : [];
577
582
  if (b.decision === "ask" && this.approvals && (this.approvals.canApprove || elicit.length > 0) && typeof hash === "string") {
578
583
  this.log.info(`ask ${name}: waiting for approval${approvers ? ` from ${approvers.join(", ") || "nobody"}` : ""} (${b.reason})`);
579
584
  approval = await this.waitForApproval(opts, () =>