@hwip/mcp-server 0.0.0-stage → 0.1.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.
Files changed (78) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +10 -0
  3. package/README.md +413 -2
  4. package/dist/agent-target.d.ts +45 -0
  5. package/dist/agent-target.js +81 -0
  6. package/dist/api-client.d.ts +162 -0
  7. package/dist/api-client.js +517 -0
  8. package/dist/ask-context-attach.d.ts +67 -0
  9. package/dist/ask-context-attach.js +147 -0
  10. package/dist/ask-seal.d.ts +118 -0
  11. package/dist/ask-seal.js +194 -0
  12. package/dist/command-seal.d.ts +49 -0
  13. package/dist/command-seal.js +90 -0
  14. package/dist/command-seq.d.ts +30 -0
  15. package/dist/command-seq.js +147 -0
  16. package/dist/config.d.ts +93 -0
  17. package/dist/config.js +561 -0
  18. package/dist/crypto.d.ts +234 -0
  19. package/dist/crypto.js +606 -0
  20. package/dist/error-format.d.ts +7 -0
  21. package/dist/error-format.js +77 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +30 -0
  24. package/dist/key-changes.d.ts +50 -0
  25. package/dist/key-changes.js +163 -0
  26. package/dist/mime.d.ts +16 -0
  27. package/dist/mime.js +58 -0
  28. package/dist/peer-key.d.ts +36 -0
  29. package/dist/peer-key.js +35 -0
  30. package/dist/poll.d.ts +24 -0
  31. package/dist/poll.js +156 -0
  32. package/dist/push-audience.d.ts +35 -0
  33. package/dist/push-audience.js +64 -0
  34. package/dist/redirect.d.ts +8 -0
  35. package/dist/redirect.js +17 -0
  36. package/dist/remote-state.d.ts +38 -0
  37. package/dist/remote-state.js +81 -0
  38. package/dist/resources/channels.d.ts +4 -0
  39. package/dist/resources/channels.js +24 -0
  40. package/dist/resources/devices.d.ts +4 -0
  41. package/dist/resources/devices.js +24 -0
  42. package/dist/sanitize.d.ts +41 -0
  43. package/dist/sanitize.js +106 -0
  44. package/dist/self-register.d.ts +34 -0
  45. package/dist/self-register.js +51 -0
  46. package/dist/server.d.ts +4 -0
  47. package/dist/server.js +63 -0
  48. package/dist/tools/agent-local.d.ts +17 -0
  49. package/dist/tools/agent-local.js +139 -0
  50. package/dist/tools/agent-send.d.ts +6 -0
  51. package/dist/tools/agent-send.js +89 -0
  52. package/dist/tools/ask.d.ts +9 -0
  53. package/dist/tools/ask.js +275 -0
  54. package/dist/tools/broadcast.d.ts +5 -0
  55. package/dist/tools/broadcast.js +42 -0
  56. package/dist/tools/clipboard.d.ts +5 -0
  57. package/dist/tools/clipboard.js +47 -0
  58. package/dist/tools/dismiss.d.ts +5 -0
  59. package/dist/tools/dismiss.js +49 -0
  60. package/dist/tools/file.d.ts +5 -0
  61. package/dist/tools/file.js +169 -0
  62. package/dist/tools/list.d.ts +4 -0
  63. package/dist/tools/list.js +44 -0
  64. package/dist/tools/lookup.d.ts +10 -0
  65. package/dist/tools/lookup.js +45 -0
  66. package/dist/tools/notify.d.ts +5 -0
  67. package/dist/tools/notify.js +118 -0
  68. package/dist/tools/rename.d.ts +5 -0
  69. package/dist/tools/rename.js +45 -0
  70. package/dist/trust-pins.d.ts +70 -0
  71. package/dist/trust-pins.js +269 -0
  72. package/dist/types.d.ts +157 -0
  73. package/dist/types.js +2 -0
  74. package/dist/url-check.d.ts +11 -0
  75. package/dist/url-check.js +57 -0
  76. package/dist/ws-wait.d.ts +72 -0
  77. package/dist/ws-wait.js +200 -0
  78. package/package.json +70 -4
