@adtl/gateway 0.7.0 → 0.8.1

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 (80) hide show
  1. package/README.md +143 -11
  2. package/dist/audit.d.ts +3 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js.map +1 -1
  5. package/dist/cli.js +10 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +167 -713
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +64 -7
  10. package/dist/config.js.map +1 -1
  11. package/dist/elicitation.d.ts +7 -1
  12. package/dist/elicitation.d.ts.map +1 -1
  13. package/dist/elicitation.js +21 -14
  14. package/dist/elicitation.js.map +1 -1
  15. package/dist/gateway.d.ts +11 -1
  16. package/dist/gateway.d.ts.map +1 -1
  17. package/dist/gateway.js +13 -5
  18. package/dist/gateway.js.map +1 -1
  19. package/dist/hooks/agentLinks.d.ts +18 -0
  20. package/dist/hooks/agentLinks.d.ts.map +1 -0
  21. package/dist/hooks/agentLinks.js +48 -0
  22. package/dist/hooks/agentLinks.js.map +1 -0
  23. package/dist/hooks/claudeCode.d.ts +4 -0
  24. package/dist/hooks/claudeCode.d.ts.map +1 -1
  25. package/dist/hooks/claudeCode.js +35 -8
  26. package/dist/hooks/claudeCode.js.map +1 -1
  27. package/dist/hooks/index.d.ts +1 -0
  28. package/dist/hooks/index.d.ts.map +1 -1
  29. package/dist/hooks/index.js +1 -0
  30. package/dist/hooks/index.js.map +1 -1
  31. package/dist/hooks/run.d.ts.map +1 -1
  32. package/dist/hooks/run.js +5 -2
  33. package/dist/hooks/run.js.map +1 -1
  34. package/dist/index.d.ts +3 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +3 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/init/suggest.d.ts +3 -1
  39. package/dist/init/suggest.d.ts.map +1 -1
  40. package/dist/init/suggest.js +8 -0
  41. package/dist/init/suggest.js.map +1 -1
  42. package/dist/invoker.d.ts +56 -6
  43. package/dist/invoker.d.ts.map +1 -1
  44. package/dist/invoker.js +121 -19
  45. package/dist/invoker.js.map +1 -1
  46. package/dist/openfga.d.ts +13 -0
  47. package/dist/openfga.d.ts.map +1 -0
  48. package/dist/openfga.js +101 -0
  49. package/dist/openfga.js.map +1 -0
  50. package/dist/startup.d.ts +10 -1
  51. package/dist/startup.d.ts.map +1 -1
  52. package/dist/startup.js +82 -11
  53. package/dist/startup.js.map +1 -1
  54. package/dist/totp.d.ts +42 -0
  55. package/dist/totp.d.ts.map +1 -0
  56. package/dist/totp.js +176 -0
  57. package/dist/totp.js.map +1 -0
  58. package/dist/totpCli.d.ts +11 -0
  59. package/dist/totpCli.d.ts.map +1 -0
  60. package/dist/totpCli.js +46 -0
  61. package/dist/totpCli.js.map +1 -0
  62. package/package.json +8 -7
  63. package/playground/server.mjs +13 -3
  64. package/src/audit.ts +3 -1
  65. package/src/cli.ts +10 -1
  66. package/src/config.ts +71 -7
  67. package/src/elicitation.ts +35 -17
  68. package/src/gateway.ts +18 -5
  69. package/src/hooks/agentLinks.ts +58 -0
  70. package/src/hooks/claudeCode.ts +40 -9
  71. package/src/hooks/index.ts +1 -0
  72. package/src/hooks/run.ts +5 -2
  73. package/src/index.ts +3 -0
  74. package/src/init/suggest.ts +10 -1
  75. package/src/invoker.ts +153 -21
  76. package/src/openfga.ts +101 -0
  77. package/src/startup.ts +79 -11
  78. package/src/totp.ts +191 -0
  79. package/src/totpCli.ts +52 -0
  80. package/src/types/qrcode-terminal.d.ts +4 -0
