Files
msd-core/src/capability-loader.cts

826 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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/<id>/capability.json (GSD_HOME defaults to ~)
* - project: <projectRoot>/.gsd/capabilities/<id>/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<string, unknown>;
interface CapManifest {
id: string;
role?: string;
version?: string;
skills?: string[];
agents?: string[];
commands?: Array<Record<string, unknown>>;
config?: Record<string, unknown>;
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, unknown>) => string[];
validateCrossCapability: (capMap: Map<string, unknown>, centralKeys: Set<string>) => 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<string, unknown>) => Registry;
loadCentralConfigKeys: () => Set<string>;
}
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 → <scope>/.gsd/capabilities/<capId>`. 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<string, string>;
}
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<string, { dir: string; scope: 'global' | 'project' }>();
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 `<scope>/.gsd/capabilities`,
* so its ledger is `<scope>/.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<string, unknown>, '_pending')) return false; // intent ⇒ uncommitted.
return ledger.isValidLedgerEntry(id, e);
}
function ledgerOverlayIds(ledger: LedgerModule, rootDir: string): {
pending: Set<string>;
committed: Set<string>;
} {
const pending = new Set<string>();
const committed = new Set<string>();
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<string, unknown>)['entries'];
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return { pending, committed };
for (const [id, entry] of Object.entries(entries as Record<string, unknown>)) {
if (!entry || typeof entry !== 'object') continue;
if ((entry as Record<string, unknown>)['_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<string, unknown>).point === 'string'
? ((g as Record<string, unknown>).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<string, string> = {};
const overlayCaps: CapManifest[] = [];
// First-party reservations — first-party always wins.
const fpCaps = (base.capabilities ?? {}) as Record<string, unknown>;
const fpBySkill = (base.bySkill ?? {}) as Record<string, unknown>;
const fpByAgent = (base.byAgent ?? {}) as Record<string, unknown>;
const fpConfigKeys = (base.configKeys ?? {}) as Record<string, unknown>;
const fpConfigSchema = (base.configSchema ?? {}) as Record<string, unknown>;
const fpFamilies = (base.commandFamilies ?? {}) as Record<string, unknown>;
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<string>();
// 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<string, unknown>(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<string> | null = null;
const getCentralKeys = (): Set<string> => {
if (!centralKeys) {
try {
centralKeys = getGenerator().loadCentralConfigKeys();
} catch {
centralKeys = new Set<string>();
}
}
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 };