* feat(#1432): capability source resolver + ledger (ADR-1244 Phase 3) ADR-1244 D3 + D4 — additive, testable-in-isolation modules (the /gsd:capability install command + consent gate are Phase 4). NEW modules only; not yet wired into bin/install.js. - src/capability-source.cts → resolveCapabilitySource(spec, opts): one seam, one adapter per source kind — local (fs copy), git (execGit clone+checkout, https/ssh/ git transports only), npm (execNpm pack --ignore-scripts + tar, NEVER npm install), tarball (https download + sha512 integrity-before-extraction + tar), registry (stub). SECURITY: install never executes capability code (copy/extract only, --ignore-scripts); integrity verified before staging; symlink members rejected at interior, source-root, AND tar-member (verbose-listing) layers; tar-slip member paths rejected pre-extraction; npm specs with shell metacharacters (incl %) rejected (execNpm uses a Windows shell); git ext::/file:// transports + leading-dash/metachar refs rejected; atomic staging (.staging→rename, restore-on-failure); full Phase 1/2 validator suite + engines.gsd pre-check on the fetched manifest. Test seam _setCapabilitySourceHttpGet. - src/capability-ledger.cts → per-runtime .gsd-capabilities.json install manifest: readLedger/writeLedger (atomic via platformWriteSync)/recordInstall (idempotent, prototype-pollution-guarded)/removeEntry/reconcile (orphan report, hardened against hostile/non-string/'..' files[] members — never throws, never oracles outside runtimeDir). - 48 new tests (34 source incl. the full security matrix, 14 ledger). Two Codex adversarial rounds; all 12 findings fixed + regression-tested. - .gitignore + eslint.config.mjs: built capability-source/ledger.cjs git+eslint-ignored (ADR-457 #551 migration coverage); CONTEXT glossary + INVENTORY rows + manifest regen. Closes #1432 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#1432): add changeset for capability source resolver + ledger Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/noble-deer-parade.md
Normal file
5
.changeset/noble-deer-parade.md
Normal file
@@ -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.
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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/<id>/` 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 <point>` 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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<id>/` (global) and `<projectRoot>/.gsd/capabilities/<id>/` (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/<id>/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/<id>/`; 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 <path>]` 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 <id> [--on\|--off] [--gate <key>=<true\|false>]` |
|
||||
|
||||
@@ -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',
|
||||
|
||||
227
src/capability-ledger.cts
Normal file
227
src/capability-ledger.cts
Normal file
@@ -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<string, LedgerEntry>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
for (const key of Object.keys(entries)) {
|
||||
const e = entries[key];
|
||||
if (typeof e !== 'object' || e === null) return null;
|
||||
const entry = e as Record<string, unknown>;
|
||||
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<string, LedgerEntry>,
|
||||
};
|
||||
} 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,
|
||||
};
|
||||
714
src/capability-source.cts
Normal file
714
src/capability-source.cts
Normal file
@@ -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/<id>-<pid>-<ts>/, 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, unknown>) => string[];
|
||||
validateCrossCapability: (capMap: Map<string, unknown>, centralKeys: Set<string>) => 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-<base64>`). 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-<base64> 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<HttpResponse>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Injectable HTTP transport (test seam)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function realHttpsGet(url: string): Promise<HttpResponse> {
|
||||
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-<base64> 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-<base64>` 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-<base64>): ${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<string, unknown>;
|
||||
try {
|
||||
cap = JSON.parse(rawManifest) as Record<string, unknown>;
|
||||
} 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<string, unknown>)['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<string, unknown>([[id, cap]]);
|
||||
const centralKeys = new Set<string>();
|
||||
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 `#<ref>`, or starts with `git+`
|
||||
* registry: `<name>@<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 #<ref>
|
||||
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: <name>@<version-or-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<string, unknown>;
|
||||
try {
|
||||
cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>;
|
||||
} 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<string, unknown>;
|
||||
try {
|
||||
cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>;
|
||||
} 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<string, unknown>;
|
||||
try {
|
||||
cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>;
|
||||
} 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<ResolveResult> {
|
||||
// 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<string, unknown>;
|
||||
try {
|
||||
cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>;
|
||||
} 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<ResolveResult> {
|
||||
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,
|
||||
};
|
||||
350
tests/capability-ledger.test.cjs
Normal file
350
tests/capability-ledger.test.cjs
Normal file
@@ -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 <filename>.tmp.<pid> 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.<pid> 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), '<<<not json>>>');
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
663
tests/capability-source.test.cjs
Normal file
663
tests/capability-source.test.cjs
Normal file
@@ -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-<base64> 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 #<ref> → 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 <gsdHome>/.gsd/capabilities/<id>/', 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');
|
||||
}
|
||||
Reference in New Issue
Block a user