@ijfw/memory-server 1.4.0 → 1.4.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.
@@ -29,7 +29,19 @@ import {
29
29
  addTrustedPublisher,
30
30
  removeTrustedPublisher,
31
31
  readTrustedPublishers,
32
+ loadPublisherKeypair,
33
+ signRotationToken,
34
+ verifyRotationToken,
32
35
  } from '../extension-signer.js';
36
+ import {
37
+ refreshTrustFromRegistry,
38
+ readCachedRegistry,
39
+ verifyRegistry,
40
+ keygenMeta,
41
+ signRegistry,
42
+ verifyRegistryFile,
43
+ DEFAULT_REGISTRY_URL,
44
+ } from '../extension-registry.js';
33
45
  import {
34
46
  deployExtensionSkillsToPlatforms,
35
47
  deployExtensionToAgentsMd,
@@ -355,6 +367,220 @@ async function deployOneExtension({ scope, name, extDir, projectRoot, result })
355
367
  }
356
368
  }
357
369
 
370
+ // === B6: Registry commands ================================================
371
+
372
+ async function cmdTrustRegistry({ args }) {
373
+ const url = args.trim() || DEFAULT_REGISTRY_URL;
374
+ try {
375
+ const r = await refreshTrustFromRegistry(url);
376
+ if (!r.ok) return { ok: false, command: 'trust-registry', error: r.error };
377
+ const lines = [];
378
+ if (r.fromCache) {
379
+ lines.push('(loaded from cache — network unavailable)');
380
+ } else if (r.diff) {
381
+ for (const kid of r.diff.added) lines.push(`+ added: ${kid}`);
382
+ for (const kid of r.diff.removed) lines.push(`- removed (revoked): ${kid}`);
383
+ for (const kid of r.diff.unchanged) lines.push(` unchanged: ${kid}`);
384
+ for (const kid of r.diff.rejected) lines.push(`! rejected: ${kid}`);
385
+ if (lines.length === 0) lines.push('registry applied — no changes');
386
+ }
387
+ for (const w of (r.warnings || [])) lines.push(`[warn] ${w}`);
388
+ return { ok: true, command: 'trust-registry', result: { url, diff: r.diff, fromCache: r.fromCache, lines } };
389
+ } catch (err) {
390
+ return { ok: false, command: 'trust-registry', error: err.message };
391
+ }
392
+ }
393
+
394
+ async function cmdRegistryStatus() {
395
+ try {
396
+ const cached = await readCachedRegistry();
397
+ if (!cached.registry) {
398
+ return { ok: true, command: 'registry-status', result: { cached: false, message: 'no cached registry' } };
399
+ }
400
+ const ageMs = cached.cachedAt ? Date.now() - cached.cachedAt : null;
401
+ const ageHours = ageMs !== null ? (ageMs / 3600000).toFixed(1) : null;
402
+ const body = JSON.stringify(cached.registry);
403
+ const sizeBytes = Buffer.byteLength(body, 'utf8');
404
+ const sigStatus = verifyRegistry(body);
405
+ return {
406
+ ok: true,
407
+ command: 'registry-status',
408
+ result: {
409
+ cached: true,
410
+ stale: cached.stale,
411
+ cached_at: cached.cachedAt ? new Date(cached.cachedAt).toISOString() : null,
412
+ age_hours: ageHours,
413
+ size_bytes: sizeBytes,
414
+ signature_valid: sigStatus.valid,
415
+ signature_reason: sigStatus.reason,
416
+ publisher_count: Object.keys(cached.registry.publishers || {}).length,
417
+ revoked_count: (cached.registry.revoked || []).length,
418
+ },
419
+ };
420
+ } catch (err) {
421
+ return { ok: false, command: 'registry-status', error: err.message };
422
+ }
423
+ }
424
+
425
+ async function cmdKeygenMeta({ args }) {
426
+ const author = args.trim();
427
+ if (!author) return { ok: false, command: 'keygen-meta', error: 'missing author name; usage: keygen-meta <author>' };
428
+ try {
429
+ const r = await keygenMeta(author);
430
+ return {
431
+ ok: true,
432
+ command: 'keygen-meta',
433
+ result: { keyId: r.keyId, publicKey: r.publicKey, dir: r.dir },
434
+ };
435
+ } catch (err) {
436
+ return { ok: false, command: 'keygen-meta', error: err.message };
437
+ }
438
+ }
439
+
440
+ async function cmdSignRegistry({ args }) {
441
+ const registryPath = args.trim();
442
+ if (!registryPath) return { ok: false, command: 'sign-registry', error: 'missing path; usage: sign-registry <path>' };
443
+ try {
444
+ const r = await signRegistry(registryPath);
445
+ return r.ok
446
+ ? { ok: true, command: 'sign-registry', result: { path: registryPath } }
447
+ : { ok: false, command: 'sign-registry', error: r.error };
448
+ } catch (err) {
449
+ return { ok: false, command: 'sign-registry', error: err.message };
450
+ }
451
+ }
452
+
453
+ async function cmdVerifyRegistry({ args }) {
454
+ const registryPath = args.trim();
455
+ if (!registryPath) return { ok: false, command: 'verify-registry', error: 'missing path; usage: verify-registry <path>' };
456
+ try {
457
+ const r = await verifyRegistryFile(registryPath);
458
+ return {
459
+ ok: r.ok,
460
+ command: 'verify-registry',
461
+ result: { path: registryPath, valid: r.valid, reason: r.reason },
462
+ };
463
+ } catch (err) {
464
+ return { ok: false, command: 'verify-registry', error: err.message };
465
+ }
466
+ }
467
+
468
+ // === B8: Key rotation + revocation =========================================
469
+
470
+ /**
471
+ * rotate-keys <oldKeyId> <newKeyId> [--out <file>]
472
+ *
473
+ * Loads both keypairs from ~/.ijfw/keys/<keyId>/, produces a rotation token
474
+ * signed by the old private key, writes JSON to --out or stdout.
475
+ */
476
+ async function cmdRotateKeys({ args }) {
477
+ const tokens = String(args || '').split(/\s+/).filter(Boolean);
478
+
479
+ // Extract --out flag
480
+ let outFile = null;
481
+ const keep = [];
482
+ for (let i = 0; i < tokens.length; i++) {
483
+ if (tokens[i] === '--out' && tokens[i + 1]) {
484
+ outFile = tokens[i + 1];
485
+ i++;
486
+ } else {
487
+ keep.push(tokens[i]);
488
+ }
489
+ }
490
+
491
+ const [oldKeyId, newKeyId] = keep;
492
+ if (!oldKeyId || !newKeyId) {
493
+ return { ok: false, command: 'rotate-keys', error: 'usage: rotate-keys <oldKeyId> <newKeyId> [--out <file>]' };
494
+ }
495
+
496
+ const oldKp = await loadPublisherKeypair(oldKeyId);
497
+ if (!oldKp) return { ok: false, command: 'rotate-keys', error: `old keypair not found: ${oldKeyId}` };
498
+
499
+ const newKp = await loadPublisherKeypair(newKeyId);
500
+ if (!newKp) return { ok: false, command: 'rotate-keys', error: `new keypair not found: ${newKeyId}` };
501
+
502
+ let token;
503
+ try {
504
+ token = signRotationToken(oldKp.privateKey, newKp.publicKey);
505
+ } catch (err) {
506
+ return { ok: false, command: 'rotate-keys', error: `sign failed: ${err.message}` };
507
+ }
508
+
509
+ const json = JSON.stringify(token, null, 2) + '\n';
510
+
511
+ if (outFile) {
512
+ try {
513
+ await fs.writeFile(path.resolve(outFile), json, 'utf8');
514
+ } catch (err) {
515
+ return { ok: false, command: 'rotate-keys', error: `write failed: ${err.message}` };
516
+ }
517
+ return { ok: true, command: 'rotate-keys', result: { token, out: path.resolve(outFile) } };
518
+ }
519
+
520
+ return { ok: true, command: 'rotate-keys', result: { token } };
521
+ }
522
+
523
+ /**
524
+ * verify-rotation-token <file>
525
+ *
526
+ * Reads a rotation token JSON, looks up the old public key from the local
527
+ * trusted-publishers store (or ~/.ijfw/keys/<oldKeyId>/public.pem as fallback),
528
+ * calls verifyRotationToken, prints verdict.
529
+ */
530
+ async function cmdVerifyRotationToken({ args }) {
531
+ const filePath = String(args || '').trim();
532
+ if (!filePath) {
533
+ return { ok: false, command: 'verify-rotation-token', error: 'usage: verify-rotation-token <file>' };
534
+ }
535
+
536
+ let raw;
537
+ try {
538
+ raw = await fs.readFile(path.resolve(filePath), 'utf8');
539
+ } catch (err) {
540
+ return { ok: false, command: 'verify-rotation-token', error: `read failed: ${err.message}` };
541
+ }
542
+
543
+ let token;
544
+ try {
545
+ token = JSON.parse(raw);
546
+ } catch (err) {
547
+ return { ok: false, command: 'verify-rotation-token', error: `JSON parse failed: ${err.message}` };
548
+ }
549
+
550
+ const oldKeyId = token && token.old_key_id;
551
+ if (!oldKeyId) {
552
+ return { ok: false, command: 'verify-rotation-token', error: 'token missing old_key_id' };
553
+ }
554
+
555
+ // Look up old public key: trusted-publishers store first, then key-dir fallback.
556
+ let oldPublicKey = null;
557
+ const store = await readTrustedPublishers();
558
+ const entry = store.publishers && store.publishers[oldKeyId];
559
+ if (entry && entry.publicKey) {
560
+ oldPublicKey = entry.publicKey;
561
+ } else {
562
+ // Fallback: key may be on disk even if not in trust store (already revoked).
563
+ const kp = await loadPublisherKeypair(oldKeyId);
564
+ if (kp) oldPublicKey = kp.publicKey;
565
+ }
566
+
567
+ if (!oldPublicKey) {
568
+ return { ok: false, command: 'verify-rotation-token', error: `old public key not found for keyId: ${oldKeyId}` };
569
+ }
570
+
571
+ const verdict = verifyRotationToken(token, oldPublicKey);
572
+ return {
573
+ ok: verdict.valid,
574
+ command: 'verify-rotation-token',
575
+ result: {
576
+ valid: verdict.valid,
577
+ reason: verdict.reason,
578
+ old_key_id: token.old_key_id,
579
+ new_key_id: token.new_key_id,
580
+ },
581
+ };
582
+ }
583
+
358
584
  async function cmdActivate({ args, projectRoot }) {
359
585
  const name = args && args.trim();
360
586
  if (!name) return { ok: false, command: 'activate', error: 'missing extension name; usage: activate <name>' };
@@ -394,11 +620,18 @@ export async function extensionDispatch({ command, args = '', projectRoot }) {
394
620
  case 'trusted': return cmdTrusted(ctx);
395
621
  case 'activate': return cmdActivate(ctx);
396
622
  case 'deactivate': return cmdDeactivate(ctx);
623
+ case 'trust-registry': return cmdTrustRegistry(ctx);
624
+ case 'registry-status': return cmdRegistryStatus(ctx);
625
+ case 'keygen-meta': return cmdKeygenMeta(ctx);
626
+ case 'sign-registry': return cmdSignRegistry(ctx);
627
+ case 'verify-registry': return cmdVerifyRegistry(ctx);
628
+ case 'rotate-keys': return cmdRotateKeys(ctx);
629
+ case 'verify-rotation-token': return cmdVerifyRotationToken(ctx);
397
630
  default:
398
631
  return {
399
632
  ok: false,
400
633
  command,
401
- error: `unknown extension command: ${command}. Supported: add | list | remove | audit | deploy-lazy | keygen | trust | untrust | trusted | activate | deactivate`,
634
+ error: `unknown extension command: ${command}. Supported: add | list | remove | audit | deploy-lazy | keygen | trust | untrust | trusted | activate | deactivate | trust-registry | registry-status | keygen-meta | sign-registry | verify-registry | rotate-keys | verify-rotation-token`,
402
635
  };
403
636
  }
404
637
  }
