@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-plan-checker.md +34 -0
- package/agents/gsd-planner.md +2 -0
- package/bin/install.js +108 -34
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +677 -2
- package/gsd-core/bin/lib/adr-parser.cjs +24 -17
- package/gsd-core/bin/lib/audit.cjs +2 -2
- package/gsd-core/bin/lib/capability-consent.cjs +763 -0
- package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
- package/gsd-core/bin/lib/capability-loader.cjs +764 -0
- package/gsd-core/bin/lib/capability-lock.cjs +553 -0
- package/gsd-core/bin/lib/capability-registry.cjs +198 -4
- package/gsd-core/bin/lib/capability-source.cjs +1242 -0
- package/gsd-core/bin/lib/capability-state.cjs +9 -6
- package/gsd-core/bin/lib/capability-trust.cjs +550 -0
- package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
- package/gsd-core/bin/lib/capability-writer.cjs +14 -5
- package/gsd-core/bin/lib/check-command-router.cjs +69 -18
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/config-loader.cjs +92 -84
- package/gsd-core/bin/lib/config-schema.cjs +26 -7
- package/gsd-core/bin/lib/config.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +149 -60
- package/gsd-core/bin/lib/gap-checker.cjs +126 -11
- package/gsd-core/bin/lib/init.cjs +91 -22
- package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
- package/gsd-core/bin/lib/milestone.cjs +41 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
- package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
- package/gsd-core/bin/lib/phase.cjs +29 -0
- package/gsd-core/bin/lib/project-root.cjs +89 -2
- package/gsd-core/bin/lib/resolution.cjs +26 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
- package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
- package/gsd-core/bin/lib/semver-compare.cjs +127 -0
- package/gsd-core/bin/lib/state-document.cjs +4 -2
- package/gsd-core/bin/lib/state.cjs +317 -161
- package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
- package/gsd-core/bin/lib/uat.cjs +39 -26
- package/gsd-core/bin/lib/verify.cjs +29 -13
- package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
- package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
- package/gsd-core/references/execute-phase-wave-guard.md +33 -0
- package/gsd-core/references/planner-antipatterns.md +48 -0
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/scout-codebase.md +2 -2
- package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -2
- package/gsd-core/workflows/execute-phase.md +4 -6
- package/package.json +3 -3
- package/scripts/gen-capability-matrix.cjs +284 -0
- package/scripts/gen-capability-registry.cjs +96 -1853
- package/scripts/lint-regression-test-names.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.allowlist.json +1 -0
- package/scripts/lint-resolution-provenance.cjs +192 -0
- package/scripts/lint-test-file-count.allowlist.json +9 -0
- package/scripts/run-tests.cjs +14 -0
- 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
|
+
};
|