@ijfw/memory-server 1.4.1 → 1.4.3

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.
@@ -0,0 +1,493 @@
1
+ /**
2
+ * hardware-signer.js — IJFW v1.4.3 W9-A2 / B15
3
+ *
4
+ * Backend abstraction for publisher signing operations. Two backends:
5
+ *
6
+ * SOFTWARE_BACKEND — private key on disk (PEM), signs in-process via
7
+ * node:crypto. Existing v1.4.0 behavior, preserved for back-compat.
8
+ *
9
+ * SSH_AGENT_BACKEND — private key never enters the IJFW process. Signing
10
+ * forwarded to the user's running ssh-agent (or hardware-token-backed
11
+ * agent like YubiKey, Solokey, gpg-agent's SSH socket, Pageant on
12
+ * Windows). Implements the OpenSSH agent wire protocol over UNIX
13
+ * sockets (or named-pipes on Windows) using node:net only.
14
+ *
15
+ * Backend resolution is FAIL-CLOSED (SEC-L-02): unknown backend names throw
16
+ * rather than silently fall through to software. This means a manifest with
17
+ * `publisher_key_backend: 'libfido2'` (not yet implemented in v1.4.3) is a
18
+ * hard error at sign-time, not a quiet downgrade to a weaker backend.
19
+ *
20
+ * Identity selection (SEC-H-03): when the ssh-agent backend signs, the
21
+ * agent is asked to enumerate identities. The expected public-key blob is
22
+ * loaded from `~/.ijfw/keys/<keyId>/backend.json` (`pubkey_blob_hex`) and
23
+ * matched against each agent identity by raw key material with
24
+ * `crypto.timingSafeEqual`. The SSH key comment is NEVER used for matching
25
+ * — comments are user-supplied strings and can collide; only raw public-key
26
+ * bytes are trustworthy.
27
+ *
28
+ * Spec: .planning/1.4.3/HANDOFF-1.4.3.md §B15
29
+ */
30
+
31
+ import {
32
+ createHash,
33
+ createPublicKey,
34
+ createPrivateKey,
35
+ sign as cryptoSign,
36
+ verify as cryptoVerify,
37
+ generateKeyPairSync,
38
+ timingSafeEqual,
39
+ } from 'node:crypto';
40
+ import { connect as netConnect } from 'node:net';
41
+ import { readFile } from 'node:fs/promises';
42
+ import { homedir } from 'node:os';
43
+ import { join } from 'node:path';
44
+
45
+ // === SSH agent wire-protocol constants =====================================
46
+ // See draft-miller-ssh-agent (OpenSSH agent protocol).
47
+ const SSH2_AGENTC_REQUEST_IDENTITIES = 11;
48
+ const SSH2_AGENT_IDENTITIES_ANSWER = 12;
49
+ const SSH2_AGENTC_SIGN_REQUEST = 13;
50
+ const SSH2_AGENT_SIGN_RESPONSE = 14;
51
+ const SSH2_AGENT_FAILURE = 5;
52
+
53
+ // SSH wire string for the Ed25519 algorithm id. Prefix of every Ed25519
54
+ // pubkey blob and signature blob.
55
+ const SSH_ED25519_ALG = 'ssh-ed25519';
56
+
57
+ /**
58
+ * Encode a buffer/string as an SSH wire "string" — uint32 length + bytes.
59
+ *
60
+ * @param {Buffer|string} v
61
+ * @returns {Buffer}
62
+ */
63
+ function sshWireString(v) {
64
+ const body = Buffer.isBuffer(v) ? v : Buffer.from(v, 'utf8');
65
+ const len = Buffer.alloc(4);
66
+ len.writeUInt32BE(body.length, 0);
67
+ return Buffer.concat([len, body]);
68
+ }
69
+
70
+ /**
71
+ * Decode a sequence of SSH wire strings from `buf` starting at `offset`.
72
+ * Returns the consumed slices and the new offset.
73
+ *
74
+ * @param {Buffer} buf
75
+ * @param {number} offset
76
+ * @returns {{ value: Buffer, next: number }}
77
+ */
78
+ function readSshString(buf, offset) {
79
+ if (offset + 4 > buf.length) {
80
+ throw new Error('SSH wire: truncated length prefix');
81
+ }
82
+ const len = buf.readUInt32BE(offset);
83
+ const start = offset + 4;
84
+ const end = start + len;
85
+ if (end > buf.length) {
86
+ throw new Error('SSH wire: truncated body');
87
+ }
88
+ return { value: buf.slice(start, end), next: end };
89
+ }
90
+
91
+ /**
92
+ * Compute the Ed25519 pubkey SSH wire blob from a 32-byte raw public key.
93
+ * Format: SSH-string("ssh-ed25519") || SSH-string(raw32).
94
+ *
95
+ * @param {Buffer} raw32
96
+ * @returns {Buffer}
97
+ */
98
+ function ed25519PubkeyBlob(raw32) {
99
+ return Buffer.concat([
100
+ sshWireString(SSH_ED25519_ALG),
101
+ sshWireString(raw32),
102
+ ]);
103
+ }
104
+
105
+ /**
106
+ * Pull the raw 32-byte Ed25519 public key out of an SPKI-DER buffer. DER
107
+ * shape is fixed-size for Ed25519 (12-byte header + 32-byte key). We
108
+ * tolerate small header variation by scanning for a 0x00,0x21 BIT STRING
109
+ * tail and lifting the last 32 bytes.
110
+ *
111
+ * @param {Buffer} spkiDer
112
+ * @returns {Buffer} 32-byte raw key
113
+ */
114
+ function ed25519RawFromSpkiDer(spkiDer) {
115
+ if (spkiDer.length < 32) {
116
+ throw new Error('Ed25519 SPKI DER too short');
117
+ }
118
+ return spkiDer.slice(spkiDer.length - 32);
119
+ }
120
+
121
+ /**
122
+ * Build an SPKI-DER for an Ed25519 raw public key. Fixed prefix (id-Ed25519
123
+ * AlgorithmIdentifier + BIT STRING wrapping) per RFC 8410.
124
+ *
125
+ * @param {Buffer} raw32
126
+ * @returns {Buffer}
127
+ */
128
+ function ed25519SpkiDerFromRaw(raw32) {
129
+ if (raw32.length !== 32) {
130
+ throw new Error('Ed25519 raw key must be 32 bytes');
131
+ }
132
+ // 30 2A — SEQUENCE (42)
133
+ // 30 05 — SEQUENCE (5)
134
+ // 06 03 2B 65 70 — OID 1.3.101.112 (Ed25519)
135
+ // 03 21 00 <32 bytes> — BIT STRING
136
+ const prefix = Buffer.from([
137
+ 0x30, 0x2a,
138
+ 0x30, 0x05,
139
+ 0x06, 0x03, 0x2b, 0x65, 0x70,
140
+ 0x03, 0x21, 0x00,
141
+ ]);
142
+ return Buffer.concat([prefix, raw32]);
143
+ }
144
+
145
+ /**
146
+ * Convert raw Ed25519 public key bytes to a PEM string.
147
+ *
148
+ * @param {Buffer} raw32
149
+ * @returns {string}
150
+ */
151
+ export function ed25519PemFromRaw(raw32) {
152
+ const der = ed25519SpkiDerFromRaw(raw32);
153
+ const b64 = der.toString('base64');
154
+ // 64-char lines per PEM convention.
155
+ const wrapped = b64.match(/.{1,64}/g).join('\n');
156
+ return `-----BEGIN PUBLIC KEY-----\n${wrapped}\n-----END PUBLIC KEY-----\n`;
157
+ }
158
+
159
+ /**
160
+ * Compute keyId fingerprint matching `extension-signer.js::publicKeyFingerprint`.
161
+ * Takes a PEM-encoded public key and returns sha256(spki-der) hex.
162
+ *
163
+ * Re-implemented here (rather than imported) to avoid a circular import
164
+ * between `extension-signer.js` (which imports this module for backend
165
+ * dispatch) and `hardware-signer.js`.
166
+ *
167
+ * @param {string} publicKeyPem
168
+ * @returns {string} 64-char lowercase hex
169
+ */
170
+ export function publicKeyFingerprint(publicKeyPem) {
171
+ const key = createPublicKey(publicKeyPem);
172
+ const der = key.export({ type: 'spki', format: 'der' });
173
+ return createHash('sha256').update(der).digest('hex');
174
+ }
175
+
176
+ // === SSH agent client =====================================================
177
+
178
+ /**
179
+ * Send one request frame and await one response frame over the SSH agent
180
+ * socket. Frame format: uint32 length || payload.
181
+ *
182
+ * @param {string} socketPath
183
+ * @param {Buffer} payload single-message payload (type byte + body)
184
+ * @param {number} [timeoutMs]
185
+ * @returns {Promise<Buffer>} response payload (type byte + body)
186
+ */
187
+ function agentRequest(socketPath, payload, timeoutMs = 5000) {
188
+ return new Promise((resolve, reject) => {
189
+ const sock = netConnect(socketPath);
190
+ let settled = false;
191
+ const settle = (err, val) => {
192
+ if (settled) return;
193
+ settled = true;
194
+ try { sock.destroy(); } catch { /* ignore */ }
195
+ if (err) reject(err); else resolve(val);
196
+ };
197
+
198
+ const timer = setTimeout(
199
+ () => settle(new Error(`SSH agent request timed out after ${timeoutMs}ms`)),
200
+ timeoutMs,
201
+ );
202
+
203
+ let buf = Buffer.alloc(0);
204
+ let expected = null;
205
+ sock.on('connect', () => {
206
+ const lenPrefix = Buffer.alloc(4);
207
+ lenPrefix.writeUInt32BE(payload.length, 0);
208
+ sock.write(Buffer.concat([lenPrefix, payload]));
209
+ });
210
+ sock.on('data', chunk => {
211
+ buf = Buffer.concat([buf, chunk]);
212
+ if (expected === null && buf.length >= 4) {
213
+ expected = buf.readUInt32BE(0);
214
+ }
215
+ if (expected !== null && buf.length >= 4 + expected) {
216
+ clearTimeout(timer);
217
+ settle(null, buf.slice(4, 4 + expected));
218
+ }
219
+ });
220
+ sock.on('error', err => {
221
+ clearTimeout(timer);
222
+ settle(new Error(`SSH agent socket error: ${err.message}`));
223
+ });
224
+ sock.on('close', () => {
225
+ clearTimeout(timer);
226
+ if (!settled) {
227
+ settle(new Error('SSH agent closed connection before responding'));
228
+ }
229
+ });
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Get the SSH agent socket path from env. Throws a clear error when unset.
235
+ *
236
+ * @returns {string}
237
+ */
238
+ function requireAgentSocket() {
239
+ const sock = process.env.SSH_AUTH_SOCK;
240
+ if (!sock || typeof sock !== 'string' || sock.length === 0) {
241
+ throw new Error(
242
+ 'SSH agent not available; set SSH_AUTH_SOCK or use --backend software',
243
+ );
244
+ }
245
+ return sock;
246
+ }
247
+
248
+ /**
249
+ * List all identities the agent is willing to enumerate. Returns
250
+ * { blob: Buffer, comment: string } per identity. Filters are applied by
251
+ * the caller — we return the raw list so SEC-H-03 selection can run on
252
+ * raw key material.
253
+ *
254
+ * @param {string} [socketPath]
255
+ * @returns {Promise<Array<{ blob: Buffer, comment: string }>>}
256
+ */
257
+ export async function listAgentIdentities(socketPath) {
258
+ const sock = socketPath || requireAgentSocket();
259
+ const payload = Buffer.from([SSH2_AGENTC_REQUEST_IDENTITIES]);
260
+ const resp = await agentRequest(sock, payload);
261
+ if (resp.length < 1) throw new Error('SSH agent: empty response');
262
+ const type = resp[0];
263
+ if (type !== SSH2_AGENT_IDENTITIES_ANSWER) {
264
+ throw new Error(`SSH agent: unexpected response type ${type}`);
265
+ }
266
+ if (resp.length < 5) throw new Error('SSH agent: truncated identities count');
267
+ const count = resp.readUInt32BE(1);
268
+ let off = 5;
269
+ const out = [];
270
+ for (let i = 0; i < count; i++) {
271
+ const blobRead = readSshString(resp, off);
272
+ off = blobRead.next;
273
+ const commentRead = readSshString(resp, off);
274
+ off = commentRead.next;
275
+ out.push({
276
+ blob: Buffer.from(blobRead.value),
277
+ comment: commentRead.value.toString('utf8'),
278
+ });
279
+ }
280
+ return out;
281
+ }
282
+
283
+ /**
284
+ * Ask the agent to sign `payload` with the identity whose pubkey blob is
285
+ * `keyBlob`. Returns the raw 64-byte Ed25519 signature (unwrapped from the
286
+ * SSH wire signature blob).
287
+ *
288
+ * @param {Buffer} keyBlob full SSH wire pubkey blob (ssh-ed25519 + raw32)
289
+ * @param {Buffer} payload bytes to sign
290
+ * @param {string} [socketPath]
291
+ * @returns {Promise<Buffer>} 64-byte raw signature
292
+ */
293
+ export async function agentSign(keyBlob, payload, socketPath) {
294
+ const sock = socketPath || requireAgentSocket();
295
+ const flags = Buffer.alloc(4); // flags=0
296
+ const body = Buffer.concat([
297
+ sshWireString(keyBlob),
298
+ sshWireString(payload),
299
+ flags,
300
+ ]);
301
+ const reqType = Buffer.from([SSH2_AGENTC_SIGN_REQUEST]);
302
+ const resp = await agentRequest(sock, Buffer.concat([reqType, body]));
303
+ if (resp.length < 1) throw new Error('SSH agent: empty sign response');
304
+ const type = resp[0];
305
+ if (type === SSH2_AGENT_FAILURE) {
306
+ throw new Error('SSH agent: sign request failed (SSH_AGENT_FAILURE)');
307
+ }
308
+ if (type !== SSH2_AGENT_SIGN_RESPONSE) {
309
+ throw new Error(`SSH agent: unexpected sign response type ${type}`);
310
+ }
311
+ // The body is one SSH wire string containing the signature blob:
312
+ // ssh-string("ssh-ed25519") || ssh-string(raw64-sig)
313
+ const sigBlob = readSshString(resp, 1).value;
314
+ let off = 0;
315
+ const alg = readSshString(sigBlob, off);
316
+ off = alg.next;
317
+ if (alg.value.toString('utf8') !== SSH_ED25519_ALG) {
318
+ throw new Error(`SSH agent: signature alg is not ssh-ed25519 (got ${alg.value.toString('utf8')})`);
319
+ }
320
+ const rawSig = readSshString(sigBlob, off).value;
321
+ if (rawSig.length !== 64) {
322
+ throw new Error(`SSH agent: Ed25519 signature must be 64 bytes (got ${rawSig.length})`);
323
+ }
324
+ return Buffer.from(rawSig);
325
+ }
326
+
327
+ // === Backend implementations ==============================================
328
+
329
+ /**
330
+ * SOFTWARE backend — wraps node:crypto Ed25519 ops over PEM keys on disk.
331
+ *
332
+ * For `sign(payload, keyId, { home })`:
333
+ * - Reads `<home>/.ijfw/keys/<keyId>/private.pem`.
334
+ * - Returns the 64-byte raw Ed25519 signature as a Uint8Array (Buffer).
335
+ */
336
+ const softwareBackend = Object.freeze({
337
+ async sign(payload, keyId, opts = {}) {
338
+ const home = opts.home || homedir();
339
+ const privPath = join(home, '.ijfw', 'keys', keyId, 'private.pem');
340
+ const pem = await readFile(privPath, 'utf8');
341
+ const key = createPrivateKey(pem);
342
+ return cryptoSign(null, Buffer.from(payload), key);
343
+ },
344
+ async verify(payload, signature, publicKeyPem) {
345
+ const key = createPublicKey(publicKeyPem);
346
+ return cryptoVerify(null, Buffer.from(payload), key, Buffer.from(signature));
347
+ },
348
+ async getPublicKey(keyId, opts = {}) {
349
+ const home = opts.home || homedir();
350
+ const pubPath = join(home, '.ijfw', 'keys', keyId, 'public.pem');
351
+ return readFile(pubPath, 'utf8');
352
+ },
353
+ });
354
+
355
+ /**
356
+ * SSH-AGENT backend — defers signing to the running SSH agent. Identity
357
+ * is resolved at sign-time by raw public-key blob (SEC-H-03), NEVER by
358
+ * the user-supplied comment.
359
+ */
360
+ const sshAgentBackend = Object.freeze({
361
+ async sign(payload, keyId, opts = {}) {
362
+ const home = opts.home || homedir();
363
+ const backendPath = join(home, '.ijfw', 'keys', keyId, 'backend.json');
364
+ let backend;
365
+ try {
366
+ backend = JSON.parse(await readFile(backendPath, 'utf8'));
367
+ } catch (err) {
368
+ throw new Error(
369
+ `SSH agent backend manifest not found for keyId ${keyId} at ${backendPath}: ${err.message}`,
370
+ );
371
+ }
372
+ if (backend.backend !== 'ssh-agent') {
373
+ throw new Error(
374
+ `keyId ${keyId} backend.json is not ssh-agent (got ${JSON.stringify(backend.backend)})`,
375
+ );
376
+ }
377
+ if (typeof backend.pubkey_blob_hex !== 'string' || backend.pubkey_blob_hex.length === 0) {
378
+ throw new Error(`keyId ${keyId} backend.json missing pubkey_blob_hex`);
379
+ }
380
+ const expected = Buffer.from(backend.pubkey_blob_hex, 'hex');
381
+ const identities = await listAgentIdentities(opts.socketPath);
382
+ // Filter to Ed25519-only — the expected blob is always ssh-ed25519.
383
+ const ed25519Prefix = sshWireString(SSH_ED25519_ALG);
384
+ const candidates = identities.filter(
385
+ ident => ident.blob.length >= ed25519Prefix.length
386
+ && ident.blob.slice(0, ed25519Prefix.length).equals(ed25519Prefix),
387
+ );
388
+ // Constant-time match by full blob. Length-mismatched entries are
389
+ // discarded before timingSafeEqual (which throws on length mismatch).
390
+ const matches = candidates.filter(ident => {
391
+ if (ident.blob.length !== expected.length) return false;
392
+ return timingSafeEqual(ident.blob, expected);
393
+ });
394
+ if (matches.length === 0) {
395
+ throw new Error(
396
+ `SSH agent identity not found for keyId ${keyId}; expected pubkey blob ${backend.pubkey_blob_hex.slice(0, 32)}...`,
397
+ );
398
+ }
399
+ if (matches.length > 1) {
400
+ throw new Error(`Ambiguous SSH agent identities for keyId ${keyId}`);
401
+ }
402
+ return agentSign(matches[0].blob, Buffer.from(payload), opts.socketPath);
403
+ },
404
+ /**
405
+ * SSH agent doesn't expose a verify primitive — Ed25519 signatures are
406
+ * universally verifiable with the raw public key, so delegate to the
407
+ * software backend's verify. Verify is always cryptographic, never
408
+ * agent-mediated.
409
+ */
410
+ async verify(payload, signature, publicKeyPem) {
411
+ return softwareBackend.verify(payload, signature, publicKeyPem);
412
+ },
413
+ async getPublicKey(keyId, opts = {}) {
414
+ return softwareBackend.getPublicKey(keyId, opts);
415
+ },
416
+ });
417
+
418
+ export const SOFTWARE_BACKEND = softwareBackend;
419
+ export const SSH_AGENT_BACKEND = sshAgentBackend;
420
+
421
+ /**
422
+ * Resolve a backend by name. FAIL-CLOSED (SEC-L-02): unknown names throw
423
+ * rather than silently falling back to software.
424
+ *
425
+ * undefined / 'software' → SOFTWARE_BACKEND
426
+ * 'ssh-agent' → SSH_AGENT_BACKEND
427
+ * anything else → throws
428
+ *
429
+ * @param {string|undefined} name
430
+ * @returns {{ sign: Function, verify: Function, getPublicKey: Function }}
431
+ */
432
+ export function resolveBackend(name) {
433
+ if (name === undefined || name === 'software') return SOFTWARE_BACKEND;
434
+ if (name === 'ssh-agent') return SSH_AGENT_BACKEND;
435
+ throw new Error(`Unsupported signing backend: ${name}`);
436
+ }
437
+
438
+ /**
439
+ * Helper: build the Ed25519 SSH wire pubkey blob from a PEM-encoded
440
+ * public key. Useful when enrolling a key — caller has a PEM and needs
441
+ * the corresponding blob to store in `backend.json`.
442
+ *
443
+ * @param {string} publicKeyPem
444
+ * @returns {Buffer}
445
+ */
446
+ export function pubkeyBlobFromPem(publicKeyPem) {
447
+ const key = createPublicKey(publicKeyPem);
448
+ const der = key.export({ type: 'spki', format: 'der' });
449
+ const raw = ed25519RawFromSpkiDer(der);
450
+ return ed25519PubkeyBlob(raw);
451
+ }
452
+
453
+ /**
454
+ * Build a fresh Ed25519 keypair purely for fixtures / tests. NOT used at
455
+ * production sign-time (production keys are generated via
456
+ * `extension-signer.js::generatePublisherKeypair`). Exposed here only for
457
+ * the pure-Node mock-agent test harness.
458
+ *
459
+ * @returns {{ publicKeyPem: string, privateKeyPem: string, rawPub: Buffer, rawPriv: Buffer, pubkeyBlob: Buffer }}
460
+ */
461
+ export function _testGenerateEd25519Fixture() {
462
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
463
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
464
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
465
+ const spkiDer = publicKey.export({ type: 'spki', format: 'der' });
466
+ const rawPub = ed25519RawFromSpkiDer(spkiDer);
467
+ // PKCS#8 for Ed25519 has the raw private key in the last 32 bytes of the
468
+ // OCTET STRING payload. We don't need the raw private for the tests
469
+ // (the mock agent re-imports the PEM directly), so this is a placeholder.
470
+ const rawPriv = Buffer.alloc(0);
471
+ return {
472
+ publicKeyPem,
473
+ privateKeyPem,
474
+ rawPub: Buffer.from(rawPub),
475
+ rawPriv,
476
+ pubkeyBlob: ed25519PubkeyBlob(Buffer.from(rawPub)),
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Low-level helpers exported for the mock SSH agent test harness only.
482
+ */
483
+ export const _testInternals = Object.freeze({
484
+ sshWireString,
485
+ readSshString,
486
+ ed25519PubkeyBlob,
487
+ SSH2_AGENTC_REQUEST_IDENTITIES,
488
+ SSH2_AGENT_IDENTITIES_ANSWER,
489
+ SSH2_AGENTC_SIGN_REQUEST,
490
+ SSH2_AGENT_SIGN_RESPONSE,
491
+ SSH2_AGENT_FAILURE,
492
+ SSH_ED25519_ALG,
493
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * ide-detect.js — IJFW v1.4.3 W9-B / B18
3
+ *
4
+ * Auto-detects which IDE is hosting the MCP server. 4-step probe:
5
+ * 1. process.env.IJFW_IDE_ID (explicit override)
6
+ * 2. process.env.npm_config_user_agent substring match
7
+ * 3. Parent process inspection (POSIX `ps`, Windows `wmic`/`powershell`)
8
+ * 4. Fallback 'unknown' — ARCH-L-01: emit one-time stderr info notice
9
+ *
10
+ * Result is cached per-process at first call (no perf cost on hot paths).
11
+ *
12
+ * Known IDE binary substrings (case-insensitive): claude, codex, gemini,
13
+ * cursor, windsurf, copilot, hermes, wayland.
14
+ *
15
+ * NOTE: parent-process inspection is performed synchronously via
16
+ * `child_process.spawnSync`. It is intentionally short-timeout and
17
+ * best-effort: any failure falls through to step 4.
18
+ */
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ const KNOWN_IDES = ['claude', 'codex', 'gemini', 'cursor', 'windsurf', 'copilot', 'hermes', 'wayland'];
23
+
24
+ const ID_PATTERN = /^[a-z0-9-]+$/;
25
+
26
+ let _cachedIde = null;
27
+ let _unknownLoggedThisProcess = false;
28
+
29
+ function matchKnownIde(s) {
30
+ if (typeof s !== 'string') return null;
31
+ const lower = s.toLowerCase();
32
+ for (const id of KNOWN_IDES) {
33
+ if (lower.includes(id)) return id;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function detectFromUserAgent() {
39
+ const ua = process.env.npm_config_user_agent;
40
+ return matchKnownIde(ua);
41
+ }
42
+
43
+ function detectFromParentProcess() {
44
+ const ppid = process.ppid;
45
+ if (!ppid || ppid <= 0) return null;
46
+ try {
47
+ if (process.platform === 'win32') {
48
+ // Use PowerShell as primary; fall back to wmic if it fails.
49
+ const r = spawnSync(
50
+ 'powershell.exe',
51
+ ['-NoProfile', '-Command', `Get-WmiObject Win32_Process -Filter "ProcessId=${ppid}" | Select-Object -ExpandProperty Name`],
52
+ { encoding: 'utf8', timeout: 1500 },
53
+ );
54
+ if (r.status === 0 && r.stdout) {
55
+ const match = matchKnownIde(r.stdout);
56
+ if (match) return match;
57
+ }
58
+ const r2 = spawnSync('wmic', ['process', 'where', `ProcessId=${ppid}`, 'get', 'Name'], { encoding: 'utf8', timeout: 1500 });
59
+ if (r2.status === 0 && r2.stdout) {
60
+ return matchKnownIde(r2.stdout);
61
+ }
62
+ return null;
63
+ }
64
+ // POSIX
65
+ const r = spawnSync('ps', ['-o', 'comm=', '-p', String(ppid)], { encoding: 'utf8', timeout: 1500 });
66
+ if (r.status === 0 && r.stdout) {
67
+ return matchKnownIde(r.stdout);
68
+ }
69
+ return null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Detect the host IDE id. Cached at first call.
77
+ *
78
+ * @returns {string} one of KNOWN_IDES, an env-var value matching ID_PATTERN,
79
+ * or 'unknown'.
80
+ */
81
+ export function detectIde(_opts = {}) {
82
+ if (_cachedIde !== null) return _cachedIde;
83
+
84
+ // 1) explicit env var override
85
+ const envOverride = process.env.IJFW_IDE_ID;
86
+ if (typeof envOverride === 'string' && envOverride.length > 0 && ID_PATTERN.test(envOverride)) {
87
+ _cachedIde = envOverride;
88
+ return _cachedIde;
89
+ }
90
+
91
+ // 2) npm_config_user_agent
92
+ const uaMatch = detectFromUserAgent();
93
+ if (uaMatch) {
94
+ _cachedIde = uaMatch;
95
+ return _cachedIde;
96
+ }
97
+
98
+ // 3) parent process inspection
99
+ const ppMatch = detectFromParentProcess();
100
+ if (ppMatch) {
101
+ _cachedIde = ppMatch;
102
+ return _cachedIde;
103
+ }
104
+
105
+ // 4) fallback
106
+ _cachedIde = 'unknown';
107
+ if (!_unknownLoggedThisProcess) {
108
+ process.stderr.write('[ijfw] info: IDE detection unavailable; cross-IDE conflict detection disabled. Set IJFW_IDE_ID to override.\n');
109
+ _unknownLoggedThisProcess = true;
110
+ }
111
+ return _cachedIde;
112
+ }
113
+
114
+ /**
115
+ * Test-only: clear cache + reset one-time stderr notice flag.
116
+ */
117
+ export function _resetIdeCacheForTest() {
118
+ _cachedIde = null;
119
+ _unknownLoggedThisProcess = false;
120
+ }
121
+
122
+ export const KNOWN_IDE_LIST = Object.freeze([...KNOWN_IDES]);
@@ -21,6 +21,10 @@ import { readFile, mkdir, appendFile, rename, stat } from 'node:fs/promises';
21
21
  import { join } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
23
 
24
+ // B18 — divergence helper imported lazily inside maybeWarnDivergence to keep
25
+ // the module side-effect-light. detectCrossIdeDivergence has its own internal
26
+ // stale-file cleanup + last-seen writer; we just consume the verdict.
27
+
24
28
  // Log rotation: when permission-events.jsonl exceeds this many lines, rename
25
29
  // to .0 (overwriting any prior .0) and start fresh. Total on disk = 2 * cap.
26
30
  const ROTATION_LINE_CAP = 10_000;
@@ -167,6 +171,33 @@ export async function logPermissionEvent(event, opts = {}) {
167
171
  }
168
172
  }
169
173
 
174
+ /**
175
+ * B18 — surface a cross-IDE divergence warning on stderr (does NOT block).
176
+ * Called by permission-check call sites once per dispatch. Returns the
177
+ * divergence verdict so callers can attach `divergent_ide: true` to event
178
+ * log entries.
179
+ *
180
+ * Best-effort: any error returns `{ divergent: false }` and never throws.
181
+ *
182
+ * @param {{ homeDir?: string }} [opts]
183
+ * @returns {Promise<{ divergent: boolean, last_writer?: string|null, current_ide?: string, age_seconds?: number|null }>}
184
+ */
185
+ export async function maybeWarnDivergence(opts = {}) {
186
+ try {
187
+ const { detectCrossIdeDivergence } = await import('./active-extension-writer.js');
188
+ const verdict = await detectCrossIdeDivergence({ homeDir: opts.homeDir });
189
+ if (verdict && verdict.divergent) {
190
+ const age = typeof verdict.age_seconds === 'number' ? `${verdict.age_seconds}s ago` : 'unknown time ago';
191
+ process.stderr.write(
192
+ `[ijfw] active extension last activated by '${verdict.last_writer}' ${age}; this IDE is '${verdict.current_ide}'\n`,
193
+ );
194
+ }
195
+ return verdict || { divergent: false };
196
+ } catch {
197
+ return { divergent: false };
198
+ }
199
+ }
200
+
170
201
  /**
171
202
  * Map an MCP tool name (+ args) to the (action, target) tuple used for
172
203
  * permission checks. Returns null for unrecognised tool names; callers