@@ -36,6 +36,7 @@ import { randomBytes } from 'node:crypto';
36
36
  import { spawn } from 'node:child_process';
37
37
  import { get as httpsGet } from 'node:https';
38
38
  import { pipeline } from 'node:stream/promises';
39
+ import { createInterface } from 'node:readline';
39
40
 
40
41
  import {
41
42
  validateExtensionManifest,
@@ -79,6 +80,35 @@ const EXTENSION_NAME_PATTERN = /^(@[a-z0-9-]+\/)?[a-z][a-z0-9-]*$/;
79
80
  // Verdicts considered acceptable for a normal install (3/3 lenses).
80
81
  const ACCEPTABLE_VERDICTS = new Set(['PASS', 'CONDITIONAL']);
81
82
 
83
+ // --- helpers: TTY confirmation ---------------------------------------------
84
+
85
+ /**
86
+ * Prompt the user to confirm an untrusted publisher by typing the last 8 chars
87
+ * of the keyId. Returns true on match, false on mismatch or EOF.
88
+ * Only called when process.stdin.isTTY === true.
89
+ *
90
+ * @param {string} keyId
91
+ * @returns {Promise<boolean>}
92
+ */
93
+ export async function promptUntrustedConfirmation(keyId) {
94
+ const expected = keyId.slice(-8);
95
+ return new Promise((resolve) => {
96
+ const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
97
+ const prompt =
98
+ `⚠️ Extension is signed by publisher keyId ${keyId} but ${keyId} is not in your trusted publishers store.\n` +
99
+ ` Type the LAST 8 CHARS of the keyId (lowercase hex) to confirm: `;
100
+ let answered = false;
101
+ rl.question(prompt, (line) => {
102
+ answered = true;
103
+ rl.close();
104
+ resolve(line.trim() === expected);
105
+ });
106
+ rl.once('close', () => {
107
+ if (!answered) resolve(false); // EOF / ctrl-D
108
+ });
109
+ });
110
+ }
111
+
82
112
  // --- helpers: source resolution -------------------------------------------
83
113
 
84
114
  /**
@@ -763,6 +793,15 @@ export async function installExtension(source, opts = {}) {
763
793
  errors: [`signature: verify failed: ${sigCheck.reason}${kidHint}`],
764
794
  };
765
795
  }
796
+ // B11: when stdin is a TTY, require the user to confirm by typing the
797
+ // last 8 chars of the keyId. Non-TTY (CI / scripted) falls through to
798
+ // the stderr-warn path unchanged — no regression for automation.
799
+ if (process.stdin.isTTY === true && sigCheck.publisherKeyId) {
800
+ const confirmed = await promptUntrustedConfirmation(sigCheck.publisherKeyId);
801
+ if (!confirmed) {
802
+ return { ok: false, errors: ['signature: untrusted confirmation cancelled'] };
803
+ }
804
+ }
766
805
  process.stderr.write(
767
806
  `[ijfw] extension-installer: signature unverified for ${manifest.name}: ${sigCheck.reason}\n`,
768
807
  );
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // IJFW v1.4.1 B7 -- shared extension permission checker.
3
+ //
4
+ // Reads ~/.ijfw/state/active-extension.json. Accepts a JSON payload on stdin
5
+ // with shape { hook_event_name, tool_name, tool_input } (Claude/Codex shape --
6
+ // callers reshape platform-specific payloads before piping here).
7
+ //
8
+ // Exit 0 = allow. Exit 1 = deny (stderr message emitted).
9
+ // With no active-extension.json: always exit 0 (backwards-compat invariant).
10
+ //
11
+ // Also appends one JSON line per check to ~/.ijfw/state/permission-events.jsonl
12
+ // (best-effort: failures are swallowed so logging never breaks tool dispatch).
13
+
14
+ import { readFile, appendFile, mkdir } from 'node:fs/promises';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ async function emitEvent(home, extensionName, toolName, allowed, reason) {
19
+ try {
20
+ const stateDir = join(home, '.ijfw', 'state');
21
+ await mkdir(stateDir, { recursive: true });
22
+ const event = { ts: new Date().toISOString(), extension: extensionName, tool: toolName, allowed };
23
+ if (reason) event.reason = reason;
24
+ await appendFile(join(stateDir, 'permission-events.jsonl'), JSON.stringify(event) + '\n', 'utf8');
25
+ } catch {
26
+ // Best-effort: swallow all errors.
27
+ }
28
+ }
29
+
30
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
31
+ const stateFile = join(home, '.ijfw', 'state', 'active-extension.json');
32
+
33
+ let active;
34
+ try {
35
+ const raw = await readFile(stateFile, 'utf8');
36
+ active = JSON.parse(raw);
37
+ if (!active || typeof active !== 'object' || !active.name || !active.permissions) {
38
+ process.stderr.write('ijfw extension permission check: malformed active-extension state\n');
39
+ process.exit(1);
40
+ }
41
+ } catch (err) {
42
+ if (err.code === 'ENOENT') process.exit(0); // no active extension -- allow
43
+ process.stderr.write(`ijfw extension permission check: ${err.message}\n`);
44
+ process.exit(1);
45
+ }
46
+
47
+ const chunks = [];
48
+ for await (const c of process.stdin) chunks.push(c);
49
+ const payload_str = chunks.join('');
50
+ if (!payload_str.trim()) process.exit(0);
51
+
52
+ let req;
53
+ try { req = JSON.parse(payload_str); } catch { process.exit(0); }
54
+
55
+ const tool = req.tool_name || '';
56
+ const writes = new Set(active.permissions.writes || []);
57
+ const reads = new Set(active.permissions.reads || []);
58
+ const writeTools = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash']);
59
+ const readTools = new Set(['Read', 'Glob', 'Grep', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch']);
60
+
61
+ const has = (set, want) =>
62
+ set.has(want) ||
63
+ set.has('*') ||
64
+ [...set].some((p) => p.endsWith(':*') && want.startsWith(p.slice(0, -1)));
65
+
66
+ if (writeTools.has(tool) && !has(writes, `tool:${tool.toLowerCase()}`) && !has(writes, 'tool:*')) {
67
+ const reason = `not in permissions.writes`;
68
+ process.stderr.write(`extension "${active.name}" not permitted to use ${tool} (declare tool:${tool.toLowerCase()} in permissions.writes)\n`);
69
+ await emitEvent(home, active.name, tool, false, reason);
70
+ process.exit(1);
71
+ }
72
+ if (readTools.has(tool) && !has(reads, `tool:${tool.toLowerCase()}`) && !has(reads, 'tool:*')) {
73
+ const reason = `not in permissions.reads`;
74
+ process.stderr.write(`extension "${active.name}" not permitted to use ${tool} (declare tool:${tool.toLowerCase()} in permissions.reads)\n`);
75
+ await emitEvent(home, active.name, tool, false, reason);
76
+ process.exit(1);
77
+ }
78
+ await emitEvent(home, active.name, tool, true);
79
+ process.exit(0);