package/dist/crypto.js ADDED
@@ -0,0 +1,606 @@
1
+ "use strict";
2
+ /**
3
+ * Per-device encryption for the MCP server — self-contained ECDH P-256 +
4
+ * AES-256-GCM. Mirrors @hwip/crypto API but bundled inline (no external
5
+ * dependency). Uses Web Crypto API via node:crypto webcrypto — Node.js 18+
6
+ * (the `crypto` global only exists unflagged from Node 19, so we import it).
7
+ *
8
+ * How it works (ADR-0007):
9
+ *
10
+ * This host holds one ECDH keypair, in ~/.hwip/device-keys.json — the
11
+ * Machine Device's, shared with `hwip listener`. Either puts its public half
12
+ * on the device record when the list lacks it (`self-register.ts`). Whichever
13
+ * process starts first creates it; the
14
+ * private half never leaves the host — the server only ever sees public
15
+ * keys. A push is encrypted once
16
+ * with a random AES key, and that key is wrapped separately for each of the
17
+ * user's registered devices using ECDH(this host, that device).
18
+ *
19
+ * That makes it end-to-end in the standard sense: the backend stores
20
+ * ciphertext plus wrapped keys it cannot unwrap.
21
+ *
22
+ * What it still does not give you:
23
+ * • Forward secrecy — the ECDH secret for a given (sender, device) pair is
24
+ * static, so a compromise of either private key retroactively opens every
25
+ * push wrapped for that pair. The per-message AES key is random; its wrap
26
+ * key is not.
27
+ * • Authenticity beyond the key pairing — nothing signs `senderPublicKey`,
28
+ * so a server that swapped it could make a push undecryptable, though not
29
+ * readable.
30
+ *
31
+ * Superseded scheme: a single account-wide keypair whose private half the
32
+ * backend escrowed so it could sync to new devices. Key escrow was removed
33
+ * server-side, which left this client waiting for a private key
34
+ * the API stopped returning — encryption was silently off for months. Nothing
35
+ * here asks for that key any more.
36
+ */
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.publicKeySender = exports.samePublicKey = exports.decryptPushBodyForDevice = exports.askContext = exports.ASK_CHOICES_CODE = exports.encryptFileForDevices = exports.encryptPushBodyForDevices = exports.disableCrypto = exports.getPublicKey = exports.getKeyPair = exports.selectRecipients = exports.cryptoStatusLine = exports.cryptoReady = exports.initCrypto = void 0;
39
+ /// <reference lib="dom" />
40
+ const fs_1 = require("fs");
41
+ const os_1 = require("os");
42
+ const path_1 = require("path");
43
+ const node_crypto_1 = require("node:crypto");
44
+ // Node 18 has no `crypto` global (unflagged only from 19.0.0) — resolve the
45
+ // Web Crypto implementation explicitly so encryption works on the declared
46
+ // minimum runtime instead of silently staying disabled.
47
+ const crypto = node_crypto_1.webcrypto;
48
+ // ─── Base64 helpers ───
49
+ const toBase64 = (buffer) => Buffer.from(buffer).toString('base64');
50
+ const fromBase64 = (base64) => {
51
+ const buf = Buffer.from(base64, 'base64');
52
+ // Slice to the exact byte range — Buffer may share a larger pooled
53
+ // ArrayBuffer, so `.buffer` alone could expose unrelated memory.
54
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
55
+ };
56
+ // ─── ECDH key management ───
57
+ const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
58
+ const generateKeyPair = async () => crypto.subtle.generateKey(ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
59
+ const exportKeyPair = async (keyPair) => {
60
+ const [publicRaw, privateRaw] = await Promise.all([
61
+ crypto.subtle.exportKey('spki', keyPair.publicKey),
62
+ crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
63
+ ]);
64
+ return { publicKey: toBase64(publicRaw), privateKey: toBase64(privateRaw) };
65
+ };
66
+ const importPublicKey = async (base64) => crypto.subtle.importKey('spki', fromBase64(base64), ECDH_PARAMS, true, []);
67
+ const importPrivateKey = async (base64) => crypto.subtle.importKey('pkcs8', fromBase64(base64), ECDH_PARAMS, true, ['deriveKey', 'deriveBits']);
68
+ const importKeyPair = async (exported) => {
69
+ const [publicKey, privateKey] = await Promise.all([
70
+ importPublicKey(exported.publicKey),
71
+ importPrivateKey(exported.privateKey),
72
+ ]);
73
+ return { publicKey, privateKey };
74
+ };
75
+ const deriveAesKey = async (privateKey, publicKey) => crypto.subtle.deriveKey({ name: 'ECDH', public: publicKey }, privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
76
+ /**
77
+ * Wrap one raw AES key for every recipient device.
78
+ *
79
+ * The payload is encrypted once and only the 44-byte wrapped key repeats, so
80
+ * an attachment costs one S3 object regardless of how many devices the account
81
+ * has. A recipient whose public key will not import is dropped rather than
82
+ * failing the send — one broken device record must not silence every push.
83
+ */
84
+ const wrapForDevices = async (rawKey, senderPrivateKey, recipients) => {
85
+ const entries = await Promise.all(recipients.map(async ({ deviceId, publicKey }) => {
86
+ try {
87
+ const sharedKey = await deriveAesKey(senderPrivateKey, await importPublicKey(publicKey));
88
+ const keyIv = crypto.getRandomValues(new Uint8Array(12));
89
+ const wrapped = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: keyIv }, sharedKey, rawKey);
90
+ return [
91
+ deviceId,
92
+ JSON.stringify({ encryptedKey: toBase64(wrapped), keyIv: toBase64(keyIv.buffer) }),
93
+ ];
94
+ }
95
+ catch (err) {
96
+ console.error(`[Crypto] Skipping device ${deviceId} — unusable public key:`, err);
97
+ return null;
98
+ }
99
+ }));
100
+ const keyMap = {};
101
+ for (const entry of entries) {
102
+ if (entry)
103
+ keyMap[entry[0]] = entry[1];
104
+ }
105
+ if (Object.keys(keyMap).length === 0)
106
+ throw new Error('No recipient device accepted the wrapped key');
107
+ return keyMap;
108
+ };
109
+ // ─── Key persistence ───
110
+ const OLD_KEYS_DIR = (0, path_1.join)(process.env.XDG_CONFIG_HOME ?? (0, path_1.join)((0, os_1.homedir)(), '.config'), 'hwip');
111
+ // Superseded account-wide keypair. Never written any more; only deleted.
112
+ const LEGACY_KEYS_PATH = (0, path_1.join)(OLD_KEYS_DIR, 'keys.json');
113
+ // This server's own keypair before it shared the machine's. Deleted too: past
114
+ // pushes each carry the `senderPublicKey` they were wrapped with, and a
115
+ // recipient opens them with its own private key — nothing needs this one.
116
+ const RETIRED_DEVICE_KEYS_PATH = (0, path_1.join)(OLD_KEYS_DIR, 'device-keys.json');
117
+ // The Machine Device keypair (ADR-0007), the same file `hwip listener` uses.
118
+ const DEVICE_KEYS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.hwip');
119
+ const DEVICE_KEYS_PATH = (0, path_1.join)(DEVICE_KEYS_DIR, 'device-keys.json');
120
+ const loadDeviceKeys = () => {
121
+ try {
122
+ const parsed = JSON.parse((0, fs_1.readFileSync)(DEVICE_KEYS_PATH, 'utf-8'));
123
+ return parsed.publicKey && parsed.privateKey ? parsed : null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ };
129
+ /**
130
+ * Create the keypair file, exclusively. The listener creates the same file,
131
+ * and two processes each writing their own pair would leave one of them
132
+ * signing with a key that is not on disk. Returns what is on disk afterwards:
133
+ * ours, or the pair that won the race.
134
+ */
135
+ const storeDeviceKeys = (exported) => {
136
+ (0, fs_1.mkdirSync)(DEVICE_KEYS_DIR, { recursive: true, mode: 0o700 });
137
+ // Written whole to a private temp name, then linked into place: `link` is
138
+ // atomic and fails if the name exists, so the file under the real name is
139
+ // never half-written, and a process that loses the race reads a complete one.
140
+ const temp = `${DEVICE_KEYS_PATH}.${process.pid}.${(0, node_crypto_1.randomBytes)(6).toString('hex')}.tmp`;
141
+ (0, fs_1.writeFileSync)(temp, JSON.stringify(exported, null, 2), { mode: 0o600, flag: 'wx' });
142
+ try {
143
+ (0, fs_1.linkSync)(temp, DEVICE_KEYS_PATH);
144
+ return exported;
145
+ }
146
+ catch (err) {
147
+ if (err.code !== 'EEXIST')
148
+ throw err;
149
+ const winner = loadDeviceKeys();
150
+ if (!winner)
151
+ throw new Error(`${DEVICE_KEYS_PATH} exists but holds no keypair`);
152
+ return winner;
153
+ }
154
+ finally {
155
+ try {
156
+ (0, fs_1.unlinkSync)(temp);
157
+ }
158
+ catch { /* already gone — fine */ }
159
+ }
160
+ };
161
+ const deleteRetiredKeys = () => {
162
+ for (const path of [LEGACY_KEYS_PATH, RETIRED_DEVICE_KEYS_PATH]) {
163
+ try {
164
+ (0, fs_1.unlinkSync)(path);
165
+ }
166
+ catch { /* not present — fine */ }
167
+ }
168
+ };
169
+ // ─── Cached state ───
170
+ let cachedKeyPair = null;
171
+ let cachedExportedPublicKey = null;
172
+ let initPromise = null;
173
+ /** The API key of a start that failed: the next send starts again with it, so a key file fixed meanwhile needs no restart. */
174
+ let failedApiKey;
175
+ /**
176
+ * Initialize crypto.
177
+ *
178
+ * This host generates its own keypair on first use and keeps it, whatever the
179
+ * account's plan: the keypair is this machine's identity, registered ungated so
180
+ * that any device can address it (ADR-0013 decision 3, amended 2026-09-16).
181
+ * The private key is created here and stays here. Every push is sealed (F16):
182
+ * there is no account setting and no host switch that turns it off.
183
+ *
184
+ * Asks the server nothing. Safe to call concurrently — deduplicates to a single init.
185
+ * Returns this host's public key, or '' when it has none.
186
+ */
187
+ const initCrypto = (apiKey) => {
188
+ if (initPromise)
189
+ return initPromise;
190
+ initPromise = (async () => {
191
+ // Local-only mode (no apiKey): used by tests and offline setups. An existing
192
+ // device keypair is adopted and a missing one is not created — nothing sends
193
+ // without an apiKey, so there is nothing to make it for.
194
+ if (!apiKey) {
195
+ const stored = loadDeviceKeys();
196
+ if (!stored) {
197
+ (0, exports.disableCrypto)();
198
+ return '';
199
+ }
200
+ cachedKeyPair = await importKeyPair(stored);
201
+ cachedExportedPublicKey = stored.publicKey;
202
+ return stored.publicKey;
203
+ }
204
+ // Drop the escrowed account keypair if an old build left one on disk — it
205
+ // holds a private key this process has no use for and the server no longer
206
+ // accepts.
207
+ deleteRetiredKeys();
208
+ const stored = loadDeviceKeys();
209
+ if (stored) {
210
+ cachedKeyPair = await importKeyPair(stored);
211
+ cachedExportedPublicKey = stored.publicKey;
212
+ return stored.publicKey;
213
+ }
214
+ const keyPair = await generateKeyPair();
215
+ const exported = await exportKeyPair(keyPair);
216
+ const onDisk = storeDeviceKeys(exported);
217
+ cachedKeyPair = onDisk === exported ? keyPair : await importKeyPair(onDisk);
218
+ cachedExportedPublicKey = onDisk.publicKey;
219
+ return onDisk.publicKey;
220
+ })().then((publicKey) => {
221
+ failedApiKey = undefined;
222
+ return publicKey;
223
+ }, (err) => {
224
+ initPromise = null;
225
+ failedApiKey = apiKey;
226
+ throw err;
227
+ });
228
+ return initPromise;
229
+ };
230
+ exports.initCrypto = initCrypto;
231
+ /**
232
+ * Resolves once the start-up `initCrypto` has settled, however it went. index.ts connects the
233
+ * MCP transport without waiting for it, so the first send waits here instead. A start that
234
+ * failed (a key file that would not load) is tried once more for each send that finds no keys.
235
+ */
236
+ const cryptoReady = async () => {
237
+ await (initPromise ?? Promise.resolve('')).then(() => undefined, () => undefined);
238
+ if (cachedKeyPair || failedApiKey === undefined)
239
+ return;
240
+ await (0, exports.initCrypto)(failedApiKey).then(() => undefined, () => undefined);
241
+ };
242
+ exports.cryptoReady = cryptoReady;
243
+ /** One line for stderr saying what `initCrypto` found. */
244
+ const cryptoStatusLine = () => {
245
+ if (!cachedKeyPair)
246
+ return '[Crypto] This machine has no device keys — every push will fail until they load.';
247
+ return `[Crypto] E2E encryption on (publicKey: ${cachedExportedPublicKey?.slice(0, 20)}...) — every push, question and command is sealed.`;
248
+ };
249
+ exports.cryptoStatusLine = cryptoStatusLine;
250
+ /**
251
+ * Keep only devices this host can actually encrypt for.
252
+ *
253
+ * A device without a public key has never run a build that registers one:
254
+ * wrapping for it produces a push it cannot open.
255
+ */
256
+ const selectRecipients = (devices) => devices
257
+ .filter((d) => !!d.publicKey)
258
+ .map(({ deviceId, publicKey }) => ({ deviceId, publicKey }));
259
+ exports.selectRecipients = selectRecipients;
260
+ const getKeyPair = () => cachedKeyPair;
261
+ exports.getKeyPair = getKeyPair;
262
+ const getPublicKey = () => cachedExportedPublicKey;
263
+ exports.getPublicKey = getPublicKey;
264
+ /**
265
+ * Drop everything, keypair included, so this host does no crypto at all.
266
+ *
267
+ * Only the keyless path in `initCrypto` uses this. Keys on disk are untouched; a restart re-adopts them.
268
+ */
269
+ const disableCrypto = () => {
270
+ cachedKeyPair = null;
271
+ cachedExportedPublicKey = null;
272
+ };
273
+ exports.disableCrypto = disableCrypto;
274
+ /**
275
+ * Encrypt a push body for the given recipient devices.
276
+ *
277
+ * Returns the wire fields the API expects: `body` carries the ciphertext and
278
+ * IV, `deviceKeyMap` the per-device wrapped keys, `senderPublicKey` the half
279
+ * recipients need to derive the same secret back.
280
+ */
281
+ const encryptPushBodyForDevices = async (input, recipients) => {
282
+ if (!cachedKeyPair || !cachedExportedPublicKey)
283
+ throw new Error('Crypto not initialized');
284
+ assertAskWithinLimits(input.ask);
285
+ const messageKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
286
+ const iv = crypto.getRandomValues(new Uint8Array(12));
287
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, messageKey, new TextEncoder().encode(JSON.stringify({
288
+ title: input.title,
289
+ body: input.body,
290
+ url: input.url,
291
+ cmd: input.cmd,
292
+ ask: input.ask,
293
+ answer: input.answer,
294
+ files: input.files,
295
+ keys: input.keys,
296
+ ifPrompt: input.ifPrompt,
297
+ context: input.context,
298
+ })));
299
+ const rawMessageKey = await crypto.subtle.exportKey('raw', messageKey);
300
+ return {
301
+ body: JSON.stringify({ ciphertext: toBase64(ciphertext), iv: toBase64(iv.buffer) }),
302
+ deviceKeyMap: await wrapForDevices(rawMessageKey, cachedKeyPair.privateKey, recipients),
303
+ senderPublicKey: cachedExportedPublicKey,
304
+ isEncrypted: true,
305
+ };
306
+ };
307
+ exports.encryptPushBodyForDevices = encryptPushBodyForDevices;
308
+ /**
309
+ * Encrypt file content for the given recipient devices.
310
+ */
311
+ const encryptFileForDevices = async (content, recipients) => {
312
+ if (!cachedKeyPair)
313
+ throw new Error('Crypto not initialized');
314
+ // Binary attachments arrive as a Buffer and must be encrypted byte for byte —
315
+ // running them through TextEncoder would UTF-8 mangle every non-ASCII byte.
316
+ // Both branches are views; subtle.encrypt honours byteOffset/byteLength, so a
317
+ // Buffer carved out of Node's pool encrypts only its own bytes.
318
+ const buffer = typeof content === 'string'
319
+ ? new TextEncoder().encode(content)
320
+ : new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
321
+ const fileKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
322
+ const iv = crypto.getRandomValues(new Uint8Array(12));
323
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, fileKey, buffer);
324
+ const rawFileKey = await crypto.subtle.exportKey('raw', fileKey);
325
+ return {
326
+ ciphertext: Buffer.from(ciphertext),
327
+ iv: toBase64(iv.buffer),
328
+ deviceKeyMap: await wrapForDevices(rawFileKey, cachedKeyPair.privateKey, recipients),
329
+ };
330
+ };
331
+ exports.encryptFileForDevices = encryptFileForDevices;
332
+ // ─── Opening a sealed push body ───
333
+ //
334
+ // Twin of the cli's `src/crypto.ts` (same wire format, same label parsing, same
335
+ // vectors in crypto-labels.vectors.json). An ask round trip opens the answer the
336
+ // phone sealed for this host with the Machine Device key.
337
+ /**
338
+ * Open this device's slot in a `deviceKeyMap` (the JSON `{ encryptedKey,
339
+ * keyIv }` `wrapForDevices` writes) and return the raw AES key inside —
340
+ * the per-file key for an attachment, or the message key for a push body.
341
+ * The ECDH secret is derived from this device's private half and the
342
+ * sender's `senderPublicKey`; anything but that pairing fails the GCM tag
343
+ * and rejects. Requires initCrypto().
344
+ */
345
+ const unwrapDeviceKeyRaw = async (entryJson, senderPublicKeyRaw) => {
346
+ if (!cachedKeyPair)
347
+ throw new Error('Crypto not initialized');
348
+ const entry = JSON.parse(entryJson); // fields are type-checked on the next line
349
+ if (typeof entry.encryptedKey !== 'string' || typeof entry.keyIv !== 'string') {
350
+ throw new Error('deviceKeyMap entry is not { encryptedKey, keyIv }');
351
+ }
352
+ const sharedKey = await deriveAesKey(cachedKeyPair.privateKey, await importPublicKey(senderPublicKeyRaw));
353
+ return crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(entry.keyIv)) }, sharedKey, fromBase64(entry.encryptedKey));
354
+ };
355
+ const MAX_ASK_ACTIONS = 10;
356
+ const MAX_ACTION_ID_CHARS = 64;
357
+ const MAX_ACTION_LABEL_CHARS = 100;
358
+ const MAX_ASK_ID_CHARS = 128;
359
+ /** Most files the summary lists, and the longest path it shows (bytes): the summary rides in a size-capped body. */
360
+ const MAX_CONTEXT_LISTED = 20;
361
+ const MAX_CONTEXT_PATH_BYTES = 128;
362
+ /** A device id as the account issues it (the cli's `subscriber-trust` DEVICE_ID). */
363
+ const ASK_DEVICE_ID = /^[A-Za-z0-9_-]{1,64}$/;
364
+ /**
365
+ * The reader drops a whole label that is over its limits, so a question sealed
366
+ * past them would arrive as one with no `ask` label. Refuse at the seal instead.
367
+ */
368
+ const assertAskWithinLimits = (ask) => {
369
+ if (!ask)
370
+ return;
371
+ if (ask.id.length === 0 || ask.id.length > MAX_ASK_ID_CHARS)
372
+ throw new Error(`ask id must be 1 to ${MAX_ASK_ID_CHARS} characters`);
373
+ if (!ASK_DEVICE_ID.test(ask.device))
374
+ throw new Error('ask device id is not a device id');
375
+ const actions = ask.actions ?? [];
376
+ // The choices are the caller's to shorten: `ASK_CHOICES_CODE` lets `prepareAsk` say so (a property, not a class,
377
+ // so it survives a module registry reset).
378
+ const choices = (message) => Object.assign(new Error(message), { code: exports.ASK_CHOICES_CODE });
379
+ if (actions.length > MAX_ASK_ACTIONS)
380
+ throw choices(`a question takes at most ${MAX_ASK_ACTIONS} choices`);
381
+ if (actions.some((a) => a.id.length > MAX_ACTION_ID_CHARS))
382
+ throw choices(`a choice id is over ${MAX_ACTION_ID_CHARS} characters`);
383
+ if (actions.some((a) => a.label.length > MAX_ACTION_LABEL_CHARS))
384
+ throw choices(`a choice label is over ${MAX_ACTION_LABEL_CHARS} characters`);
385
+ };
386
+ /** The `code` of the errors above that a shorter choice list fixes. */
387
+ exports.ASK_CHOICES_CODE = 'ask-choices-over-limits';
388
+ /** Named keys inside a sealed command: non-empty strings only, like the cli socket path's
389
+ * `isKeyList` — the listener allowlists the names. */
390
+ const sealedKeys = (value) => {
391
+ if (!Array.isArray(value) || value.length === 0)
392
+ return undefined;
393
+ if (!value.every((item) => typeof item === 'string'))
394
+ return undefined;
395
+ return [...value];
396
+ };
397
+ const fileMetas = (value) => {
398
+ if (!Array.isArray(value))
399
+ return undefined;
400
+ const metas = [];
401
+ for (const item of value) {
402
+ if (typeof item !== 'object' || item === null)
403
+ return undefined;
404
+ if (!('iv' in item) || !('name' in item) || !('type' in item) || !('size' in item))
405
+ return undefined;
406
+ const { iv, name, type, size } = item;
407
+ if (typeof iv !== 'string' || typeof name !== 'string' || typeof type !== 'string' || typeof size !== 'number')
408
+ return undefined;
409
+ metas.push({ iv, name, type, size });
410
+ }
411
+ return metas;
412
+ };
413
+ const commandLabel = (value) => {
414
+ if (typeof value !== 'object' || value === null)
415
+ return undefined;
416
+ if (!('n' in value) || !('session' in value) || !('device' in value))
417
+ return undefined;
418
+ const { n, session, device } = value;
419
+ if (typeof n !== 'number' || typeof session !== 'string' || typeof device !== 'string')
420
+ return undefined;
421
+ if (!('from' in value) || value.from === undefined)
422
+ return { n, session, device };
423
+ if (typeof value.from !== 'string')
424
+ return undefined;
425
+ return { n, session, device, from: value.from };
426
+ };
427
+ const askActions = (value) => {
428
+ if (!Array.isArray(value) || value.length > MAX_ASK_ACTIONS)
429
+ return undefined;
430
+ const actions = [];
431
+ for (const item of value) {
432
+ if (typeof item !== 'object' || item === null || !('id' in item) || !('label' in item))
433
+ return undefined;
434
+ const { id, label } = item;
435
+ if (typeof id !== 'string' || id.length > MAX_ACTION_ID_CHARS)
436
+ return undefined;
437
+ if (typeof label !== 'string' || label.length > MAX_ACTION_LABEL_CHARS)
438
+ return undefined;
439
+ if (!('style' in item) || item.style === undefined) {
440
+ actions.push({ id, label });
441
+ continue;
442
+ }
443
+ if (typeof item.style !== 'string')
444
+ return undefined;
445
+ actions.push({ id, label, style: item.style });
446
+ }
447
+ return actions;
448
+ };
449
+ const askLabel = (value) => {
450
+ if (typeof value !== 'object' || value === null)
451
+ return undefined;
452
+ if (!('id' in value) || !('device' in value))
453
+ return undefined;
454
+ const { id, device } = value;
455
+ if (typeof id !== 'string' || typeof device !== 'string')
456
+ return undefined;
457
+ if (id === '' || id.length > MAX_ASK_ID_CHARS || !ASK_DEVICE_ID.test(device))
458
+ return undefined;
459
+ const label = { id, device };
460
+ if ('actions' in value && value.actions !== undefined) {
461
+ const actions = askActions(value.actions);
462
+ if (!actions)
463
+ return undefined;
464
+ label.actions = actions;
465
+ }
466
+ if ('placeholder' in value && value.placeholder !== undefined) {
467
+ if (typeof value.placeholder !== 'string')
468
+ return undefined;
469
+ label.placeholder = value.placeholder;
470
+ }
471
+ return label;
472
+ };
473
+ const count = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
474
+ const contextFile = (value) => {
475
+ if (typeof value !== 'object' || value === null)
476
+ return undefined;
477
+ const { path, status, added, removed } = value;
478
+ const binary = value.binary;
479
+ if (typeof path !== 'string' || path === '' || Buffer.byteLength(path) > MAX_CONTEXT_PATH_BYTES)
480
+ return undefined;
481
+ if (status !== 'M' && status !== 'A' && status !== 'D' && status !== '?')
482
+ return undefined;
483
+ const a = count(added);
484
+ const r = count(removed);
485
+ if (a === undefined || r === undefined)
486
+ return undefined;
487
+ if (binary === undefined)
488
+ return { path, status, added: a, removed: r };
489
+ return binary === true ? { path, status, added: a, removed: r, binary } : undefined;
490
+ };
491
+ /** The `context` summary of a sealed question, or `undefined` when it is absent or not well formed (the question still opens without it). */
492
+ const askContext = (value) => {
493
+ if (typeof value !== 'object' || value === null)
494
+ return undefined;
495
+ const v = value;
496
+ if (v.diff !== 'ok' && v.diff !== 'no_repo' && v.diff !== 'failed')
497
+ return undefined;
498
+ const files = count(v.files);
499
+ const added = count(v.added);
500
+ const removed = count(v.removed);
501
+ const moreFiles = count(v.moreFiles);
502
+ const screenLines = count(v.screenLines);
503
+ const masked = count(v.masked);
504
+ if (files === undefined || added === undefined || removed === undefined)
505
+ return undefined;
506
+ if (moreFiles === undefined || screenLines === undefined || masked === undefined)
507
+ return undefined;
508
+ if (!Array.isArray(v.listed) || v.listed.length > MAX_CONTEXT_LISTED)
509
+ return undefined;
510
+ const listed = [];
511
+ for (const item of v.listed) {
512
+ const file = contextFile(item);
513
+ if (!file)
514
+ return undefined;
515
+ listed.push(file);
516
+ }
517
+ const cut = v.cut;
518
+ if (typeof cut !== 'object' || cut === null || typeof cut.diff !== 'boolean' || typeof cut.screen !== 'boolean')
519
+ return undefined;
520
+ return { diff: v.diff, files, added, removed, listed, moreFiles, screenLines, masked, cut: { diff: cut.diff, screen: cut.screen } };
521
+ };
522
+ exports.askContext = askContext;
523
+ const answerLabel = (value) => {
524
+ if (typeof value !== 'object' || value === null || !('ask' in value))
525
+ return undefined;
526
+ if (typeof value.ask !== 'string' || value.ask === '' || value.ask.length > MAX_ASK_ID_CHARS)
527
+ return undefined;
528
+ const label = { ask: value.ask };
529
+ if ('actionId' in value && value.actionId !== undefined) {
530
+ if (typeof value.actionId !== 'string')
531
+ return undefined;
532
+ label.actionId = value.actionId;
533
+ }
534
+ if ('exit' in value && value.exit !== undefined) {
535
+ if (value.exit !== true)
536
+ return undefined;
537
+ label.exit = true;
538
+ }
539
+ return label;
540
+ };
541
+ /**
542
+ * Decrypt a push body sealed by `encryptPushBodyForDevices` (any client's
543
+ * twin of it) for this device: `body` is the JSON `{ ciphertext, iv }`
544
+ * envelope, the message key comes out of this device's `deviceKeyMap` slot.
545
+ * Returns the `{ title, body, url }` the sender put in, the `cmd` label of a
546
+ * sealed command, the `ask` / `answer` label of a sealed question / answer and
547
+ * the `files` list and the `context` summary — each only when well formed. `context` is read from any body;
548
+ * whether it belongs there (a question) is the caller's to decide.
549
+ */
550
+ const decryptPushBodyForDevice = async (body, entryJson, senderPublicKeyRaw) => {
551
+ const envelope = JSON.parse(body); // fields are type-checked on the next line
552
+ if (typeof envelope.ciphertext !== 'string' || typeof envelope.iv !== 'string') {
553
+ throw new Error('push body is not a { ciphertext, iv } envelope');
554
+ }
555
+ const rawKey = await unwrapDeviceKeyRaw(entryJson, senderPublicKeyRaw);
556
+ const messageKey = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, ['decrypt']);
557
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(fromBase64(envelope.iv)) }, messageKey, fromBase64(envelope.ciphertext));
558
+ // A SyntaxError from JSON.parse quotes the start of the text it could not read, and this text
559
+ // is authenticated plaintext: the message must not carry it. Same for the TypeError of `null`.
560
+ let parsed;
561
+ try {
562
+ parsed = JSON.parse(new TextDecoder().decode(plaintext));
563
+ }
564
+ catch {
565
+ throw new Error('sealed body is not JSON');
566
+ }
567
+ if (typeof parsed !== 'object' || parsed === null)
568
+ throw new Error('sealed body is not a JSON object');
569
+ // Every field is type-checked below; a wrong shape is dropped, not trusted.
570
+ const str = (v) => (typeof v === 'string' ? v : undefined);
571
+ return { title: str(parsed.title), body: str(parsed.body), url: str(parsed.url), cmd: commandLabel(parsed.cmd), ask: askLabel(parsed.ask), answer: answerLabel(parsed.answer), files: fileMetas(parsed.files), keys: sealedKeys(parsed.keys), ifPrompt: str(parsed.ifPrompt), context: (0, exports.askContext)(parsed.context) };
572
+ };
573
+ exports.decryptPushBodyForDevice = decryptPushBodyForDevice;
574
+ // ─── Comparing device keys ───
575
+ /** DER header of a P-256 SPKI whose key is an uncompressed point; the 65-byte X9.63 point follows it. */
576
+ const P256_SPKI_PREFIX = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex');
577
+ const P256_POINT_BYTES = 65;
578
+ /**
579
+ * Whether two strings are the same device public key (twin of the cli's
580
+ * `samePublicKey`). A device key has exactly one spelling — standard padded
581
+ * Base64 of the 91-byte SPKI with an uncompressed point, what WebCrypto's
582
+ * `exportKey('spki')` and the app write — so two keys are the same exactly
583
+ * when their strings are. A string in any other spelling equals nothing,
584
+ * itself included.
585
+ */
586
+ const samePublicKey = (a, b) => {
587
+ if (a !== b)
588
+ return false;
589
+ const der = Buffer.from(a, 'base64');
590
+ return der.length === P256_SPKI_PREFIX.length + P256_POINT_BYTES
591
+ && der.subarray(0, P256_SPKI_PREFIX.length).equals(P256_SPKI_PREFIX)
592
+ && der[P256_SPKI_PREFIX.length] === 0x04
593
+ && der.toString('base64') === a;
594
+ };
595
+ exports.samePublicKey = samePublicKey;
596
+ /**
597
+ * The x coordinate of a device key's point, in hex (twin of the cli's `publicKeySender`): what the listener counts
598
+ * sealed command numbers by in the trust file's `commandKeys`. null for a string that is not a device key.
599
+ */
600
+ const publicKeySender = (spkiBase64) => {
601
+ if (!(0, exports.samePublicKey)(spkiBase64, spkiBase64))
602
+ return null;
603
+ const point = P256_SPKI_PREFIX.length;
604
+ return Buffer.from(spkiBase64, 'base64').subarray(point + 1, point + 33).toString('hex');
605
+ };
606
+ exports.publicKeySender = publicKeySender;
@@ -0,0 +1,7 @@
1
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
+ import type { ToolError } from './types.js';
3
+ export declare const textResult: (obj: unknown) => CallToolResult;
4
+ export declare const errorResult: (obj: ToolError) => CallToolResult;
5
+ export declare const hookNotConfiguredError: () => CallToolResult;
6
+ export declare const formatToolError: (err: unknown) => CallToolResult;
7
+ //# sourceMappingURL=error-format.d.ts.map