package/src/totp.ts ADDED
@@ -0,0 +1,191 @@
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
+
20
+ const STEP_SECONDS = 30;
21
+ const DIGITS = 6;
22
+ const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
23
+
24
+ /** The Keychain service name, also how the hooks recognize commands that read it. */
25
+ export const TOTP_KEYCHAIN_SERVICE = "adtl-totp";
26
+
27
+ export function base32Encode(bytes: Buffer): string {
28
+ let bits = 0;
29
+ let value = 0;
30
+ let out = "";
31
+ for (const b of bytes) {
32
+ value = (value << 8) | b;
33
+ bits += 8;
34
+ while (bits >= 5) {
35
+ out += BASE32[(value >>> (bits - 5)) & 31];
36
+ bits -= 5;
37
+ }
38
+ value &= (1 << bits) - 1;
39
+ }
40
+ return bits > 0 ? out + BASE32[(value << (5 - bits)) & 31] : out;
41
+ }
42
+
43
+ export function base32Decode(text: string): Buffer {
44
+ let bits = 0;
45
+ let value = 0;
46
+ const out: number[] = [];
47
+ for (const c of text.toUpperCase().replace(/[\s=]/g, "")) {
48
+ const i = BASE32.indexOf(c);
49
+ if (i < 0) throw new Error("The TOTP secret is not valid base32");
50
+ value = (value << 5) | i;
51
+ bits += 5;
52
+ if (bits >= 8) {
53
+ out.push((value >>> (bits - 8)) & 255);
54
+ bits -= 8;
55
+ }
56
+ value &= (1 << bits) - 1;
57
+ }
58
+ return Buffer.from(out);
59
+ }
60
+
61
+ /** The code for one time step (RFC 4226's HOTP with the step as the counter). */
62
+ export function totpCode(secret: Buffer, step: number, digits = DIGITS): string {
63
+ const counter = Buffer.alloc(8);
64
+ counter.writeBigUInt64BE(BigInt(step));
65
+ const mac = createHmac("sha1", secret).update(counter).digest();
66
+ const offset = mac[mac.length - 1]! & 15;
67
+ const n = mac.readUInt32BE(offset) & 0x7fffffff;
68
+ return String(n % 10 ** digits).padStart(digits, "0");
69
+ }
70
+
71
+ export function totpStep(ms: number): number {
72
+ return Math.floor(ms / 1000 / STEP_SECONDS);
73
+ }
74
+
75
+ /** The `otpauth://` URI authenticator apps scan. */
76
+ export function totpUri(secretBase32: string, account: string): string {
77
+ const label = encodeURIComponent(`ADTL:${account}`);
78
+ return `otpauth://totp/${label}?secret=${secretBase32}&issuer=ADTL&algorithm=SHA1&digits=${DIGITS}&period=${STEP_SECONDS}`;
79
+ }
80
+
81
+ export interface TotpState {
82
+ /** The last time step a code was accepted for. */
83
+ lastStep(): number;
84
+ setLastStep(step: number): void;
85
+ }
86
+
87
+ export class FileTotpState implements TotpState {
88
+ constructor(private readonly path: string) {}
89
+
90
+ lastStep(): number {
91
+ try {
92
+ const n = Number(readFileSync(this.path, "utf8").trim());
93
+ return Number.isSafeInteger(n) ? n : Number.MAX_SAFE_INTEGER; // damaged: accept nothing
94
+ } catch (err) {
95
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0;
96
+ throw err;
97
+ }
98
+ }
99
+
100
+ setLastStep(step: number): void {
101
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
102
+ writeFileSync(this.path, String(step), { encoding: "utf8", mode: 0o600 });
103
+ }
104
+ }
105
+
106
+ export class TotpVerifier {
107
+ constructor(
108
+ private readonly secret: Buffer,
109
+ private readonly state: TotpState,
110
+ private readonly now: () => number = Date.now,
111
+ ) {}
112
+
113
+ /** True if `code` is right for now, give or take one step, and that step or a later one wasn't used yet. */
114
+ verify(code: string): boolean {
115
+ const given = Buffer.from(code.trim());
116
+ const current = totpStep(this.now());
117
+ const last = this.state.lastStep();
118
+ let matched: number | undefined;
119
+ for (const step of [current - 1, current, current + 1]) {
120
+ const expected = Buffer.from(totpCode(this.secret, step));
121
+ if (given.length === expected.length && timingSafeEqual(given, expected) && step > last) matched = step;
122
+ }
123
+ if (matched === undefined) return false;
124
+ this.state.setLastStep(matched);
125
+ return true;
126
+ }
127
+ }
128
+
129
+ export interface TotpSecretStore {
130
+ /** Where the secret is kept, for messages. */
131
+ readonly where: string;
132
+ read(): string | undefined;
133
+ write(secretBase32: string): void;
134
+ }
135
+
136
+ /** `ADTL_TOTP_FILE`, or `~/.adtl/totp-secret`. */
137
+ export function totpSecretPath(env: NodeJS.ProcessEnv = process.env): string {
138
+ return env.ADTL_TOTP_FILE?.trim() || join(homedir(), ".adtl", "totp-secret");
139
+ }
140
+
141
+ function totpStatePath(env: NodeJS.ProcessEnv): string {
142
+ return join(dirname(totpSecretPath(env)), "totp-last-step");
143
+ }
144
+
145
+ class FileSecretStore implements TotpSecretStore {
146
+ readonly where: string;
147
+ constructor(private readonly path: string) {
148
+ this.where = path;
149
+ }
150
+ read(): string | undefined {
151
+ try {
152
+ return readFileSync(this.path, "utf8").trim() || undefined;
153
+ } catch (err) {
154
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
155
+ throw err;
156
+ }
157
+ }
158
+ write(secretBase32: string): void {
159
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
160
+ writeFileSync(this.path, `${secretBase32}\n`, { encoding: "utf8", mode: 0o600 });
161
+ }
162
+ }
163
+
164
+ class KeychainSecretStore implements TotpSecretStore {
165
+ readonly where = "the macOS Keychain";
166
+ read(): string | undefined {
167
+ try {
168
+ return execFileSync("security", ["find-generic-password", "-s", TOTP_KEYCHAIN_SERVICE, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
169
+ } catch {
170
+ return undefined; // no such item
171
+ }
172
+ }
173
+ write(secretBase32: string): void {
174
+ execFileSync("security", ["add-generic-password", "-U", "-a", "adtl", "-s", TOTP_KEYCHAIN_SERVICE, "-w", secretBase32], { stdio: "ignore" });
175
+ }
176
+ }
177
+
178
+ /** The macOS Keychain, unless `ADTL_TOTP_FILE` is set; a file on other systems. */
179
+ export function totpSecretStore(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): TotpSecretStore {
180
+ return platform === "darwin" && !env.ADTL_TOTP_FILE?.trim() ? new KeychainSecretStore() : new FileSecretStore(totpSecretPath(env));
181
+ }
182
+
183
+ export function newTotpSecret(): string {
184
+ return base32Encode(randomBytes(20));
185
+ }
186
+
187
+ /** The verifier for `gateway.approvals.totp`, or undefined if no secret was set up. */
188
+ export function loadTotpVerifier(env: NodeJS.ProcessEnv = process.env, store: TotpSecretStore = totpSecretStore(env)): TotpVerifier | undefined {
189
+ const secret = store.read();
190
+ return secret ? new TotpVerifier(base32Decode(secret), new FileTotpState(totpStatePath(env))) : undefined;
191
+ }
package/src/totpCli.ts ADDED
@@ -0,0 +1,52 @@
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, type TotpSecretStore } from "./totp.js";
10
+
11
+ export const TOTP_USAGE = `Usage: adtl-gateway totp setup [--force]
12
+ adtl-gateway totp check <code>
13
+
14
+ setup makes a secret, stores it, and shows a QR code to scan into an
15
+ authenticator app. --force replaces an existing secret. check tells you
16
+ whether a code matches right now, without using it up. Then set
17
+ gateway.approvals.totp: true.`;
18
+
19
+ export interface TotpCommandResult {
20
+ stdout: string;
21
+ exitCode: number;
22
+ }
23
+
24
+ function qr(text: string): Promise<string> {
25
+ return new Promise((resolve) => qrcode.generate(text, { small: true }, resolve));
26
+ }
27
+
28
+ export async function runTotpCommand(argv: string[], opts: { store?: TotpSecretStore; now?: () => number } = {}): Promise<TotpCommandResult> {
29
+ const store = opts.store ?? totpSecretStore();
30
+ const [cmd, ...rest] = argv;
31
+ if (cmd === "setup" && rest.every((a) => a === "--force")) {
32
+ if (store.read() && !rest.includes("--force")) {
33
+ return { stdout: `A secret is already stored in ${store.where}. Use --force to replace it; codes from the old one stop working.`, exitCode: 1 };
34
+ }
35
+ const secret = newTotpSecret();
36
+ store.write(secret);
37
+ const uri = totpUri(secret, userInfo().username);
38
+ return {
39
+ 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>`,
40
+ exitCode: 0,
41
+ };
42
+ }
43
+ if (cmd === "check" && rest.length === 1) {
44
+ const secret = store.read();
45
+ if (!secret) return { stdout: "No secret yet: run adtl-gateway totp setup", exitCode: 1 };
46
+ const key = base32Decode(secret);
47
+ const step = totpStep((opts.now ?? Date.now)());
48
+ const ok = [step - 1, step, step + 1].some((s) => totpCode(key, s) === rest[0]);
49
+ return { stdout: ok ? "The code matches." : "The code doesn't match. Check the time on your phone.", exitCode: ok ? 0 : 1 };
50
+ }
51
+ return { stdout: TOTP_USAGE, exitCode: cmd === undefined || cmd === "--help" || cmd === "-h" ? 0 : 1 };
52
+ }
@@ -0,0 +1,4 @@
1
+ declare module "qrcode-terminal" {
2
+ const qrcode: { generate(text: string, opts: { small: boolean }, callback: (qr: string) => void): void };
3
+ export default qrcode;
4
+ }