@opengsd/gsd-core 1.5.0 → 1.6.0-rc.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 (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/bin/install.js +108 -34
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +677 -2
  7. package/gsd-core/bin/lib/adr-parser.cjs +24 -17
  8. package/gsd-core/bin/lib/audit.cjs +2 -2
  9. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  10. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  12. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  14. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  15. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  16. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  17. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  18. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  20. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  21. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  22. package/gsd-core/bin/lib/config-loader.cjs +92 -84
  23. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  24. package/gsd-core/bin/lib/config.cjs +1 -1
  25. package/gsd-core/bin/lib/decisions.cjs +149 -60
  26. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  27. package/gsd-core/bin/lib/init.cjs +91 -22
  28. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  29. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  31. package/gsd-core/bin/lib/milestone.cjs +41 -2
  32. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  33. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  34. package/gsd-core/bin/lib/phase.cjs +29 -0
  35. package/gsd-core/bin/lib/project-root.cjs +89 -2
  36. package/gsd-core/bin/lib/resolution.cjs +26 -0
  37. package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
  38. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  39. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  40. package/gsd-core/bin/lib/state-document.cjs +4 -2
  41. package/gsd-core/bin/lib/state.cjs +317 -161
  42. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  43. package/gsd-core/bin/lib/uat.cjs +39 -26
  44. package/gsd-core/bin/lib/verify.cjs +29 -13
  45. package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
  46. package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
  47. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  48. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  49. package/gsd-core/references/planner-antipatterns.md +48 -0
  50. package/gsd-core/references/planning-config.md +3 -0
  51. package/gsd-core/references/scout-codebase.md +2 -2
  52. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  53. package/gsd-core/workflows/discuss-phase.md +1 -2
  54. package/gsd-core/workflows/execute-phase.md +4 -6
  55. package/package.json +3 -3
  56. package/scripts/gen-capability-matrix.cjs +284 -0
  57. package/scripts/gen-capability-registry.cjs +96 -1853
  58. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  59. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  60. package/scripts/lint-resolution-provenance.cjs +192 -0
  61. package/scripts/lint-test-file-count.allowlist.json +9 -0
  62. package/scripts/run-tests.cjs +14 -0
  63. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -0,0 +1,1242 @@
1
+ "use strict";
2
+ /**
3
+ * capability-source.cts — Capability source resolver (ADR-1244 Phase 3, Decision D3).
4
+ *
5
+ * One seam `resolveCapabilitySource(spec, opts)` with an adapter per source kind.
6
+ * Each adapter: fetch → verify integrity/SHA → check engines.gsd → return a STAGED,
7
+ * VALIDATED bundle.
8
+ *
9
+ * SECURITY CONTRACT:
10
+ * - Install NEVER executes capability code. Copy/extract only.
11
+ * - All subprocesses routed through shell-command-projection.cjs seam (windowsHide,
12
+ * argv arrays, no shell string interpolation).
13
+ * - Integrity verified BEFORE extraction when provided.
14
+ * - engines.gsd pre-checked before staging.
15
+ * - Full validator suite run on manifest before finalizing.
16
+ * - Staging atomicity: stage under .staging/<id>-<pid>-<ts>/, renameSync on success,
17
+ * rmSync on any failure.
18
+ * - No raw spawnSync / execSync / shell strings.
19
+ *
20
+ * ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc.
21
+ *
22
+ * Exports: resolveCapabilitySource, parseSpec, _setCapabilitySourceHttpGet,
23
+ * _setHttpsGetImpl, _readManifestBounded, MAX_RESPONSE_BYTES,
24
+ * MANIFEST_MAX_BYTES, MAX_STAGED_BUNDLE_BYTES, MAX_STAGED_BUNDLE_ENTRIES
25
+ */
26
+ var __importDefault = (this && this.__importDefault) || function (mod) {
27
+ return (mod && mod.__esModule) ? mod : { "default": mod };
28
+ };
29
+ const node_fs_1 = __importDefault(require("node:fs"));
30
+ const node_path_1 = __importDefault(require("node:path"));
31
+ const node_os_1 = __importDefault(require("node:os"));
32
+ const node_https_1 = __importDefault(require("node:https"));
33
+ const node_crypto_1 = __importDefault(require("node:crypto"));
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const shellSeam = require('./shell-command-projection.cjs');
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const capValidator = require('./capability-validator.cjs');
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
39
+ const semverMod = require('./semver-compare.cjs');
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const ledgerMod = require('./capability-ledger.cjs');
42
+ /**
43
+ * DOS-1 (#1461): GENEROUS but BOUNDED cap on a fetched capability source response. `realHttpsGet`
44
+ * previously accumulated `res.on('data')` chunks with NO ceiling, so a hostile or accidental
45
+ * oversized tarball (e.g. an HTTP endpoint streaming gigabytes) would buffer unbounded into memory
46
+ * and OOM the process. A real capability bundle is a few hundred KiB of declarative JSON + small
47
+ * artifacts; 64 MiB is far more than any legitimate bundle yet still a hard ceiling. Enforced two
48
+ * ways: (1) a `content-length` header over the cap is rejected BEFORE buffering any body; (2) the
49
+ * cumulative streamed byte count is tracked across `data` events and the request is destroyed +
50
+ * rejected the instant it exceeds the cap (covers chunked / missing-content-length responses).
51
+ */
52
+ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
53
+ /**
54
+ * #1461 finding 2 (HIGH): GENEROUS but BOUNDED cap on an UNTRUSTED `capability.json` read during
55
+ * resolve/staging. Every untrusted manifest (tarball / npm / git / local staging) MUST be read via
56
+ * the SHARED bounded reader (`readSmallRegularFile`: open → fstat → require-regular-file → size-cap →
57
+ * read-exactly-size), NOT a raw `fs.readFileSync`. A raw read of an oversized extracted-or-local
58
+ * `capability.json` reads unbounded into memory (OOM), and a FIFO/device/non-regular manifest BLOCKS
59
+ * the resolver forever. A legitimate manifest is a few KiB of declarative JSON; 8 MiB is far more than
60
+ * any real capability.json yet a hard ceiling. The reader returns null for a genuinely-missing file
61
+ * (ENOENT) and THROWS for non-regular/oversized/IO — both are mapped to a clear "manifest not
62
+ * found / refused" rejection (fail-closed: the source never resolves).
63
+ */
64
+ const MANIFEST_MAX_BYTES = 8 * 1024 * 1024;
65
+ /**
66
+ * #1461 finding 1 (HIGH): ONE uniform aggregate byte-budget over the STAGED bundle directory. The HTTP
67
+ * fetch is capped (MAX_RESPONSE_BYTES), but `copyDirRecursive` / `fs.copyFileSync`, `git clone`,
68
+ * `npm pack`, and `tar -x` were only TIMEOUT-bounded — so a huge local source tree, a giant git repo, a
69
+ * large npm package, or a gzip/tar bomb that expands far beyond the compressed download cap could fill
70
+ * disk during staging. This single budget, enforced at the common staging chokepoint (stageValidated,
71
+ * AFTER the source is copied into staging and BEFORE validation/promotion), uniformly bounds the RESULT
72
+ * of every adapter: it sums the regular-file bytes of the staged dir via a BOUNDED streaming walk and
73
+ * fails closed if the total exceeds the cap. 128 MiB is generous for a real capability bundle (a few
74
+ * hundred KiB of declarative JSON + small artifacts) yet hard-bounds a bomb.
75
+ *
76
+ * RESIDUAL (#1461 finding 4): this bounds the staged RESULT — it rejects an oversized install BEFORE
77
+ * promotion, but a transient disk-fill DURING extraction/clone (before the post-staging walk runs) is a
78
+ * residual a fully-airtight bound would need a streaming byte-quota DURING extraction/clone (e.g. a
79
+ * cgroup/disk-quota or a custom streaming extractor) to close. This is a stated, proportionate limit:
80
+ * this resolver path is USER-INITIATED `install` only (the cloned-repo / loader overlay path does NOT
81
+ * invoke the resolver and is bounded separately by capability-consent's bundleContentHash caps), and
82
+ * staging happens under a temp/.staging dir that is rmSync'd on any failure.
83
+ */
84
+ const MAX_STAGED_BUNDLE_BYTES = 128 * 1024 * 1024;
85
+ /**
86
+ * #1461 finding 1: a cumulative ENTRY-count ceiling for the staged-dir budget walk so the enumeration
87
+ * ITSELF is bounded (a hostile bundle with millions of tiny files / a very deep tree cannot force
88
+ * unbounded readdir work before the byte cap trips). 100k entries is far more than any real bundle.
89
+ */
90
+ const MAX_STAGED_BUNDLE_ENTRIES = 100_000;
91
+ let _httpsGetImpl = node_https_1.default.get;
92
+ /** Test seam: override the low-level https.get transport used by realHttpsGet. Pass null to restore. */
93
+ function _setHttpsGetImpl(fn) {
94
+ _httpsGetImpl = fn ?? node_https_1.default.get;
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // Injectable HTTP transport (test seam)
98
+ // ---------------------------------------------------------------------------
99
+ function realHttpsGet(url) {
100
+ return new Promise((resolve, reject) => {
101
+ const req = _httpsGetImpl(url, { headers: { 'User-Agent': 'gsd-core-capability-source/1.0' } }, (res) => {
102
+ // DOS-1: reject early if the server ADVERTISES a body over the cap — no bytes buffered.
103
+ const contentLength = Number(res.headers?.['content-length']);
104
+ if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
105
+ req.destroy();
106
+ res.destroy?.();
107
+ reject(new Error(`response exceeds ${MAX_RESPONSE_BYTES} bytes (content-length ${contentLength}) fetching ${url}`));
108
+ return;
109
+ }
110
+ const chunks = [];
111
+ let received = 0;
112
+ let aborted = false;
113
+ res.on('data', (c) => {
114
+ if (aborted)
115
+ return;
116
+ received += c.length;
117
+ // DOS-1: enforce the ceiling on the ACTUAL streamed bytes (covers chunked / lying or
118
+ // absent content-length). Destroy the request/response and reject — never keep buffering.
119
+ if (received > MAX_RESPONSE_BYTES) {
120
+ aborted = true;
121
+ req.destroy();
122
+ res.destroy?.();
123
+ reject(new Error(`response exceeds ${MAX_RESPONSE_BYTES} bytes fetching ${url}`));
124
+ return;
125
+ }
126
+ chunks.push(c);
127
+ });
128
+ res.on('end', () => {
129
+ if (aborted)
130
+ return;
131
+ const body = Buffer.concat(chunks);
132
+ if (res.statusCode !== 200) {
133
+ reject(new Error(`HTTP ${res.statusCode ?? 0} fetching ${url}`));
134
+ return;
135
+ }
136
+ resolve({ statusCode: res.statusCode ?? 0, body });
137
+ });
138
+ res.on('error', reject);
139
+ });
140
+ req.setTimeout(30_000, () => {
141
+ req.destroy(new Error(`timeout after 30000ms fetching ${url}`));
142
+ });
143
+ req.on('error', reject);
144
+ });
145
+ }
146
+ let _httpGet = realHttpsGet;
147
+ /**
148
+ * Test seam: replace the HTTP transport. Pass null to restore the real transport.
149
+ */
150
+ function _setCapabilitySourceHttpGet(fn) {
151
+ _httpGet = fn ?? realHttpsGet;
152
+ }
153
+ // ---------------------------------------------------------------------------
154
+ // Helpers
155
+ // ---------------------------------------------------------------------------
156
+ /** Resolve the running GSD version; fail-closed to '0.0.0'. */
157
+ function readHostVersion() {
158
+ try {
159
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
160
+ const pkg = require('../../../package.json');
161
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
162
+ }
163
+ catch {
164
+ return '0.0.0';
165
+ }
166
+ }
167
+ /** Compute sha512-<base64> integrity over a buffer. */
168
+ function computeIntegrity(buf) {
169
+ const digest = node_crypto_1.default.createHash('sha512').update(buf).digest('base64');
170
+ return `sha512-${digest}`;
171
+ }
172
+ /** Verify buf against an `sha512-<base64>` integrity string. Throws on mismatch. */
173
+ function verifyIntegrity(buf, expected) {
174
+ const prefix = 'sha512-';
175
+ if (!expected.startsWith(prefix)) {
176
+ throw new Error(`Unsupported integrity algorithm (expected sha512-<base64>): ${expected}`);
177
+ }
178
+ const expectedBase64 = expected.slice(prefix.length);
179
+ const actual = node_crypto_1.default.createHash('sha512').update(buf).digest('base64');
180
+ if (actual !== expectedBase64) {
181
+ throw new Error(`Integrity mismatch: expected sha512-${expectedBase64} but got sha512-${actual}`);
182
+ }
183
+ }
184
+ /**
185
+ * #1461 finding 2 (HIGH): read an UNTRUSTED `capability.json` (extracted or local) via the SHARED
186
+ * bounded reader and parse it as a JSON object, failing CLOSED on every untrusted-input condition.
187
+ * Replaces the raw `fs.readFileSync(manifestPath,'utf8')` at each resolve/staging site so an oversized
188
+ * manifest cannot read unbounded (OOM) and a FIFO/device/non-regular manifest cannot BLOCK forever.
189
+ * - ENOENT (reader returns null) → throw `<notFoundMessage>` (genuinely missing).
190
+ * - non-regular / oversized / IO (reader THROWS) → throw `<notFoundMessage>: <reason>` (refused).
191
+ * - not valid JSON → throw the caller's invalid-JSON message.
192
+ * - not a JSON object → throw the caller's not-an-object message.
193
+ */
194
+ function readManifestBounded(manifestPath, notFoundMessage) {
195
+ let raw;
196
+ try {
197
+ raw = ledgerMod.readSmallRegularFile(manifestPath, MANIFEST_MAX_BYTES);
198
+ }
199
+ catch (err) {
200
+ // Non-regular (FIFO/device/dir), oversized, or IO error — fail closed with a clear message.
201
+ throw new Error(`${notFoundMessage}: ${err.message}`);
202
+ }
203
+ if (raw === null) {
204
+ throw new Error(notFoundMessage); // genuinely missing (ENOENT).
205
+ }
206
+ let cap;
207
+ try {
208
+ cap = JSON.parse(raw);
209
+ }
210
+ catch {
211
+ throw new Error('capability.json is not valid JSON');
212
+ }
213
+ if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) {
214
+ throw new Error('capability.json must be a JSON object');
215
+ }
216
+ return cap;
217
+ }
218
+ /**
219
+ * #1460 CS-1: read a locally-produced `npm pack` `.tgz` as RAW BYTES via a bounded fd read so a
220
+ * supplied `--integrity` can be verified over the tarball (same SRI sha512 domain as the tarball
221
+ * adapter) before extraction/staging. `readSmallRegularFile` decodes utf8 (corrupting binary), so
222
+ * this reads the Buffer directly while keeping the same fail-closed discipline: open → fstat →
223
+ * require a regular file (a FIFO/device cannot BLOCK or be misread) → size-cap (MAX_RESPONSE_BYTES,
224
+ * the same ceiling the HTTP fetch enforces) → read exactly fstat.size bytes.
225
+ */
226
+ function readPackTarball(tgzPath) {
227
+ let fd;
228
+ try {
229
+ fd = node_fs_1.default.openSync(tgzPath, 'r');
230
+ }
231
+ catch (err) {
232
+ throw new Error(`Cannot read npm pack tarball: ${tgzPath}: ${err.message}`);
233
+ }
234
+ try {
235
+ const st = node_fs_1.default.fstatSync(fd);
236
+ if (!st.isFile()) {
237
+ throw new Error(`Refusing to read non-regular npm pack tarball: ${tgzPath}`);
238
+ }
239
+ if (st.size > MAX_RESPONSE_BYTES) {
240
+ throw new Error(`npm pack tarball exceeds ${MAX_RESPONSE_BYTES} bytes: ${tgzPath}`);
241
+ }
242
+ const buf = Buffer.allocUnsafe(st.size);
243
+ let read = 0;
244
+ while (read < st.size) {
245
+ const n = node_fs_1.default.readSync(fd, buf, read, st.size - read, read);
246
+ if (n === 0)
247
+ break;
248
+ read += n;
249
+ }
250
+ return read === st.size ? buf : buf.subarray(0, read);
251
+ }
252
+ finally {
253
+ try {
254
+ node_fs_1.default.closeSync(fd);
255
+ }
256
+ catch { /* best-effort */ }
257
+ }
258
+ }
259
+ /**
260
+ * Reject spec/id values containing path separators or `..`.
261
+ * Throws if the id is unsafe.
262
+ */
263
+ function assertSafeId(id) {
264
+ if (!id || /[/\\]/.test(id) || id.includes('..')) {
265
+ throw new Error(`Capability id "${id}" is invalid: must be kebab-case with no path separators or ".."`);
266
+ }
267
+ }
268
+ // Shell-injection metacharacters + whitespace/control. execNpm runs under a shell
269
+ // on Windows (the npm shim), so an npm: spec must not carry any of these — they are
270
+ // never valid in a real npm package spec (scope/name@version|tag|^range|~range).
271
+ const SHELL_METACHAR_RE = /[;&|$`()<>!"'\\%\s]/;
272
+ /** Reject an npm package spec that could break out of the (Windows) shell. */
273
+ function assertSafeNpmSpec(pkgSpec) {
274
+ if (SHELL_METACHAR_RE.test(pkgSpec)) {
275
+ throw new Error(`Unsafe npm package spec (shell metacharacters not allowed): "${pkgSpec}"`);
276
+ }
277
+ }
278
+ /**
279
+ * Allowlist git transports. Git's `ext::`/`fd::` remote helpers are external-command
280
+ * bridges (arbitrary code execution if protocol.*.allow is permissive), and `file://`
281
+ * enables local-path tricks — only network transports are permitted.
282
+ */
283
+ function assertSafeGitUrl(url) {
284
+ if (!/^(https?|ssh|git):\/\//i.test(url)) {
285
+ throw new Error(`Unsupported git transport for "${url}": only https://, ssh://, and git:// are allowed`);
286
+ }
287
+ }
288
+ /**
289
+ * Copy a directory tree recursively into destDir — STREAMING and BUDGETED.
290
+ *
291
+ * SECURITY: symlinks are REJECTED (fail closed). A fetched bundle could otherwise
292
+ * smuggle a symlink (e.g. `id_rsa -> ~/.ssh/id_rsa`) that fs.copyFileSync would
293
+ * FOLLOW, copying an arbitrary host file's bytes into the staged capability dir.
294
+ * Dirent.isSymbolicLink() reflects the entry itself (lstat semantics), so this
295
+ * catches both file and directory symlinks before any copy.
296
+ *
297
+ * #1461 finding 1 (HIGH, ROUND 2): the copy ITSELF is bounded. The former
298
+ * `fs.readdirSync(src, { withFileTypes: true })` materialized the ENTIRE directory-entry
299
+ * array into memory BEFORE any budget could run — and copyDirRecursive runs at staging time
300
+ * BEFORE the post-copy assertStagedBundleWithinBudget walk. So a hostile local/git/npm/tar
301
+ * source whose tree has a directory holding millions of tiny files (fetch < 64 MiB, but a
302
+ * colossal dirent array) OOMs the process during the COPY, before the post-copy budget can
303
+ * fail closed. We now STREAM each directory via fs.opendirSync + dir.readSync() (one entry at
304
+ * a time, never the whole array) and thread CUMULATIVE counters across the recursion — total
305
+ * entries (cap MAX_STAGED_BUNDLE_ENTRIES) and total regular-file bytes (cap
306
+ * MAX_STAGED_BUNDLE_BYTES) — throwing the MOMENT either is exceeded, DURING the copy, before
307
+ * reading/copying the rest. The shared mutable `budget` object mirrors bundleContentHash's
308
+ * cumulative walk in capability-consent. The throw propagates to stageValidated's catch, which
309
+ * rmSync's the staging dir (fail closed, no partial bundle promoted).
310
+ */
311
+ function copyDirRecursive(src, dest, budget = { entries: 0, bytes: 0 }) {
312
+ node_fs_1.default.mkdirSync(dest, { recursive: true });
313
+ let dir;
314
+ try {
315
+ dir = node_fs_1.default.opendirSync(src);
316
+ }
317
+ catch (err) {
318
+ throw new Error(`Cannot read source directory "${src}": ${err.message}`);
319
+ }
320
+ try {
321
+ for (;;) {
322
+ let entry;
323
+ try {
324
+ entry = dir.readSync();
325
+ }
326
+ catch (err) {
327
+ throw new Error(`Cannot read source directory "${src}": ${err.message}`);
328
+ }
329
+ if (entry === null)
330
+ break;
331
+ // BOUND THE ENUMERATION ITSELF: count this entry and fail closed BEFORE it is processed,
332
+ // so a huge directory (or deep tree) is never read in full into memory first.
333
+ budget.entries++;
334
+ if (budget.entries > MAX_STAGED_BUNDLE_ENTRIES) {
335
+ throw new Error(`Refusing to stage bundle: entry count exceeds the maximum of ${MAX_STAGED_BUNDLE_ENTRIES}`);
336
+ }
337
+ const srcPath = node_path_1.default.join(src, entry.name);
338
+ const destPath = node_path_1.default.join(dest, entry.name);
339
+ if (entry.isSymbolicLink()) {
340
+ throw new Error(`Refusing to stage symlink in capability bundle: ${entry.name}`);
341
+ }
342
+ else if (entry.isDirectory()) {
343
+ copyDirRecursive(srcPath, destPath, budget);
344
+ }
345
+ else if (entry.isFile()) {
346
+ // Cumulative byte budget: lstat the entry (NOT stat — a symlink is already rejected above,
347
+ // but lstat is the authoritative size of the regular file being copied) and fail closed the
348
+ // MOMENT the running total crosses the cap, BEFORE copying the oversized file's bytes.
349
+ let st;
350
+ try {
351
+ st = node_fs_1.default.lstatSync(srcPath);
352
+ }
353
+ catch (err) {
354
+ throw new Error(`Cannot lstat source entry "${srcPath}": ${err.message}`);
355
+ }
356
+ budget.bytes += st.size;
357
+ if (budget.bytes > MAX_STAGED_BUNDLE_BYTES) {
358
+ throw new Error(`Refusing to stage bundle: total staged size exceeds the maximum of ` +
359
+ `${MAX_STAGED_BUNDLE_BYTES} bytes (possible oversized source tree, git repo, npm package, or tar bomb)`);
360
+ }
361
+ node_fs_1.default.copyFileSync(srcPath, destPath);
362
+ }
363
+ // Non-regular entries (sockets, fifos, devices) are silently skipped.
364
+ }
365
+ }
366
+ finally {
367
+ try {
368
+ dir.closeSync();
369
+ }
370
+ catch { /* best-effort: no fd leak per opened Dir */ }
371
+ }
372
+ }
373
+ /**
374
+ * #1461 finding 1 (HIGH): sum the total regular-file bytes under `stagedDir` via a BOUNDED streaming
375
+ * walk and fail closed if the total exceeds MAX_STAGED_BUNDLE_BYTES. This is the SINGLE uniform bound on
376
+ * the RESULT of staging for EVERY adapter (local copy / git clone / npm pack / tar extraction) — placed
377
+ * at the common chokepoint in stageValidated AFTER copyDirRecursive and BEFORE validation/promotion.
378
+ *
379
+ * Bounded like capability-consent.bundleContentHash's enumeration: each level is STREAMED via
380
+ * fs.opendirSync + dir.readSync() with a CUMULATIVE entry counter (`count.n`) that throws the moment it
381
+ * exceeds MAX_STAGED_BUNDLE_ENTRIES — BEFORE the rest of a huge/deep level is read — so a hostile bundle
382
+ * with millions of tiny files or a very deep tree cannot force unbounded readdir/memory work before the
383
+ * byte cap trips. Per-entry: lstat (NOT stat) so a symlink is detected as itself; symlinks and other
384
+ * non-regular entries are REJECTED (fail closed — copyDirRecursive already refuses symlinks at copy time,
385
+ * but a fresh lstat here is the authoritative check on what actually landed in staging). Regular-file
386
+ * st.size is accumulated and the walk throws the moment the running total crosses the cap.
387
+ */
388
+ function assertStagedBundleWithinBudget(stagedDir) {
389
+ const total = { bytes: 0 };
390
+ const count = { n: 0 };
391
+ const walk = (absDir) => {
392
+ let dir;
393
+ try {
394
+ dir = node_fs_1.default.opendirSync(absDir);
395
+ }
396
+ catch (err) {
397
+ throw new Error(`Cannot read staged directory "${absDir}": ${err.message}`);
398
+ }
399
+ const levelEntries = [];
400
+ try {
401
+ for (;;) {
402
+ let ent;
403
+ try {
404
+ ent = dir.readSync();
405
+ }
406
+ catch (err) {
407
+ throw new Error(`Cannot read staged directory "${absDir}": ${err.message}`);
408
+ }
409
+ if (ent === null)
410
+ break;
411
+ // BOUND THE ENUMERATION ITSELF: fail closed before this entry is retained, so a huge directory
412
+ // (or deep tree) cannot be loaded in full first.
413
+ count.n++;
414
+ if (count.n > MAX_STAGED_BUNDLE_ENTRIES) {
415
+ throw new Error(`Refusing to stage bundle: entry count exceeds the maximum of ${MAX_STAGED_BUNDLE_ENTRIES}`);
416
+ }
417
+ levelEntries.push(ent);
418
+ }
419
+ }
420
+ finally {
421
+ try {
422
+ dir.closeSync();
423
+ }
424
+ catch { /* best-effort */ }
425
+ }
426
+ for (const ent of levelEntries) {
427
+ const abs = node_path_1.default.join(absDir, ent.name);
428
+ let st;
429
+ try {
430
+ st = node_fs_1.default.lstatSync(abs);
431
+ }
432
+ catch (err) {
433
+ throw new Error(`Cannot lstat staged entry "${abs}": ${err.message}`);
434
+ }
435
+ if (st.isSymbolicLink()) {
436
+ // Defense in depth: copyDirRecursive already refuses symlinks, but the budget walk is the
437
+ // authoritative re-check on what actually landed in staging.
438
+ throw new Error(`Refusing to stage symlink in capability bundle: ${abs}`);
439
+ }
440
+ if (st.isDirectory()) {
441
+ walk(abs);
442
+ continue;
443
+ }
444
+ if (!st.isFile()) {
445
+ // Sockets / FIFOs / devices are not part of a real capability bundle.
446
+ throw new Error(`Refusing to stage non-regular file in capability bundle: ${abs}`);
447
+ }
448
+ total.bytes += st.size;
449
+ if (total.bytes > MAX_STAGED_BUNDLE_BYTES) {
450
+ throw new Error(`Refusing to stage bundle: total staged size exceeds the maximum of ` +
451
+ `${MAX_STAGED_BUNDLE_BYTES} bytes (possible oversized source tree, git repo, npm package, or tar bomb)`);
452
+ }
453
+ }
454
+ };
455
+ walk(stagedDir);
456
+ }
457
+ /**
458
+ * Defense-in-depth against tar-slip: list the archive members and reject any with
459
+ * an absolute path or a `..` segment BEFORE extraction (system tar mostly guards
460
+ * this, but the hard contract is "traversal rejected", so we verify explicitly).
461
+ * Symlink members that survive extraction are caught later by copyDirRecursive.
462
+ *
463
+ * #1461 finding 2 (MED): the former per-member declared-size parse (parseTarMemberSize) was REMOVED.
464
+ * It scanned the verbose listing for a date-looking token and treated the previous token as the size,
465
+ * but on BSD `tar -tv` the owner/group columns PRECEDE the size, so a member owner/group like "Jan"
466
+ * mis-anchored the scan → fail-OPEN (a bomb's real size column skipped). The staged-dir aggregate
467
+ * budget (assertStagedBundleWithinBudget, #1461 finding 1) is now the real, non-spoofable bound on the
468
+ * extracted RESULT, so the fragile header parse is redundant. This function keeps only the NAME and
469
+ * TYPE guards (traversal / symlink / hardlink), which are unambiguous and not size-dependent.
470
+ */
471
+ function assertSafeTarMembers(execTar, tgzPath) {
472
+ // (1) Member NAMES — reject path traversal (absolute / "..").
473
+ const listing = execTar('tar', ['-tzf', tgzPath], { timeout: 60_000 });
474
+ if (listing.exitCode !== 0) {
475
+ throw new Error(`tar listing failed (exit ${listing.exitCode}): ${listing.stderr}`);
476
+ }
477
+ for (const line of listing.stdout.split('\n')) {
478
+ const member = line.trim();
479
+ if (!member)
480
+ continue;
481
+ if (member.startsWith('/') || node_path_1.default.isAbsolute(member) || member.split(/[/\\]/).includes('..')) {
482
+ throw new Error(`Refusing to extract tarball with unsafe member path: "${member}"`);
483
+ }
484
+ }
485
+ // (2) Member TYPES — reject symlink/hardlink members BEFORE extraction. A symlink
486
+ // member with a safe name is created during `tar -x` and a later member can be
487
+ // written THROUGH it to escape the extract dir (the post-extraction copy guard is
488
+ // too late). The verbose listing marks links: leading 'l'/'h' in the mode column
489
+ // and a " -> " / " link to " suffix (GNU + bsd tar).
490
+ const verbose = execTar('tar', ['-tvzf', tgzPath], { timeout: 60_000 });
491
+ if (verbose.exitCode !== 0) {
492
+ throw new Error(`tar verbose listing failed (exit ${verbose.exitCode}): ${verbose.stderr}`);
493
+ }
494
+ for (const line of verbose.stdout.split('\n')) {
495
+ if (!line.trim())
496
+ continue;
497
+ if (line.includes(' -> ') || line.includes(' link to ') || /^\s*[lh]/.test(line)) {
498
+ throw new Error('Refusing to extract tarball containing a symlink or hardlink member');
499
+ }
500
+ }
501
+ }
502
+ /**
503
+ * Validate the fetched capability manifest and stage it atomically.
504
+ *
505
+ * Runs the full validation suite (validateCapability → materializeHookFragments →
506
+ * validateAgainstContract → validateConsumesGlobal → validateCrossCapability).
507
+ * On success, renames the staging dir to the final dir and returns the result.
508
+ * On any failure, removes the staging dir and throws.
509
+ */
510
+ function stageValidated(opts) {
511
+ const { sourceDir, id, gsdHome, hostVersion, source, integrity } = opts;
512
+ const promote = opts.promote !== false;
513
+ // Safety: validate id before using it in a path.
514
+ assertSafeId(id);
515
+ const capabilitiesRoot = node_path_1.default.join(gsdHome, '.gsd', 'capabilities');
516
+ const stagingRoot = node_path_1.default.join(capabilitiesRoot, '.staging');
517
+ const stagingDir = node_path_1.default.join(stagingRoot, `${id}-${process.pid}-${Date.now()}`);
518
+ const finalDir = node_path_1.default.join(capabilitiesRoot, id);
519
+ // Reject a source-ROOT that is itself a symlink (copyDirRecursive guards interior
520
+ // entries, but readdirSync would follow a symlinked root).
521
+ if (node_fs_1.default.lstatSync(sourceDir).isSymbolicLink()) {
522
+ throw new Error(`Refusing to stage a symlinked source directory: ${sourceDir}`);
523
+ }
524
+ node_fs_1.default.mkdirSync(stagingDir, { recursive: true });
525
+ try {
526
+ // Copy source into staging — STREAMING + BUDGETED (#1461 finding 1, ROUND 2). copyDirRecursive now
527
+ // enforces BOTH the entry-count and aggregate-byte budget DURING the copy (per-entry, via opendirSync
528
+ // + readSync, never readdirSync of the whole array), so a hostile source with millions of tiny files
529
+ // or an oversized artifact fails closed IN-PROCESS before the whole directory is materialized — it can
530
+ // no longer OOM the process before a post-copy walk runs. The catch below rmSync's the staging dir on
531
+ // throw, so an over-budget bundle never lands at the final location.
532
+ copyDirRecursive(sourceDir, stagingDir);
533
+ // #1461 finding 1 (HIGH): belt-and-suspenders aggregate byte-budget re-verification on what ACTUALLY
534
+ // landed in staging. copyDirRecursive (above) is now the PRIMARY in-process bound — it fails closed
535
+ // DURING the copy — so this post-copy walk is no longer the sole guard, but it is kept as a cheap
536
+ // authoritative re-lstat of the staged RESULT at the common chokepoint AFTER staging and BEFORE
537
+ // validation/promotion: it re-checks the entry/byte caps and re-rejects any symlink / non-regular
538
+ // entry on the real staged tree (every staging path here flows through copyDirRecursive — there is no
539
+ // in-place-dir staging path — so the copy already bounds it; this is defense in depth).
540
+ //
541
+ // RESIDUAL (#1461 finding 4): the copy and this walk bound the staged RESULT (rejects an oversized
542
+ // install before promotion); a transient disk-fill DURING extraction/clone (system tar/git/npm write
543
+ // to a temp dir BEFORE copyDirRecursive streams it into staging) is a residual a fully-airtight bound
544
+ // would need a streaming byte-quota DURING extraction/clone to close. Proportionate: this resolver
545
+ // path is USER-INITIATED `install` only (the cloned-repo / loader overlay path does NOT invoke the
546
+ // resolver and is bounded separately), and the temp/.staging dirs are removed on any failure.
547
+ assertStagedBundleWithinBudget(stagingDir);
548
+ // Read and parse the capability manifest via the SHARED bounded reader (#1461 finding 2): an
549
+ // oversized/non-regular staged capability.json is refused (fail-closed) rather than read unbounded.
550
+ const manifestPath = node_path_1.default.join(stagingDir, 'capability.json');
551
+ const cap = readManifestBounded(manifestPath, `capability.json not found in staged directory: ${stagingDir}`);
552
+ // engines.gsd pre-check — reject before staging finalizes (unless the caller owns the gate).
553
+ const engines = cap['engines'];
554
+ if (!opts.skipEnginesGate && engines && typeof engines === 'object' && !Array.isArray(engines)) {
555
+ const gsdRange = engines['gsd'];
556
+ if (typeof gsdRange === 'string' && gsdRange) {
557
+ if (!semverMod.semverSatisfies(hostVersion, gsdRange)) {
558
+ throw new Error(`Capability requires engines.gsd "${gsdRange}" but running GSD is ${hostVersion}`);
559
+ }
560
+ }
561
+ }
562
+ // Structural validation (validateCapability enforces id===folderId).
563
+ const validationErrs = capValidator.validateCapability(cap, id);
564
+ if (validationErrs.length > 0) {
565
+ throw new Error(`Capability validation failed: ${validationErrs.join('; ')}`);
566
+ }
567
+ // Materialize hook fragments (returns errors, does not throw).
568
+ const fragErrs = capValidator.materializeHookFragments(structuredClone(cap), stagingDir);
569
+ if (fragErrs.length > 0) {
570
+ throw new Error(`Hook fragment validation failed: ${fragErrs.join('; ')}`);
571
+ }
572
+ // Cross-capability validations (contract, consumes, cross-capability).
573
+ const capMap = new Map([[id, cap]]);
574
+ const centralKeys = new Set();
575
+ const crossErrs = [
576
+ ...capValidator.validateAgainstContract(cap, id),
577
+ ...capValidator.validateConsumesGlobal(capMap),
578
+ ...capValidator.validateCrossCapability(capMap, centralKeys),
579
+ ];
580
+ if (crossErrs.length > 0) {
581
+ throw new Error(`Cross-capability validation failed: ${crossErrs.join('; ')}`);
582
+ }
583
+ // When promote === false the caller owns the swap (ADR-1244 Phase 4 upgrade path):
584
+ // return the validated staging dir as-is, leaving it on disk for the caller to rename.
585
+ if (!promote) {
586
+ const version = typeof cap['version'] === 'string' ? cap['version'] : '';
587
+ return { id, version, stagedDir: stagingDir, integrity, source };
588
+ }
589
+ // All validation passed — promote staging to final.
590
+ // Replacement is move-aside-then-rename (not rm-then-rename): rename the old
591
+ // bundle aside (atomic), move the new one in, restore the old one if the second
592
+ // rename fails. This avoids leaving the capability missing on a failed swap.
593
+ // (The fully-atomic stage-then-swap with the ledger as commit point — for upgrades —
594
+ // lives in capability-lifecycle.cjs and uses promote:false above.)
595
+ if (node_fs_1.default.existsSync(finalDir)) {
596
+ const backupDir = `${finalDir}.old-${process.pid}-${Date.now()}`;
597
+ node_fs_1.default.renameSync(finalDir, backupDir);
598
+ try {
599
+ node_fs_1.default.renameSync(stagingDir, finalDir);
600
+ }
601
+ catch (err) {
602
+ try {
603
+ node_fs_1.default.renameSync(backupDir, finalDir);
604
+ }
605
+ catch { /* best-effort restore */ }
606
+ throw err;
607
+ }
608
+ try {
609
+ node_fs_1.default.rmSync(backupDir, { recursive: true, force: true });
610
+ }
611
+ catch { /* best-effort */ }
612
+ }
613
+ else {
614
+ node_fs_1.default.renameSync(stagingDir, finalDir);
615
+ }
616
+ const version = typeof cap['version'] === 'string' ? cap['version'] : '';
617
+ return { id, version, stagedDir: finalDir, integrity, source };
618
+ }
619
+ catch (err) {
620
+ // Atomicity: always clean up the staging dir on failure.
621
+ try {
622
+ node_fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
623
+ }
624
+ catch { /* best-effort */ }
625
+ throw err;
626
+ }
627
+ }
628
+ // ---------------------------------------------------------------------------
629
+ // parseSpec
630
+ // ---------------------------------------------------------------------------
631
+ /**
632
+ * Detect the source kind from a raw spec string.
633
+ *
634
+ * Kind detection rules (first match wins):
635
+ * local: starts with `./ | ../ | /` (absolute path)
636
+ * npm: starts with `npm:` prefix
637
+ * tarball: `https://…` ending in `.tgz` or `.tar.gz`
638
+ * git: `https://…git`, URL with `#<ref>`, or starts with `git+`
639
+ * registry: `<name>@<registry>` form (no URL scheme)
640
+ */
641
+ function parseSpec(spec) {
642
+ if (typeof spec !== 'string' || spec.trim() === '') {
643
+ throw new Error('Capability spec must be a non-empty string');
644
+ }
645
+ const s = spec.trim();
646
+ // local: relative or absolute path
647
+ if (s.startsWith('./') || s.startsWith('../') || node_path_1.default.isAbsolute(s)) {
648
+ return { kind: 'local', raw: spec, target: s };
649
+ }
650
+ // npm: explicit `npm:` prefix
651
+ if (s.startsWith('npm:')) {
652
+ const pkgSpec = s.slice('npm:'.length);
653
+ if (!pkgSpec)
654
+ throw new Error(`Invalid npm spec: "${spec}" — package spec is empty after "npm:"`);
655
+ assertSafeNpmSpec(pkgSpec);
656
+ return { kind: 'npm', raw: spec, target: pkgSpec };
657
+ }
658
+ // tarball: https URL ending in .tgz or .tar.gz
659
+ if (/^https?:\/\/.+\.t(gz|ar\.gz)$/i.test(s)) {
660
+ return { kind: 'tarball', raw: spec, target: s };
661
+ }
662
+ // git: git+ prefix, https URL ending in .git, or URL with #<ref>
663
+ if (s.startsWith('git+') ||
664
+ /^https?:\/\/.+\.git$/i.test(s) ||
665
+ (/^https?:\/\//.test(s) && s.includes('#'))) {
666
+ let url = s.startsWith('git+') ? s.slice('git+'.length) : s;
667
+ let ref;
668
+ const hashIdx = url.indexOf('#');
669
+ if (hashIdx !== -1) {
670
+ ref = url.slice(hashIdx + 1);
671
+ url = url.slice(0, hashIdx);
672
+ }
673
+ assertSafeGitUrl(url);
674
+ if (ref !== undefined && (SHELL_METACHAR_RE.test(ref) || ref.startsWith('-'))) {
675
+ // Leading '-' would be parsed as a git option, not a ref.
676
+ throw new Error(`Unsafe git ref (shell metacharacters or leading dash not allowed): "${ref}"`);
677
+ }
678
+ return { kind: 'git', raw: spec, target: url, ...(ref !== undefined ? { ref } : {}) };
679
+ }
680
+ // registry: <name>@<version-or-registry> — no URL scheme
681
+ if (/^[a-zA-Z0-9@/_-]/.test(s) && !s.startsWith('http')) {
682
+ return { kind: 'registry', raw: spec, target: s };
683
+ }
684
+ throw new Error(`Cannot determine source kind for capability spec: "${spec}"`);
685
+ }
686
+ // ---------------------------------------------------------------------------
687
+ // Source adapters
688
+ // ---------------------------------------------------------------------------
689
+ function resolveLocal(parsed, opts, gsdHome, hostVersion) {
690
+ // #1460 CS-1: a local path is a directory tree, not a single downloadable artifact, so there is
691
+ // no stable byte stream to verify a sha512 SRI pin against. A supplied `--integrity` is therefore
692
+ // REJECTED with an actionable error rather than being silently dropped (the prior behaviour staged
693
+ // with integrity:null, so the user believed content was pinned when it was not).
694
+ if (opts.integrity) {
695
+ throw new Error('integrity pinning is not supported for local sources');
696
+ }
697
+ const absPath = node_path_1.default.resolve(parsed.target);
698
+ if (!node_fs_1.default.existsSync(absPath)) {
699
+ throw new Error(`Local capability path does not exist: ${absPath}`);
700
+ }
701
+ // Read id from capability.json to know the staging dest — via the SHARED bounded reader (#1461
702
+ // finding 2): an oversized/non-regular local capability.json is refused, never read unbounded.
703
+ const manifestPath = node_path_1.default.join(absPath, 'capability.json');
704
+ const cap = readManifestBounded(manifestPath, `Cannot read capability.json from local path: ${manifestPath}`);
705
+ const id = typeof cap['id'] === 'string' ? cap['id'] : '';
706
+ if (!id)
707
+ throw new Error('capability.json missing "id" field');
708
+ return stageValidated({ sourceDir: absPath, id, gsdHome, hostVersion, source: parsed.raw, integrity: null, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
709
+ }
710
+ function resolveGit(parsed, opts, gsdHome, hostVersion) {
711
+ const execGit = opts.execOverrides?.git ?? shellSeam.execGit;
712
+ // #1460 CS-1: a git working tree has no single downloadable artifact to verify a sha512 SRI pin
713
+ // against (a clone is a directory tree, and the digest would vary with pack/checkout details). A
714
+ // supplied `--integrity` is therefore REJECTED with an actionable error rather than silently
715
+ // dropped (the prior behaviour staged with integrity:null). Pin a git source by COMMIT instead.
716
+ if (opts.integrity) {
717
+ throw new Error('integrity pinning is not supported for git sources; pin the commit with #sha:<commit>');
718
+ }
719
+ const cloneDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-cap-git-'));
720
+ try {
721
+ // Clone (copy only — no hooks execute on clone, no npm install).
722
+ const cloneResult = execGit(['clone', '--depth', '1', '--', parsed.target, cloneDir], { timeout: 60_000 });
723
+ if (cloneResult.exitCode !== 0) {
724
+ throw new Error(`git clone failed (exit ${cloneResult.exitCode}): ${cloneResult.stderr}`);
725
+ }
726
+ // Optional ref checkout. The ref is a commit-ish (tag/branch/sha), NOT a path,
727
+ // so it goes BEFORE the `--` pathspec terminator (a leading-dash ref is rejected
728
+ // at parse time, so it cannot be misread as an option here).
729
+ if (parsed.ref) {
730
+ const checkoutResult = execGit(['-C', cloneDir, 'checkout', parsed.ref, '--'], { timeout: 60_000 });
731
+ if (checkoutResult.exitCode !== 0) {
732
+ throw new Error(`git checkout "${parsed.ref}" failed (exit ${checkoutResult.exitCode}): ${checkoutResult.stderr}`);
733
+ }
734
+ }
735
+ // Read id from capability.json via the SHARED bounded reader (#1461 finding 2): a cloned repo's
736
+ // oversized/non-regular capability.json is refused, never read unbounded.
737
+ const manifestPath = node_path_1.default.join(cloneDir, 'capability.json');
738
+ const cap = readManifestBounded(manifestPath, `capability.json not found in cloned repo: ${parsed.target}`);
739
+ const id = typeof cap['id'] === 'string' ? cap['id'] : '';
740
+ if (!id)
741
+ throw new Error('capability.json missing "id" field');
742
+ return stageValidated({ sourceDir: cloneDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: null, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
743
+ }
744
+ finally {
745
+ try {
746
+ node_fs_1.default.rmSync(cloneDir, { recursive: true, force: true });
747
+ }
748
+ catch { /* best-effort */ }
749
+ }
750
+ }
751
+ function resolveNpm(parsed, opts, gsdHome, hostVersion) {
752
+ const execNpm = opts.execOverrides?.npm ?? shellSeam.execNpm;
753
+ // tar override: injected for tests; default delegates to shell seam execTool.
754
+ const execTar = opts.execOverrides?.tar ?? shellSeam.execTool;
755
+ const tmpPackDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-cap-npm-pack-'));
756
+ const extractDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-cap-npm-ext-'));
757
+ try {
758
+ // npm pack — creates a tarball. CRITICAL: `npm pack` runs prepack/prepare
759
+ // lifecycle scripts by default, which would EXECUTE fetched code — so we pass
760
+ // --ignore-scripts to guarantee copy-only. NEVER npm install.
761
+ const packResult = execNpm(['pack', '--ignore-scripts', '--silent', '--pack-destination', tmpPackDir, '--', parsed.target], { timeout: 60_000 });
762
+ if (packResult.exitCode !== 0) {
763
+ throw new Error(`npm pack failed (exit ${packResult.exitCode}): ${packResult.stderr}`);
764
+ }
765
+ // Locate the produced .tgz.
766
+ const tarballs = node_fs_1.default.readdirSync(tmpPackDir).filter((f) => f.endsWith('.tgz'));
767
+ if (tarballs.length === 0) {
768
+ throw new Error(`npm pack produced no .tgz in ${tmpPackDir}`);
769
+ }
770
+ const tgzPath = node_path_1.default.join(tmpPackDir, tarballs[0]);
771
+ // #1460 CS-1: a supplied `--integrity` is verified over the `.tgz` BYTES (same SRI sha512
772
+ // domain as the tarball adapter) BEFORE anything is staged or promoted — never silently
773
+ // dropped. The recorded integrity is always the computed digest of the produced tarball.
774
+ // `npm pack --ignore-scripts` (above) ran no capability code, so reading these bytes is
775
+ // copy-only. A mismatch throws here, before assertSafeTarMembers / extraction / staging.
776
+ const tgzBytes = readPackTarball(tgzPath);
777
+ const computedIntegrity = computeIntegrity(tgzBytes);
778
+ if (opts.integrity) {
779
+ verifyIntegrity(tgzBytes, opts.integrity);
780
+ }
781
+ // Reject tar-slip member paths before extracting.
782
+ assertSafeTarMembers(execTar, tgzPath);
783
+ // Extract — copy only, no scripts. npm tarballs nest under package/.
784
+ const tarResult = execTar('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 60_000 });
785
+ if (tarResult.exitCode !== 0) {
786
+ throw new Error(`tar extraction failed (exit ${tarResult.exitCode}): ${tarResult.stderr}`);
787
+ }
788
+ // npm tarballs nest under package/; fall back to root.
789
+ const packageDir = node_path_1.default.join(extractDir, 'package');
790
+ const sourceDir = node_fs_1.default.existsSync(node_path_1.default.join(packageDir, 'capability.json')) ? packageDir : extractDir;
791
+ // Read id from capability.json via the SHARED bounded reader (#1461 finding 2): an extracted
792
+ // oversized/non-regular capability.json is refused, never read unbounded.
793
+ const manifestPath = node_path_1.default.join(sourceDir, 'capability.json');
794
+ const cap = readManifestBounded(manifestPath, `capability.json not found after npm pack extraction from: ${parsed.target}`);
795
+ const id = typeof cap['id'] === 'string' ? cap['id'] : '';
796
+ if (!id)
797
+ throw new Error('capability.json missing "id" field');
798
+ return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: computedIntegrity, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
799
+ }
800
+ finally {
801
+ try {
802
+ node_fs_1.default.rmSync(tmpPackDir, { recursive: true, force: true });
803
+ }
804
+ catch { /* best-effort */ }
805
+ try {
806
+ node_fs_1.default.rmSync(extractDir, { recursive: true, force: true });
807
+ }
808
+ catch { /* best-effort */ }
809
+ }
810
+ }
811
+ async function resolveTarball(parsed, opts, gsdHome, hostVersion) {
812
+ // tar override: injected for tests; default delegates to shell seam execTool.
813
+ const execTar = opts.execOverrides?.tar ?? shellSeam.execTool;
814
+ // Fetch buffer — always reject non-200 (realHttpsGet enforces this).
815
+ const resp = await _httpGet(parsed.target);
816
+ // Integrity check BEFORE any bytes touch disk (if provided).
817
+ const computedIntegrity = computeIntegrity(resp.body);
818
+ if (opts.integrity) {
819
+ verifyIntegrity(resp.body, opts.integrity);
820
+ }
821
+ const extractDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-cap-tar-'));
822
+ const tgzPath = node_path_1.default.join(extractDir, '_download.tgz');
823
+ try {
824
+ node_fs_1.default.writeFileSync(tgzPath, resp.body);
825
+ // Reject tar-slip member paths before extracting.
826
+ assertSafeTarMembers(execTar, tgzPath);
827
+ const tarResult = execTar('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 60_000 });
828
+ if (tarResult.exitCode !== 0) {
829
+ throw new Error(`tar extraction failed (exit ${tarResult.exitCode}): ${tarResult.stderr}`);
830
+ }
831
+ // Locate capability.json — root or package/ (npm tarball shape).
832
+ const packageDir = node_path_1.default.join(extractDir, 'package');
833
+ const sourceDir = node_fs_1.default.existsSync(node_path_1.default.join(packageDir, 'capability.json')) ? packageDir : extractDir;
834
+ // Read id from capability.json via the SHARED bounded reader (#1461 finding 2): an extracted
835
+ // oversized/non-regular capability.json is refused, never read unbounded.
836
+ const manifestPath = node_path_1.default.join(sourceDir, 'capability.json');
837
+ const cap = readManifestBounded(manifestPath, `capability.json not found in tarball from: ${parsed.target}`);
838
+ const id = typeof cap['id'] === 'string' ? cap['id'] : '';
839
+ if (!id)
840
+ throw new Error('capability.json missing "id" field');
841
+ return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: computedIntegrity, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
842
+ }
843
+ finally {
844
+ try {
845
+ node_fs_1.default.rmSync(extractDir, { recursive: true, force: true });
846
+ }
847
+ catch { /* best-effort */ }
848
+ }
849
+ }
850
+ // ---------------------------------------------------------------------------
851
+ // Main resolver
852
+ // ---------------------------------------------------------------------------
853
+ /**
854
+ * Resolve a capability spec, validate it, and stage it into the GSD capabilities dir.
855
+ *
856
+ * @param spec - Source spec string. Kind auto-detected via parseSpec.
857
+ * @param opts - Optional overrides for hostVersion, gsdHome, integrity, exec/http seams.
858
+ * @returns - Resolved bundle descriptor with stagedDir path.
859
+ */
860
+ async function resolveCapabilitySource(spec, opts = {}) {
861
+ const parsed = parseSpec(spec);
862
+ const hostVersion = opts.hostVersion ?? readHostVersion();
863
+ const gsdHome = opts.gsdHome ?? process.env['GSD_HOME'] ?? node_os_1.default.homedir();
864
+ switch (parsed.kind) {
865
+ case 'local':
866
+ return resolveLocal(parsed, opts, gsdHome, hostVersion);
867
+ case 'git':
868
+ return resolveGit(parsed, opts, gsdHome, hostVersion);
869
+ case 'npm':
870
+ return resolveNpm(parsed, opts, gsdHome, hostVersion);
871
+ case 'tarball':
872
+ return resolveTarball(parsed, opts, gsdHome, hostVersion);
873
+ case 'registry':
874
+ throw new Error('registry source kind is not yet implemented (no first-party registry endpoint)');
875
+ default: {
876
+ // TypeScript exhaustiveness guard.
877
+ const _never = parsed.kind;
878
+ throw new Error(`Unknown source kind: ${String(_never)}`);
879
+ }
880
+ }
881
+ }
882
+ // ---------------------------------------------------------------------------
883
+ // Latest-version peek (ADR-1244 D6 "Update available?" per-source matrix; #1463)
884
+ // ---------------------------------------------------------------------------
885
+ /**
886
+ * #1463: timeouts for the LIGHT remote peek the `outdated` verb performs. These are deliberately the
887
+ * SAME bounds the resolve path uses for the analogous heavy operations (CONTEXT.md "every git/npm
888
+ * subprocess needs a timeout"): a hung registry/remote must DEGRADE the verb (status 'unknown'), never
889
+ * hang it. The peek is a metadata-only read (`git ls-remote --tags`, `npm view … version`), NOT a
890
+ * clone / pack / extract.
891
+ */
892
+ const PEEK_GIT_TIMEOUT_MS = 30_000;
893
+ const PEEK_NPM_TIMEOUT_MS = 60_000;
894
+ /**
895
+ * #1463: parse the output of `git ls-remote --tags <url>` and return the HIGHEST stable-triplet semver
896
+ * tag, or null when no parseable semver tag exists. UNTRUSTED-DATA RULE: the remote's ref names are
897
+ * treated purely as data — each line is `<sha>\t<ref>` (e.g. `<sha>\trefs/tags/v1.2.0`); we strip
898
+ * `refs/tags/`, ignore the `^{}` peeled-annotation entries (they would otherwise double-count and the
899
+ * `^{}` suffix is not a version), strip a leading `v`, and keep only STABLE x.y.z triplets
900
+ * (isStableTripletSemver) so a `-rc`/junk tag never wins. The max is selected via compareSemverCore so
901
+ * 1.10.0 correctly beats 1.2.0 (numeric, not lexical). Pure + deterministic → property-tested.
902
+ */
903
+ function pickHighestSemverTag(lsRemoteOutput) {
904
+ if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '')
905
+ return null;
906
+ let best = null;
907
+ for (const rawLine of lsRemoteOutput.split('\n')) {
908
+ const line = rawLine.trim();
909
+ if (line === '')
910
+ continue;
911
+ // `<sha>\t<ref>` — take the ref (last whitespace-delimited token); a line without a tab/ref is junk.
912
+ const tabIdx = line.search(/\s/);
913
+ const ref = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim();
914
+ if (!ref.startsWith('refs/tags/'))
915
+ continue;
916
+ let tag = ref.slice('refs/tags/'.length);
917
+ // Ignore the peeled-annotation entry `refs/tags/<tag>^{}` — same tag, not a distinct version.
918
+ if (tag.endsWith('^{}'))
919
+ continue;
920
+ if (tag.startsWith('v'))
921
+ tag = tag.slice(1);
922
+ // Keep only stable x.y.z triplets — a prerelease/junk tag is not an "available stable version".
923
+ if (!semverMod.isStableTripletSemver(tag))
924
+ continue;
925
+ if (best === null || semverMod.compareSemverCore(tag, best) > 0)
926
+ best = tag;
927
+ }
928
+ return best;
929
+ }
930
+ const NPM_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
931
+ /**
932
+ * #1463: split an npm package spec (the `parsed.target` for an `npm:` source) into its package NAME and
933
+ * its trailing version/range selector. The selector is everything after the `@` that separates name from
934
+ * version — for a SCOPED package (`@scope/name@<sel>`) that is the LAST `@`, NOT the leading scope `@`;
935
+ * for an unscoped package (`name@<sel>`) it is the single non-leading `@`. A spec with no such `@`
936
+ * (`@scope/name`, `name`) has selector `''` (tracks the npm `latest` dist-tag).
937
+ *
938
+ * Pure string parse on an already-shell-safe spec (assertSafeNpmSpec ran in parseSpec). Used ONLY to
939
+ * classify the recorded source (exact-pin vs range vs latest) and to range-filter `npm view` output —
940
+ * the subprocess invocation still passes the FULL `parsed.target` unchanged.
941
+ */
942
+ function splitNpmSpec(target) {
943
+ // Find the `@` that introduces the version selector: search from the END, but stop at index 0 (the
944
+ // leading `@` of a scope is never a version separator).
945
+ const at = target.lastIndexOf('@');
946
+ if (at <= 0)
947
+ return { name: target, selector: '' };
948
+ return { name: target.slice(0, at), selector: target.slice(at + 1) };
949
+ }
950
+ /**
951
+ * #1463: pull the ONE canonical version token out of a single `npm view <spec> version` output line, or
952
+ * null when the line carries no version in a canonical position. UNTRUSTED-DATA RULE: the line is data.
953
+ *
954
+ * #1463 Fix 1 (R Medium): the version MUST come from its CANONICAL position, NOT from "any x.y.z token on
955
+ * the line" — a package NAME can itself contain a version-like substring (`@scope/cap-1.2.3@1.0.0`) and
956
+ * the old any-token scan returned the NAME's `1.2.3` instead of the resolved `1.0.0`. npm prints lines
957
+ * shaped `<name>@<version> '<version>'` (range/multi-match) or a single bare `<version>` token (latest
958
+ * dist-tag). Canonical extraction:
959
+ * 1. Prefer the QUOTED token (`'x.y.z'`) when present — that is npm's explicit version annotation.
960
+ * 2. Else take the token after the LAST `@` of the leading `name@version` segment (the first
961
+ * whitespace-delimited field), mirroring splitNpmSpec's scoped last-`@` rule so a scope `@` is not
962
+ * mistaken for the version separator.
963
+ * 3. Else (a single bare line with no `@` and no quotes) treat the whole first field as the version.
964
+ * The candidate is validated against NPM_VERSION_RE; a version-like substring embedded in the NAME is
965
+ * never consulted. Returns the canonical version (unvalidated against any range) or null. Pure.
966
+ */
967
+ function extractNpmLineVersion(line) {
968
+ // 1. Quoted annotation `'x.y.z'` — npm's explicit version field.
969
+ const quoted = line.match(/'([^']+)'/);
970
+ if (quoted && NPM_VERSION_RE.test(quoted[1]))
971
+ return quoted[1];
972
+ // 2/3. The leading `name@version` (or bare `version`) field is the first whitespace-delimited token.
973
+ const head = line.split(/\s+/, 1)[0];
974
+ if (head === undefined || head === '')
975
+ return null;
976
+ // Last `@` that is not a leading scope `@` (index 0) separates name from version; no such `@` ⇒ the
977
+ // whole head is the candidate (a bare `version` line). Never read a substring inside the NAME portion.
978
+ const at = head.lastIndexOf('@');
979
+ const candidate = at > 0 ? head.slice(at + 1) : head;
980
+ return NPM_VERSION_RE.test(candidate) ? candidate : null;
981
+ }
982
+ /**
983
+ * #1463: return the HIGHEST version across `npm view <spec> version` stdout that satisfies `range`
984
+ * (compareSemverCore for max; semverSatisfies for the range bound), or null when none parse / match.
985
+ * UNTRUSTED-DATA RULE: the output is treated purely as data. npm prints ONE annotated line per matching
986
+ * version for a multi-version range, e.g.:
987
+ * @org/pkg@1.0.0 '1.0.0'
988
+ * @org/pkg@1.10.0 '1.10.0'
989
+ * and a single bare line for a single match. Each line yields at most ONE canonical version (via
990
+ * extractNpmLineVersion — Fix 1: the version's canonical position, NOT any token, so a version-like
991
+ * substring in the package NAME never poisons the result). We keep only versions satisfying the recorded
992
+ * range and pick the numeric max so 1.10.0 beats 1.2.0. An empty selector means "no range constraint"
993
+ * (track latest) → every parseable version qualifies. Pure + deterministic.
994
+ */
995
+ function pickHighestNpmVersion(viewOutput, range) {
996
+ if (typeof viewOutput !== 'string')
997
+ return null;
998
+ let best = null;
999
+ for (const rawLine of viewOutput.split('\n')) {
1000
+ const line = rawLine.trim();
1001
+ if (line === '')
1002
+ continue;
1003
+ const tok = extractNpmLineVersion(line);
1004
+ if (tok === null)
1005
+ continue;
1006
+ // Empty range = no constraint (track latest); otherwise the version must satisfy the recorded range.
1007
+ if (range !== '' && !semverMod.semverSatisfies(tok, range))
1008
+ continue;
1009
+ if (best === null || semverMod.compareSemverCore(tok, best) > 0)
1010
+ best = tok;
1011
+ }
1012
+ return best;
1013
+ }
1014
+ /**
1015
+ * #1463 Fix 2 (R Medium): classify the git ref FRAGMENT (`parsed.ref`, the raw text after `#`) by KIND.
1016
+ * parseSpec captures the WHOLE `#…` fragment as a raw string and does NOT split the kind, so we parse the
1017
+ * `sha:` / `tag:` prefix here. The kind decides mutability:
1018
+ * - 'sha' (`#sha:<commit>`) → IMMUTABLE pin (a commit never moves).
1019
+ * - 'tag' (`#tag:<name>`) → IMMUTABLE pin (a tag is opted-into; `update` re-checks-out the SAME tag).
1020
+ * - 'bare' (`#<ref>`) → AMBIGUOUS: it is either a tag (immutable) or a branch (MUTABLE). The
1021
+ * caller must resolve it remotely (git ls-remote <url> <ref>) before
1022
+ * deciding pinned-vs-not — a bare branch ref is NEVER pinned.
1023
+ * - 'none' → no `#<ref>`: tracks the default branch (peek highest tag).
1024
+ * Pure string parse on an already-shell-safe ref (parseSpec asserted it). `sha:`/`tag:` are matched
1025
+ * case-insensitively with optional surrounding whitespace; the prefix's value is returned for diagnostics.
1026
+ */
1027
+ function classifyGitRef(parsed) {
1028
+ const raw = typeof parsed.ref === 'string' ? parsed.ref.trim() : '';
1029
+ if (raw === '')
1030
+ return { kind: 'none', value: '' };
1031
+ const shaMatch = /^sha:(.+)$/i.exec(raw);
1032
+ if (shaMatch)
1033
+ return { kind: 'sha', value: shaMatch[1].trim() };
1034
+ const tagMatch = /^tag:(.+)$/i.exec(raw);
1035
+ if (tagMatch)
1036
+ return { kind: 'tag', value: tagMatch[1].trim() };
1037
+ return { kind: 'bare', value: raw };
1038
+ }
1039
+ /**
1040
+ * #1463 Fix 2 (R Medium): resolve a bare ambiguous git ref to its KIND at the remote with a bounded
1041
+ * `git ls-remote <url> <ref>` (the SAME safe seam as the tag peek: argv + `--`, never a shell string).
1042
+ * ls-remote prints `<sha>\t<full-ref>` lines for every matching ref. A ref that matches under
1043
+ * `refs/tags/` is an immutable TAG; one under `refs/heads/` is a MUTABLE branch. UNTRUSTED-DATA RULE:
1044
+ * the remote's ref strings are data — we only test the canonical `refs/tags/` vs `refs/heads/` prefix on
1045
+ * the ref column (last whitespace-delimited token of each line). Returns:
1046
+ * 'tag' — at least one matching ref under refs/tags/ (and none ambiguous-conflicting branch).
1047
+ * 'branch' — at least one matching ref under refs/heads/.
1048
+ * 'unknown' — ls-remote error / timeout / non-zero / empty / unresolvable / conflicting output.
1049
+ * NEVER throws (DEGRADE). Bounded by PEEK_GIT_TIMEOUT_MS (≤30s).
1050
+ */
1051
+ function classifyBareGitRefRemote(url, ref, execGit) {
1052
+ let r;
1053
+ try {
1054
+ // Metadata-only ref lookup; `--` terminates options so a hostile URL/ref can't be read as a flag
1055
+ // (both are already transport-/shell-safe via parseSpec). The ref filters ls-remote server-side.
1056
+ r = execGit(['ls-remote', '--', url, ref], { timeout: PEEK_GIT_TIMEOUT_MS });
1057
+ }
1058
+ catch {
1059
+ return 'unknown';
1060
+ }
1061
+ if (!r || r.exitCode !== 0 || r.signal)
1062
+ return 'unknown';
1063
+ let sawTag = false;
1064
+ let sawBranch = false;
1065
+ for (const rawLine of (r.stdout || '').split('\n')) {
1066
+ const line = rawLine.trim();
1067
+ if (line === '')
1068
+ continue;
1069
+ const tabIdx = line.indexOf('\t');
1070
+ const refName = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim();
1071
+ if (refName.startsWith('refs/tags/'))
1072
+ sawTag = true;
1073
+ else if (refName.startsWith('refs/heads/'))
1074
+ sawBranch = true;
1075
+ }
1076
+ // A clean single-kind resolution wins; anything ambiguous (both, or neither) degrades to unknown so a
1077
+ // mutable branch is never silently treated as an immutable tag (and vice-versa).
1078
+ if (sawTag && !sawBranch)
1079
+ return 'tag';
1080
+ if (sawBranch && !sawTag)
1081
+ return 'branch';
1082
+ return 'unknown';
1083
+ }
1084
+ /**
1085
+ * #1463: resolve the LATEST available version for a recorded capability source string, per ADR-1244 D6
1086
+ * ("Update available?" is a per-source matrix). This is a LIGHT remote PEEK — metadata only — never a
1087
+ * re-clone / re-pack / re-extract. It NEVER throws: every error / timeout / unsupported source DEGRADES
1088
+ * to a status the `outdated` verb can render. Per-kind behaviour:
1089
+ *
1090
+ * - git `git ls-remote --tags <url>` → highest stable semver tag (pickHighestSemverTag). status 'ok'.
1091
+ * - npm `npm view <pkg> version` (latest dist-tag) → the reported version. status 'ok'.
1092
+ * - local re-read capability.json at the path (bounded reader) → its `version`. status 'ok'.
1093
+ * - tarball one immutable URL, not auto-detectable per D6 → status 'manual' (no version).
1094
+ * - registry resolveCapabilitySource throws (unimplemented) → status 'unsupported'.
1095
+ *
1096
+ * BOUNDED SUBPROCESSES (CONTEXT.md): git ls-remote ≤30s, npm view ≤60s; on timeout / non-zero / error /
1097
+ * empty-or-unparseable output → status 'unknown' (DEGRADE, never crash the verb).
1098
+ *
1099
+ * The exec seam mirrors the resolver: opts.execOverrides.{git,npm} (or the default shell seam) so a test
1100
+ * can mock the remote PEEK with no network I/O.
1101
+ */
1102
+ function peekLatestVersion(source, opts = {}) {
1103
+ let parsed;
1104
+ try {
1105
+ parsed = parseSpec(source);
1106
+ }
1107
+ catch (err) {
1108
+ // An unparseable recorded source cannot be peeked — DEGRADE (do not throw).
1109
+ return { status: 'unknown', version: null, reason: `unparseable source: ${err.message}` };
1110
+ }
1111
+ switch (parsed.kind) {
1112
+ case 'git': {
1113
+ const execGit = opts.execOverrides?.git ?? shellSeam.execGit;
1114
+ // #1463 Fix 2 (R Medium): classify the recorded ref by KIND before deciding pinned-vs-not. An
1115
+ // IMMUTABLE pin (`#sha:`/`#tag:`) is NEVER outdated — `update` re-resolves the SAME commit/tag, so
1116
+ // a newer remote tag is irrelevant; report 'pinned' WITHOUT any peek. A BARE `#<ref>` is ambiguous
1117
+ // (tag OR branch): we MUST classify it remotely so a MUTABLE branch is never falsely 'pinned'.
1118
+ const refKind = classifyGitRef(parsed);
1119
+ if (refKind.kind === 'sha' || refKind.kind === 'tag') {
1120
+ return { status: 'pinned', version: null, reason: `git source pinned to ${refKind.kind} "${refKind.value}"; update will not move it` };
1121
+ }
1122
+ if (refKind.kind === 'bare') {
1123
+ // Resolve the ambiguous ref at the remote (same safe execGit seam: argv + `--`).
1124
+ const resolved = classifyBareGitRefRemote(parsed.target, refKind.value, execGit);
1125
+ if (resolved === 'tag') {
1126
+ // An immutable tag → pinned (the ref the user recorded is a tag, not a moving branch).
1127
+ return { status: 'pinned', version: null, reason: `git source ref "${refKind.value}" resolves to an immutable tag; update will not move it` };
1128
+ }
1129
+ // A branch (MUTABLE) or an unresolvable/ambiguous result. The ledger records NO installed commit
1130
+ // sha for git sources (integrity is null), so a moved branch HEAD cannot be compared against the
1131
+ // installed commit → DEGRADE to 'unknown'. The HARD INVARIANT holds: a branch is NEVER 'pinned'.
1132
+ const reason = resolved === 'branch'
1133
+ ? `git source tracks mutable branch "${refKind.value}"; no installed commit recorded to compare against`
1134
+ : `git source ref "${refKind.value}" could not be classified (tag vs branch) at the remote`;
1135
+ return { status: 'unknown', version: null, reason };
1136
+ }
1137
+ // refKind.kind === 'none' — no `#<ref>`, tracks the default branch: peek the highest remote tag.
1138
+ let r;
1139
+ try {
1140
+ // Metadata-only: ls-remote lists refs without cloning. `--` terminates options so a hostile
1141
+ // URL cannot be read as a flag (the URL is already transport-allowlisted by parseSpec).
1142
+ r = execGit(['ls-remote', '--tags', '--', parsed.target], { timeout: PEEK_GIT_TIMEOUT_MS });
1143
+ }
1144
+ catch (err) {
1145
+ return { status: 'unknown', version: null, reason: `git ls-remote error: ${err.message}` };
1146
+ }
1147
+ if (!r || r.exitCode !== 0 || r.signal) {
1148
+ const reason = r && r.signal ? `git ls-remote timed out (signal ${r.signal})`
1149
+ : `git ls-remote exit ${r ? r.exitCode : 'n/a'}`;
1150
+ return { status: 'unknown', version: null, reason };
1151
+ }
1152
+ const latest = pickHighestSemverTag(r.stdout || '');
1153
+ if (latest === null)
1154
+ return { status: 'unknown', version: null, reason: 'no semver tags at remote' };
1155
+ return { status: 'ok', version: latest };
1156
+ }
1157
+ case 'npm': {
1158
+ // #1463: classify the recorded npm spec — what would `update` resolve it to?
1159
+ // exact version (`@1.2.3`) → PINNED: update re-installs the SAME version, never outdated.
1160
+ // range (`@^1`, `@~1.2`, …) → peek and pick the HIGHEST version satisfying the range (multi-line).
1161
+ // no version (bare name) → peek the single `latest` dist-tag version.
1162
+ const { selector } = splitNpmSpec(parsed.target);
1163
+ // An EXACT version selector is an immutable pin (a single x.y.z[-pre], no range operator/wildcard).
1164
+ if (selector !== '' && NPM_VERSION_RE.test(selector)) {
1165
+ return { status: 'pinned', version: selector, reason: `npm source pinned to exact version "${selector}"; update will not move it` };
1166
+ }
1167
+ const execNpm = opts.execOverrides?.npm ?? shellSeam.execNpm;
1168
+ let r;
1169
+ try {
1170
+ // Mirrors scripts/check-latest-version.cjs (checkLatestVersion): `npm view <spec> version` reports
1171
+ // the matching version(s). parsed.target is the npm package spec (parseSpec asserted it is free of
1172
+ // shell metacharacters) and is passed UNCHANGED — for a range npm prints every matching version,
1173
+ // for a bare name the single latest. `--` terminates options.
1174
+ r = execNpm(['view', '--', parsed.target, 'version'], { timeout: PEEK_NPM_TIMEOUT_MS });
1175
+ }
1176
+ catch (err) {
1177
+ return { status: 'unknown', version: null, reason: `npm view error: ${err.message}` };
1178
+ }
1179
+ if (!r || r.exitCode !== 0 || r.signal) {
1180
+ const reason = r && r.signal ? `npm view timed out (signal ${r.signal})`
1181
+ : `npm view exit ${r ? r.exitCode : 'n/a'}`;
1182
+ return { status: 'unknown', version: null, reason };
1183
+ }
1184
+ // Treat the OUTPUT as untrusted: extract every version token (npm prints one annotated line per
1185
+ // matching version for a range, a bare token for a single match) and pick the HIGHEST that
1186
+ // satisfies the recorded range (empty selector = no constraint → latest). Garbage / no match →
1187
+ // DEGRADE to 'unknown'.
1188
+ const version = pickHighestNpmVersion(r.stdout || '', selector);
1189
+ if (version === null) {
1190
+ return { status: 'unknown', version: null, reason: `npm view returned no matching semver version: ${(r.stdout || '').trim() || '(empty)'}` };
1191
+ }
1192
+ return { status: 'ok', version };
1193
+ }
1194
+ case 'local': {
1195
+ // Re-read the recorded local capability.json (bounded reader) for its current declared version.
1196
+ let cap;
1197
+ try {
1198
+ const manifestPath = node_path_1.default.join(node_path_1.default.resolve(parsed.target), 'capability.json');
1199
+ cap = readManifestBounded(manifestPath, `local capability.json not readable: ${parsed.target}`);
1200
+ }
1201
+ catch (err) {
1202
+ return { status: 'unknown', version: null, reason: `local re-read failed: ${err.message}` };
1203
+ }
1204
+ const version = typeof cap['version'] === 'string' ? cap['version'] : '';
1205
+ if (!version)
1206
+ return { status: 'unknown', version: null, reason: 'local capability.json missing version' };
1207
+ return { status: 'ok', version };
1208
+ }
1209
+ case 'tarball':
1210
+ // D6: a bare tarball URL is one immutable artifact — there is no catalogue to query, so update
1211
+ // availability cannot be auto-detected. Surface 'manual' (the user must point install at a new URL).
1212
+ return { status: 'manual', version: null, reason: 'tarball sources cannot be auto-checked; re-install from a new URL' };
1213
+ case 'registry':
1214
+ // The registry adapter is unimplemented (resolveCapabilitySource throws for it).
1215
+ return { status: 'unsupported', version: null, reason: 'registry source kind is not yet implemented' };
1216
+ default: {
1217
+ const _never = parsed.kind;
1218
+ return { status: 'unknown', version: null, reason: `unknown source kind: ${String(_never)}` };
1219
+ }
1220
+ }
1221
+ }
1222
+ module.exports = {
1223
+ resolveCapabilitySource,
1224
+ parseSpec,
1225
+ _setCapabilitySourceHttpGet,
1226
+ _setHttpsGetImpl,
1227
+ // #1461 finding 3 test seam: the exact bounded reader stageValidated uses on the COPIED manifest, so
1228
+ // a test can exercise the staged re-read directly (not just the local pre-read that shadows it).
1229
+ _readManifestBounded: readManifestBounded,
1230
+ // #1463: D6 "Update available?" per-source latest-version peek + the pure parsers it composes (the
1231
+ // git highest-semver-tag parser, the npm spec splitter, and the npm-view range/version picker).
1232
+ peekLatestVersion,
1233
+ pickHighestSemverTag,
1234
+ splitNpmSpec,
1235
+ pickHighestNpmVersion,
1236
+ MAX_RESPONSE_BYTES,
1237
+ MANIFEST_MAX_BYTES,
1238
+ MAX_STAGED_BUNDLE_BYTES,
1239
+ MAX_STAGED_BUNDLE_ENTRIES,
1240
+ PEEK_GIT_TIMEOUT_MS,
1241
+ PEEK_NPM_TIMEOUT_MS,
1242
+ };