/** * capability-loader.cts — runtime Capability Registry overlay (ADR-1244 D2). * * Promotes the registry from a frozen data file to a module with an interface: * * loadRegistry({ includeInstalled }) -> composed registry * * It composes the **first-party frozen registry** (the committed, generated * `capability-registry.cjs`) with a **validated installed overlay** — third-party * capability manifests read at runtime from per-scope install roots: * - global: $GSD_HOME/.gsd/capabilities//capability.json (GSD_HOME defaults to ~) * - project: /.gsd/capabilities//capability.json * * Invariants enforced over the merged set (first-party ∪ overlay): * - First-party always wins: an overlay whose `id`, owned skill/agent stem, or * federated config key collides with first-party (or uses a reserved `gsd-` / * `gsd-core-` / `anthropic-` id prefix) is rejected. * - Load-time re-gate (default-resilient): an overlay that fails validation or * whose `engines.gsd` does not satisfy the running GSD version is SKIPPED * with a warning — it never crashes the loop. EXCEPTION (per-hook-kind * policy): a skipped capability that declares a `gate` is recorded in * `_overlay.incompatibleGateCapIds` so the loop resolver can fail CLOSED for * that gate rather than silently proceeding as if it had passed. * * The merged registry is materialized by the canonical `buildRegistry` * (re-exported from the generator, which ships) over a cap-map reconstructed * from the frozen registry's capability objects plus the accepted overlay * capabilities — so every derived view (bySkill, byLoopPoint, configSchema, * capabilityClusters, profileMembership, …) is computed by exactly one builder * and cannot drift from the first-party path. * * Install never executes capability code here (staging/exec belongs to ADR-1244 * D3/D5); this module only READS and VALIDATES declarations. */ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; type Registry = Record; interface CapManifest { id: string; role?: string; version?: string; skills?: string[]; agents?: string[]; commands?: Array>; config?: Record; gates?: unknown[]; engines?: { gsd?: string }; } interface ValidatorModule { validateCapability: (cap: unknown, id: string) => string[]; /** Returns an error array (e.g. fragment path escapes the capability dir) — NOT a throw. */ materializeHookFragments: (cap: unknown, capDir: string) => string[]; validateAgainstContract: (cap: unknown, capId: string) => string[]; validateConsumesGlobal: (capMap: Map) => string[]; validateCrossCapability: (capMap: Map, centralKeys: Set) => string[]; } interface SemverModule { semverSatisfies: (version: unknown, range: unknown) => boolean; } interface ProjectRootModule { findProjectRoot: (startDir: string) => string | null; /** #1459 IC-01/CB-4: the canonical realpath'd consent project root (RECORD/LOOKUP/revoke parity). */ consentProjectRoot: (cwd: string) => string; } interface GeneratorModule { buildRegistry: (capMap: Map) => Registry; loadCentralConfigKeys: () => Set; } interface LedgerModule { /** THE single per-entry validator (shared with capability-ledger's readers) — loader parity. */ isValidLedgerEntry: (id: unknown, entry: unknown) => boolean; /** Shared fd-based bounded reader: content, null for ENOENT, or THROWS (non-regular/oversized/IO). */ readSmallRegularFile: (filePath: string, maxBytes: number) => string | null; } interface ConsentModule { /** * #1459 CB-1/CB-2: the consent decision is bound to the RECOMPUTED full-bundle content hash, not * the repo-plantable ledger integrity nor the executable-only disclosure signature. */ hasProjectConsent: (args: { gsdHome?: string; projectRoot: string; id: string; contentHash: string; }) => boolean; /** Recompute the full-bundle content hash over capDir (manifest AND artifacts AND identity). */ bundleContentHash: (capDir: string) => string; } export interface LoadRegistryOptions { /** When true, compose the validated installed overlay on top of first-party. */ includeInstalled?: boolean; /** Working directory used to locate the project-scoped overlay root. */ cwd?: string; /** Override the global overlay home (defaults to GSD_HOME env or os.homedir()). */ gsdHome?: string; /** Override the running GSD version used for engines.gsd satisfaction. */ hostVersion?: string; } export interface OverlaySkip { id: string; scope: 'global' | 'project'; reason: string; /** * #1459 IC-02: a STRUCTURAL discriminant for the skip so consumers (gsd-tools `list`) classify a * warning by `kind`, not by matching the human-readable `reason` prose (which is free to change). * `'unconsented'` is the project-scope no-consent-record case the list command marks INACTIVE; other * skips carry no `kind` (they are first-party-wins / validation / engines / pending diagnostics). */ kind?: 'unconsented'; } export interface BlockedGate { /** Loop extension point the skipped capability declared a gate at. */ point: string; /** The skipped capability's id. */ capId: string; /** Why the capability was skipped. */ reason: string; } export interface OverlayMeta { /** Capabilities skipped at load, with the reason (surfaced to the user). */ warnings: OverlaySkip[]; /** Skipped capabilities that declared a gate — the loop must fail CLOSED for these. */ incompatibleGateCapIds: string[]; /** * Per-point fail-closed records: for each gate a skipped capability declared at * a known loop point, the loop resolver must inject a blocking gate at that * point rather than proceeding as if the gate had passed. */ blockedGates: BlockedGate[]; /** * Absolute install-root directory for each ACCEPTED OVERLAY (third-party) capability that * declares `commands` — `capId → /.gsd/capabilities/`. First-party capabilities * are NOT listed here (their command modules ship in `bin/lib/`). ADR-1244 Phase 5 (D7) uses this * to dispatch a third-party command family by `require()`-ing its router module FROM the install * root, confined to that root. Only committed (non-`_pending`) capabilities reach this map, so its * presence is the consent+commit signal a runtime dispatcher needs. */ commandRoots: Record; } const RESERVED_ID_PREFIX = /^(gsd-|gsd-core-|anthropic-)/; const GSD_HOME_DIRNAME = '.gsd'; /** * GENEROUS DoS backstop for the bounded per-scope ledger read (mirrors capability-ledger's * LEDGER_MAX_BYTES). The project-scope ledger is repo-plantable untrusted content; reading it via * the shared fd reader (regular-file + size cap) means a FIFO/device/symlinked ledger can no longer * BLOCK (the #1459 raw-readFileSync hang) or read unbounded. */ const LEDGER_MAX_BYTES = 8 * 1024 * 1024; /** * #1459 finding 2 (HIGH): GENEROUS DoS backstop on a project-plantable `capability.json`. The loader * MUST read the manifest via the shared bounded fd reader (regular-file + size cap, no FIFO hang), * NOT a raw `fs.readFileSync` — a repo-planted FIFO/device manifest would otherwise BLOCK the loader * forever and an oversized manifest would read unbounded into memory (OOM). A legitimate manifest is a * few KiB of declarative JSON; 8 MiB is wildly more than any real capability.json. A null/oversized/ * non-regular read → SKIP the overlay (warning), fail-closed. */ const MANIFEST_MAX_BYTES = 8 * 1024 * 1024; function errMessage(e: unknown): string { return e instanceof Error ? e.message : String(e); } // --------------------------------------------------------------------------- // Test seams (#1461). The validator and generator are normally `require()`d // fresh inside loadRegistry. These optional overrides let a test inject a // validator whose cross-capability check THROWS (OVL-1) or a generator whose // buildRegistry THROWS (OVL-2), to prove the loader still NEVER crashes the // loop — it skips the offending overlay with a warning / falls back to the // frozen first-party registry. Pass null to restore the real module. // --------------------------------------------------------------------------- let _validatorOverride: ValidatorModule | null = null; let _generatorOverride: GeneratorModule | null = null; /** Test seam: override the capability validator module. Pass null to restore. */ function _setValidatorForTest(v: ValidatorModule | null): void { _validatorOverride = v; } /** Test seam: override the registry generator module. Pass null to restore. */ function _setGeneratorForTest(g: GeneratorModule | null): void { _generatorOverride = g; } /** Resolve the running GSD version; fail-closed to '0.0.0' if it cannot be read. */ function readHostVersion(): string { try { // gsd-core/bin/lib/ -> repo/package root is three levels up. // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const pkg: { version?: string } = require('../../../package.json'); return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0'; } catch { return '0.0.0'; } } /** * Canonicalize a directory path for dedup/scope-escalation comparison. #1459 finding 1 (HIGH): the dedup * MUST collapse two DIFFERENT LEXICAL paths that name the SAME PHYSICAL directory (a symlink) to one key, * else a symlinked GSD_HOME aliasing the project root is scanned once as trusted 'global' BEFORE the * 'project' scan and the in-repo `.gsd/capabilities` bundle bypasses the CB-3 consent gate via aliasing. * `fs.realpathSync` resolves symlinks to the physical path; on ENOENT/IO error it falls back to * `path.resolve` (a not-yet-created overlay dir cannot be realpath'd). * * #1459 CONVERGENCE finding 3 (LOW/MED): the realpath FAILURE must be reported to the caller (the * `realpathFailed` flag), NOT silently swallowed. The old behavior — fall back to `path.resolve` while * preserving the candidate's ORIGINAL scope — was not strictly fail-safe: a symlinked GSD_HOME whose * realpath THROWS (a race / odd-FS) would key on its SYMLINK-LEXICAL path, which differs from the * project candidate's realpath'd key, so the two would NOT merge and the aliased global root would be * scanned as trusted-'global' (no consent record required) — parking an aliased project tree in the * trusted-global slot. The caller (`overlayRoots`) uses `realpathFailed` to classify a realpath-failed * GLOBAL candidate CONSERVATIVELY (consent-required 'project'), so a race/odd-FS can never aliased-upgrade * an in-repo bundle to trusted-global. The fallback key is still `path.resolve` (best-effort dedup); a * normal ENOENT (the global capabilities dir simply does not exist yet) still resolves to no scan because * the later readdir fails — the conservative reclassification is harmless when there is nothing to read. */ function canonicalDir(dir: string): { path: string; realpathFailed: boolean; enoent: boolean } { try { return { path: fs.realpathSync(dir), realpathFailed: false, enoent: false }; } catch (err) { // #1459 finding 1 (round 6): distinguish a NON-EXISTENT overlay dir (ENOENT — there is simply nothing // to scan at that scope, so the fail-safe demotion must NOT fire) from a realpath that fails for ANOTHER // reason (race / odd-FS / EIO / EACCES — the dir may exist but is uncanonicalizable, so we cannot prove // physical distinctness and MUST fail safe toward needs-consent). const code = (err as NodeJS.ErrnoException).code; const enoent = code === 'ENOENT' || code === 'ENOTDIR'; return { path: path.resolve(dir), realpathFailed: true, enoent }; } } /** * The ordered overlay install roots (global first, then project), deduped by * CANONICAL (realpath'd) absolute path so a single physical directory is never scanned twice (which * would otherwise self-report a spurious id collision when the project lives * under the GSD home, or in tests where both resolve to the same fixture). * * #1459 CB-3: when the consent-global home resolves EQUAL to (or an ancestor whose .gsd collides with) * a GENUINE project root, the global overlay dir and the project overlay dir are the SAME directory. * The dedup must NOT then keep it as 'global' (trusted, no consent record required) — that would let an * in-repo bundle bypass consent simply because GSD_HOME pointed at the repo. On a collision the * surviving scope escalates to the MORE RESTRICTIVE 'project' (consent-required), but ONLY when the * colliding root is a GENUINE marker'd project (a `.planning/` dir or a `.git`). `findProjectRoot` is * total — it returns `cwd` itself when no marker exists — so a bare GSD_HOME with no project marker * (the user's own home; also the test-fixture `cwd === home` no-op) must stay 'global' and NOT spuriously * demand consent. * * #1459 finding 1 (HIGH): BOTH the dedup key AND the CB-3 collision comparison are keyed on the * realpath'd path (canonicalDir), so a symlinked GSD_HOME that physically IS the project root collides * and escalates to consent-required 'project' — it can no longer be aliased into the trusted-global slot. * * #1459 finding 1 (HIGH, ROUND 6): the trusted-global slot is now gated on PROVABLE distinctness from the * project tree — realpath(global) AND realpath(project) must BOTH succeed AND resolve to DIFFERENT physical * paths. The earlier one-sided rule (demote only a realpath-FAILED *global* candidate) still allowed the * symlinked-GSD_HOME bypass: when GSD_HOME aliases the project root, the GLOBAL candidate realpaths fine * while the PROJECT candidate's realpath fails, so the keys never collide and the in-repo bundle stays in * the no-consent global slot. If distinctness cannot be proven (either realpath throws, or both resolve * EQUAL) AND there is a genuine project root, the global is demoted to consent-required 'project'. */ function hasGenuineProjectMarker(dir: string): boolean { try { const planning = path.join(dir, '.planning'); if (fs.existsSync(planning) && fs.statSync(planning).isDirectory()) return true; } catch { /* fall through */ } try { if (fs.existsSync(path.join(dir, '.git'))) return true; } catch { /* fall through */ } return false; } function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope: 'global' | 'project' }> { const roots: Array<{ dir: string; scope: 'global' | 'project' }> = []; const byPath = new Map(); const add = (dir: string, scope: 'global' | 'project', canonical: { path: string; realpathFailed: boolean }, genuineProject = false): void => { const resolved = path.resolve(dir); // #1459 finding 1: the DEDUP KEY (and thus the CB-3 scope-escalation comparison) is the CANONICAL // (realpath'd) path, so a symlinked GSD_HOME that physically IS the project root collides here (and // escalates below) instead of being scanned as a distinct trusted 'global' root. The SCANNED path // (`entry.dir`) stays the lexical `path.resolve` value — the readdir/commandRoots path is unchanged // for the common (non-symlinked) case; only the dedup/escalation decision is realpath-aware. const key = canonical.path; const existing = byPath.get(key); if (existing) { // CB-3: a dir already claimed escalates to the more restrictive scope ONLY for a GENUINE project // root — so a real GSD_HOME == projectRoot (incl. via a symlink) still requires consent, while a // marker-less home stays trusted-global (and the test-fixture cwd===home no-op is preserved). if (existing.scope === 'global' && scope === 'project' && genuineProject) existing.scope = 'project'; return; } const entry = { dir: resolved, scope }; byPath.set(key, entry); roots.push(entry); }; const home = gsdHome || process.env['GSD_HOME'] || os.homedir(); const globalDir = path.join(home, GSD_HOME_DIRNAME, 'capabilities'); const globalCanon = canonicalDir(globalDir); let projectRoot: string | null = null; try { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const projectRootMod: ProjectRootModule = require('./project-root.cjs'); projectRoot = projectRootMod.findProjectRoot(cwd); } catch { projectRoot = null; } const projectDir = projectRoot ? path.join(projectRoot, GSD_HOME_DIRNAME, 'capabilities') : null; const projectCanon = projectDir ? canonicalDir(projectDir) : null; // #1459 finding 1 (HIGH, round 6): the global overlay root is trusted (consent-FREE) ONLY when we can // PROVE it is a distinct physical directory from the project overlay tree — i.e. realpath(global) AND // realpath(project) BOTH succeed AND resolve to DIFFERENT physical paths. A one-sided rule (demote only a // realpath-FAILED *global* candidate) left the symlinked-GSD_HOME bypass open: when GSD_HOME is a symlink // alias of the project root, the GLOBAL candidate realpaths fine (stays trusted-global) while the PROJECT // candidate's realpath fails → the two keys never collide → the in-repo bundle stays in the no-consent // global slot. So the global is demoted to consent-required 'project' (only when there IS a GENUINE // project root, so a marker-less home / cwd===home stays trusted-global) whenever distinctness cannot be // proven: EITHER realpath throws, OR both succeed but resolve EQUAL (an alias). When the demoted-global // and the project candidate physically coincide they then dedup onto one consent-required entry; when // they are merely unprovable-distinct (e.g. global realpath failed) the global is independently demoted // so an aliased in-repo tree it would scan still requires a record. A genuinely non-existent global dir // (ENOENT) realpath-fails too, but its later readdir fails, so this demotion is a harmless no-op there. let globalScope: 'global' | 'project' = 'global'; if (projectRoot && projectCanon && hasGenuineProjectMarker(projectRoot)) { // The fail-safe only matters when there IS an in-repo overlay tree to protect. A NON-EXISTENT project // overlay dir (ENOENT) has nothing to bypass into the trusted-global slot, so the global stays trusted // (and a genuinely distinct real global cap is not spuriously demoted — the control case). Otherwise, // demote the global to consent-required 'project' UNLESS we can PROVE physical distinctness: // - the project overlay actually exists (or can't be proven absent), AND // - either realpath can't canonicalize one side (race/odd-FS → can't prove distinct), OR // - both canonicalize EQUAL (an alias — GSD_HOME physically IS the project root). const projectAbsent = projectCanon.realpathFailed && projectCanon.enoent; if (!projectAbsent) { const provablyDistinct = !globalCanon.realpathFailed && !projectCanon.realpathFailed && globalCanon.path !== projectCanon.path; if (!provablyDistinct) globalScope = 'project'; } } add(globalDir, globalScope, globalCanon); if (projectDir && projectCanon) { add(projectDir, 'project', projectCanon, hasGenuineProjectMarker(projectRoot as string)); } return roots; } /** * Resolve the PROJECT ROOT for `cwd` used to LOOK UP a project-scope consent record (#1459). Delegates * to the SINGLE canonical `consentProjectRoot` helper (IC-01/CB-4) so the loader's lookup key always * matches the install RECORD key and the `trust revoke` key — installing from a subdir then resolves * to the same realpath'd project root the loader checks (no install-then-inactive). Falls back to * `cwd` if the project-root module cannot be loaded at all (the consent store realpaths it). */ function projectRootFor(cwd: string): string { try { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const projectRootMod: ProjectRootModule = require('./project-root.cjs'); return projectRootMod.consentProjectRoot(cwd); } catch { /* fall through */ } return cwd; } /** * Read the per-scope ledger co-located with an overlay root (the root is `/.gsd/capabilities`, * so its ledger is `/.gsd-capabilities.json`) and classify its ids: * - `pending`: ids carrying an in-flight `_pending` intent (crashed/uncommitted install/upgrade) * — must not be activated until reconciliation completes. * - `committed`: ids with a ledger entry and NO `_pending` — i.e. an install the user actually * completed (and, for executable surfaces, CONSENTED to). This is the authoritative * consent signal required before dispatching a capability's CLI COMMANDS (ADR-1244 * Phase 5 / D7): a bundle merely dropped on disk with no ledger entry is NOT * consented and its command family must not be dispatchable. * Never throws: a missing/invalid ledger yields empty sets. */ /** * Is `e` a structurally-valid COMMITTED ledger entry for `id`? Delegates the structural shape to * capability-ledger's SHARED `isValidLedgerEntry` (loader/ledger validator PARITY — #1459 ROOT FIX: * the loader previously hand-duplicated the shape and could drift), and ADDS the loader-specific * "committed = valid AND carries NO `_pending` marker" semantic. A malformed/tampered/pending entry * fails this check and is therefore NOT treated as committed — fail closed. */ function isCommittedLedgerEntry(ledger: LedgerModule, id: string, e: unknown): boolean { if (!e || typeof e !== 'object' || Array.isArray(e)) return false; if (Object.prototype.hasOwnProperty.call(e as Record, '_pending')) return false; // intent ⇒ uncommitted. return ledger.isValidLedgerEntry(id, e); } function ledgerOverlayIds(ledger: LedgerModule, rootDir: string): { pending: Set; committed: Set; } { const pending = new Set(); const committed = new Set(); try { const ledgerPath = path.join(rootDir, '..', '..', '.gsd-capabilities.json'); // #1459 (HIGH): read the per-scope ledger via the SHARED fd-based bounded reader (open → fstat → // require regular file → size cap → read exactly size). The previous raw `fs.readFileSync` BLOCKED // forever on a repo-planted FIFO ledger (a project-scope DoS) and read an oversized file whole. const content = ledger.readSmallRegularFile(ledgerPath, LEDGER_MAX_BYTES); if (content === null) return { pending, committed }; // genuinely missing. const parsed: unknown = JSON.parse(content); if (!parsed || typeof parsed !== 'object') return { pending, committed }; const entries = (parsed as Record)['entries']; if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return { pending, committed }; for (const [id, entry] of Object.entries(entries as Record)) { if (!entry || typeof entry !== 'object') continue; if ((entry as Record)['_pending']) { pending.add(id); // a truthy in-flight intent — defer/skip until reconciliation } else if (isCommittedLedgerEntry(ledger, id, entry)) { committed.add(id); // a genuine, structurally-valid commit } // else: malformed / tampered / falsy-_pending → neither (fail closed: declarative-only) } } catch { /* missing/invalid/non-regular/oversized ledger — no pending, no committed (fail closed) */ } return { pending, committed }; } /** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */ function withOverlayMeta(reg: Registry, meta: OverlayMeta): Registry { return Object.assign({}, reg, { _overlay: meta }); } /** * Loop extension points a capability declares a gate at (the `point` strings off `cap.gates`). * SINGLE source of truth shared by BOTH the per-candidate `skip()` closure AND the OVL-2 * buildRegistry-failure fallback (#1461) so a dropped gate-declaring overlay fails CLOSED via the * SAME extraction the per-candidate path uses — never one path blocking and the other failing open. * * #1461 finding 1 (HIGH): this MUST be TOTAL over an UNTRUSTED, possibly-malformed manifest — it * runs on a candidate BEFORE per-candidate validation has confirmed the shape. A null `cap`, a * non-object `cap`, a non-array `cap.gates` (e.g. `gates: {}` / `gates: null`), or a malformed gate * ENTRY (`gates: [null]` / `gates: ["x"]` / a gate with a non-string `point`) must NEVER throw: it * returns only the extractable `point` strings, filtering null/non-object/malformed entries. A * `null` gate has no extractable point, so it contributes nothing (no spurious fail-closed block). */ function gatePointsOf(cap: unknown): string[] { if (!cap || typeof cap !== 'object') return []; const gates = (cap as { gates?: unknown }).gates; if (!Array.isArray(gates)) return []; return (gates as unknown[]) .map((g) => g && typeof g === 'object' && typeof (g as Record).point === 'string' ? ((g as Record).point as string) : null, ) .filter((p): p is string => typeof p === 'string'); } /** * Load the capability registry, optionally composing the installed overlay. * * @returns the registry object (same shape as `capability-registry.cjs`). When * overlays are considered, an `_overlay` field carries skip warnings and the * fail-closed gate list. With `includeInstalled` falsy, the frozen first-party * registry is returned unchanged (identity-stable). */ export function loadRegistry(options: LoadRegistryOptions = {}): Registry { // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const base: Registry = require('./capability-registry.cjs'); if (!options.includeInstalled) return base; // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const validator: ValidatorModule = _validatorOverride ?? require('./capability-validator.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const semver: SemverModule = require('./semver-compare.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const ledgerMod: LedgerModule = require('./capability-ledger.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const consentMod: ConsentModule = require('./capability-consent.cjs'); const cwd = options.cwd || process.cwd(); const hostVersion = options.hostVersion || readHostVersion(); // The user-owned consent home — SAME `gsdHome || GSD_HOME || homedir()` rule the CLI uses, so the // consent the CLI records is the consent the loader checks. The consent store NEVER lives in a repo. const gsdHome = options.gsdHome || process.env['GSD_HOME'] || os.homedir(); const warnings: OverlaySkip[] = []; const incompatibleGateCapIds: string[] = []; const blockedGates: BlockedGate[] = []; const commandRoots: Record = {}; const overlayCaps: CapManifest[] = []; // First-party reservations — first-party always wins. const fpCaps = (base.capabilities ?? {}) as Record; const fpBySkill = (base.bySkill ?? {}) as Record; const fpByAgent = (base.byAgent ?? {}) as Record; const fpConfigKeys = (base.configKeys ?? {}) as Record; const fpConfigSchema = (base.configSchema ?? {}) as Record; const fpFamilies = (base.commandFamilies ?? {}) as Record; const fpIds = new Set(Object.keys(fpCaps)); const claimedSkills = new Set(Object.keys(fpBySkill)); const claimedAgents = new Set(Object.keys(fpByAgent)); const claimedConfig = new Set([...Object.keys(fpConfigKeys), ...Object.keys(fpConfigSchema)]); const claimedFamilies = new Set(Object.keys(fpFamilies)); const acceptedIds = new Set(); // Running merged cap-map (first-party ∪ accepted overlays). A candidate is // accepted only if the FULL cross-capability suite stays clean after adding it // (first-party alone is clean, so any new error is the candidate's fault) — the // overlay can never violate the same invariants the build-time generator enforces. const acceptedMap = new Map(Object.entries(fpCaps)); // Generator (buildRegistry + central config keys) loaded lazily — only when at // least one overlay candidate exists, so the no-overlay fast path stays cheap. let generatorMod: GeneratorModule | null = null; const getGenerator = (): GeneratorModule => { if (generatorMod) return generatorMod; // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment const mod: GeneratorModule = _generatorOverride ?? require('../../../scripts/gen-capability-registry.cjs'); generatorMod = mod; return mod; }; let centralKeys: Set | null = null; const getCentralKeys = (): Set => { if (!centralKeys) { try { centralKeys = getGenerator().loadCentralConfigKeys(); } catch { centralKeys = new Set(); } } return centralKeys; }; for (const root of overlayRoots(cwd, options.gsdHome)) { let entries: fs.Dirent[]; try { entries = fs.readdirSync(root.dir, { withFileTypes: true }); } catch { continue; // no overlay dir at this scope — normal } // Ids whose ledger entry carries an in-flight `_pending` intent (a crashed/uncommitted // install or upgrade). They are NOT yet committed, so they must not be activated — reconcile // will roll them forward or back. Fail OPEN (skip without a gate block): an uncommitted gate // is not a real installed gate. See capability-lifecycle.cts (ADR-1244 Phase 4). const { pending: pendingIds, committed: committedIds } = ledgerOverlayIds(ledgerMod, root.dir); for (const ent of entries) { if (!ent.isDirectory()) continue; const id = ent.name; const capDir = path.join(root.dir, id); const manifestPath = path.join(capDir, 'capability.json'); if (pendingIds.has(id)) { warnings.push({ id, scope: root.scope, reason: 'install/upgrade in progress (uncommitted) — deferred until reconciliation' }); continue; } let cap: CapManifest; try { // #1459 finding 2 (HIGH): read the manifest via the SHARED fd-based bounded reader (open → fstat // → require regular file → size cap → read exactly size). A project-planted FIFO/device manifest // can no longer BLOCK the loader (the raw readFileSync hang) and an oversized manifest can no // longer read unbounded. A null read (genuinely missing OR refused as non-regular/oversized) → // skip the overlay, fail-closed. const manifestRaw = ledgerMod.readSmallRegularFile(manifestPath, MANIFEST_MAX_BYTES); if (manifestRaw === null) { warnings.push({ id, scope: root.scope, reason: 'capability.json missing, non-regular (FIFO/device), or exceeds the size cap — skipped' }); continue; } cap = JSON.parse(manifestRaw) as CapManifest; } catch (e) { warnings.push({ id, scope: root.scope, reason: 'unreadable or invalid capability.json: ' + errMessage(e) }); continue; } // Points at which this capability declares a gate — used to fail CLOSED if // the capability is skipped (a skipped deploy gate must block, not pass). const gatePoints: string[] = gatePointsOf(cap); const declaresGate = gatePoints.length > 0; const skip = (reason: string): void => { warnings.push({ id, scope: root.scope, reason }); if (declaresGate) { incompatibleGateCapIds.push(id); for (const point of gatePoints) blockedGates.push({ point, capId: id, reason }); } }; // #1461 finding 1 (HIGH): make the ENTIRE per-candidate processing body TOTAL. The committed // validator is NOT total for malformed ARRAY entries — validateGate/validateStep/ // validateContribution dereference an entry (`.point`, `.into`, …) BEFORE any shape check, so a // manifest with `gates: [null]` (or `steps: [null]` / `contributions: [null]`) makes // validateCapability THROW `Cannot read properties of null (reading 'point')`. That throw was // OUTSIDE any per-candidate guard → it escaped loadRegistry and crashed EVERY consumer // (loop-resolver, config-loader, surface, capability-state, gsd-tools). ADR-1244 D2 mandates a // malformed overlay is SKIPPED with a warning, never crashes the loop. Wrapping the whole body // (manifest already parsed above) means ANY throw from ANY validator/step becomes a structured // `skip()` + continue to the next candidate — which ALSO fail-closes a declared gate (the `skip` // closure records incompatibleGateCapIds/blockedGates for the extractable gate points). The // existing structured skip/continue paths inside are unchanged; this is a fail-safe BACKSTOP for // a validator/step that THROWS rather than returning errors. `continue` inside this try simply // advances the `for` loop (there is no finally to interfere). try { // 1. Reserved namespace — third-party may not impersonate first-party. if (RESERVED_ID_PREFIX.test(id)) { skip('id uses a reserved first-party prefix (gsd-/gsd-core-/anthropic-)'); continue; } // 2. Per-capability structural + version-envelope validation. const errs = validator.validateCapability(cap, id); if (errs.length) { skip('failed validation: ' + errs.join('; ')); continue; } // 3. First-party wins + overlay/overlay de-dup on id, skill, agent, config key. if (fpIds.has(id) || acceptedIds.has(id)) { skip('id collides with an already-registered capability'); continue; } const skills: string[] = Array.isArray(cap.skills) ? cap.skills : []; const agents: string[] = Array.isArray(cap.agents) ? cap.agents : []; const cfgKeys: string[] = cap.config && typeof cap.config === 'object' && !Array.isArray(cap.config) ? Object.keys(cap.config) : []; const skillClash = skills.find((s) => claimedSkills.has(s)); if (skillClash) { skip('owns skill "' + skillClash + '" already owned by another capability'); continue; } const agentClash = agents.find((a) => claimedAgents.has(a)); if (agentClash) { skip('owns agent "' + agentClash + '" already owned by another capability'); continue; } const cfgClash = cfgKeys.find((k) => claimedConfig.has(k)); if (cfgClash) { skip('owns config key "' + cfgClash + '" already owned by another capability'); continue; } const families: string[] = Array.isArray(cap.commands) ? cap.commands .map((c) => (c && typeof c === 'object' && typeof c.family === 'string' ? c.family : null)) .filter((f): f is string => typeof f === 'string') : []; const familyClash = families.find((f) => claimedFamilies.has(f)); if (familyClash) { skip('owns command family "' + familyClash + '" already owned by another capability'); continue; } // 4. Load-time engines.gsd re-gate. const range = cap.engines?.gsd; if (typeof range === 'string' && range && !semver.semverSatisfies(hostVersion, range)) { skip('incompatible with GSD ' + hostVersion + ' (requires engines.gsd "' + range + '")'); continue; } // 5. #1459 — USER-OWNED CONSENT GATE (TRUST-1 + TRUST-3). For a PROJECT-scope overlay the // authoritative consent signal is NOT the in-repo ledger (repo-plantable: a clone/fork // activated executable surfaces AND declarative loop surfaces with no user decision) but a // record in the user-owned consent store on THIS machine, bound to (realpath(projectRoot), // id, RECOMPUTED full-bundle content hash). If there is NO matching record we do NOT push // the cap into acceptedMap/overlayCaps and do NOT set a commandRoot → the cap is // DISCOVERED-BUT-INACTIVE (a warning records why). This single gate closes BOTH // command-dispatch (TRUST-1) and declarative-surface (TRUST-3) activation. GLOBAL scope is // under the user's own home and is trusted as before (no consent record required). // // CONVERGENCE finding 1 (HIGH): this gate now runs BEFORE the heavy/unbounded pre-activation // work (materializeHookFragments — which reads each `fragment.path` off disk — and the full // cross-capability validation). A forged in-repo PROJECT overlay can point a `fragment.path` // at an in-bundle FIFO/oversized file; materializing it BEFORE the consent check would // hang/OOM the loader before the unconsented → inactive fail-closed path is reached. Running // the (already bounded + fail-closed) consent recompute FIRST means an unconsented project // overlay skips with NO further disk work. The gate's DECISION is identical — only the // work-ordering moved (consented project overlays + GLOBAL overlays still materialize below). // // CONTENT BINDING (#1459 round 2, CB-1/CB-2/TRUST2-5): the binding is the bundle CONTENT // HASH recomputed HERE over the on-disk capDir (manifest AND artifacts AND identity) — NOT // the ledger `integrity` (which is `''` for path/git/dir installs and taken verbatim from // the repo-plantable project ledger → degenerate `'' === ''`) and NOT the executable-only // disclosure signature (a declarative-only swap leaves it constant). Any tamper — a swapped // declarative capability.json, an edited hook script, an empty-integrity local install — // changes the recomputed hash and the cap stays inactive. `bundleContentHash` is itself // bounded + fail-closed (it refuses non-regular bundle files and reads via the shared bounded // reader), so it cannot hang on a forged FIFO bundle file. The whole lookup is wrapped so a // consent-store read / hash-recompute failure fails CLOSED (inactive), never crashing the // loop (the loader must stay non-throwing end to end). // // IRREDUCIBLE TOCTOU LIMIT (#1459 / mirrors the #1462 lock-release residual): the hash // verified HERE binds the bundle's on-disk content at THIS instant. A local writer racing // between this verification and the capability's LATER execution (a hook firing, a command // dispatch) can still mutate the bundle files after the check passes — this is a filesystem // primitive limit, not a loader bug: short of fd-pinned execution or an atomic content // snapshot (which needs native support we do not have here), no userspace check can close the // window between "verify content" and "execute content". This is documented, not dismissed: // the gate is the strongest defense available at this layer (any persisted tamper is caught on // the NEXT load), and the residual race requires an attacker already able to write the project // tree at execution time. if (root.scope === 'project') { let consented = false; try { consented = consentMod.hasProjectConsent({ gsdHome, projectRoot: projectRootFor(cwd), id, contentHash: consentMod.bundleContentHash(capDir), }); } catch { consented = false; // fail closed — a consent-store/hash-recompute failure never activates a cap. } if (!consented) { // DISCOVERED-BUT-INACTIVE: no user consent record on this machine. NOT a gate block (an // unconsented project gate is not a real installed gate — same fail-open posture as // `_pending`); it simply does not contribute any surface. #1459 IC-02: tag the skip with the // structural `kind: 'unconsented'` so gsd-tools `list` marks it INACTIVE by discriminant, not // by matching the (changeable) reason prose. NOTE (convergence finding 1): we `continue` here // BEFORE materializeHookFragments, so an unconsented project overlay's fragment files are never // read — a forged FIFO/oversized fragment cannot hang/OOM the loop. warnings.push({ id, scope: root.scope, kind: 'unconsented', reason: 'discovered — no user consent record (inactive)' }); continue; } } // 5b. Materialize path-based hook fragments (resolved against the overlay dir). Runs AFTER the // project consent gate (convergence finding 1) so only a CONSENTED project overlay (or a // trusted GLOBAL overlay) reaches the fragment reads. materializeHookFragments RETURNS errors // (e.g. a fragment path escaping the capability dir, OR — convergence finding 1(b) — a fragment // that is non-regular/oversized and refused by the shared bounded reader) — capture them; an // un-materializable fragment is a skip, never a hang. let fragErrs: string[]; try { fragErrs = validator.materializeHookFragments(cap, capDir) || []; } catch (e) { skip('hook fragment could not be materialized: ' + errMessage(e)); continue; } if (fragErrs.length) { skip('invalid hook fragment: ' + fragErrs.join('; ')); continue; } // 6. Full cross-capability validation over the merged set (the same invariants // the build-time generator enforces): contract roles, consumes-satisfiability, // owner-uniqueness, config-key exclusivity vs central schema, requires acyclicity // + tier-monotone. Incremental: add the candidate, validate, drop on any error. acceptedMap.set(id, cap); // #1461 OVL-1 (HIGH): these validators are CONTRACTED to RETURN error arrays, but one can THROW // (e.g. validateConsumesGlobal asserting on a duplicate producer). An unguarded throw here // escapes loadRegistry and crashes EVERY consumer (loop-resolver, config-loader, surface, // capability-state, gsd-tools). ADR-1244 D2: a malformed overlay is SKIPPED with a warning, // never crashes the loop. So a throwing validator is treated EXACTLY like a validation failure: // drop this one candidate (with a warning) and continue — the rest of the overlay set is // unaffected. (The returns-errors path below is unchanged.) let crossErrs: string[]; try { crossErrs = [ ...validator.validateAgainstContract(cap, id), ...validator.validateConsumesGlobal(acceptedMap), ...validator.validateCrossCapability(acceptedMap, getCentralKeys()), ]; } catch (e) { acceptedMap.delete(id); skip('cross-capability validation error: ' + errMessage(e)); continue; } if (crossErrs.length) { acceptedMap.delete(id); skip('cross-capability validation failed: ' + crossErrs.slice(0, 3).join('; ')); continue; } // Accepted. overlayCaps.push(cap); acceptedIds.add(id); for (const s of skills) claimedSkills.add(s); for (const a of agents) claimedAgents.add(a); for (const k of cfgKeys) claimedConfig.add(k); for (const f of families) claimedFamilies.add(f); // Record the install root for a third-party cap that ships command modules, so a runtime // dispatcher can require() the router FROM the install root (ADR-1244 Phase 5 / D7). Gated on // a COMMITTED ledger entry (committedIds): executable CLI commands run only for a capability // the user actually installed+consented to via the lifecycle — a bundle merely dropped on // disk with no ledger entry provides declarative surfaces (Phase 2) but is NOT command- // dispatchable. (Project-scope ledgers live in the repo tree and are thus only as trustworthy // as the repo — see docs/explanation/capability-trust-model.md.) if (families.length > 0 && committedIds.has(id)) commandRoots[id] = capDir; } catch (e) { // #1461 finding 1 (HIGH): ANY throw from ANY validator/step in the per-candidate body lands // here — drop just THIS candidate with a structured skip-warning and continue with the rest of // the overlay set (the loop is never crashed). `skip()` ALSO fail-closes the candidate's // declared gates (incompatibleGateCapIds/blockedGates) so a malformed gate-declaring overlay // blocks rather than silently passing. Remove any half-committed acceptedMap entry so the // partially-processed candidate cannot leak into the final buildRegistry compose. acceptedMap.delete(id); skip('overlay processing error: ' + errMessage(e)); continue; } } } const meta: OverlayMeta = { warnings, incompatibleGateCapIds, blockedGates, commandRoots }; if (overlayCaps.length === 0) { // Nothing to compose. Return the frozen registry unchanged when there is // also nothing to report (identity-stable); otherwise attach diagnostics. if (warnings.length === 0) return base; return withOverlayMeta(base, meta); } // Compose via the canonical builder so every derived view matches first-party. // acceptedMap already holds first-party ∪ accepted overlays (validated above). // // #1461 OVL-2 (HIGH): an overlay can pass every per-candidate step yet trip a STRICTER whole-build // check inside buildRegistry (config-slice shape, topo cycle across the merged set, configFormat // parity). An unguarded buildRegistry throw escapes loadRegistry and crashes the loop. ADR-1244 D2 // mandates NEVER-CRASH: on a compose failure, fall back to the frozen FIRST-PARTY registry plus a // warning recording why — the loop still gets a usable registry, just without the overlay surfaces. try { const merged = getGenerator().buildRegistry(acceptedMap); return withOverlayMeta(merged, meta); } catch (e) { const reason = 'buildRegistry failed composing overlays: ' + errMessage(e) + '; falling back to first-party'; meta.warnings.push({ id: '*', scope: 'global', reason }); // #1461 finding 3 (LOW): the fallback DROPS every accepted overlay, so NO dropped overlay may // retain a command root. A stale `commandRoots[capId]` would let a runtime dispatcher require()/ // run a third-party command family FROM the install root of a capability the fallback decided NOT // to load. Clear the map (the first-party base never lists overlay commandRoots — first-party // command modules ship in bin/lib/, not via _overlay.commandRoots). meta.commandRoots = {}; // #1461 OVL-2 fail-CLOSED on compose failure (HIGH): the fallback DROPS every accepted overlay, // so any accepted overlay that DECLARED a gate would have its gate silently vanish → a blocking // gate FAILS OPEN, violating ADR-1244 (a skipped capability declaring a gate must FAIL CLOSED). // Record each dropped gate-declaring overlay's gate as blocked using the SAME extraction the // per-candidate `skip()` closure uses (gatePointsOf), so loop-resolver injects the synthetic // blocking gate at each declared point exactly as it would for a per-candidate skip. for (const cap of overlayCaps) { const gatePoints = gatePointsOf(cap); if (gatePoints.length === 0) continue; meta.incompatibleGateCapIds.push(cap.id); for (const point of gatePoints) meta.blockedGates.push({ point, capId: cap.id, reason }); } return withOverlayMeta(base, meta); } } module.exports = { loadRegistry, _setValidatorForTest, _setGeneratorForTest };