/** * capability-source.cts — Capability source resolver (ADR-1244 Phase 3, Decision D3). * * One seam `resolveCapabilitySource(spec, opts)` with an adapter per source kind. * Each adapter: fetch → verify integrity/SHA → check engines.gsd → return a STAGED, * VALIDATED bundle. * * SECURITY CONTRACT: * - Install NEVER executes capability code. Copy/extract only. * - All subprocesses routed through shell-command-projection.cjs seam (windowsHide, * argv arrays, no shell string interpolation). * - Integrity verified BEFORE extraction when provided. * - engines.gsd pre-checked before staging. * - Full validator suite run on manifest before finalizing. * - Staging atomicity: stage under .staging/--/, renameSync on success, * rmSync on any failure. * - No raw spawnSync / execSync / shell strings. * * ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc. * * Exports: resolveCapabilitySource, parseSpec, _setCapabilitySourceHttpGet, * _setHttpsGetImpl, _readManifestBounded, MAX_RESPONSE_BYTES, * MANIFEST_MAX_BYTES, MAX_STAGED_BUNDLE_BYTES, MAX_STAGED_BUNDLE_ENTRIES */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import https from 'node:https'; import crypto from 'node:crypto'; // eslint-disable-next-line @typescript-eslint/no-require-imports const shellSeam = require('./shell-command-projection.cjs') as { execGit: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; execNpm: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; execTool: (program: string, args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; retryRenameSync: (fromPath: string, toPath: string) => void; }; // eslint-disable-next-line @typescript-eslint/no-require-imports const capValidator = require('./capability-validator.cjs') as ValidatorModule; // eslint-disable-next-line @typescript-eslint/no-require-imports const semverMod = require('./semver-compare.cjs') as { semverSatisfies: (version: unknown, range: unknown) => boolean; compareSemverCore: (a: unknown, b: unknown) => -1 | 0 | 1; isStableTripletSemver: (v: unknown) => boolean; }; // eslint-disable-next-line @typescript-eslint/no-require-imports const ledgerMod = require('./capability-ledger.cjs') as { /** Shared fd-based bounded reader: content, null for ENOENT, or THROWS (non-regular/oversized/IO). */ readSmallRegularFile: (filePath: string, maxBytes: number) => string | null; }; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface SpawnResult { exitCode: number; stdout: string; stderr: string; signal: NodeJS.Signals | null; error: Error | null; } interface ValidatorModule { validateCapability: (cap: unknown, id: string) => string[]; materializeHookFragments: (cap: unknown, capDir: string) => string[]; validateAgainstContract: (cap: unknown, capId: string) => string[]; validateConsumesGlobal: (capMap: Map) => string[]; validateCrossCapability: (capMap: Map, centralKeys: Set) => string[]; } /** Parsed spec discriminant. */ type SpecKind = 'registry' | 'git' | 'npm' | 'tarball' | 'local'; interface ParsedSpec { kind: SpecKind; /** The original raw spec string. */ raw: string; /** Resolved URL / path / package-spec, depending on kind. */ target: string; /** Optional ref (git only). */ ref?: string; } /** Tar-only exec signature (program is always 'tar', injected for testability). */ type TarExecFn = (program: string, args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; /** Options accepted by resolveCapabilitySource. */ interface ResolveOptions { /** Running GSD version. Defaults to package.json version, fail-closed to '0.0.0'. */ hostVersion?: string; /** Override the GSD home directory (where .gsd/capabilities/ lives). */ gsdHome?: string; /** Expected integrity string (`sha512-`). When provided, integrity is verified * before any bytes are committed to the final location. */ integrity?: string; /** * When false, stop after validation and return the staging dir WITHOUT promoting it to the * final capabilities location. The caller then owns the atomic stage-then-swap + ledger * commit ordering (ADR-1244 Phase 4 / D6 upgrade path). Defaults to true (promote in place), * preserving the original install behavior. */ promote?: boolean; /** * When true, SKIP the resolver's `engines.gsd` hard-throw during staging. The caller becomes * responsible for the engines gate. Used by capability-lifecycle so its own `checkEngines` can * gate the install AND surface a `compatVersions` downgrade hint (the resolver throw would * pre-empt that). Staging remains copy-only and is never promoted by the lifecycle when the * engines check fails, so nothing incompatible is ever activated. Defaults to false (throw). */ skipEnginesGate?: boolean; /** Injectable exec overrides for tests — keys match the shell-seam functions. */ execOverrides?: { git?: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; npm?: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult; tar?: TarExecFn; }; } /** Resolved + staged bundle descriptor. */ interface ResolveResult { id: string; version: string; stagedDir: string; /** sha512- digest of the staged capability.json, or null for local/git sources. */ integrity: string | null; /** The original spec string. */ source: string; } /** Injectable HTTP response shape. */ interface HttpResponse { statusCode: number; body: Buffer; } type HttpGetFn = (url: string) => Promise; /** * DOS-1 (#1461): GENEROUS but BOUNDED cap on a fetched capability source response. `realHttpsGet` * previously accumulated `res.on('data')` chunks with NO ceiling, so a hostile or accidental * oversized tarball (e.g. an HTTP endpoint streaming gigabytes) would buffer unbounded into memory * and OOM the process. A real capability bundle is a few hundred KiB of declarative JSON + small * artifacts; 64 MiB is far more than any legitimate bundle yet still a hard ceiling. Enforced two * ways: (1) a `content-length` header over the cap is rejected BEFORE buffering any body; (2) the * cumulative streamed byte count is tracked across `data` events and the request is destroyed + * rejected the instant it exceeds the cap (covers chunked / missing-content-length responses). */ const MAX_RESPONSE_BYTES = 64 * 1024 * 1024; /** * #1461 finding 2 (HIGH): GENEROUS but BOUNDED cap on an UNTRUSTED `capability.json` read during * resolve/staging. Every untrusted manifest (tarball / npm / git / local staging) MUST be read via * the SHARED bounded reader (`readSmallRegularFile`: open → fstat → require-regular-file → size-cap → * read-exactly-size), NOT a raw `fs.readFileSync`. A raw read of an oversized extracted-or-local * `capability.json` reads unbounded into memory (OOM), and a FIFO/device/non-regular manifest BLOCKS * the resolver forever. A legitimate manifest is a few KiB of declarative JSON; 8 MiB is far more than * any real capability.json yet a hard ceiling. The reader returns null for a genuinely-missing file * (ENOENT) and THROWS for non-regular/oversized/IO — both are mapped to a clear "manifest not * found / refused" rejection (fail-closed: the source never resolves). */ const MANIFEST_MAX_BYTES = 8 * 1024 * 1024; /** * #1461 finding 1 (HIGH): ONE uniform aggregate byte-budget over the STAGED bundle directory. The HTTP * fetch is capped (MAX_RESPONSE_BYTES), but `copyDirRecursive` / `fs.copyFileSync`, `git clone`, * `npm pack`, and `tar -x` were only TIMEOUT-bounded — so a huge local source tree, a giant git repo, a * large npm package, or a gzip/tar bomb that expands far beyond the compressed download cap could fill * disk during staging. This single budget, enforced at the common staging chokepoint (stageValidated, * AFTER the source is copied into staging and BEFORE validation/promotion), uniformly bounds the RESULT * of every adapter: it sums the regular-file bytes of the staged dir via a BOUNDED streaming walk and * fails closed if the total exceeds the cap. 128 MiB is generous for a real capability bundle (a few * hundred KiB of declarative JSON + small artifacts) yet hard-bounds a bomb. * * RESIDUAL (#1461 finding 4): this bounds the staged RESULT — it rejects an oversized install BEFORE * promotion, but a transient disk-fill DURING extraction/clone (before the post-staging walk runs) is a * residual a fully-airtight bound would need a streaming byte-quota DURING extraction/clone (e.g. a * cgroup/disk-quota or a custom streaming extractor) to close. This is a stated, proportionate limit: * this resolver path is USER-INITIATED `install` only (the cloned-repo / loader overlay path does NOT * invoke the resolver and is bounded separately by capability-consent's bundleContentHash caps), and * staging happens under a temp/.staging dir that is rmSync'd on any failure. */ const MAX_STAGED_BUNDLE_BYTES = 128 * 1024 * 1024; /** * #1461 finding 1: a cumulative ENTRY-count ceiling for the staged-dir budget walk so the enumeration * ITSELF is bounded (a hostile bundle with millions of tiny files / a very deep tree cannot force * unbounded readdir work before the byte cap trips). 100k entries is far more than any real bundle. */ const MAX_STAGED_BUNDLE_ENTRIES = 100_000; /** * The low-level `https.get`-shaped transport. Extracted as an overridable module-level reference so * a test can inject a fake response stream (chunked / oversized / content-length-tagged) to exercise * the MAX_RESPONSE_BYTES enforcement in realHttpsGet WITHOUT real network I/O. Defaults to the real * node:https get. (The higher-level `_httpGet` seam below short-circuits realHttpsGet entirely and is * used by the integrity tests; this seam is specifically for the streaming/size-cap path.) */ type HttpsGetImpl = typeof https.get; let _httpsGetImpl: HttpsGetImpl = https.get; /** Test seam: override the low-level https.get transport used by realHttpsGet. Pass null to restore. */ function _setHttpsGetImpl(fn: HttpsGetImpl | null): void { _httpsGetImpl = fn ?? https.get; } // --------------------------------------------------------------------------- // Injectable HTTP transport (test seam) // --------------------------------------------------------------------------- function realHttpsGet(url: string): Promise { return new Promise((resolve, reject) => { const req = _httpsGetImpl( url, { headers: { 'User-Agent': 'gsd-core-capability-source/1.0' } }, (res) => { // DOS-1: reject early if the server ADVERTISES a body over the cap — no bytes buffered. const contentLength = Number(res.headers?.['content-length']); if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { req.destroy(); res.destroy?.(); reject( new Error( `response exceeds ${MAX_RESPONSE_BYTES} bytes (content-length ${contentLength}) fetching ${url}` ) ); return; } const chunks: Buffer[] = []; let received = 0; let aborted = false; res.on('data', (c: Buffer) => { if (aborted) return; received += c.length; // DOS-1: enforce the ceiling on the ACTUAL streamed bytes (covers chunked / lying or // absent content-length). Destroy the request/response and reject — never keep buffering. if (received > MAX_RESPONSE_BYTES) { aborted = true; req.destroy(); res.destroy?.(); reject(new Error(`response exceeds ${MAX_RESPONSE_BYTES} bytes fetching ${url}`)); return; } chunks.push(c); }); res.on('end', () => { if (aborted) return; const body = Buffer.concat(chunks); if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode ?? 0} fetching ${url}`)); return; } resolve({ statusCode: res.statusCode ?? 0, body }); }); res.on('error', reject); } ); req.setTimeout(30_000, () => { req.destroy(new Error(`timeout after 30000ms fetching ${url}`)); }); req.on('error', reject); }); } let _httpGet: HttpGetFn = realHttpsGet; /** * Test seam: replace the HTTP transport. Pass null to restore the real transport. */ function _setCapabilitySourceHttpGet(fn: HttpGetFn | null): void { _httpGet = fn ?? realHttpsGet; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Resolve the running GSD version; fail-closed to '0.0.0'. * * Prefer the authoritative `gsd-core/VERSION` the installer writes for EVERY runtime * (libDir = gsd-core/bin/lib/, so `../../VERSION` = gsd-core/VERSION), so installed * layouts report the true version even when the walked-up `../../../package.json` is * absent or belongs to the user's own project (#1920). Fall back to the runtime-root * package.json (dev/source tree), then fail-closed. Mirrors resolveVersionFrom() (#1383). */ function readHostVersion(libDir: string = __dirname): string { const SEMVER_PREFIX = /^\d+\.\d+\.\d+/; try { const v = fs.readFileSync(path.join(libDir, '..', '..', 'VERSION'), 'utf8').trim(); if (SEMVER_PREFIX.test(v)) return v; } catch { /* not an installed tree (no gsd-core/VERSION) */ } try { // eslint-disable-next-line @typescript-eslint/no-require-imports const pkg = require(path.join(libDir, '..', '..', '..', 'package.json')) as { version?: string }; if (pkg && typeof pkg.version === 'string' && SEMVER_PREFIX.test(pkg.version)) return pkg.version; } catch { /* runtime root has no package.json */ } return '0.0.0'; } /** Compute sha512- integrity over a buffer. */ function computeIntegrity(buf: Buffer): string { const digest = crypto.createHash('sha512').update(buf).digest('base64'); return `sha512-${digest}`; } /** Verify buf against an `sha512-` integrity string. Throws on mismatch. */ function verifyIntegrity(buf: Buffer, expected: string): void { const prefix = 'sha512-'; if (!expected.startsWith(prefix)) { throw new Error(`Unsupported integrity algorithm (expected sha512-): ${expected}`); } const expectedBase64 = expected.slice(prefix.length); const actual = crypto.createHash('sha512').update(buf).digest('base64'); if (actual !== expectedBase64) { throw new Error( `Integrity mismatch: expected sha512-${expectedBase64} but got sha512-${actual}` ); } } /** * #1461 finding 2 (HIGH): read an UNTRUSTED `capability.json` (extracted or local) via the SHARED * bounded reader and parse it as a JSON object, failing CLOSED on every untrusted-input condition. * Replaces the raw `fs.readFileSync(manifestPath,'utf8')` at each resolve/staging site so an oversized * manifest cannot read unbounded (OOM) and a FIFO/device/non-regular manifest cannot BLOCK forever. * - ENOENT (reader returns null) → throw `` (genuinely missing). * - non-regular / oversized / IO (reader THROWS) → throw `: ` (refused). * - not valid JSON → throw the caller's invalid-JSON message. * - not a JSON object → throw the caller's not-an-object message. */ function readManifestBounded( manifestPath: string, notFoundMessage: string, ): Record { let raw: string | null; try { raw = ledgerMod.readSmallRegularFile(manifestPath, MANIFEST_MAX_BYTES); } catch (err) { // Non-regular (FIFO/device/dir), oversized, or IO error — fail closed with a clear message. throw new Error(`${notFoundMessage}: ${(err as Error).message}`); } if (raw === null) { throw new Error(notFoundMessage); // genuinely missing (ENOENT). } let cap: unknown; try { cap = JSON.parse(raw); } catch { throw new Error('capability.json is not valid JSON'); } if (typeof cap !== 'object' || cap === null || Array.isArray(cap)) { throw new Error('capability.json must be a JSON object'); } return cap as Record; } /** * #1460 CS-1: read a locally-produced `npm pack` `.tgz` as RAW BYTES via a bounded fd read so a * supplied `--integrity` can be verified over the tarball (same SRI sha512 domain as the tarball * adapter) before extraction/staging. `readSmallRegularFile` decodes utf8 (corrupting binary), so * this reads the Buffer directly while keeping the same fail-closed discipline: open → fstat → * require a regular file (a FIFO/device cannot BLOCK or be misread) → size-cap (MAX_RESPONSE_BYTES, * the same ceiling the HTTP fetch enforces) → read exactly fstat.size bytes. */ function readPackTarball(tgzPath: string): Buffer { let fd: number; try { fd = fs.openSync(tgzPath, 'r'); } catch (err) { throw new Error(`Cannot read npm pack tarball: ${tgzPath}: ${(err as Error).message}`); } try { const st = fs.fstatSync(fd); if (!st.isFile()) { throw new Error(`Refusing to read non-regular npm pack tarball: ${tgzPath}`); } if (st.size > MAX_RESPONSE_BYTES) { throw new Error(`npm pack tarball exceeds ${MAX_RESPONSE_BYTES} bytes: ${tgzPath}`); } const buf = Buffer.allocUnsafe(st.size); let read = 0; while (read < st.size) { const n = fs.readSync(fd, buf, read, st.size - read, read); if (n === 0) break; read += n; } return read === st.size ? buf : buf.subarray(0, read); } finally { try { fs.closeSync(fd); } catch { /* best-effort */ } } } /** * Reject spec/id values containing path separators or `..`. * Throws if the id is unsafe. */ function assertSafeId(id: string): void { if (!id || /[/\\]/.test(id) || id.includes('..')) { throw new Error( `Capability id "${id}" is invalid: must be kebab-case with no path separators or ".."` ); } } // Shell-injection metacharacters + whitespace/control. execNpm runs under a shell // on Windows (the npm shim), so an npm: spec must not carry any of these — they are // never valid in a real npm package spec (scope/name@version|tag|^range|~range). const SHELL_METACHAR_RE = /[;&|$`()<>!"'\\%\s]/; /** Reject an npm package spec that could break out of the (Windows) shell. */ function assertSafeNpmSpec(pkgSpec: string): void { if (SHELL_METACHAR_RE.test(pkgSpec)) { throw new Error(`Unsafe npm package spec (shell metacharacters not allowed): "${pkgSpec}"`); } } /** * Allowlist git transports. Git's `ext::`/`fd::` remote helpers are external-command * bridges (arbitrary code execution if protocol.*.allow is permissive), and `file://` * enables local-path tricks — only network transports are permitted. */ function assertSafeGitUrl(url: string): void { if (!/^(https?|ssh|git):\/\//i.test(url)) { throw new Error( `Unsupported git transport for "${url}": only https://, ssh://, and git:// are allowed` ); } } /** * Copy a directory tree recursively into destDir — STREAMING and BUDGETED. * * SECURITY: symlinks are REJECTED (fail closed). A fetched bundle could otherwise * smuggle a symlink (e.g. `id_rsa -> ~/.ssh/id_rsa`) that fs.copyFileSync would * FOLLOW, copying an arbitrary host file's bytes into the staged capability dir. * Dirent.isSymbolicLink() reflects the entry itself (lstat semantics), so this * catches both file and directory symlinks before any copy. * * #1461 finding 1 (HIGH, ROUND 2): the copy ITSELF is bounded. The former * `fs.readdirSync(src, { withFileTypes: true })` materialized the ENTIRE directory-entry * array into memory BEFORE any budget could run — and copyDirRecursive runs at staging time * BEFORE the post-copy assertStagedBundleWithinBudget walk. So a hostile local/git/npm/tar * source whose tree has a directory holding millions of tiny files (fetch < 64 MiB, but a * colossal dirent array) OOMs the process during the COPY, before the post-copy budget can * fail closed. We now STREAM each directory via fs.opendirSync + dir.readSync() (one entry at * a time, never the whole array) and thread CUMULATIVE counters across the recursion — total * entries (cap MAX_STAGED_BUNDLE_ENTRIES) and total regular-file bytes (cap * MAX_STAGED_BUNDLE_BYTES) — throwing the MOMENT either is exceeded, DURING the copy, before * reading/copying the rest. The shared mutable `budget` object mirrors bundleContentHash's * cumulative walk in capability-consent. The throw propagates to stageValidated's catch, which * rmSync's the staging dir (fail closed, no partial bundle promoted). */ function copyDirRecursive( src: string, dest: string, budget: { entries: number; bytes: number } = { entries: 0, bytes: 0 }, ): void { fs.mkdirSync(dest, { recursive: true }); let dir: fs.Dir; try { dir = fs.opendirSync(src); } catch (err) { throw new Error(`Cannot read source directory "${src}": ${(err as Error).message}`); } try { for (;;) { let entry: fs.Dirent | null; try { entry = dir.readSync(); } catch (err) { throw new Error(`Cannot read source directory "${src}": ${(err as Error).message}`); } if (entry === null) break; // BOUND THE ENUMERATION ITSELF: count this entry and fail closed BEFORE it is processed, // so a huge directory (or deep tree) is never read in full into memory first. budget.entries++; if (budget.entries > MAX_STAGED_BUNDLE_ENTRIES) { throw new Error( `Refusing to stage bundle: entry count exceeds the maximum of ${MAX_STAGED_BUNDLE_ENTRIES}` ); } const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isSymbolicLink()) { throw new Error(`Refusing to stage symlink in capability bundle: ${entry.name}`); } else if (entry.isDirectory()) { copyDirRecursive(srcPath, destPath, budget); } else if (entry.isFile()) { // Cumulative byte budget: lstat the entry (NOT stat — a symlink is already rejected above, // but lstat is the authoritative size of the regular file being copied) and fail closed the // MOMENT the running total crosses the cap, BEFORE copying the oversized file's bytes. let st: fs.Stats; try { st = fs.lstatSync(srcPath); } catch (err) { throw new Error(`Cannot lstat source entry "${srcPath}": ${(err as Error).message}`); } budget.bytes += st.size; if (budget.bytes > MAX_STAGED_BUNDLE_BYTES) { throw new Error( `Refusing to stage bundle: total staged size exceeds the maximum of ` + `${MAX_STAGED_BUNDLE_BYTES} bytes (possible oversized source tree, git repo, npm package, or tar bomb)` ); } fs.copyFileSync(srcPath, destPath); } // Non-regular entries (sockets, fifos, devices) are silently skipped. } } finally { try { dir.closeSync(); } catch { /* best-effort: no fd leak per opened Dir */ } } } /** * #1461 finding 1 (HIGH): sum the total regular-file bytes under `stagedDir` via a BOUNDED streaming * walk and fail closed if the total exceeds MAX_STAGED_BUNDLE_BYTES. This is the SINGLE uniform bound on * the RESULT of staging for EVERY adapter (local copy / git clone / npm pack / tar extraction) — placed * at the common chokepoint in stageValidated AFTER copyDirRecursive and BEFORE validation/promotion. * * Bounded like capability-consent.bundleContentHash's enumeration: each level is STREAMED via * fs.opendirSync + dir.readSync() with a CUMULATIVE entry counter (`count.n`) that throws the moment it * exceeds MAX_STAGED_BUNDLE_ENTRIES — BEFORE the rest of a huge/deep level is read — so a hostile bundle * with millions of tiny files or a very deep tree cannot force unbounded readdir/memory work before the * byte cap trips. Per-entry: lstat (NOT stat) so a symlink is detected as itself; symlinks and other * non-regular entries are REJECTED (fail closed — copyDirRecursive already refuses symlinks at copy time, * but a fresh lstat here is the authoritative check on what actually landed in staging). Regular-file * st.size is accumulated and the walk throws the moment the running total crosses the cap. */ function assertStagedBundleWithinBudget(stagedDir: string): void { const total = { bytes: 0 }; const count = { n: 0 }; const walk = (absDir: string): void => { let dir: fs.Dir; try { dir = fs.opendirSync(absDir); } catch (err) { throw new Error(`Cannot read staged directory "${absDir}": ${(err as Error).message}`); } const levelEntries: fs.Dirent[] = []; try { for (;;) { let ent: fs.Dirent | null; try { ent = dir.readSync(); } catch (err) { throw new Error(`Cannot read staged directory "${absDir}": ${(err as Error).message}`); } if (ent === null) break; // BOUND THE ENUMERATION ITSELF: fail closed before this entry is retained, so a huge directory // (or deep tree) cannot be loaded in full first. count.n++; if (count.n > MAX_STAGED_BUNDLE_ENTRIES) { throw new Error( `Refusing to stage bundle: entry count exceeds the maximum of ${MAX_STAGED_BUNDLE_ENTRIES}` ); } levelEntries.push(ent); } } finally { try { dir.closeSync(); } catch { /* best-effort */ } } for (const ent of levelEntries) { const abs = path.join(absDir, ent.name); let st: fs.Stats; try { st = fs.lstatSync(abs); } catch (err) { throw new Error(`Cannot lstat staged entry "${abs}": ${(err as Error).message}`); } if (st.isSymbolicLink()) { // Defense in depth: copyDirRecursive already refuses symlinks, but the budget walk is the // authoritative re-check on what actually landed in staging. throw new Error(`Refusing to stage symlink in capability bundle: ${abs}`); } if (st.isDirectory()) { walk(abs); continue; } if (!st.isFile()) { // Sockets / FIFOs / devices are not part of a real capability bundle. throw new Error(`Refusing to stage non-regular file in capability bundle: ${abs}`); } total.bytes += st.size; if (total.bytes > MAX_STAGED_BUNDLE_BYTES) { throw new Error( `Refusing to stage bundle: total staged size exceeds the maximum of ` + `${MAX_STAGED_BUNDLE_BYTES} bytes (possible oversized source tree, git repo, npm package, or tar bomb)` ); } } }; walk(stagedDir); } /** * Defense-in-depth against tar-slip: list the archive members and reject any with * an absolute path or a `..` segment BEFORE extraction (system tar mostly guards * this, but the hard contract is "traversal rejected", so we verify explicitly). * Symlink members that survive extraction are caught later by copyDirRecursive. * * #1461 finding 2 (MED): the former per-member declared-size parse (parseTarMemberSize) was REMOVED. * It scanned the verbose listing for a date-looking token and treated the previous token as the size, * but on BSD `tar -tv` the owner/group columns PRECEDE the size, so a member owner/group like "Jan" * mis-anchored the scan → fail-OPEN (a bomb's real size column skipped). The staged-dir aggregate * budget (assertStagedBundleWithinBudget, #1461 finding 1) is now the real, non-spoofable bound on the * extracted RESULT, so the fragile header parse is redundant. This function keeps only the NAME and * TYPE guards (traversal / symlink / hardlink), which are unambiguous and not size-dependent. */ function assertSafeTarMembers(execTar: TarExecFn, tgzPath: string): void { // (1) Member NAMES — reject path traversal (absolute / ".."). const listing = execTar('tar', ['-tzf', tgzPath], { timeout: 60_000 }); if (listing.exitCode !== 0) { throw new Error(`tar listing failed (exit ${listing.exitCode}): ${listing.stderr}`); } for (const line of listing.stdout.split('\n')) { const member = line.trim(); if (!member) continue; if (member.startsWith('/') || path.isAbsolute(member) || member.split(/[/\\]/).includes('..')) { throw new Error(`Refusing to extract tarball with unsafe member path: "${member}"`); } } // (2) Member TYPES — reject symlink/hardlink members BEFORE extraction. A symlink // member with a safe name is created during `tar -x` and a later member can be // written THROUGH it to escape the extract dir (the post-extraction copy guard is // too late). The verbose listing marks links: leading 'l'/'h' in the mode column // and a " -> " / " link to " suffix (GNU + bsd tar). const verbose = execTar('tar', ['-tvzf', tgzPath], { timeout: 60_000 }); if (verbose.exitCode !== 0) { throw new Error(`tar verbose listing failed (exit ${verbose.exitCode}): ${verbose.stderr}`); } for (const line of verbose.stdout.split('\n')) { if (!line.trim()) continue; if (line.includes(' -> ') || line.includes(' link to ') || /^\s*[lh]/.test(line)) { throw new Error('Refusing to extract tarball containing a symlink or hardlink member'); } } } /** * Validate the fetched capability manifest and stage it atomically. * * Runs the full validation suite (validateCapability → materializeHookFragments → * validateAgainstContract → validateConsumesGlobal → validateCrossCapability). * On success, renames the staging dir to the final dir and returns the result. * On any failure, removes the staging dir and throws. */ function stageValidated(opts: { sourceDir: string; id: string; gsdHome: string; hostVersion: string; source: string; integrity: string | null; promote?: boolean; skipEnginesGate?: boolean; }): ResolveResult { const { sourceDir, id, gsdHome, hostVersion, source, integrity } = opts; const promote = opts.promote !== false; // Safety: validate id before using it in a path. assertSafeId(id); const capabilitiesRoot = path.join(gsdHome, '.gsd', 'capabilities'); const stagingRoot = path.join(capabilitiesRoot, '.staging'); const stagingDir = path.join(stagingRoot, `${id}-${process.pid}-${Date.now()}`); const finalDir = path.join(capabilitiesRoot, id); // Reject a source-ROOT that is itself a symlink (copyDirRecursive guards interior // entries, but readdirSync would follow a symlinked root). if (fs.lstatSync(sourceDir).isSymbolicLink()) { throw new Error(`Refusing to stage a symlinked source directory: ${sourceDir}`); } fs.mkdirSync(stagingDir, { recursive: true }); try { // Copy source into staging — STREAMING + BUDGETED (#1461 finding 1, ROUND 2). copyDirRecursive now // enforces BOTH the entry-count and aggregate-byte budget DURING the copy (per-entry, via opendirSync // + readSync, never readdirSync of the whole array), so a hostile source with millions of tiny files // or an oversized artifact fails closed IN-PROCESS before the whole directory is materialized — it can // no longer OOM the process before a post-copy walk runs. The catch below rmSync's the staging dir on // throw, so an over-budget bundle never lands at the final location. copyDirRecursive(sourceDir, stagingDir); // #1461 finding 1 (HIGH): belt-and-suspenders aggregate byte-budget re-verification on what ACTUALLY // landed in staging. copyDirRecursive (above) is now the PRIMARY in-process bound — it fails closed // DURING the copy — so this post-copy walk is no longer the sole guard, but it is kept as a cheap // authoritative re-lstat of the staged RESULT at the common chokepoint AFTER staging and BEFORE // validation/promotion: it re-checks the entry/byte caps and re-rejects any symlink / non-regular // entry on the real staged tree (every staging path here flows through copyDirRecursive — there is no // in-place-dir staging path — so the copy already bounds it; this is defense in depth). // // RESIDUAL (#1461 finding 4): the copy and this walk bound the staged RESULT (rejects an oversized // install before promotion); a transient disk-fill DURING extraction/clone (system tar/git/npm write // to a temp dir BEFORE copyDirRecursive streams it into staging) is a residual a fully-airtight bound // would need a streaming byte-quota DURING extraction/clone to close. Proportionate: this resolver // path is USER-INITIATED `install` only (the cloned-repo / loader overlay path does NOT invoke the // resolver and is bounded separately), and the temp/.staging dirs are removed on any failure. assertStagedBundleWithinBudget(stagingDir); // Read and parse the capability manifest via the SHARED bounded reader (#1461 finding 2): an // oversized/non-regular staged capability.json is refused (fail-closed) rather than read unbounded. const manifestPath = path.join(stagingDir, 'capability.json'); const cap = readManifestBounded( manifestPath, `capability.json not found in staged directory: ${stagingDir}`, ); // engines.gsd pre-check — reject before staging finalizes (unless the caller owns the gate). const engines = cap['engines']; if (!opts.skipEnginesGate && engines && typeof engines === 'object' && !Array.isArray(engines)) { const gsdRange = (engines as Record)['gsd']; if (typeof gsdRange === 'string' && gsdRange) { if (!semverMod.semverSatisfies(hostVersion, gsdRange)) { throw new Error( `Capability requires engines.gsd "${gsdRange}" but running GSD is ${hostVersion}` ); } } } // Structural validation (validateCapability enforces id===folderId). const validationErrs = capValidator.validateCapability(cap, id); if (validationErrs.length > 0) { throw new Error(`Capability validation failed: ${validationErrs.join('; ')}`); } // Materialize hook fragments (returns errors, does not throw). const fragErrs = capValidator.materializeHookFragments(structuredClone(cap), stagingDir); if (fragErrs.length > 0) { throw new Error(`Hook fragment validation failed: ${fragErrs.join('; ')}`); } // Cross-capability validations (contract, consumes, cross-capability). const capMap = new Map([[id, cap]]); const centralKeys = new Set(); const crossErrs = [ ...capValidator.validateAgainstContract(cap, id), ...capValidator.validateConsumesGlobal(capMap), ...capValidator.validateCrossCapability(capMap, centralKeys), ]; if (crossErrs.length > 0) { throw new Error(`Cross-capability validation failed: ${crossErrs.join('; ')}`); } // When promote === false the caller owns the swap (ADR-1244 Phase 4 upgrade path): // return the validated staging dir as-is, leaving it on disk for the caller to rename. if (!promote) { const version = typeof cap['version'] === 'string' ? cap['version'] : ''; return { id, version, stagedDir: stagingDir, integrity, source }; } // All validation passed — promote staging to final. // Replacement is move-aside-then-rename (not rm-then-rename): rename the old // bundle aside (atomic), move the new one in, restore the old one if the second // rename fails. This avoids leaving the capability missing on a failed swap. // (The fully-atomic stage-then-swap with the ledger as commit point — for upgrades — // lives in capability-lifecycle.cjs and uses promote:false above.) if (fs.existsSync(finalDir)) { const backupDir = `${finalDir}.old-${process.pid}-${Date.now()}`; shellSeam.retryRenameSync(finalDir, backupDir); try { shellSeam.retryRenameSync(stagingDir, finalDir); } catch (err) { try { shellSeam.retryRenameSync(backupDir, finalDir); } catch { /* best-effort restore */ } throw err; } try { fs.rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ } } else { shellSeam.retryRenameSync(stagingDir, finalDir); } const version = typeof cap['version'] === 'string' ? cap['version'] : ''; return { id, version, stagedDir: finalDir, integrity, source }; } catch (err) { // Atomicity: always clean up the staging dir on failure. try { fs.rmSync(stagingDir, { recursive: true, force: true }); } catch { /* best-effort */ } throw err; } } // --------------------------------------------------------------------------- // parseSpec // --------------------------------------------------------------------------- /** * Detect the source kind from a raw spec string. * * Kind detection rules (first match wins): * local: starts with `./ | ../ | /` (absolute path) * npm: starts with `npm:` prefix * tarball: `https://…` ending in `.tgz` or `.tar.gz` * git: `https://…git`, URL with `#`, or starts with `git+` * registry: `@` form (no URL scheme) */ function parseSpec(spec: string): ParsedSpec { if (typeof spec !== 'string' || spec.trim() === '') { throw new Error('Capability spec must be a non-empty string'); } const s = spec.trim(); // local: relative or absolute path if (s.startsWith('./') || s.startsWith('../') || path.isAbsolute(s)) { return { kind: 'local', raw: spec, target: s }; } // npm: explicit `npm:` prefix if (s.startsWith('npm:')) { const pkgSpec = s.slice('npm:'.length); if (!pkgSpec) throw new Error(`Invalid npm spec: "${spec}" — package spec is empty after "npm:"`); assertSafeNpmSpec(pkgSpec); return { kind: 'npm', raw: spec, target: pkgSpec }; } // tarball: https URL ending in .tgz or .tar.gz if (/^https?:\/\/.+\.t(gz|ar\.gz)$/i.test(s)) { return { kind: 'tarball', raw: spec, target: s }; } // git: git+ prefix, https URL ending in .git, or URL with # if ( s.startsWith('git+') || /^https?:\/\/.+\.git$/i.test(s) || (/^https?:\/\//.test(s) && s.includes('#')) ) { let url = s.startsWith('git+') ? s.slice('git+'.length) : s; let ref: string | undefined; const hashIdx = url.indexOf('#'); if (hashIdx !== -1) { ref = url.slice(hashIdx + 1); url = url.slice(0, hashIdx); } assertSafeGitUrl(url); if (ref !== undefined && (SHELL_METACHAR_RE.test(ref) || ref.startsWith('-'))) { // Leading '-' would be parsed as a git option, not a ref. throw new Error(`Unsafe git ref (shell metacharacters or leading dash not allowed): "${ref}"`); } return { kind: 'git', raw: spec, target: url, ...(ref !== undefined ? { ref } : {}) }; } // registry: @ — no URL scheme if (/^[a-zA-Z0-9@/_-]/.test(s) && !s.startsWith('http')) { return { kind: 'registry', raw: spec, target: s }; } throw new Error(`Cannot determine source kind for capability spec: "${spec}"`); } // --------------------------------------------------------------------------- // Source adapters // --------------------------------------------------------------------------- function resolveLocal( parsed: ParsedSpec, opts: ResolveOptions, gsdHome: string, hostVersion: string ): ResolveResult { // #1460 CS-1: a local path is a directory tree, not a single downloadable artifact, so there is // no stable byte stream to verify a sha512 SRI pin against. A supplied `--integrity` is therefore // REJECTED with an actionable error rather than being silently dropped (the prior behaviour staged // with integrity:null, so the user believed content was pinned when it was not). if (opts.integrity) { throw new Error('integrity pinning is not supported for local sources'); } const absPath = path.resolve(parsed.target); if (!fs.existsSync(absPath)) { throw new Error(`Local capability path does not exist: ${absPath}`); } // Read id from capability.json to know the staging dest — via the SHARED bounded reader (#1461 // finding 2): an oversized/non-regular local capability.json is refused, never read unbounded. const manifestPath = path.join(absPath, 'capability.json'); const cap = readManifestBounded( manifestPath, `Cannot read capability.json from local path: ${manifestPath}`, ); const id = typeof cap['id'] === 'string' ? cap['id'] : ''; if (!id) throw new Error('capability.json missing "id" field'); return stageValidated({ sourceDir: absPath, id, gsdHome, hostVersion, source: parsed.raw, integrity: null, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate }); } function resolveGit( parsed: ParsedSpec, opts: ResolveOptions, gsdHome: string, hostVersion: string ): ResolveResult { const execGit = opts.execOverrides?.git ?? shellSeam.execGit; // #1460 CS-1: a git working tree has no single downloadable artifact to verify a sha512 SRI pin // against (a clone is a directory tree, and the digest would vary with pack/checkout details). A // supplied `--integrity` is therefore REJECTED with an actionable error rather than silently // dropped (the prior behaviour staged with integrity:null). Pin a git source by COMMIT instead. if (opts.integrity) { throw new Error('integrity pinning is not supported for git sources; pin the commit with #sha:'); } const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-git-')); try { // Clone (copy only — no hooks execute on clone, no npm install). const cloneResult = execGit(['clone', '--depth', '1', '--', parsed.target, cloneDir], { timeout: 60_000 }); if (cloneResult.exitCode !== 0) { throw new Error(`git clone failed (exit ${cloneResult.exitCode}): ${cloneResult.stderr}`); } // Optional ref checkout. The ref is a commit-ish (tag/branch/sha), NOT a path, // so it goes BEFORE the `--` pathspec terminator (a leading-dash ref is rejected // at parse time, so it cannot be misread as an option here). if (parsed.ref) { const checkoutResult = execGit(['-C', cloneDir, 'checkout', parsed.ref, '--'], { timeout: 60_000 }); if (checkoutResult.exitCode !== 0) { throw new Error( `git checkout "${parsed.ref}" failed (exit ${checkoutResult.exitCode}): ${checkoutResult.stderr}` ); } } // Read id from capability.json via the SHARED bounded reader (#1461 finding 2): a cloned repo's // oversized/non-regular capability.json is refused, never read unbounded. const manifestPath = path.join(cloneDir, 'capability.json'); const cap = readManifestBounded( manifestPath, `capability.json not found in cloned repo: ${parsed.target}`, ); const id = typeof cap['id'] === 'string' ? cap['id'] : ''; if (!id) throw new Error('capability.json missing "id" field'); return stageValidated({ sourceDir: cloneDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: null, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate }); } finally { try { fs.rmSync(cloneDir, { recursive: true, force: true }); } catch { /* best-effort */ } } } function resolveNpm( parsed: ParsedSpec, opts: ResolveOptions, gsdHome: string, hostVersion: string ): ResolveResult { const execNpm = opts.execOverrides?.npm ?? shellSeam.execNpm; // tar override: injected for tests; default delegates to shell seam execTool. const execTar: TarExecFn = opts.execOverrides?.tar ?? shellSeam.execTool; const tmpPackDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-npm-pack-')); const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-npm-ext-')); try { // npm pack — creates a tarball. CRITICAL: `npm pack` runs prepack/prepare // lifecycle scripts by default, which would EXECUTE fetched code — so we pass // --ignore-scripts to guarantee copy-only. NEVER npm install. const packResult = execNpm( ['pack', '--ignore-scripts', '--silent', '--pack-destination', tmpPackDir, '--', parsed.target], { timeout: 60_000 } ); if (packResult.exitCode !== 0) { throw new Error(`npm pack failed (exit ${packResult.exitCode}): ${packResult.stderr}`); } // Locate the produced .tgz. const tarballs = fs.readdirSync(tmpPackDir).filter((f) => f.endsWith('.tgz')); if (tarballs.length === 0) { throw new Error(`npm pack produced no .tgz in ${tmpPackDir}`); } const tgzPath = path.join(tmpPackDir, tarballs[0]); // #1460 CS-1: a supplied `--integrity` is verified over the `.tgz` BYTES (same SRI sha512 // domain as the tarball adapter) BEFORE anything is staged or promoted — never silently // dropped. The recorded integrity is always the computed digest of the produced tarball. // `npm pack --ignore-scripts` (above) ran no capability code, so reading these bytes is // copy-only. A mismatch throws here, before assertSafeTarMembers / extraction / staging. const tgzBytes = readPackTarball(tgzPath); const computedIntegrity = computeIntegrity(tgzBytes); if (opts.integrity) { verifyIntegrity(tgzBytes, opts.integrity); } // Reject tar-slip member paths before extracting. assertSafeTarMembers(execTar, tgzPath); // Extract — copy only, no scripts. npm tarballs nest under package/. const tarResult = execTar('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 60_000 }); if (tarResult.exitCode !== 0) { throw new Error(`tar extraction failed (exit ${tarResult.exitCode}): ${tarResult.stderr}`); } // npm tarballs nest under package/; fall back to root. const packageDir = path.join(extractDir, 'package'); const sourceDir = fs.existsSync(path.join(packageDir, 'capability.json')) ? packageDir : extractDir; // Read id from capability.json via the SHARED bounded reader (#1461 finding 2): an extracted // oversized/non-regular capability.json is refused, never read unbounded. const manifestPath = path.join(sourceDir, 'capability.json'); const cap = readManifestBounded( manifestPath, `capability.json not found after npm pack extraction from: ${parsed.target}`, ); const id = typeof cap['id'] === 'string' ? cap['id'] : ''; if (!id) throw new Error('capability.json missing "id" field'); return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: computedIntegrity, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate }); } finally { try { fs.rmSync(tmpPackDir, { recursive: true, force: true }); } catch { /* best-effort */ } try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { /* best-effort */ } } } async function resolveTarball( parsed: ParsedSpec, opts: ResolveOptions, gsdHome: string, hostVersion: string ): Promise { // tar override: injected for tests; default delegates to shell seam execTool. const execTar: TarExecFn = opts.execOverrides?.tar ?? shellSeam.execTool; // Fetch buffer — always reject non-200 (realHttpsGet enforces this). const resp = await _httpGet(parsed.target); // Integrity check BEFORE any bytes touch disk (if provided). const computedIntegrity = computeIntegrity(resp.body); if (opts.integrity) { verifyIntegrity(resp.body, opts.integrity); } const extractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-tar-')); const tgzPath = path.join(extractDir, '_download.tgz'); try { fs.writeFileSync(tgzPath, resp.body); // Reject tar-slip member paths before extracting. assertSafeTarMembers(execTar, tgzPath); const tarResult = execTar('tar', ['-xzf', tgzPath, '-C', extractDir], { timeout: 60_000 }); if (tarResult.exitCode !== 0) { throw new Error(`tar extraction failed (exit ${tarResult.exitCode}): ${tarResult.stderr}`); } // Locate capability.json — root or package/ (npm tarball shape). const packageDir = path.join(extractDir, 'package'); const sourceDir = fs.existsSync(path.join(packageDir, 'capability.json')) ? packageDir : extractDir; // Read id from capability.json via the SHARED bounded reader (#1461 finding 2): an extracted // oversized/non-regular capability.json is refused, never read unbounded. const manifestPath = path.join(sourceDir, 'capability.json'); const cap = readManifestBounded( manifestPath, `capability.json not found in tarball from: ${parsed.target}`, ); const id = typeof cap['id'] === 'string' ? cap['id'] : ''; if (!id) throw new Error('capability.json missing "id" field'); return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: computedIntegrity, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate }); } finally { try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { /* best-effort */ } } } // --------------------------------------------------------------------------- // Main resolver // --------------------------------------------------------------------------- /** * Resolve a capability spec, validate it, and stage it into the GSD capabilities dir. * * @param spec - Source spec string. Kind auto-detected via parseSpec. * @param opts - Optional overrides for hostVersion, gsdHome, integrity, exec/http seams. * @returns - Resolved bundle descriptor with stagedDir path. */ async function resolveCapabilitySource(spec: string, opts: ResolveOptions = {}): Promise { const parsed = parseSpec(spec); const hostVersion = opts.hostVersion ?? readHostVersion(); const gsdHome = opts.gsdHome ?? process.env['GSD_HOME'] ?? os.homedir(); switch (parsed.kind) { case 'local': return resolveLocal(parsed, opts, gsdHome, hostVersion); case 'git': return resolveGit(parsed, opts, gsdHome, hostVersion); case 'npm': return resolveNpm(parsed, opts, gsdHome, hostVersion); case 'tarball': return resolveTarball(parsed, opts, gsdHome, hostVersion); case 'registry': throw new Error( 'registry source kind is not yet implemented (no first-party registry endpoint)' ); default: { // TypeScript exhaustiveness guard. const _never: never = parsed.kind; throw new Error(`Unknown source kind: ${String(_never)}`); } } } // --------------------------------------------------------------------------- // Latest-version peek (ADR-1244 D6 "Update available?" per-source matrix; #1463) // --------------------------------------------------------------------------- /** * #1463: timeouts for the LIGHT remote peek the `outdated` verb performs. These are deliberately the * SAME bounds the resolve path uses for the analogous heavy operations (CONTEXT.md "every git/npm * subprocess needs a timeout"): a hung registry/remote must DEGRADE the verb (status 'unknown'), never * hang it. The peek is a metadata-only read (`git ls-remote --tags`, `npm view … version`), NOT a * clone / pack / extract. */ const PEEK_GIT_TIMEOUT_MS = 30_000; const PEEK_NPM_TIMEOUT_MS = 60_000; /** * Status of a single per-source latest-version peek. * - ok a latest version was resolved (compare it to installed). * - pinned the recorded source is pinned to an immutable/explicit ref (git `#sha:`/`#tag:`/`#`) * or an EXACT npm version (`npm:@1.2.3`). `update` re-resolves to the SAME ref/version, * so it can never be "outdated" — version (when known) is informational only. * - manual a bare tarball URL — one immutable artifact, no catalogue to query. * - unsupported the source kind has no implemented peek (registry). * - unknown the peek failed / timed out / returned unparseable output (DEGRADE, never thrown). */ type PeekStatus = 'ok' | 'pinned' | 'manual' | 'unsupported' | 'unknown'; /** Result of peekLatestVersion: a status discriminant + the resolved version when status==='ok'. */ interface PeekResult { status: PeekStatus; /** The latest version string when status==='ok'; null otherwise. */ version: string | null; /** Optional human-readable reason for a non-ok status (DEGRADE diagnostics, never thrown). */ reason?: string; } /** * #1463: parse the output of `git ls-remote --tags ` and return the HIGHEST stable-triplet semver * tag, or null when no parseable semver tag exists. UNTRUSTED-DATA RULE: the remote's ref names are * treated purely as data — each line is `\t` (e.g. `\trefs/tags/v1.2.0`); we strip * `refs/tags/`, ignore the `^{}` peeled-annotation entries (they would otherwise double-count and the * `^{}` suffix is not a version), strip a leading `v`, and keep only STABLE x.y.z triplets * (isStableTripletSemver) so a `-rc`/junk tag never wins. The max is selected via compareSemverCore so * 1.10.0 correctly beats 1.2.0 (numeric, not lexical). Pure + deterministic → property-tested. */ function pickHighestSemverTag(lsRemoteOutput: string): string | null { if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') return null; let best: string | null = null; for (const rawLine of lsRemoteOutput.split('\n')) { const line = rawLine.trim(); if (line === '') continue; // `\t` — take the ref (last whitespace-delimited token); a line without a tab/ref is junk. const tabIdx = line.search(/\s/); const ref = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim(); if (!ref.startsWith('refs/tags/')) continue; let tag = ref.slice('refs/tags/'.length); // Ignore the peeled-annotation entry `refs/tags/^{}` — same tag, not a distinct version. if (tag.endsWith('^{}')) continue; if (tag.startsWith('v')) tag = tag.slice(1); // Keep only stable x.y.z triplets — a prerelease/junk tag is not an "available stable version". if (!semverMod.isStableTripletSemver(tag)) continue; if (best === null || semverMod.compareSemverCore(tag, best) > 0) best = tag; } return best; } const NPM_VERSION_RE = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; /** * #1463: split an npm package spec (the `parsed.target` for an `npm:` source) into its package NAME and * its trailing version/range selector. The selector is everything after the `@` that separates name from * version — for a SCOPED package (`@scope/name@`) that is the LAST `@`, NOT the leading scope `@`; * for an unscoped package (`name@`) it is the single non-leading `@`. A spec with no such `@` * (`@scope/name`, `name`) has selector `''` (tracks the npm `latest` dist-tag). * * Pure string parse on an already-shell-safe spec (assertSafeNpmSpec ran in parseSpec). Used ONLY to * classify the recorded source (exact-pin vs range vs latest) and to range-filter `npm view` output — * the subprocess invocation still passes the FULL `parsed.target` unchanged. */ function splitNpmSpec(target: string): { name: string; selector: string } { // Find the `@` that introduces the version selector: search from the END, but stop at index 0 (the // leading `@` of a scope is never a version separator). const at = target.lastIndexOf('@'); if (at <= 0) return { name: target, selector: '' }; return { name: target.slice(0, at), selector: target.slice(at + 1) }; } /** * #1463: pull the ONE canonical version token out of a single `npm view version` output line, or * null when the line carries no version in a canonical position. UNTRUSTED-DATA RULE: the line is data. * * #1463 Fix 1 (R Medium): the version MUST come from its CANONICAL position, NOT from "any x.y.z token on * the line" — a package NAME can itself contain a version-like substring (`@scope/cap-1.2.3@1.0.0`) and * the old any-token scan returned the NAME's `1.2.3` instead of the resolved `1.0.0`. npm prints lines * shaped `@ ''` (range/multi-match) or a single bare `` token (latest * dist-tag). Canonical extraction: * 1. Prefer the QUOTED token (`'x.y.z'`) when present — that is npm's explicit version annotation. * 2. Else take the token after the LAST `@` of the leading `name@version` segment (the first * whitespace-delimited field), mirroring splitNpmSpec's scoped last-`@` rule so a scope `@` is not * mistaken for the version separator. * 3. Else (a single bare line with no `@` and no quotes) treat the whole first field as the version. * The candidate is validated against NPM_VERSION_RE; a version-like substring embedded in the NAME is * never consulted. Returns the canonical version (unvalidated against any range) or null. Pure. */ function extractNpmLineVersion(line: string): string | null { // 1. Quoted annotation `'x.y.z'` — npm's explicit version field. const quoted = line.match(/'([^']+)'/); if (quoted && NPM_VERSION_RE.test(quoted[1])) return quoted[1]; // 2/3. The leading `name@version` (or bare `version`) field is the first whitespace-delimited token. const head = line.split(/\s+/, 1)[0]; if (head === undefined || head === '') return null; // Last `@` that is not a leading scope `@` (index 0) separates name from version; no such `@` ⇒ the // whole head is the candidate (a bare `version` line). Never read a substring inside the NAME portion. const at = head.lastIndexOf('@'); const candidate = at > 0 ? head.slice(at + 1) : head; return NPM_VERSION_RE.test(candidate) ? candidate : null; } /** * #1463: return the HIGHEST version across `npm view version` stdout that satisfies `range` * (compareSemverCore for max; semverSatisfies for the range bound), or null when none parse / match. * UNTRUSTED-DATA RULE: the output is treated purely as data. npm prints ONE annotated line per matching * version for a multi-version range, e.g.: * @org/pkg@1.0.0 '1.0.0' * @org/pkg@1.10.0 '1.10.0' * and a single bare line for a single match. Each line yields at most ONE canonical version (via * extractNpmLineVersion — Fix 1: the version's canonical position, NOT any token, so a version-like * substring in the package NAME never poisons the result). We keep only versions satisfying the recorded * range and pick the numeric max so 1.10.0 beats 1.2.0. An empty selector means "no range constraint" * (track latest) → every parseable version qualifies. Pure + deterministic. */ function pickHighestNpmVersion(viewOutput: string, range: string): string | null { if (typeof viewOutput !== 'string') return null; let best: string | null = null; for (const rawLine of viewOutput.split('\n')) { const line = rawLine.trim(); if (line === '') continue; const tok = extractNpmLineVersion(line); if (tok === null) continue; // Empty range = no constraint (track latest); otherwise the version must satisfy the recorded range. if (range !== '' && !semverMod.semverSatisfies(tok, range)) continue; if (best === null || semverMod.compareSemverCore(tok, best) > 0) best = tok; } return best; } /** * #1463 Fix 2 (R Medium): classify the git ref FRAGMENT (`parsed.ref`, the raw text after `#`) by KIND. * parseSpec captures the WHOLE `#…` fragment as a raw string and does NOT split the kind, so we parse the * `sha:` / `tag:` prefix here. The kind decides mutability: * - 'sha' (`#sha:`) → IMMUTABLE pin (a commit never moves). * - 'tag' (`#tag:`) → IMMUTABLE pin (a tag is opted-into; `update` re-checks-out the SAME tag). * - 'bare' (`#`) → AMBIGUOUS: it is either a tag (immutable) or a branch (MUTABLE). The * caller must resolve it remotely (git ls-remote ) before * deciding pinned-vs-not — a bare branch ref is NEVER pinned. * - 'none' → no `#`: tracks the default branch (peek highest tag). * Pure string parse on an already-shell-safe ref (parseSpec asserted it). `sha:`/`tag:` are matched * case-insensitively with optional surrounding whitespace; the prefix's value is returned for diagnostics. */ function classifyGitRef(parsed: ParsedSpec): { kind: 'none' | 'sha' | 'tag' | 'bare'; value: string } { const raw = typeof parsed.ref === 'string' ? parsed.ref.trim() : ''; if (raw === '') return { kind: 'none', value: '' }; const shaMatch = /^sha:(.+)$/i.exec(raw); if (shaMatch) return { kind: 'sha', value: shaMatch[1].trim() }; const tagMatch = /^tag:(.+)$/i.exec(raw); if (tagMatch) return { kind: 'tag', value: tagMatch[1].trim() }; return { kind: 'bare', value: raw }; } /** * #1463 Fix 2 (R Medium): resolve a bare ambiguous git ref to its KIND at the remote with a bounded * `git ls-remote ` (the SAME safe seam as the tag peek: argv + `--`, never a shell string). * ls-remote prints `\t` lines for every matching ref. A ref that matches under * `refs/tags/` is an immutable TAG; one under `refs/heads/` is a MUTABLE branch. UNTRUSTED-DATA RULE: * the remote's ref strings are data — we only test the canonical `refs/tags/` vs `refs/heads/` prefix on * the ref column (last whitespace-delimited token of each line). Returns: * 'tag' — at least one matching ref under refs/tags/ (and none ambiguous-conflicting branch). * 'branch' — at least one matching ref under refs/heads/. * 'unknown' — ls-remote error / timeout / non-zero / empty / unresolvable / conflicting output. * NEVER throws (DEGRADE). Bounded by PEEK_GIT_TIMEOUT_MS (≤30s). */ function classifyBareGitRefRemote( url: string, ref: string, execGit: (args: string[], o?: { timeout?: number }) => SpawnResult, ): 'tag' | 'branch' | 'unknown' { let r: SpawnResult; try { // Metadata-only ref lookup; `--` terminates options so a hostile URL/ref can't be read as a flag // (both are already transport-/shell-safe via parseSpec). The ref filters ls-remote server-side. r = execGit(['ls-remote', '--', url, ref], { timeout: PEEK_GIT_TIMEOUT_MS }); } catch { return 'unknown'; } if (!r || r.exitCode !== 0 || r.signal) return 'unknown'; let sawTag = false; let sawBranch = false; for (const rawLine of (r.stdout || '').split('\n')) { const line = rawLine.trim(); if (line === '') continue; const tabIdx = line.indexOf('\t'); const refName = tabIdx === -1 ? line : line.slice(tabIdx + 1).trim(); if (refName.startsWith('refs/tags/')) sawTag = true; else if (refName.startsWith('refs/heads/')) sawBranch = true; } // A clean single-kind resolution wins; anything ambiguous (both, or neither) degrades to unknown so a // mutable branch is never silently treated as an immutable tag (and vice-versa). if (sawTag && !sawBranch) return 'tag'; if (sawBranch && !sawTag) return 'branch'; return 'unknown'; } /** * #1463: resolve the LATEST available version for a recorded capability source string, per ADR-1244 D6 * ("Update available?" is a per-source matrix). This is a LIGHT remote PEEK — metadata only — never a * re-clone / re-pack / re-extract. It NEVER throws: every error / timeout / unsupported source DEGRADES * to a status the `outdated` verb can render. Per-kind behaviour: * * - git `git ls-remote --tags ` → highest stable semver tag (pickHighestSemverTag). status 'ok'. * - npm `npm view version` (latest dist-tag) → the reported version. status 'ok'. * - local re-read capability.json at the path (bounded reader) → its `version`. status 'ok'. * - tarball one immutable URL, not auto-detectable per D6 → status 'manual' (no version). * - registry resolveCapabilitySource throws (unimplemented) → status 'unsupported'. * * BOUNDED SUBPROCESSES (CONTEXT.md): git ls-remote ≤30s, npm view ≤60s; on timeout / non-zero / error / * empty-or-unparseable output → status 'unknown' (DEGRADE, never crash the verb). * * The exec seam mirrors the resolver: opts.execOverrides.{git,npm} (or the default shell seam) so a test * can mock the remote PEEK with no network I/O. */ function peekLatestVersion( source: string, opts: { execOverrides?: { git?: (args: string[], o?: { timeout?: number }) => SpawnResult; npm?: (args: string[], o?: { timeout?: number }) => SpawnResult }; } = {}, ): PeekResult { let parsed: ParsedSpec; try { parsed = parseSpec(source); } catch (err) { // An unparseable recorded source cannot be peeked — DEGRADE (do not throw). return { status: 'unknown', version: null, reason: `unparseable source: ${(err as Error).message}` }; } switch (parsed.kind) { case 'git': { const execGit = opts.execOverrides?.git ?? shellSeam.execGit; // #1463 Fix 2 (R Medium): classify the recorded ref by KIND before deciding pinned-vs-not. An // IMMUTABLE pin (`#sha:`/`#tag:`) is NEVER outdated — `update` re-resolves the SAME commit/tag, so // a newer remote tag is irrelevant; report 'pinned' WITHOUT any peek. A BARE `#` is ambiguous // (tag OR branch): we MUST classify it remotely so a MUTABLE branch is never falsely 'pinned'. const refKind = classifyGitRef(parsed); if (refKind.kind === 'sha' || refKind.kind === 'tag') { return { status: 'pinned', version: null, reason: `git source pinned to ${refKind.kind} "${refKind.value}"; update will not move it` }; } if (refKind.kind === 'bare') { // Resolve the ambiguous ref at the remote (same safe execGit seam: argv + `--`). const resolved = classifyBareGitRefRemote(parsed.target, refKind.value, execGit); if (resolved === 'tag') { // An immutable tag → pinned (the ref the user recorded is a tag, not a moving branch). return { status: 'pinned', version: null, reason: `git source ref "${refKind.value}" resolves to an immutable tag; update will not move it` }; } // A branch (MUTABLE) or an unresolvable/ambiguous result. The ledger records NO installed commit // sha for git sources (integrity is null), so a moved branch HEAD cannot be compared against the // installed commit → DEGRADE to 'unknown'. The HARD INVARIANT holds: a branch is NEVER 'pinned'. const reason = resolved === 'branch' ? `git source tracks mutable branch "${refKind.value}"; no installed commit recorded to compare against` : `git source ref "${refKind.value}" could not be classified (tag vs branch) at the remote`; return { status: 'unknown', version: null, reason }; } // refKind.kind === 'none' — no `#`, tracks the default branch: peek the highest remote tag. let r: SpawnResult; try { // Metadata-only: ls-remote lists refs without cloning. `--` terminates options so a hostile // URL cannot be read as a flag (the URL is already transport-allowlisted by parseSpec). r = execGit(['ls-remote', '--tags', '--', parsed.target], { timeout: PEEK_GIT_TIMEOUT_MS }); } catch (err) { return { status: 'unknown', version: null, reason: `git ls-remote error: ${(err as Error).message}` }; } if (!r || r.exitCode !== 0 || r.signal) { const reason = r && r.signal ? `git ls-remote timed out (signal ${r.signal})` : `git ls-remote exit ${r ? r.exitCode : 'n/a'}`; return { status: 'unknown', version: null, reason }; } const latest = pickHighestSemverTag(r.stdout || ''); if (latest === null) return { status: 'unknown', version: null, reason: 'no semver tags at remote' }; return { status: 'ok', version: latest }; } case 'npm': { // #1463: classify the recorded npm spec — what would `update` resolve it to? // exact version (`@1.2.3`) → PINNED: update re-installs the SAME version, never outdated. // range (`@^1`, `@~1.2`, …) → peek and pick the HIGHEST version satisfying the range (multi-line). // no version (bare name) → peek the single `latest` dist-tag version. const { selector } = splitNpmSpec(parsed.target); // An EXACT version selector is an immutable pin (a single x.y.z[-pre], no range operator/wildcard). if (selector !== '' && NPM_VERSION_RE.test(selector)) { return { status: 'pinned', version: selector, reason: `npm source pinned to exact version "${selector}"; update will not move it` }; } const execNpm = opts.execOverrides?.npm ?? shellSeam.execNpm; let r: SpawnResult; try { // Mirrors scripts/check-latest-version.cjs (checkLatestVersion): `npm view version` reports // the matching version(s). parsed.target is the npm package spec (parseSpec asserted it is free of // shell metacharacters) and is passed UNCHANGED — for a range npm prints every matching version, // for a bare name the single latest. `--` terminates options. r = execNpm(['view', '--', parsed.target, 'version'], { timeout: PEEK_NPM_TIMEOUT_MS }); } catch (err) { return { status: 'unknown', version: null, reason: `npm view error: ${(err as Error).message}` }; } if (!r || r.exitCode !== 0 || r.signal) { const reason = r && r.signal ? `npm view timed out (signal ${r.signal})` : `npm view exit ${r ? r.exitCode : 'n/a'}`; return { status: 'unknown', version: null, reason }; } // Treat the OUTPUT as untrusted: extract every version token (npm prints one annotated line per // matching version for a range, a bare token for a single match) and pick the HIGHEST that // satisfies the recorded range (empty selector = no constraint → latest). Garbage / no match → // DEGRADE to 'unknown'. const version = pickHighestNpmVersion(r.stdout || '', selector); if (version === null) { return { status: 'unknown', version: null, reason: `npm view returned no matching semver version: ${(r.stdout || '').trim() || '(empty)'}` }; } return { status: 'ok', version }; } case 'local': { // Re-read the recorded local capability.json (bounded reader) for its current declared version. let cap: Record; try { const manifestPath = path.join(path.resolve(parsed.target), 'capability.json'); cap = readManifestBounded(manifestPath, `local capability.json not readable: ${parsed.target}`); } catch (err) { return { status: 'unknown', version: null, reason: `local re-read failed: ${(err as Error).message}` }; } const version = typeof cap['version'] === 'string' ? cap['version'] : ''; if (!version) return { status: 'unknown', version: null, reason: 'local capability.json missing version' }; return { status: 'ok', version }; } case 'tarball': // D6: a bare tarball URL is one immutable artifact — there is no catalogue to query, so update // availability cannot be auto-detected. Surface 'manual' (the user must point install at a new URL). return { status: 'manual', version: null, reason: 'tarball sources cannot be auto-checked; re-install from a new URL' }; case 'registry': // The registry adapter is unimplemented (resolveCapabilitySource throws for it). return { status: 'unsupported', version: null, reason: 'registry source kind is not yet implemented' }; default: { const _never: never = parsed.kind; return { status: 'unknown', version: null, reason: `unknown source kind: ${String(_never)}` }; } } } // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- export = { resolveCapabilitySource, parseSpec, _setCapabilitySourceHttpGet, _setHttpsGetImpl, // #1461 finding 3 test seam: the exact bounded reader stageValidated uses on the COPIED manifest, so // a test can exercise the staged re-read directly (not just the local pre-read that shadows it). _readManifestBounded: readManifestBounded, // #1463: D6 "Update available?" per-source latest-version peek + the pure parsers it composes (the // git highest-semver-tag parser, the npm spec splitter, and the npm-view range/version picker). peekLatestVersion, pickHighestSemverTag, splitNpmSpec, pickHighestNpmVersion, MAX_RESPONSE_BYTES, MANIFEST_MAX_BYTES, MAX_STAGED_BUNDLE_BYTES, MAX_STAGED_BUNDLE_ENTRIES, PEEK_GIT_TIMEOUT_MS, PEEK_NPM_TIMEOUT_MS, };