diff --git a/.changeset/noble-deer-parade.md b/.changeset/noble-deer-parade.md new file mode 100644 index 000000000..956b27fc7 --- /dev/null +++ b/.changeset/noble-deer-parade.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1443 +--- +**Capability source resolver + install ledger** — `resolveCapabilitySource(spec)` fetches a capability from a local path, git repo, npm package, or tarball URL, verifies it (sha512 integrity before staging, `engines.gsd` compatibility, full conformance validation) and stages a bundle **without executing any capability code** (copy/extract only — `npm pack --ignore-scripts`, never `npm install`; symlink/tar-slip/shell-metacharacter/unsafe-transport inputs rejected). A per-runtime ledger records what each install wrote for atomic, reversible upgrade/remove. Foundation (ADR-1244 Phase 3) for the upcoming `gsd capability install` command. diff --git a/.gitignore b/.gitignore index 3d0cbaff1..b7a041e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,8 @@ build/ # Published via prepublishOnly; built before test via pretest. Grows as modules migrate. /tsconfig.build.tsbuildinfo /gsd-core/bin/lib/capability-loader.cjs +/gsd-core/bin/lib/capability-source.cjs +/gsd-core/bin/lib/capability-ledger.cjs /gsd-core/bin/lib/markdown-sectionizer.cjs /gsd-core/bin/lib/resolution.cjs /gsd-core/bin/lib/research-store.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 27b72f2d1..962390f4f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -181,6 +181,12 @@ Runtime seam (`gsd-core/bin/lib/capability-loader.cjs`, ADR-1244 D2) that compos ### Capability Validator Shared conformance validator (`gsd-core/bin/lib/capability-validator.cjs`, ADR-1244 D2) extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share one validator implementation. Exports the same `validateCapability(manifest)` surface consumed by both the generator (build-time) and `capability-loader.cjs` (runtime). Generative-parity is CI-guarded: a drift between the generator's validation logic and the extracted module is a hard failure. Callers that previously inlined validation against the generator's internal helpers are migrated to import this module directly. Source of truth: `gsd-core/bin/lib/capability-validator.cjs`. +### Capability Source Resolver +ADR-1244 D3 fetch-and-stage seam (`gsd-core/bin/lib/capability-source.cjs`). Primary interface: `resolveCapabilitySource(spec, opts) → { id, version, stagedDir, integrity, source }`. Parses specs via `parseSpec` and dispatches to one adapter per source kind: `local` (fs copy from a `./`-prefixed path), `git` (clone `--depth 1` + checkout via `execGit`; https/ssh/git transports only — `ext::` and `file://` are rejected), `npm` (pack via `execNpm --ignore-scripts` + tar extract — NEVER `npm install`, no lifecycle scripts; shell-metacharacter spec rejection for Windows shell safety), `tarball` (HTTPS download + sha512 integrity verify BEFORE extraction + tar extract), and `registry` (explicit stub — no first-party endpoint yet). Security contract: install never executes capability code (copy/extract only); integrity is verified before staging; tar-slip member paths and symlinks are rejected. Staging is atomic: a per-pid/timestamp scratch directory under `.staging/` is renamed into `$GSD_HOME/.gsd/capabilities//` on success and removed on failure. The Phase 1/2 validator suite runs on the fetched manifest before finalizing; `engines.gsd` is pre-checked. Test seam: `_setCapabilitySourceHttpGet`. + +### Capability Ledger +ADR-1244 D4 per-runtime install manifest (`gsd-core/bin/lib/capability-ledger.cjs`). Leaf module (only `node:fs`/`node:path` plus `shell-command-projection`'s `platformWriteSync`). Records `{ id, version, source, integrity, files[], sharedEdits[{file,marker}] }` per installed capability in `.gsd-capabilities.json` at the runtime config dir root. Exports: `readLedger` (structural-validated, never throws), `writeLedger` (atomic via `platformWriteSync`), `recordInstall` (idempotent, prototype-pollution-guarded), `removeEntry`, and `reconcile` (reports orphans whose `files[]` are missing on disk; hardened against non-string/`..` members; never mutates). Serves as the atomic commit point for Phase-4 upgrade/remove and the reconciliation basis for detecting stale entries after out-of-band deletions. + ### Loop Extension Point A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks ` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 8e537b452..4eb003f50 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -279,8 +279,10 @@ "audit-command-router.cjs", "audit.cjs", "capability-activation.cjs", + "capability-ledger.cjs", "capability-loader.cjs", "capability-registry.cjs", + "capability-source.cjs", "capability-state.cjs", "capability-validator.cjs", "capability-writer.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 639c77498..8ebd21ef5 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -390,8 +390,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | | `capability-activation.cjs` | Capability activation resolver shared by config validation and capability-state consumers — resolves registry-owned config keys from raw runtime config without re-centralizing migrated settings | +| `capability-ledger.cjs` | Per-runtime install ledger (ADR-1244 D4) — atomic read/write of `.gsd-capabilities.json` recording `{ id, version, source, integrity, files[], sharedEdits[] }` per installed capability; exports `readLedger`/`writeLedger`/`recordInstall`/`removeEntry`/`reconcile` (orphan detection); atomic commit point and reconciliation basis for Phase-4 upgrade/remove | | `capability-loader.cjs` | Runtime Capability Registry overlay (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay read from `$GSD_HOME/.gsd/capabilities//` (global) and `/.gsd/capabilities//` (project); first-party-wins on id/skill/agent/config collisions, reserved-namespace rejection, load-time `engines.gsd` re-gate (skip-with-warning), and gate-kind fail-closed via `_overlay.blockedGates`; composes through the canonical `buildRegistry` so derived views never drift | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations (`capabilities//capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) | +| `capability-source.cjs` | Capability source resolver (ADR-1244 D3) — `resolveCapabilitySource(spec, opts)` fetches and stages a capability from local path, git (https/ssh/git transports only), npm pack (no lifecycle scripts), tarball (sha512 integrity verify before extraction), or registry (stub); tar-slip/symlink rejection; atomic staging to `$GSD_HOME/.gsd/capabilities//`; no capability code executes during install | | `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b/6) — composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; exports pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir ]` emitting `{ runtimeConfigDir, capabilities[] }` | | `capability-validator.cjs` | Shared runtime-callable capability validator (ADR-1244 D2) — extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share ONE validation implementation (generative-parity guarded); exports `validateCapability`/`validateCrossCapability`/`validateVersionEnvelope`/`validateConsumesGlobal`/… plus the closed-vocabulary sets and `SEMVER_RE` | | `capability-writer.cjs` | Capability State Writer (ADR-1213) — write-side inverse of the resolver; projects desired per-capability enabled/gates onto surface + config substrates, then re-resolves (assert-and-report); exports `setCapabilityState` and I/O handler `cmdCapabilitySet`; command surface: `gsd-tools capability set [--on\|--off] [--gate =]` | diff --git a/eslint.config.mjs b/eslint.config.mjs index 98f24c76d..8b9fba50b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -40,6 +40,8 @@ export default tseslint.config( // ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs. 'gsd-core/bin/lib/semver-compare.cjs', 'gsd-core/bin/lib/capability-loader.cjs', + 'gsd-core/bin/lib/capability-source.cjs', + 'gsd-core/bin/lib/capability-ledger.cjs', 'gsd-core/bin/lib/resolution.cjs', 'gsd-core/bin/lib/plan-drift-guard.cjs', 'gsd-core/bin/lib/cli-exit.cjs', diff --git a/src/capability-ledger.cts b/src/capability-ledger.cts new file mode 100644 index 000000000..d14a720a9 --- /dev/null +++ b/src/capability-ledger.cts @@ -0,0 +1,227 @@ +/** + * Capability ledger module — ADR-1244 Phase 3 (Decision D4). + * + * Manages a per-runtime install manifest (`.gsd-capabilities.json`) that records + * what each capability install wrote. Serves as the atomic commit point and + * reconciliation basis for Phase 4 upgrade/remove operations. + * + * LEAF MODULE — imports ONLY: node:fs, node:path, node:os, and + * ./shell-command-projection.cjs (for platformWriteSync). No other src/ imports. + * + * Exports: + * readLedger(runtimeDir) — structural-validated read, never throws + * writeLedger(runtimeDir, ledger) — atomic write via platformWriteSync + * recordInstall(runtimeDir, entry) — idempotent upsert of a ledger entry + * removeEntry(runtimeDir, capId) — remove a single entry by id + * reconcile(runtimeDir) — report orphans / stale entries (read-only) + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { platformWriteSync } = require('./shell-command-projection.cjs') as { + platformWriteSync: (filePath: string, content: string) => void; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const LEDGER_FILE_NAME = '.gsd-capabilities.json'; +const LEDGER_SCHEMA_VERSION = '1'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface LedgerEntry { + id: string; + version: string; + source: string; + integrity: string; + files: string[]; + sharedEdits: Array<{ file: string; marker: string }>; +} + +interface LedgerFile { + /** Ledger schema version — currently '1'. */ + version: string; + /** ISO-8601 timestamp of the last write. */ + updatedAt: string; + /** Map of capability id → LedgerEntry. */ + entries: Record; +} + +// --------------------------------------------------------------------------- +// IO helpers +// --------------------------------------------------------------------------- + +/** + * Read and structurally validate the ledger file. + * + * Returns null if the file is missing, unreadable, or structurally invalid. + * Never throws. + */ +function readLedger(runtimeDir: string): LedgerFile | null { + const filePath = path.join(runtimeDir, LEDGER_FILE_NAME); + try { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) return null; + const p = parsed as Record; + if (typeof p['version'] !== 'string') return null; + if (typeof p['updatedAt'] !== 'string') return null; + if (typeof p['entries'] !== 'object' || p['entries'] === null || Array.isArray(p['entries'])) return null; + // Shallow-validate each entry + const entries = p['entries'] as Record; + for (const key of Object.keys(entries)) { + const e = entries[key]; + if (typeof e !== 'object' || e === null) return null; + const entry = e as Record; + if (typeof entry['id'] !== 'string') return null; + if (typeof entry['version'] !== 'string') return null; + if (typeof entry['source'] !== 'string') return null; + if (typeof entry['integrity'] !== 'string') return null; + if (!Array.isArray(entry['files'])) return null; + if (!Array.isArray(entry['sharedEdits'])) return null; + } + return { + version: p['version'], + updatedAt: p['updatedAt'], + entries: entries as Record, + }; + } catch { + return null; + } +} + +/** + * Write the ledger atomically via platformWriteSync (mkdirSync + tmp+rename). + */ +function writeLedger(runtimeDir: string, ledger: LedgerFile): void { + platformWriteSync( + path.join(runtimeDir, LEDGER_FILE_NAME), + JSON.stringify(ledger, null, 2) + '\n', + ); +} + +// --------------------------------------------------------------------------- +// Mutation operations +// --------------------------------------------------------------------------- + +/** + * Record a capability installation in the ledger (idempotent). + * + * If an entry with the same id already exists it is replaced. The `updatedAt` + * timestamp is refreshed on every call. Rejects ids that would cause prototype + * pollution (__proto__, constructor, prototype). + */ +function recordInstall(runtimeDir: string, entry: LedgerEntry): void { + // Prototype-pollution guard — inline literal checks (CodeQL-safe pattern). + if (entry.id === '__proto__' || entry.id === 'constructor' || entry.id === 'prototype') { + // Silently ignore — the id is invalid and must never reach the ledger. + return; + } + + const existing = readLedger(runtimeDir); + const ledger: LedgerFile = existing ?? { + version: LEDGER_SCHEMA_VERSION, + updatedAt: new Date().toISOString(), + entries: {}, + }; + + ledger.entries[entry.id] = entry; + ledger.updatedAt = new Date().toISOString(); + + writeLedger(runtimeDir, ledger); +} + +/** + * Remove a single capability entry from the ledger by id. + * + * Returns true if the entry was present and removed, false if not found. + */ +function removeEntry(runtimeDir: string, capId: string): boolean { + const ledger = readLedger(runtimeDir); + if (ledger === null) return false; + if (!Object.prototype.hasOwnProperty.call(ledger.entries, capId)) return false; + delete ledger.entries[capId]; + ledger.updatedAt = new Date().toISOString(); + writeLedger(runtimeDir, ledger); + return true; +} + +// --------------------------------------------------------------------------- +// Reconciliation +// --------------------------------------------------------------------------- + +interface ReconcileResult { + /** Entries whose recorded files are partially or fully missing on disk. */ + orphans: Array<{ id: string; missing: string[] }>; + /** Reserved for future use — capabilities whose source has been superseded. */ + stale: string[]; + /** Non-fatal warnings (e.g. unreadable ledger). */ + warnings: string[]; +} + +/** + * Check ledger consistency against the filesystem. + * + * Read-only — never mutates the ledger or the filesystem. Reports: + * - orphans: entries with one or more recorded files missing on disk. + * - stale: (reserved, always empty in Phase 3). + * - warnings: problems encountered while reading the ledger. + */ +function reconcile(runtimeDir: string): ReconcileResult { + const result: ReconcileResult = { orphans: [], stale: [], warnings: [] }; + + const ledger = readLedger(runtimeDir); + if (ledger === null) { + const filePath = path.join(runtimeDir, LEDGER_FILE_NAME); + if (fs.existsSync(filePath)) { + result.warnings.push(`Ledger file exists but could not be parsed: ${filePath}`); + } + // Missing ledger is not a warning — it simply means nothing has been installed. + return result; + } + + for (const id of Object.keys(ledger.entries)) { + const entry = ledger.entries[id]; + const missing: string[] = []; + for (const file of entry.files) { + // Harden against hostile ledger JSON: a non-string member, or one that is + // absolute or escapes runtimeDir via "..", must not crash reconcile or become + // an existence oracle for files outside the runtime config dir. + if (typeof file !== 'string' || file === '' || path.isAbsolute(file) || file.split(/[/\\]/).includes('..')) { + // Note: do NOT String(file) — a hostile value like { toString: null } would throw. + const shown = typeof file === 'string' ? file : `<${typeof file}>`; + result.warnings.push(`Ledger entry "${id}" has an invalid file path; skipped: ${shown}`); + continue; + } + const resolved = path.join(runtimeDir, file); + if (!fs.existsSync(resolved)) { + missing.push(file); + } + } + if (missing.length > 0) { + result.orphans.push({ id, missing }); + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export = { + readLedger, + writeLedger, + recordInstall, + removeEntry, + reconcile, + // Exported for testing / introspection + LEDGER_FILE_NAME, +}; diff --git a/src/capability-source.cts b/src/capability-source.cts new file mode 100644 index 000000000..d67cf1539 --- /dev/null +++ b/src/capability-source.cts @@ -0,0 +1,714 @@ +/** + * 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 + */ + +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; +}; + +// 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; +}; + +// --------------------------------------------------------------------------- +// 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; + /** 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; + +// --------------------------------------------------------------------------- +// Injectable HTTP transport (test seam) +// --------------------------------------------------------------------------- + +function realHttpsGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get( + url, + { headers: { 'User-Agent': 'gsd-core-capability-source/1.0' } }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + 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'. */ +function readHostVersion(): string { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const pkg = require('../../../package.json') as { version?: string }; + return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0'; + } catch { + 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}` + ); + } +} + +/** + * 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. + * + * 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. + */ +function copyDirRecursive(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + 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); + } else if (entry.isFile()) { + fs.copyFileSync(srcPath, destPath); + } + // Non-regular entries (sockets, fifos, devices) are silently skipped. + } +} + +/** + * 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. + */ +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; +}): ResolveResult { + const { sourceDir, id, gsdHome, hostVersion, source, integrity } = opts; + + // 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. + copyDirRecursive(sourceDir, stagingDir); + + // Read and parse the capability manifest. + const manifestPath = path.join(stagingDir, 'capability.json'); + let rawManifest: string; + try { + rawManifest = fs.readFileSync(manifestPath, 'utf8'); + } catch { + throw new Error(`capability.json not found in staged directory: ${stagingDir}`); + } + let cap: Record; + try { + cap = JSON.parse(rawManifest) as Record; + } 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'); + } + + // engines.gsd pre-check — reject before staging finalizes. + const engines = cap['engines']; + if (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('; ')}`); + } + + // 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. + // (Fully-atomic stage-then-swap for upgrades is finished in Phase 4 / ADR-1244 D6.) + if (fs.existsSync(finalDir)) { + const backupDir = `${finalDir}.old-${process.pid}-${Date.now()}`; + fs.renameSync(finalDir, backupDir); + try { + fs.renameSync(stagingDir, finalDir); + } catch (err) { + try { fs.renameSync(backupDir, finalDir); } catch { /* best-effort restore */ } + throw err; + } + try { fs.rmSync(backupDir, { recursive: true, force: true }); } catch { /* best-effort */ } + } else { + fs.renameSync(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 { + 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. + const manifestPath = path.join(absPath, 'capability.json'); + let cap: Record; + try { + cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + } catch { + throw new Error(`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 }); +} + +function resolveGit( + parsed: ParsedSpec, + opts: ResolveOptions, + gsdHome: string, + hostVersion: string +): ResolveResult { + const execGit = opts.execOverrides?.git ?? shellSeam.execGit; + + 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. + const manifestPath = path.join(cloneDir, 'capability.json'); + let cap: Record; + try { + cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + } catch { + throw new Error(`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 }); + } 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]); + + // 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. + const manifestPath = path.join(sourceDir, 'capability.json'); + let cap: Record; + try { + cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + } catch { + throw new Error(`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: null }); + } 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. + const manifestPath = path.join(sourceDir, 'capability.json'); + let cap: Record; + try { + cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + } catch { + throw new Error(`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 }); + } 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)}`); + } + } +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export = { + resolveCapabilitySource, + parseSpec, + _setCapabilitySourceHttpGet, +}; diff --git a/tests/capability-ledger.test.cjs b/tests/capability-ledger.test.cjs new file mode 100644 index 000000000..c6dc2a71f --- /dev/null +++ b/tests/capability-ledger.test.cjs @@ -0,0 +1,350 @@ +/** + * Unit tests for the capability ledger module (ADR-1244 Phase 3, Decision D4). + * + * Tests are hermetic: each uses its own tmpdir created by createTempDir and + * cleaned up in t.after(). No shared state between tests. + */ + +'use strict'; + +const { test, mock } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const capLedger = require('../gsd-core/bin/lib/capability-ledger.cjs'); +const { + readLedger, + writeLedger, + recordInstall, + removeEntry, + reconcile, + LEDGER_FILE_NAME, +} = capLedger; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal valid LedgerEntry. */ +function makeEntry(id = 'test-cap', overrides = {}) { + return { + id, + version: '1.0.0', + source: 'registry:test', + integrity: 'sha256-abc123', + files: [], + sharedEdits: [], + ...overrides, + }; +} + +/** Build a minimal valid LedgerFile. */ +function makeLedger(overrides = {}) { + return { + version: '1', + updatedAt: new Date().toISOString(), + entries: {}, + ...overrides, + }; +} + +/** Return all tmp files left in dir (matches .tmp. pattern). */ +function orphanTmpFiles(dir) { + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir).filter((n) => /\.tmp\.\d+$/.test(n)); +} + +// --------------------------------------------------------------------------- +// readLedger — missing file +// --------------------------------------------------------------------------- + +test('readLedger returns null for a missing file (no throw)', (t) => { + const dir = createTempDir('ledger-missing-'); + t.after(() => cleanup(dir)); + + const result = readLedger(dir); + assert.equal(result, null, 'must return null for a missing ledger file'); +}); + +// --------------------------------------------------------------------------- +// readLedger — corrupt JSON +// --------------------------------------------------------------------------- + +test('readLedger returns null for corrupt JSON (no throw)', (t) => { + const dir = createTempDir('ledger-corrupt-'); + t.after(() => cleanup(dir)); + + fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), 'NOT { valid JSON }\n'); + + const result = readLedger(dir); + assert.equal(result, null, 'must return null for corrupt JSON'); +}); + +// --------------------------------------------------------------------------- +// writeLedger / readLedger round-trip +// --------------------------------------------------------------------------- + +test('writeLedger then readLedger round-trips a valid ledger', (t) => { + const dir = createTempDir('ledger-roundtrip-'); + t.after(() => cleanup(dir)); + + const ledger = makeLedger({ + entries: { + 'my-cap': makeEntry('my-cap', { files: ['commands/gsd/my-cap.md'] }), + }, + }); + + writeLedger(dir, ledger); + const readBack = readLedger(dir); + + assert.ok(readBack !== null, 'readLedger must return the written ledger'); + assert.equal(readBack.version, '1'); + assert.equal(typeof readBack.updatedAt, 'string'); + assert.ok('my-cap' in readBack.entries, 'entry must survive the round-trip'); + assert.deepEqual(readBack.entries['my-cap'].files, ['commands/gsd/my-cap.md']); +}); + +// --------------------------------------------------------------------------- +// writeLedger — no orphan .tmp file +// --------------------------------------------------------------------------- + +test('writeLedger leaves no orphan .tmp file after a successful write', (t) => { + const dir = createTempDir('ledger-no-orphan-'); + t.after(() => cleanup(dir)); + + writeLedger(dir, makeLedger()); + + const orphans = orphanTmpFiles(dir); + assert.deepEqual(orphans, [], 'must leave no .tmp. orphan after write'); + // The real ledger file must exist. + assert.equal(fs.existsSync(path.join(dir, LEDGER_FILE_NAME)), true); +}); + +// --------------------------------------------------------------------------- +// recordInstall — idempotent (same id twice → one entry, replaced) +// --------------------------------------------------------------------------- + +test('recordInstall is idempotent: same id twice yields one entry with the latest data', (t) => { + const dir = createTempDir('ledger-idempotent-'); + t.after(() => cleanup(dir)); + + recordInstall(dir, makeEntry('cap-a', { version: '1.0.0' })); + recordInstall(dir, makeEntry('cap-a', { version: '2.0.0' })); + + const ledger = readLedger(dir); + assert.ok(ledger !== null); + const ids = Object.keys(ledger.entries); + assert.equal(ids.length, 1, 'must have exactly one entry'); + assert.equal(ledger.entries['cap-a'].version, '2.0.0', 'entry must reflect the last write'); +}); + +// --------------------------------------------------------------------------- +// recordInstall — __proto__ injection rejected +// --------------------------------------------------------------------------- + +test('recordInstall rejects a __proto__ id without polluting Object.prototype', (t) => { + const dir = createTempDir('ledger-proto-'); + t.after(() => cleanup(dir)); + + // Capture the prototype BEFORE calling recordInstall. + const preBefore = Object.prototype['injected']; + + recordInstall(dir, makeEntry('__proto__', { integrity: 'evil' })); + + // Prototype must not have been polluted. + assert.equal(Object.prototype['injected'], preBefore); + assert.equal(({}).__proto__['injected'], preBefore); + + // The ledger file should either not exist or contain zero entries. + const ledger = readLedger(dir); + if (ledger !== null) { + assert.equal(Object.keys(ledger.entries).length, 0, + '__proto__ id must not appear in entries'); + } +}); + +test('recordInstall rejects "constructor" and "prototype" ids', (t) => { + const dir = createTempDir('ledger-proto2-'); + t.after(() => cleanup(dir)); + + recordInstall(dir, makeEntry('constructor')); + recordInstall(dir, makeEntry('prototype')); + + const ledger = readLedger(dir); + if (ledger !== null) { + assert.ok(!('constructor' in ledger.entries), '"constructor" must be excluded'); + assert.ok(!('prototype' in ledger.entries), '"prototype" must be excluded'); + } +}); + +// --------------------------------------------------------------------------- +// removeEntry — removes target + returns true/false +// --------------------------------------------------------------------------- + +test('removeEntry removes only the target entry and returns true', (t) => { + const dir = createTempDir('ledger-remove-'); + t.after(() => cleanup(dir)); + + recordInstall(dir, makeEntry('cap-x')); + recordInstall(dir, makeEntry('cap-y')); + + const removed = removeEntry(dir, 'cap-x'); + assert.equal(removed, true, 'must return true when the entry existed'); + + const ledger = readLedger(dir); + assert.ok(ledger !== null); + assert.ok(!('cap-x' in ledger.entries), 'cap-x must be gone'); + assert.ok('cap-y' in ledger.entries, 'cap-y must remain'); +}); + +test('removeEntry returns false when the id does not exist', (t) => { + const dir = createTempDir('ledger-remove-miss-'); + t.after(() => cleanup(dir)); + + recordInstall(dir, makeEntry('cap-z')); + + const removed = removeEntry(dir, 'nonexistent'); + assert.equal(removed, false, 'must return false when the entry is absent'); + + // The remaining entry must be untouched. + const ledger = readLedger(dir); + assert.ok(ledger !== null); + assert.ok('cap-z' in ledger.entries); +}); + +// --------------------------------------------------------------------------- +// reconcile — orphans when recorded files are missing +// --------------------------------------------------------------------------- + +test('reconcile reports orphans when a recorded file is missing on disk', (t) => { + const dir = createTempDir('ledger-reconcile-miss-'); + t.after(() => cleanup(dir)); + + recordInstall(dir, makeEntry('cap-missing', { + files: ['commands/gsd/cap-missing.md', 'agents/gsd-cap.md'], + })); + + const result = reconcile(dir); + assert.equal(result.warnings.length, 0); + assert.equal(result.orphans.length, 1, 'must report one orphan entry'); + assert.equal(result.orphans[0].id, 'cap-missing'); + assert.deepEqual( + result.orphans[0].missing.sort(), + ['agents/gsd-cap.md', 'commands/gsd/cap-missing.md'].sort(), + ); +}); + +// --------------------------------------------------------------------------- +// reconcile — empty result when all files are present +// --------------------------------------------------------------------------- + +test('reconcile returns empty orphans when all recorded files exist on disk', (t) => { + const dir = createTempDir('ledger-reconcile-ok-'); + t.after(() => cleanup(dir)); + + // Create the files that will be recorded. + const subdir = path.join(dir, 'commands', 'gsd'); + fs.mkdirSync(subdir, { recursive: true }); + fs.writeFileSync(path.join(subdir, 'cap-present.md'), '# cap\n'); + + recordInstall(dir, makeEntry('cap-present', { + files: ['commands/gsd/cap-present.md'], + })); + + const result = reconcile(dir); + assert.equal(result.warnings.length, 0); + assert.deepEqual(result.orphans, [], 'must report no orphans when files exist'); + assert.deepEqual(result.stale, []); +}); + +// --------------------------------------------------------------------------- +// reconcile — warning for corrupt ledger (file exists but not parseable) +// --------------------------------------------------------------------------- + +test('reconcile issues a warning when the ledger file is corrupt', (t) => { + const dir = createTempDir('ledger-reconcile-corrupt-'); + t.after(() => cleanup(dir)); + + fs.writeFileSync(path.join(dir, LEDGER_FILE_NAME), '<<>>'); + + const result = reconcile(dir); + assert.equal(result.orphans.length, 0, 'no orphans for unreadable ledger'); + assert.ok(result.warnings.length > 0, 'must emit at least one warning'); + assert.ok( + result.warnings[0].includes('could not be parsed') || result.warnings[0].includes(dir), + 'warning must reference the ledger file or describe the parse failure', + ); +}); + +// --------------------------------------------------------------------------- +// fs fault-injection — platformWriteSync fallback via mock.method(fs, 'renameSync') +// --------------------------------------------------------------------------- + +test('writeLedger succeeds via platformWriteSync fallback when renameSync fails', (t) => { + const dir = createTempDir('ledger-fault-'); + t.after(() => cleanup(dir)); + + // Capture the real renameSync BEFORE installing the mock (avoids calling + // the mock's own wrapper in the fallback path — mirrors the concurrent-write + // test in feat-3595-fs-fault-injection-atomic-write.test.cjs). + const originalRename = fs.renameSync; + let renameCalls = 0; + + const renameMock = mock.method(fs, 'renameSync', (src, dest) => { + renameCalls++; + if (renameCalls === 1) { + // Simulate a cross-device rename failure. + const err = new Error('EXDEV: cross-device link not permitted'); + err.code = 'EXDEV'; + throw err; + } + return originalRename.call(fs, src, dest); + }); + t.after(() => renameMock.mock.restore()); + + const ledger = makeLedger({ + entries: { 'fault-cap': makeEntry('fault-cap') }, + }); + writeLedger(dir, ledger); + + // File must exist and be parseable despite the rename failure. + const readBack = readLedger(dir); + assert.ok(readBack !== null, 'ledger must be readable after fallback write'); + assert.ok('fault-cap' in readBack.entries, 'entry must survive the fallback write'); + + // No orphan tmp files must remain. + assert.deepEqual(orphanTmpFiles(dir), [], 'no tmp orphan after fallback write'); + + assert.equal(renameCalls, 1, 'renameSync was invoked exactly once before falling back'); +}); + +// ADR-1244 D4 (adversarial re-review): reconcile must never THROW on hostile JSON. +// A non-string files[] member like { toString: null } would crash String(file); a +// '..' or absolute member would otherwise become an existence oracle outside runtimeDir. +test('reconcile does not throw on hostile files[] members (non-string, "..", absolute)', () => { + const dir = createTempDir('gsd-ledger-hostile-'); + try { + // Hand-write a ledger whose files[] contains hostile members. + const ledger = { + version: '1', + updatedAt: '2026-01-01T00:00:00.000Z', + entries: { + evil: { + id: 'evil', version: '1.0.0', source: 'overlay-global', integrity: 'x', + files: [{ toString: null, valueOf: null }, '../../../etc/passwd', '/etc/shadow', '', 123], + sharedEdits: [], + }, + }, + }; + writeLedger(dir, ledger); + let result; + assert.doesNotThrow(() => { result = reconcile(dir); }, 'reconcile must not throw on hostile members'); + // Every hostile member is skipped with a warning; none becomes an orphan/oracle. + assert.ok(result.warnings.length >= 1, 'hostile members must be reported as warnings'); + assert.deepEqual(result.orphans, [], 'no hostile member is treated as a real (missing) file'); + } finally { + cleanup(dir); + } +}); diff --git a/tests/capability-source.test.cjs b/tests/capability-source.test.cjs new file mode 100644 index 000000000..971d1fa63 --- /dev/null +++ b/tests/capability-source.test.cjs @@ -0,0 +1,663 @@ +'use strict'; + +/** + * capability-source.test.cjs — ADR-1244 Phase 3, Decision D3. + * + * Tests for resolveCapabilitySource + parseSpec: + * - parseSpec kind-detection table (all kinds + error cases) + * - local adapter: happy path, invalid manifest, engines.gsd incompatibility + * - registry kind: throws 'not yet implemented' + * - tarball adapter: integrity matching / mismatching (via injected HTTP seam) + * - security: shell metacharacters in specs/args → only reach exec override as + * argv array (not interpolated into a shell string) + * - security: capability id containing ../ → rejected + * - staging atomicity: validation failure leaves no dir under capabilities/ + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const { cleanup, createTempDir } = require('./helpers.cjs'); + +// The module under test — loaded from the built .cjs artifact. +const capSource = require('../gsd-core/bin/lib/capability-source.cjs'); +const { + resolveCapabilitySource, + parseSpec, + _setCapabilitySourceHttpGet, +} = capSource; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a minimal but valid capability manifest for tests. */ +function featureCap(id, extra = {}) { + return { + id, + role: 'feature', + version: '1.0.0', + title: id, + description: 'test capability', + tier: 'standard', + requires: [], + engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], + agents: [], + hooks: [], + config: {}, + steps: [], + contributions: [], + gates: [], + ...extra, + }; +} + +/** + * Create a temp directory with a capability.json inside. + * Returns the directory path. + */ +function makeLocalCap(cap) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-local-')); + fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(cap), 'utf8'); + return dir; +} + +/** Compute sha512- from a Buffer. */ +function sha512b64(buf) { + return 'sha512-' + crypto.createHash('sha512').update(buf).digest('base64'); +} + +// --------------------------------------------------------------------------- +// parseSpec — kind detection +// --------------------------------------------------------------------------- + +describe('parseSpec — kind detection', () => { + test('relative path ./foo → local', () => { + const p = parseSpec('./my-cap'); + assert.strictEqual(p.kind, 'local'); + assert.strictEqual(p.raw, './my-cap'); + }); + + test('relative path ../foo → local', () => { + const p = parseSpec('../my-cap'); + assert.strictEqual(p.kind, 'local'); + }); + + test('absolute path /home/user/my-cap → local', () => { + const p = parseSpec('/home/user/my-cap'); + assert.strictEqual(p.kind, 'local'); + }); + + test('npm: prefix → npm', () => { + const p = parseSpec('npm:my-capability@1.0.0'); + assert.strictEqual(p.kind, 'npm'); + assert.strictEqual(p.target, 'my-capability@1.0.0'); + }); + + test('tarball https URL ending .tgz → tarball', () => { + const p = parseSpec('https://example.com/cap.tgz'); + assert.strictEqual(p.kind, 'tarball'); + assert.strictEqual(p.target, 'https://example.com/cap.tgz'); + }); + + test('tarball https URL ending .tar.gz → tarball', () => { + const p = parseSpec('https://example.com/cap.tar.gz'); + assert.strictEqual(p.kind, 'tarball'); + }); + + test('git https URL ending .git → git', () => { + const p = parseSpec('https://github.com/org/repo.git'); + assert.strictEqual(p.kind, 'git'); + assert.strictEqual(p.target, 'https://github.com/org/repo.git'); + assert.strictEqual(p.ref, undefined); + }); + + test('git URL with # → git with ref extracted', () => { + const p = parseSpec('https://github.com/org/repo#v1.2.3'); + assert.strictEqual(p.kind, 'git'); + assert.strictEqual(p.ref, 'v1.2.3'); + assert.ok(!p.target.includes('#'), 'URL must not include # fragment'); + }); + + test('git+ prefix → git', () => { + const p = parseSpec('git+https://github.com/org/repo.git'); + assert.strictEqual(p.kind, 'git'); + }); + + test('registry-style name@version (no scheme) → registry', () => { + const p = parseSpec('my-org/capability@2.0.0'); + assert.strictEqual(p.kind, 'registry'); + }); + + test('bare package name → registry', () => { + const p = parseSpec('my-capability'); + assert.strictEqual(p.kind, 'registry'); + }); + + test('empty string → throws', () => { + assert.throws(() => parseSpec(''), /non-empty/i); + }); + + test('whitespace-only string → throws', () => { + assert.throws(() => parseSpec(' '), /non-empty/i); + }); + + test('null coerced (wrong type) → throws', () => { + // @ts-expect-error intentional wrong type for test + assert.throws(() => parseSpec(null), /non-empty|string/i); + }); + + test('npm: with empty package spec → throws', () => { + assert.throws(() => parseSpec('npm:'), /empty after "npm:"/i); + }); +}); + +// --------------------------------------------------------------------------- +// local adapter +// --------------------------------------------------------------------------- + +describe('local adapter — happy path', () => { + let gsdHome = ''; + let capDir = ''; + + beforeEach(() => { + gsdHome = createTempDir('gsd-home-'); + capDir = makeLocalCap(featureCap('test-cap-local')); + }); + + afterEach(() => { + cleanup(gsdHome); + cleanup(capDir); + }); + + test('resolves a valid local capability — staged dir exists with capability.json', async () => { + const result = await resolveCapabilitySource(capDir, { gsdHome, hostVersion: '1.5.0' }); + assert.strictEqual(result.id, 'test-cap-local'); + assert.strictEqual(result.version, '1.0.0'); + assert.ok(fs.existsSync(result.stagedDir), 'staged dir must exist'); + assert.ok( + fs.existsSync(path.join(result.stagedDir, 'capability.json')), + 'capability.json must be present in staged dir' + ); + assert.strictEqual(result.source, capDir); + }); + + test('staging creates the capability under /.gsd/capabilities//', async () => { + const result = await resolveCapabilitySource(capDir, { gsdHome, hostVersion: '1.5.0' }); + const expectedDir = path.join(gsdHome, '.gsd', 'capabilities', 'test-cap-local'); + assert.strictEqual(result.stagedDir, expectedDir); + assert.ok(fs.existsSync(expectedDir)); + }); +}); + +describe('local adapter — invalid manifest', () => { + let gsdHome = ''; + + beforeEach(() => { gsdHome = createTempDir('gsd-home-'); }); + afterEach(() => { cleanup(gsdHome); }); + + test('manifest missing "version" field → throws AND no staged dir remains', async () => { + const cap = featureCap('no-version-cap'); + delete cap.version; + const capDir = makeLocalCap(cap); + + try { + await assert.rejects( + () => resolveCapabilitySource(capDir, { gsdHome, hostVersion: '1.5.0' }), + (err) => { + assert.ok(err instanceof Error, 'must throw an Error'); + return true; + } + ); + } finally { + cleanup(capDir); + } + + // No staged dir should remain. + const capabilitiesDir = path.join(gsdHome, '.gsd', 'capabilities'); + if (fs.existsSync(capabilitiesDir)) { + const entries = fs.readdirSync(capabilitiesDir).filter((e) => e !== '.staging'); + assert.strictEqual(entries.length, 0, 'No capability dirs must remain after failure'); + } + }); + + test('manifest with invalid role → throws AND no staged dir remains', async () => { + const cap = featureCap('bad-role-cap'); + cap.role = 'totally-invalid-role-xyz'; + const capDir = makeLocalCap(cap); + + try { + await assert.rejects( + () => resolveCapabilitySource(capDir, { gsdHome, hostVersion: '1.5.0' }), + /valid|role|validation/i + ); + } finally { + cleanup(capDir); + } + + const capabilitiesDir = path.join(gsdHome, '.gsd', 'capabilities'); + if (fs.existsSync(capabilitiesDir)) { + const entries = fs.readdirSync(capabilitiesDir).filter((e) => e !== '.staging'); + assert.strictEqual(entries.length, 0, 'No capability dirs must remain after failure'); + } + }); + + test('missing capability.json → throws', async () => { + const dir = createTempDir('no-manifest-'); + try { + await assert.rejects( + () => resolveCapabilitySource(dir, { gsdHome, hostVersion: '1.5.0' }), + /capability\.json/i + ); + } finally { + cleanup(dir); + } + }); +}); + +describe('local adapter — engines.gsd incompatibility', () => { + let gsdHome = ''; + + beforeEach(() => { gsdHome = createTempDir('gsd-home-'); }); + afterEach(() => { cleanup(gsdHome); }); + + test('engines.gsd ">=99.0.0" with hostVersion 1.5.0 → throws before staging', async () => { + const cap = featureCap('incompat-cap', { engines: { gsd: '>=99.0.0' } }); + const capDir = makeLocalCap(cap); + + try { + await assert.rejects( + () => resolveCapabilitySource(capDir, { gsdHome, hostVersion: '1.5.0' }), + /engines\.gsd|requires|incompatible/i + ); + } finally { + cleanup(capDir); + } + + // No staged directory should exist. + const finalDir = path.join(gsdHome, '.gsd', 'capabilities', 'incompat-cap'); + assert.ok(!fs.existsSync(finalDir), 'staged dir must not exist for incompatible capability'); + }); +}); + +// --------------------------------------------------------------------------- +// registry kind — explicit stub +// --------------------------------------------------------------------------- + +describe('registry kind', () => { + test('throws "not yet implemented" for registry specs', async () => { + await assert.rejects( + () => resolveCapabilitySource('my-cap@1.0.0', { gsdHome: os.tmpdir(), hostVersion: '1.5.0' }), + /not yet implemented/i + ); + }); +}); + +// --------------------------------------------------------------------------- +// tarball adapter — integrity verification via injected HTTP seam +// --------------------------------------------------------------------------- + +describe('tarball adapter — integrity via _setCapabilitySourceHttpGet', () => { + let gsdHome = ''; + + beforeEach(() => { gsdHome = createTempDir('gsd-home-'); }); + afterEach(() => { + _setCapabilitySourceHttpGet(null); // restore real transport + cleanup(gsdHome); + }); + + test('matching integrity → resolves successfully', async () => { + const cap = featureCap('tarball-cap'); + const tgzBuf = _fakeTarball(cap); + const integrity = sha512b64(tgzBuf); + + _setCapabilitySourceHttpGet(() => Promise.resolve({ statusCode: 200, body: tgzBuf })); + + // Inject a tar extractor that writes the capability.json to extractDir. + const result = await resolveCapabilitySource( + 'https://example.com/tarball-cap.tgz', + { + gsdHome, + hostVersion: '1.5.0', + integrity, + execOverrides: { + tar: (_prog, args, _opts) => { + // Name listing (assertSafeTarMembers step 1): safe member names. + if (args[0] === '-tzf') { + return { exitCode: 0, stdout: 'capability.json\n', stderr: '', signal: null, error: null }; + } + // Verbose listing (assertSafeTarMembers step 2): regular file, no link. + if (args[0] === '-tvzf') { + return { exitCode: 0, stdout: '-rw-r--r-- 0 user group 10 Jan 1 2020 capability.json\n', stderr: '', signal: null, error: null }; + } + // Extraction pass: args = ['-xzf', tgzPath, '-C', extractDir] + const extractDir = args[args.indexOf('-C') + 1]; + fs.writeFileSync( + path.join(extractDir, 'capability.json'), + JSON.stringify(cap), + 'utf8' + ); + return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; + }, + }, + } + ); + + assert.strictEqual(result.id, 'tarball-cap'); + assert.ok(result.integrity && result.integrity.startsWith('sha512-'), 'integrity must be set'); + assert.ok(fs.existsSync(result.stagedDir)); + }); + + test('mismatching integrity → throws BEFORE staging (no staged dir)', async () => { + const cap = featureCap('tarball-mismatch'); + const tgzBuf = _fakeTarball(cap); + const badIntegrity = 'sha512-' + Buffer.from('totally-wrong').toString('base64'); + + _setCapabilitySourceHttpGet(() => Promise.resolve({ statusCode: 200, body: tgzBuf })); + + await assert.rejects( + () => + resolveCapabilitySource('https://example.com/tarball-mismatch.tgz', { + gsdHome, + hostVersion: '1.5.0', + integrity: badIntegrity, + execOverrides: { + tar: (_prog, args, _opts) => { + // Should never be reached — integrity check fires first. + const extractDir = args[args.indexOf('-C') + 1]; + fs.writeFileSync( + path.join(extractDir, 'capability.json'), + JSON.stringify(cap), + 'utf8' + ); + return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; + }, + }, + }), + /integrity mismatch|mismatch/i + ); + + // No staged directory must exist. + const finalDir = path.join(gsdHome, '.gsd', 'capabilities', 'tarball-mismatch'); + assert.ok(!fs.existsSync(finalDir), 'staged dir must NOT exist after integrity mismatch'); + }); +}); + +// --------------------------------------------------------------------------- +// Security: shell metacharacters in spec / args → arrive as argv array +// --------------------------------------------------------------------------- + +describe('security: shell metacharacters do not escape into a shell string', () => { + let gsdHome = ''; + + beforeEach(() => { gsdHome = createTempDir('gsd-home-'); }); + afterEach(() => { cleanup(gsdHome); }); + + test('git spec with shell metacharacters — captured as argv array, not shell string', async () => { + const capturedCalls = []; + + // The injected execGit captures every call; we verify the spec appears verbatim + // as an array element, never interpolated into a string with shell operators. + const maliciousUrl = 'https://github.com/org/repo.git; rm -rf /tmp/evil'; + + const fakeGit = (args, _opts) => { + capturedCalls.push([...args]); + // Simulate failing clone so we don't need a real repo. + return { exitCode: 128, stdout: '', stderr: 'not a git repository', signal: null, error: null }; + }; + + await assert.rejects( + () => + resolveCapabilitySource(`git+${maliciousUrl}`, { + gsdHome, + hostVersion: '1.5.0', + execOverrides: { git: fakeGit }, + }), + /clone failed|git/i + ); + + // The call must have been made with the URL as a discrete argv element. + assert.ok(capturedCalls.length > 0, 'execGit must have been called'); + const cloneCall = capturedCalls[0]; + // Argv must include the URL as a single token — never split or shell-interpolated. + // The semicolon and "rm -rf" must be a single string element, not two elements. + // The key security property: if we ran this in a shell, `; rm -rf /tmp/evil` would + // be a separate command. By routing through argv array, it is inert. + assert.ok( + cloneCall.some((arg) => arg === maliciousUrl || arg.includes('rm -rf')), + 'malicious characters must appear in argv array (inert), not as a parsed shell command' + ); + // None of the individual args should be shell commands like just 'rm' or '-rf'. + const hasStandaloneRm = cloneCall.some((arg) => arg === 'rm'); + assert.ok(!hasStandaloneRm, 'shell metacharacters must not be parsed into separate argv elements'); + }); + + test('npm spec with shell metacharacters is REJECTED before exec (execNpm uses a Windows shell)', async () => { + const capturedCalls = []; + const fakeNpm = (args) => { + capturedCalls.push([...args]); + return { exitCode: 1, stdout: '', stderr: 'not found', signal: null, error: null }; + }; + for (const evil of ['`rm -rf /`', 'pkg; rm -rf', 'pkg && calc', 'pkg|cat /etc/passwd', 'pkg$(whoami)', 'pkg >out', "pkg'", 'pkg"x']) { + await assert.rejects( + () => resolveCapabilitySource(`npm:${evil}`, { gsdHome, hostVersion: '1.5.0', execOverrides: { npm: fakeNpm } }), + /unsafe npm package spec/i, + `npm:${evil} must be rejected at parse` + ); + } + assert.equal(capturedCalls.length, 0, 'execNpm must NEVER be called for an unsafe npm spec'); + }); + + test('a valid npm spec reaches execNpm as a discrete argv element WITH --ignore-scripts', async () => { + const capturedCalls = []; + const fakeNpm = (args) => { + capturedCalls.push([...args]); + return { exitCode: 1, stdout: '', stderr: 'not found', signal: null, error: null }; + }; + await assert.rejects( + () => resolveCapabilitySource('npm:@org/cap@^1.2.0', { gsdHome, hostVersion: '1.5.0', execOverrides: { npm: fakeNpm } }), + /npm pack failed|not found/i + ); + const packCall = capturedCalls[0]; + assert.ok(packCall.includes('@org/cap@^1.2.0'), 'valid spec passed as a single discrete argv element'); + assert.ok(packCall.includes('--ignore-scripts'), 'npm pack MUST pass --ignore-scripts (no lifecycle code execution)'); + assert.ok(packCall.includes('pack'), 'must be `npm pack`, never `npm install`'); + }); + + test('git transport allowlist: ext::/file:// transports are rejected at parse', async () => { + for (const evil of ['git+ext::sh -c "evil"', 'git+file:///etc', 'git+fd::7']) { + await assert.rejects( + () => resolveCapabilitySource(evil, { gsdHome, hostVersion: '1.5.0' }), + /unsupported git transport/i, + `${evil} must be rejected` + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Security: symlink + tar-slip rejection +// --------------------------------------------------------------------------- + +describe('security: symlink and tar-slip rejection', () => { + let gsdHome = ''; + beforeEach(() => { gsdHome = createTempDir('gsd-home-'); }); + afterEach(() => { cleanup(gsdHome); }); + + test('local bundle containing a symlink is refused (copyFileSync would follow it)', async (t) => { + const dir = createTempDir('gsd-local-symlink-'); + t.after(() => cleanup(dir)); + fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(featureCap('symlink-cap')), 'utf8'); + // Plant a symlink pointing at a host file. + const secret = createTempDir('gsd-secret-'); + t.after(() => cleanup(secret)); + fs.writeFileSync(path.join(secret, 'id_rsa'), 'PRIVATE', 'utf8'); + try { + fs.symlinkSync(path.join(secret, 'id_rsa'), path.join(dir, 'leaked')); + } catch { + t.skip('symlink not supported on this platform'); + return; + } + await assert.rejects( + () => resolveCapabilitySource(dir, { gsdHome, hostVersion: '1.5.0' }), + /symlink/i + ); + assert.ok(!fs.existsSync(path.join(gsdHome, '.gsd', 'capabilities', 'symlink-cap')), 'no staged dir after symlink refusal'); + }); + + test('tarball with a tar-slip member (..) is refused before extraction', async () => { + const cap = featureCap('slip-cap'); + const tgzBuf = _fakeTarball(cap); + _setCapabilitySourceHttpGet(() => Promise.resolve({ statusCode: 200, body: tgzBuf })); + let extracted = false; + await assert.rejects( + () => resolveCapabilitySource('https://example.com/slip.tgz', { + gsdHome, hostVersion: '1.5.0', + execOverrides: { + tar: (_prog, args) => { + if (args[0] === '-tzf') { + // Listing reveals a traversal member → must be rejected. + return { exitCode: 0, stdout: 'capability.json\n../../../etc/evil\n', stderr: '', signal: null, error: null }; + } + extracted = true; // extraction must NOT happen + return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; + }, + }, + }), + /unsafe member path/i + ); + assert.equal(extracted, false, 'extraction must not run when a member path is unsafe'); + }); + + test('tarball containing a SYMLINK member is refused before extraction', async () => { + const cap = featureCap('symmember-cap'); + const tgzBuf = _fakeTarball(cap); + _setCapabilitySourceHttpGet(() => Promise.resolve({ statusCode: 200, body: tgzBuf })); + let extracted = false; + await assert.rejects( + () => resolveCapabilitySource('https://example.com/sym.tgz', { + gsdHome, hostVersion: '1.5.0', + execOverrides: { + tar: (_prog, args) => { + if (args[0] === '-tzf') { + // Names look safe... + return { exitCode: 0, stdout: 'capability.json\nleak\n', stderr: '', signal: null, error: null }; + } + if (args[0] === '-tvzf') { + // ...but the verbose listing reveals a symlink member → reject. + return { exitCode: 0, stdout: '-rw-r--r-- 0 u g 10 Jan 1 2020 capability.json\nlrwxr-xr-x 0 u g 0 Jan 1 2020 leak -> /etc/passwd\n', stderr: '', signal: null, error: null }; + } + extracted = true; + return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; + }, + }, + }), + /symlink or hardlink member/i + ); + assert.equal(extracted, false, 'extraction must not run when a symlink member is present'); + }); +}); + +// --------------------------------------------------------------------------- +// Security: capability id path traversal → rejected +// --------------------------------------------------------------------------- + +describe('security: path traversal in capability id', () => { + let gsdHome = ''; + + beforeEach(() => { gsdHome = createTempDir('gsd-home-'); }); + afterEach(() => { cleanup(gsdHome); }); + + test('capability id containing ../ is rejected before staging', async () => { + // Build a local dir with a capability.json whose id contains path traversal. + const cap = featureCap('../evil-escape'); + const capDir = makeLocalCap(cap); + + try { + await assert.rejects( + () => resolveCapabilitySource(capDir, { gsdHome, hostVersion: '1.5.0' }), + /invalid|path separator|kebab-case|\.\./i + ); + } finally { + cleanup(capDir); + } + + // Nothing must have been written under gsdHome. + const capRoot = path.join(gsdHome, '.gsd', 'capabilities'); + if (fs.existsSync(capRoot)) { + const entries = fs.readdirSync(capRoot).filter((e) => e !== '.staging'); + assert.strictEqual(entries.length, 0, 'no capability must be staged with traversal id'); + } + }); + + test('capability id containing / is rejected', async () => { + const cap = featureCap('org/evil'); + const capDir = makeLocalCap(cap); + + try { + await assert.rejects( + () => resolveCapabilitySource(capDir, { gsdHome, hostVersion: '1.5.0' }), + /invalid|path separator|kebab-case/i + ); + } finally { + cleanup(capDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// Staging atomicity: validation failure leaves no directory +// --------------------------------------------------------------------------- + +describe('staging atomicity', () => { + let gsdHome = ''; + + beforeEach(() => { gsdHome = createTempDir('gsd-home-'); }); + afterEach(() => { cleanup(gsdHome); }); + + test('validation failure leaves no directory under capabilities/ (only .staging may exist briefly)', async () => { + // Deliberately invalid cap: missing required fields beyond id/version. + const cap = { id: 'atomicity-test', version: '1.0.0', role: 'totally-invalid-role-xyz' }; + const capDir = makeLocalCap(cap); + + try { + await assert.rejects( + () => resolveCapabilitySource(capDir, { gsdHome, hostVersion: '1.5.0' }), + (err) => err instanceof Error + ); + } finally { + cleanup(capDir); + } + + // The final capability directory must NOT exist. + const finalDir = path.join(gsdHome, '.gsd', 'capabilities', 'atomicity-test'); + assert.ok(!fs.existsSync(finalDir), 'Final capability dir must be absent after validation failure'); + + // .staging dir should be cleaned up too (best-effort assertion — it's async). + const stagingRoot = path.join(gsdHome, '.gsd', 'capabilities', '.staging'); + if (fs.existsSync(stagingRoot)) { + const stagingEntries = fs.readdirSync(stagingRoot); + assert.strictEqual(stagingEntries.length, 0, '.staging must be empty after cleanup'); + } + }); +}); + +// --------------------------------------------------------------------------- +// Fake tarball helper (not a real .tgz — the tar override bypasses extraction) +// --------------------------------------------------------------------------- + +/** + * Returns a Buffer that acts as a "tarball" for tests that inject a fake tar extractor. + * The content is arbitrary; tests use the execOverrides.tar hook to write fixture files + * into the extractDir instead of calling real tar. + */ +function _fakeTarball(cap) { + // We just need a buffer; the injected tar override does the actual "extraction". + return Buffer.from(JSON.stringify({ _fakeTarball: true, id: cap.id }), 'utf8'); +}