From 1abebbf4fd5698555b1fd74c1e20c54600185da5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 18 Jun 2026 23:37:43 -0400 Subject: [PATCH] feat(#1434): registry-driven dispatch for third-party capabilities (ADR-1244 Phase 5) (#1450) ADR-1244 Phase 5 (D7). dispatchOverlayCapabilityCommand in gsd-tools.cjs dispatches an installed third-party capability command family via loadRegistry({includeInstalled}), gated on a committed ledger entry (consent) and confined to the capability's install root (defaultRequireFromInstallRoot: bare-.cjs basename + realpath containment, rejects ../ traversal + symlink escape); same own-property/function/sync/ExitError guards as the first-party path. capability-loader records _overlay.commandRoots only for accepted overlay caps with a committed, structurally-valid ledger entry (fail closed). First-party graphify/intel/audit unchanged (already on the registry seam). 3 Codex rounds converged + /security-review (no HIGH) + /code-review (Approve); gsd-test green both platforms; CI green. Closes #1434. Co-Authored-By: Claude Opus 4.8 --- .changeset/plucky-tigers-dart.md | 5 + CONTEXT.md | 3 + docs/ARCHITECTURE.md | 9 + docs/COMMANDS.md | 8 + .../explanation/the-capability-trust-model.md | 37 +++ gsd-core/bin/gsd-tools.cjs | 123 ++++++++- src/capability-loader.cts | 76 +++++- tests/capability-command-dispatch.test.cjs | 243 +++++++++++++++++- tests/capability-loader.test.cjs | 86 +++++++ 9 files changed, 576 insertions(+), 14 deletions(-) create mode 100644 .changeset/plucky-tigers-dart.md diff --git a/.changeset/plucky-tigers-dart.md b/.changeset/plucky-tigers-dart.md new file mode 100644 index 000000000..755246961 --- /dev/null +++ b/.changeset/plucky-tigers-dart.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1450 +--- +**Third-party capabilities can ship dispatchable CLI commands (ADR-1244 Phase 5)** — a capability that declares a `commands` family is now dispatched by `gsd-tools ` via the registry, the same seam the first-party `graphify`/`intel`/`audit` commands already use. Third-party command dispatch runs only for an installed, consented capability (a committed ledger entry) and loads the router module strictly from that capability's own install root (basename + realpath confinement, rejecting `..` traversal and symlink escape); a bundle merely present on disk with no install record keeps its declarative surfaces but is never command-dispatchable. (#1450) diff --git a/CONTEXT.md b/CONTEXT.md index 66409c256..e3fb5eed0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -193,6 +193,9 @@ ADR-1244 Phase 4 (D5) PURE policy module (`gsd-core/bin/lib/capability-trust.cjs ### Capability Lifecycle ADR-1244 Phase 4 (D5+D6) orchestration seam (`gsd-core/bin/lib/capability-lifecycle.cjs`) composing the source resolver, ledger, and trust gate into the mutating operations. Exports: `installCapability` (pre-fetch source gate → resolve copy-only with `promote:false` → trust verdict → promote + apply marker-stamped shared edits → **ledger commit**; nothing written on block/abort), `upgradeCapability` (atomic stage-then-swap: old set aside, new swapped in, shared edits re-derived, **ledger committed**, backup dropped; re-prompts when the executable set changed), `removeCapability` (strip only `_gsdCapability`-marked shared-config entries — user hand-edits preserved — delete exactly the ledger-recorded files, then drop the entry; `CAPABILITY_DATA` preserved unless `removeData`), `reconcileCapabilities` (crash recovery driven by the ledger's `_pending {kind,backupName,sharedFiles}` INTENT — not a version comparison: roll an uncommitted upgrade back by restoring the backup, an uncommitted fresh install away entirely, and re-sync shared config from the winning bundle, guaranteeing no half-state), plus `applyCapabilitySharedEdits`/`stripCapabilitySharedEdits` (marker-isolated JSON edits, prototype-pollution-guarded). All four mutating ops + reconcile take a cross-process lock (`.gsd/capabilities/.lock`, atomic stale-steal) so a concurrent reconcile can't clear a live intent. Capability code never executes during any operation. The source resolver's `promote:false`/`skipEnginesGate` options are the seams that let this module own the swap/commit ordering and the engines gate (with `compatVersions` downgrade hint). +### Capability Command Dispatch +ADR-1244 Phase 5 (D7) registry-driven dispatch of capability command families. First-party families (`graphify`/`intel`/`audit`, shipped in `bin/lib/`) dispatch via `dispatchCapabilityCommand` (`gsd-core/bin/gsd-tools.cjs`) against the FROZEN `capability-registry.cjs` `commandFamilies` (confined to `bin/lib/`) — unchanged. Third-party (installed overlay) families dispatch via `dispatchOverlayCapabilityCommand`: after the first-party path returns false, it calls `loadRegistry({ includeInstalled, cwd })` and dispatches a family iff its `capId` is in `_overlay.commandRoots` — which `capability-loader.cjs` populates ONLY for accepted overlay capabilities that (a) declare `commands` AND (b) have a **committed** ledger entry (present, non-`_pending`); a bundle dropped on disk with no ledger entry is NOT command-dispatchable (consent gate). The router module is `require()`'d FROM the capability's install root via `defaultRequireFromInstallRoot` (bare-`.cjs` basename + `realpath` containment, rejecting `..` traversal and symlink escape); same own-property/function/sync-only guards as the first-party path. Wired into the `runCommand` default arm before "Unknown command". Project-scope ledgers live in the repo tree and are only as trustworthy as the repo — see `docs/explanation/the-capability-trust-model.md` "project-scope trust boundary". + ### Loop Extension Point A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in Execute; 12 total) where Capabilities register hooks. Three hook kinds: `step` (runs as its own sequenced unit), `contribution` (injects into the core step's prompt/context), and `gate` (checks and optionally blocks via a declared `blocking` flag). Each hook declares the artifacts it produces and consumes; hook order is derived by topological sort of that produces/consumes graph (capability-id tiebreak), which also defines data flow — file-artifact based, surviving `/clear` and fresh executor contexts. Hooks are surfaced by runtime resolution with concrete projection: the workflow calls a query that resolves the active hooks and returns fully-rendered, ordered markdown for the executor. Failure is default-resilient — a non-gate hook that errors is skipped with a warning; a hook may opt into `onError: halt`. Part of the Capability system. ADR-857 phase 3c ships the registry-consuming query layer: `gsd-core/bin/lib/loop-resolver.cjs` exposes `resolveLoopHooks({ point, registry, config })` (pure, no I/O), `renderLoopHooks(resolved)` (pure markdown renderer), and `cmdLoopRenderHooks(cwd, point, raw, opts)` (I/O entry point); activated via `gsd-tools loop render-hooks ` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9f2b62611..630293e65 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -306,6 +306,15 @@ See [`docs/INVENTORY.md`](INVENTORY.md#hooks) for the authoritative hook roster. CJS command family routers dispatch through `CommandRoutingHub`. The hub owns the no-throw pure-result contract (`hub.dispatch()` catches internal exceptions and returns `{ ok: false, kind, ...typedPayload }`) and the closed runtime error taxonomy (`UnknownCommand`, `InvalidArgs`, `HandlerRefusal`, `HandlerFailure`). Router adapters remain thin CLI translators — they build the hub, call `dispatch`, then map the Result to `output()`/`error()` calls. The runtime is single-path (no dual-runtime mode selection). See `docs/adr/0174-retire-gsd-sdk-package-boundary.md`. +### Capability Command Dispatch (`gsd-core/bin/gsd-tools.cjs`, ADR-1244 D7) + +Command families declared by capabilities (`commands: [{ family, module, router }]`) are dispatched from the registry rather than a hardcoded switch. The `runCommand` default arm tries, in order: + +1. **First-party** — `dispatchCapabilityCommand` against the frozen `capability-registry.cjs` `commandFamilies`, loading the router from `bin/lib/`. The in-tree families (`graphify`, `intel`, `audit`) reach their routers this way (the legacy hardcoded switch is retired). +2. **Third-party (installed overlay)** — `dispatchOverlayCapabilityCommand` calls `loadRegistry({ includeInstalled })` and dispatches a family only when its `capId` appears in `_overlay.commandRoots`. The loader lists a command root **only** for an accepted overlay capability with a **committed** ledger entry (consent gate), and the router module is `require()`'d **from that capability's install root**, confined by basename validation + `realpath` containment (rejecting `..` traversal and symlink escape). This is the one point where third-party capability code executes; see [the capability trust model](explanation/the-capability-trust-model.md) for the consent + confinement + project-scope trust boundary. + +Both paths share the same guards: prototype-pollution-safe command keys, an own-property router check, and synchronous-only routers (an async router is a fail-fast error). + ### Research Module (`src/research-{store,provider}.cts`, `src/package-legitimacy.cts`) The Research Module implements an **L2-hybrid seam**: code owns the cache, provider policy, and package legitimacy verdicts; MCP owns the actual network fetch. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index b5511fcb5..55db9cd12 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1644,6 +1644,14 @@ The check is also run as part of `npm test` via `tests/enh-2789-description-budg --- +## Capability commands (third-party) + +A capability can ship its own command family by declaring `commands: [{ family, module, router }]` in its `capability.json` (ADR-1244 D7). Once the capability is **installed and consented** (a committed entry exists in the per-runtime `.gsd-capabilities.json` ledger), running `gsd-tools …` (equivalently the `gsd ` wrapper) dispatches to the capability's router. The first-party families `graphify`, `intel`, and `audit-uat`/`audit-open` use exactly this registry-driven seam. + +Dispatch is gated for safety: the router module is loaded **only from the capability's own install root** (a bare `.cjs` basename, traversal- and symlink-confined), and a capability that is merely present on disk **without** a committed ledger entry is **not** command-dispatchable (its declarative skills/agents/config still load). A project-scoped capability's commands are only as trustworthy as the repository they ship in — see [The capability trust model](explanation/the-capability-trust-model.md). + +--- + ## Related - [Configuration Reference](CONFIGURATION.md) diff --git a/docs/explanation/the-capability-trust-model.md b/docs/explanation/the-capability-trust-model.md index 96b073e1f..ae65aca93 100644 --- a/docs/explanation/the-capability-trust-model.md +++ b/docs/explanation/the-capability-trust-model.md @@ -87,3 +87,40 @@ one you pinned," **not** "every line of code that will run is the code you revie who want a stronger guarantee should vendor their dependencies into the capability or ship a lockfile; the consent prompt always discloses when a capability ships command modules, which is the surface through which transitive code reaches your process. + +## Where third-party code runs: command dispatch (Phase 5 / D7) + +A capability may declare a **command family** (`commands: [{ family, module, router }]`). When you +run `gsd-tools …`, GSD dispatches it by `require()`-ing the named module's router function. +This is the one place a third-party capability's own code executes in the GSD CLI process, so it is +gated twice: + +1. **Consent.** A third-party family is dispatchable only if its capability has a **committed + ledger entry** — i.e. you installed it through the lifecycle (and, for executable surfaces, + consented). The loader records a dispatchable "command root" only for committed (non-`_pending`) + capabilities; a bundle merely *present* on disk with no ledger entry still contributes its + declarative surfaces (skills/agents/config) but is **not** command-dispatchable. +2. **Confinement.** The router module is loaded **from the capability's own install root** — a bare + `.cjs` basename, `realpath`-confined to that directory, rejecting `..` traversal and symlink + escape. A capability can never reach code outside its own bundle, and a first-party command + (`graphify`/`intel`/`audit`, which ship in `bin/lib/`) can never be shadowed by a third-party one. + +### The project-scope trust boundary (be honest about it) + +Capabilities can be installed **globally** (under your home, `$GSD_HOME/.gsd/capabilities/`) or +**project-scoped** (under a repository, `/.gsd/capabilities/`). The consent signal is +the ledger, and the project-scope ledger (`/.gsd-capabilities.json`) lives **inside the +repository**. A repository you check out can therefore ship both a capability bundle *and* a +ledger that marks it "committed." Running `gsd-tools ` inside such a repo will execute +its code. + +This is the same trust boundary that already applies to a repository's build scripts, npm +`postinstall`, or a project-scoped capability's hooks (which GSD has loaded since Phase 2) — and it +is **narrower** than those, because a command never fires on its own: you have to type it. GSD does +not (and with plain files cannot) cryptographically distinguish a genuine project-local install from +a forged project ledger. The honest guidance: + +- A **global** install's consent record lives outside any repo and is trustworthy. +- A **project-scoped** capability is only as trustworthy as the repository it ships in — treat + running its commands like running that repo's other code. Review repos before running `gsd` + commands in them, and prefer global installs for capabilities you want to trust across projects. diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index a4d6842e6..a952f421f 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -386,6 +386,120 @@ function dispatchCapabilityCommand({ command, args, cwd, raw, error, registry, r return true; } +/** + * Require a THIRD-PARTY capability's router module from its install root, confined to that root. + * The module name must be a bare `.cjs` basename (same conservative pattern the generator enforces). + * The install root is realpath-resolved (defeating symlinked path components) and the resolved + * module must live strictly inside it; the module file is then realpath-checked so a symlinked file + * cannot escape the root either. ADR-1244 Phase 5 (D7). + * + * @param {string} installRoot Absolute install-root dir of the owning capability + * @param {string} m Bare `.cjs` module basename from the capability manifest + * @returns {*} the required module + */ +function defaultRequireFromInstallRoot(installRoot, m) { + if (typeof m !== 'string' || !/^[A-Za-z0-9._-]+\.cjs$/.test(m)) { + throw new Error('capability module must be a bare .cjs basename: ' + JSON.stringify(m)); + } + // Realpath the root so a symlinked ancestor can't widen confinement. + const realRoot = fs.realpathSync(installRoot); + const resolved = path.resolve(realRoot, m); + if (resolved === realRoot || !resolved.startsWith(realRoot + path.sep)) { + throw new Error('capability module path escapes its install root: ' + JSON.stringify(m)); + } + // The module file itself must not be a symlink pointing outside the root. + const realResolved = fs.realpathSync(resolved); + if (realResolved !== realRoot && !realResolved.startsWith(realRoot + path.sep)) { + throw new Error('capability module resolves outside its install root (symlink): ' + JSON.stringify(m)); + } + return require(realResolved); +} + +/** + * Dispatch a THIRD-PARTY (installed overlay) capability command family — ADR-1244 Phase 5 (D7). + * This is where third-party code executes, so it is doubly gated: + * - CONSENT: `loadRegistry({ includeInstalled })` excludes `_pending` (unconsented) capabilities, + * and only third-party caps that declared `commands` appear in `_overlay.commandRoots`. A capId + * absent from `commandRoots` is first-party (handled by dispatchCapabilityCommand) or not an + * installed overlay — we fall through. + * - CONFINEMENT: the router module is `require()`'d FROM the capability's install root, confined to + * that root (basename validation + realpath containment), so a manifest can never reach code + * outside its own bundle. + * Returns true when consumed (suppress "Unknown command"), false to fall through. + * + * @param {object} opts + * @param {Function} [opts.loadRegistry] Injectable overlay loader (for tests) + * @param {Function} [opts.requireModule] Injectable (installRoot, module) loader (for tests) + */ +function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, loadRegistry, requireModule }) { + if (command === '__proto__' || command === 'constructor' || command === 'prototype') { + return false; + } + + let reg; + try { + const load = loadRegistry !== undefined ? loadRegistry : require('./lib/capability-loader.cjs').loadRegistry; + reg = load({ includeInstalled: true, cwd }); + } catch (_) { + return false; // overlay load failed — fall through to "Unknown command" + } + + const families = reg && reg.commandFamilies; + const commandRoots = reg && reg._overlay && reg._overlay.commandRoots; + if (!families || typeof families !== 'object' || !commandRoots || typeof commandRoots !== 'object') { + return false; // no installed overlay command families + } + + const entry = families[command]; + if (!entry || typeof entry !== 'object') return false; + + // Only THIRD-PARTY overlay caps are dispatched here. A capId present in commandRoots is an + // accepted, committed (consented) overlay cap; a capId absent is first-party or not an overlay. + const capId = entry.capId; + if (typeof capId !== 'string' || !Object.prototype.hasOwnProperty.call(commandRoots, capId)) { + return false; + } + const installRoot = commandRoots[capId]; + if (typeof installRoot !== 'string' || !installRoot) return false; + + const loadModule = requireModule !== undefined ? requireModule : defaultRequireFromInstallRoot; + let mod; + try { + mod = loadModule(installRoot, entry.module); + } catch (_) { + error('capability command "' + command + '" module "' + entry.module + '" failed to load from its install root'); + return true; // consumed — don't emit "Unknown command" + } + + if (!mod || !Object.prototype.hasOwnProperty.call(mod, entry.router)) { + error('capability command "' + command + '" router "' + entry.router + '" is not an own export of module "' + entry.module + '"'); + return true; + } + const fn = mod[entry.router]; + if (typeof fn !== 'function') { + error('capability command "' + command + '" router "' + entry.router + '" is not a function in module "' + entry.module + '"'); + return true; + } + + let _result; + try { + _result = fn({ args, cwd, raw, error }); + } catch (e) { + if (e instanceof ExitError) throw e; + error( + 'capability command "' + command + '" router "' + entry.router + '" in module "' + entry.module + '" threw: ' + (e && e.message ? e.message : String(e)), + ERROR_REASON.SDK_FAIL_FAST, + ); + } + if (_result && typeof _result.then === 'function') { + error( + 'capability command "' + command + '" router "' + entry.router + '" in module "' + entry.module + '" must be synchronous (returned a Promise); async capability routers are not supported.', + ERROR_REASON.SDK_FAIL_FAST, + ); + } + return true; +} + // ─── Arg parsing helpers ────────────────────────────────────────────────────── // ─── CLI Router ─────────────────────────────────────────────────────────────── @@ -2207,6 +2321,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // this returns true when a registered capability owns the command, false otherwise. if (dispatchCapabilityCommand({ command, args, cwd, raw, error })) break; + // ADR-1244 Phase 5 (D7): if no first-party family owns the command, try an INSTALLED + // THIRD-PARTY (overlay) capability — dispatched only if committed/consented and only by + // require()-ing its router FROM the capability's install root (confined to that root). + if (dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error })) break; + // #3243: if the caller passed a dotted form (e.g. "foo.bar"), the shim // above split it so `command` here is the head ("foo"). Use // originalCommand to reconstruct the original dotted form and suggest @@ -2236,4 +2355,6 @@ if (require.main === module) { // ─── Exports (for tests) ────────────────────────────────────────────────────── // ADR-959: export dispatchCapabilityCommand so tests can exercise it with // synthetic registry + requireModule injections. -module.exports = { dispatchCapabilityCommand }; +// ADR-1244 Phase 5: export dispatchOverlayCapabilityCommand + defaultRequireFromInstallRoot for +// the third-party overlay dispatch + install-root confinement tests. +module.exports = { dispatchCapabilityCommand, dispatchOverlayCapabilityCommand, defaultRequireFromInstallRoot }; diff --git a/src/capability-loader.cts b/src/capability-loader.cts index 1f1294609..d64f47e80 100644 --- a/src/capability-loader.cts +++ b/src/capability-loader.cts @@ -107,6 +107,15 @@ export interface OverlayMeta { * 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-)/; @@ -161,23 +170,57 @@ function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope /** * Read the per-scope ledger co-located with an overlay root (the root is `/.gsd/capabilities`, - * so its ledger is `/.gsd-capabilities.json`) and return the ids carrying an in-flight - * `_pending` intent — i.e. crashed/uncommitted installs or upgrades that must not be activated until - * reconciliation completes. Never throws: a missing/invalid ledger yields an empty set. + * 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. */ -function pendingOverlayIds(rootDir: string): Set { - const out = new Set(); +/** + * Is `e` a structurally-valid COMMITTED ledger entry for `id`? Mirrors the required shape that + * capability-ledger's readLedger enforces (id/version/source/integrity strings + files/sharedEdits + * arrays), and requires the key to equal `entry.id` and the entry to carry NO `_pending` marker. + * A malformed or tampered entry fails this check and is therefore NOT treated as consent — fail + * closed (Codex R2 low): only a genuine lifecycle-written commit authorizes command dispatch. + */ +function isCommittedLedgerEntry(id: string, e: unknown): boolean { + if (!e || typeof e !== 'object' || Array.isArray(e)) return false; + const r = e as Record; + if (Object.prototype.hasOwnProperty.call(r, '_pending')) return false; // committed entries carry no intent + return ( + r['id'] === id && + typeof r['version'] === 'string' && + typeof r['source'] === 'string' && + typeof r['integrity'] === 'string' && + Array.isArray(r['files']) && + Array.isArray(r['sharedEdits']) + ); +} + +function ledgerOverlayIds(rootDir: string): { pending: Set; committed: Set } { + const pending = new Set(); + const committed = new Set(); try { const ledgerPath = path.join(rootDir, '..', '..', '.gsd-capabilities.json'); const parsed: unknown = JSON.parse(fs.readFileSync(ledgerPath, 'utf8')); - if (!parsed || typeof parsed !== 'object') return out; + if (!parsed || typeof parsed !== 'object') return { pending, committed }; const entries = (parsed as Record)['entries']; - if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return out; + 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' && (entry as Record)['_pending']) out.add(id); + 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(id, entry)) { + committed.add(id); // a genuine, structurally-valid commit — the consent signal + } + // else: malformed / tampered / falsy-_pending → neither (fail closed: declarative-only) } - } catch { /* missing/invalid ledger — nothing pending */ } - return out; + } catch { /* missing/invalid ledger — no pending, no committed */ } + return { pending, committed }; } /** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */ @@ -209,6 +252,7 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry { const warnings: OverlaySkip[] = []; const incompatibleGateCapIds: string[] = []; const blockedGates: BlockedGate[] = []; + const commandRoots: Record = {}; const overlayCaps: CapManifest[] = []; // First-party reservations — first-party always wins. @@ -265,7 +309,7 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry { // 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 pendingIds = pendingOverlayIds(root.dir); + const { pending: pendingIds, committed: committedIds } = ledgerOverlayIds(root.dir); for (const ent of entries) { if (!ent.isDirectory()) continue; const id = ent.name; @@ -377,10 +421,18 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry { 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/the-capability-trust-model.md.) + if (families.length > 0 && committedIds.has(id)) commandRoots[id] = capDir; } } - const meta: OverlayMeta = { warnings, incompatibleGateCapIds, blockedGates }; + const meta: OverlayMeta = { warnings, incompatibleGateCapIds, blockedGates, commandRoots }; if (overlayCaps.length === 0) { // Nothing to compose. Return the frozen registry unchanged when there is diff --git a/tests/capability-command-dispatch.test.cjs b/tests/capability-command-dispatch.test.cjs index 972263d68..35efd819f 100644 --- a/tests/capability-command-dispatch.test.cjs +++ b/tests/capability-command-dispatch.test.cjs @@ -11,8 +11,16 @@ const { describe, test } = 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 { dispatchCapabilityCommand } = require('../gsd-core/bin/gsd-tools.cjs'); +const { + dispatchCapabilityCommand, + dispatchOverlayCapabilityCommand, + defaultRequireFromInstallRoot, +} = require('../gsd-core/bin/gsd-tools.cjs'); +const { cleanup } = require('./helpers.cjs'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -776,3 +784,236 @@ describe('dispatchCapabilityCommand — real registry behavior-preservation', () assert.strictEqual(result, false, 'unknown command against real registry must return false'); }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ADR-1244 Phase 5 (D7) — third-party overlay command dispatch +// ═══════════════════════════════════════════════════════════════════════════════ + +/** Synthetic overlay registry: commandFamilies + _overlay.commandRoots (capId → install dir). */ +function makeOverlayRegistry(families, commandRoots) { + return { commandFamilies: families, _overlay: { warnings: [], incompatibleGateCapIds: [], blockedGates: [], commandRoots } }; +} + +describe('dispatchOverlayCapabilityCommand — third-party overlay (Phase 5)', () => { + test('happy path: a third-party family (capId in commandRoots) dispatches from its install root', () => { + const calls = []; + const loadRegistry = () => makeOverlayRegistry( + { mycmd: { capId: 'thirdparty', module: 'router.cjs', router: 'run' } }, + { thirdparty: '/install/root/thirdparty' }, + ); + const requireModule = (installRoot, m) => { + assert.strictEqual(installRoot, '/install/root/thirdparty', 'module required FROM the install root'); + assert.strictEqual(m, 'router.cjs'); + return { run: (ctx) => calls.push(ctx) }; + }; + const result = dispatchOverlayCapabilityCommand({ + command: 'mycmd', args: ['a'], cwd: '/p', raw: false, error: () => {}, loadRegistry, requireModule, + }); + assert.strictEqual(result, true); + assert.strictEqual(calls.length, 1); + assert.deepEqual(calls[0].args, ['a']); + }); + + test('a FIRST-PARTY family (capId NOT in commandRoots) falls through (handled by frozen-registry dispatch)', () => { + let required = false; + const loadRegistry = () => makeOverlayRegistry( + { graphify: { capId: 'graphify', module: 'graphify-command-router.cjs', router: 'routeGraphifyCommand' } }, + {}, // graphify is first-party → not in commandRoots + ); + const result = dispatchOverlayCapabilityCommand({ + command: 'graphify', args: [], cwd: '/p', raw: false, error: () => {}, + loadRegistry, requireModule: () => { required = true; return {}; }, + }); + assert.strictEqual(result, false, 'first-party must fall through, not be dispatched as overlay'); + assert.strictEqual(required, false, 'first-party module must NOT be required from an install root'); + }); + + test('unknown family → false', () => { + const loadRegistry = () => makeOverlayRegistry({}, {}); + assert.strictEqual(dispatchOverlayCapabilityCommand({ command: 'nope', args: [], cwd: '/p', raw: false, error: () => {}, loadRegistry, requireModule: () => ({}) }), false); + }); + + test('no _overlay / no commandRoots on the registry → false', () => { + assert.strictEqual(dispatchOverlayCapabilityCommand({ command: 'x', args: [], cwd: '/p', raw: false, error: () => {}, loadRegistry: () => ({ commandFamilies: { x: { capId: 'x', module: 'm.cjs', router: 'r' } } }), requireModule: () => ({}) }), false); + }); + + test('loadRegistry throwing → false (falls through to Unknown)', () => { + assert.strictEqual(dispatchOverlayCapabilityCommand({ command: 'x', args: [], cwd: '/p', raw: false, error: () => {}, loadRegistry: () => { throw new Error('overlay scan failed'); }, requireModule: () => ({}) }), false); + }); + + test('prototype-pollution command keys → false (never reach the registry)', () => { + for (const command of ['__proto__', 'constructor', 'prototype']) { + let loaded = false; + const r = dispatchOverlayCapabilityCommand({ command, args: [], cwd: '/p', raw: false, error: () => {}, loadRegistry: () => { loaded = true; return makeOverlayRegistry({}, {}); }, requireModule: () => ({}) }); + assert.strictEqual(r, false); + assert.strictEqual(loaded, false, command + ' must short-circuit before loadRegistry'); + } + }); + + test('CONSENT NEGATIVE PROOF: a family whose capId is absent from commandRoots is never require()d', () => { + // Models an unconsented/_pending cap: the loader excludes it from commandRoots, so even though + // the (synthetic) commandFamilies names it, dispatch must NOT load its module. + let required = false; + const loadRegistry = () => makeOverlayRegistry( + { evil: { capId: 'evil', module: 'evil.cjs', router: 'run' } }, + {}, // 'evil' NOT consented → absent from commandRoots + ); + const result = dispatchOverlayCapabilityCommand({ command: 'evil', args: [], cwd: '/p', raw: false, error: () => {}, loadRegistry, requireModule: () => { required = true; return { run() {} }; } }); + assert.strictEqual(result, false); + assert.strictEqual(required, false, 'an unconsented capability module must never be required'); + }); + + test('module load failure → error diagnostic + consumed (true)', () => { + const errs = []; + const loadRegistry = () => makeOverlayRegistry({ x: { capId: 'tp', module: 'm.cjs', router: 'r' } }, { tp: '/root' }); + const result = dispatchOverlayCapabilityCommand({ command: 'x', args: [], cwd: '/p', raw: false, error: (m) => errs.push(m), loadRegistry, requireModule: () => { throw new Error('boom'); } }); + assert.strictEqual(result, true); + assert.ok(errs.some((e) => /failed to load from its install root/.test(e))); + }); + + test('router not an own export → error + consumed', () => { + const errs = []; + const loadRegistry = () => makeOverlayRegistry({ x: { capId: 'tp', module: 'm.cjs', router: 'toString' } }, { tp: '/root' }); + const result = dispatchOverlayCapabilityCommand({ command: 'x', args: [], cwd: '/p', raw: false, error: (m) => errs.push(m), loadRegistry, requireModule: () => ({}) }); + assert.strictEqual(result, true); + assert.ok(errs.some((e) => /is not an own export/.test(e))); + }); + + test('router not a function → error + consumed', () => { + const errs = []; + const loadRegistry = () => makeOverlayRegistry({ x: { capId: 'tp', module: 'm.cjs', router: 'r' } }, { tp: '/root' }); + const result = dispatchOverlayCapabilityCommand({ command: 'x', args: [], cwd: '/p', raw: false, error: (m) => errs.push(m), loadRegistry, requireModule: () => ({ r: 42 }) }); + assert.strictEqual(result, true); + assert.ok(errs.some((e) => /is not a function/.test(e))); + }); + + test('async router (returns a Promise) → SDK fail-fast diagnostic', () => { + const errs = []; + const loadRegistry = () => makeOverlayRegistry({ x: { capId: 'tp', module: 'm.cjs', router: 'r' } }, { tp: '/root' }); + dispatchOverlayCapabilityCommand({ command: 'x', args: [], cwd: '/p', raw: false, error: (m) => errs.push(m), loadRegistry, requireModule: () => ({ r: () => Promise.resolve() }) }); + assert.ok(errs.some((e) => /must be synchronous/.test(e))); + }); +}); + +// ─── defaultRequireFromInstallRoot — real-filesystem confinement (negative proof) ─── + +describe('defaultRequireFromInstallRoot — install-root confinement (Phase 5)', () => { + const dirs = []; + const mkroot = () => { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-disp-')); dirs.push(d); return d; }; + test.after(() => { for (const d of dirs) cleanup(d); }); + + test('loads a bare .cjs module that lives inside the install root', () => { + const root = mkroot(); + fs.writeFileSync(path.join(root, 'router.cjs'), 'module.exports = { run: () => 7 };', 'utf8'); + const mod = defaultRequireFromInstallRoot(root, 'router.cjs'); + assert.strictEqual(mod.run(), 7); + }); + + test('rejects a non-.cjs / path-separator / .. module name', () => { + const root = mkroot(); + assert.throws(() => defaultRequireFromInstallRoot(root, 'router.js'), /bare \.cjs basename/); + assert.throws(() => defaultRequireFromInstallRoot(root, '../escape.cjs'), /bare \.cjs basename/); + assert.throws(() => defaultRequireFromInstallRoot(root, 'sub/router.cjs'), /bare \.cjs basename/); + assert.throws(() => defaultRequireFromInstallRoot(root, '/abs/router.cjs'), /bare \.cjs basename/); + }); + + test('NEGATIVE PROOF: a symlinked module pointing OUTSIDE the install root is not loaded', () => { + const root = mkroot(); + const outside = mkroot(); + const secret = path.join(outside, 'secret.cjs'); + fs.writeFileSync(secret, 'module.exports = { run: () => "PWNED" };', 'utf8'); + // A bare-basename symlink inside the root whose real target escapes the root. + let linked = true; + try { fs.symlinkSync(secret, path.join(root, 'router.cjs')); } catch { linked = false; } + if (!linked) return; // platform without symlink perms — skip + assert.throws(() => defaultRequireFromInstallRoot(root, 'router.cjs'), /outside its install root/); + }); +}); + +// ─── End-to-end: real loadRegistry + real require + real ledger (consent + confinement) ─── + +describe('dispatchOverlayCapabilityCommand — end-to-end (real loadRegistry, real require, real ledger)', () => { + const homes = []; + let savedGsdHome; + const mkhome = () => { const h = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-e2e-')); homes.push(h); return h; }; + + function validCap(id, family) { + return { + id, role: 'feature', version: '1.0.0', title: id, description: 'e2e cap', tier: 'standard', + requires: [], engines: { gsd: '>=1.0.0' }, runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], + commands: [{ family, module: 'router.cjs', router: 'run' }], + }; + } + // The router writes a marker file so "did it execute?" is a filesystem fact (negative proof). + const ROUTER_BODY = "module.exports = { run: (ctx) => { require('fs').writeFileSync(require('path').join(ctx.cwd, 'RAN.txt'), String((ctx.args||[]).join(','))); } };"; + + function placeBundle(home, id, family, { committed }) { + const dir = path.join(home, '.gsd', 'capabilities', id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(validCap(id, family)), 'utf8'); + fs.writeFileSync(path.join(dir, 'router.cjs'), ROUTER_BODY, 'utf8'); + if (committed) { + fs.writeFileSync( + path.join(home, '.gsd-capabilities.json'), + JSON.stringify({ version: '1', updatedAt: 'x', entries: { [id]: { id, version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [] } } }), + 'utf8', + ); + } + } + + test.beforeEach(() => { savedGsdHome = process.env.GSD_HOME; }); + test.afterEach(() => { if (savedGsdHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = savedGsdHome; }); + test.after(() => { for (const h of homes) cleanup(h); }); + + test('a COMMITTED (consented) third-party command runs, from its install root', () => { + const home = mkhome(); + placeBundle(home, 'e2ecap', 'e2e-cmd', { committed: true }); + process.env.GSD_HOME = home; // global overlay scope = home/.gsd/capabilities + const errs = []; + const result = dispatchOverlayCapabilityCommand({ command: 'e2e-cmd', args: ['hello'], cwd: home, raw: false, error: (m) => errs.push(m) }); + assert.strictEqual(result, true, 'consented command consumed: ' + JSON.stringify(errs)); + assert.strictEqual(fs.readFileSync(path.join(home, 'RAN.txt'), 'utf8'), 'hello', 'router executed with forwarded args'); + }); + + test('NEGATIVE PROOF: a dropped bundle with NO ledger entry is never dispatched / never executes', () => { + const home = mkhome(); + placeBundle(home, 'evilcap', 'evil-cmd', { committed: false }); // bundle on disk, NO ledger + process.env.GSD_HOME = home; + const result = dispatchOverlayCapabilityCommand({ command: 'evil-cmd', args: ['x'], cwd: home, raw: false, error: () => {} }); + assert.strictEqual(result, false, 'unconsented family must fall through to Unknown'); + assert.strictEqual(fs.existsSync(path.join(home, 'RAN.txt')), false, 'the dropped module must NEVER execute'); + }); +}); + +// ─── Overlay router error semantics (parity with the first-party path) ─── + +describe('dispatchOverlayCapabilityCommand — router error semantics', () => { + function overlayReg() { + return { commandFamilies: { x: { capId: 'tp', module: 'm.cjs', router: 'run' } }, _overlay: { warnings: [], incompatibleGateCapIds: [], blockedGates: [], commandRoots: { tp: '/root' } } }; + } + + test('overlay router throwing an ExitError → propagates unchanged, error() NOT called', () => { + const thrown = new ExitError(1, 'intentional-exit'); + const errs = []; + let caught; + try { + dispatchOverlayCapabilityCommand({ + command: 'x', args: [], cwd: '/p', raw: false, error: (m) => errs.push(m), + loadRegistry: overlayReg, requireModule: () => ({ run: () => { throw thrown; } }), + }); + } catch (e) { caught = e; } + assert.strictEqual(caught, thrown, 'the original ExitError must propagate unchanged'); + assert.strictEqual(errs.length, 0, 'error() must not be called when an ExitError propagates'); + }); + + test('overlay router throwing a generic Error → attributed error() + consumed (true)', () => { + const errs = []; + const result = dispatchOverlayCapabilityCommand({ + command: 'x', args: [], cwd: '/p', raw: false, error: (m, reason) => errs.push({ m, reason }), + loadRegistry: overlayReg, requireModule: () => ({ run: () => { throw new Error('kaboom'); } }), + }); + assert.strictEqual(result, true, 'consumed'); + assert.ok(errs.some((e) => /threw: kaboom/.test(e.m)), 'router throw attributed to the command'); + }); +}); diff --git a/tests/capability-loader.test.cjs b/tests/capability-loader.test.cjs index 24f451abd..6e20ca183 100644 --- a/tests/capability-loader.test.cjs +++ b/tests/capability-loader.test.cjs @@ -146,6 +146,92 @@ describe('loadRegistry — uncommitted (_pending) overlay is not activated (ADR- }); }); +describe('loadRegistry — _overlay.commandRoots (ADR-1244 Phase 5 dispatch)', () => { + // commandRoots requires a COMMITTED ledger entry (the consent signal). Write one. + function writeCommittedLedger(home, ids) { + const entries = {}; + for (const id of ids) entries[id] = { id, version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [] }; + fs.writeFileSync(path.join(home, '.gsd-capabilities.json'), JSON.stringify({ version: '1', updatedAt: '2026-01-01T00:00:00Z', entries }), 'utf8'); + } + + test('a COMMITTED overlay cap that declares commands records its absolute install root', (t) => { + const home = makeOverlayHome([featureCap('tpcap', { commands: [{ family: 'tp-cmd', module: 'router.cjs', router: 'run' }] })]); + t.after(() => cleanup(home)); + writeCommittedLedger(home, ['tpcap']); + const reg = load(home); + assert.ok(reg.capabilities['tpcap'], 'overlay cap accepted'); + assert.ok(reg._overlay && reg._overlay.commandRoots, '_overlay.commandRoots present'); + assert.strictEqual(reg._overlay.commandRoots['tpcap'], path.join(home, '.gsd', 'capabilities', 'tpcap'), 'install root recorded'); + }); + + test('CONSENT NEGATIVE PROOF: a cap with commands but NO ledger entry (bundle dropped on disk) is NOT in commandRoots', (t) => { + // No ledger written at all — models a repo that ships .gsd/capabilities/ without an install. + const home = makeOverlayHome([featureCap('dropped', { commands: [{ family: 'dropped-cmd', module: 'router.cjs', router: 'run' }] })]); + t.after(() => cleanup(home)); + const reg = load(home); + const roots = (reg._overlay && reg._overlay.commandRoots) || {}; + assert.ok(!('dropped' in roots), 'an uninstalled (no-ledger) cap must not be command-dispatchable'); + // Declarative surfaces still load (Phase 2 behavior unchanged) — only command dispatch is gated. + assert.ok(reg.capabilities['dropped'], 'declarative surfaces still compose'); + }); + + test('an overlay cap WITHOUT commands is not in commandRoots, and first-party families are absent too', (t) => { + const home = makeOverlayHome([ + featureCap('nocmd', { skills: ['nocmd-skill'] }), + featureCap('tpcap', { commands: [{ family: 'tp-cmd', module: 'router.cjs', router: 'run' }] }), + ]); + t.after(() => cleanup(home)); + writeCommittedLedger(home, ['tpcap']); + const reg = load(home); + const roots = reg._overlay.commandRoots; + assert.ok(!('nocmd' in roots), 'declarative overlay cap not in commandRoots'); + assert.ok(!('graphify' in roots) && !('intel' in roots), 'first-party families never appear in commandRoots'); + }); + + test('CONSENT NEGATIVE PROOF: a _pending (uncommitted) overlay cap with commands is NOT in commandRoots', (t) => { + const home = makeOverlayHome([featureCap('pendcmd', { commands: [{ family: 'pend-cmd', module: 'router.cjs', router: 'run' }] })]); + t.after(() => cleanup(home)); + fs.writeFileSync( + path.join(home, '.gsd-capabilities.json'), + JSON.stringify({ + version: '1', updatedAt: '2026-01-01T00:00:00Z', + entries: { pendcmd: { id: 'pendcmd', version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [], _pending: { kind: 'install', backupName: null, sharedFiles: [] } } }, + }), + 'utf8', + ); + const reg = load(home); + const roots = (reg._overlay && reg._overlay.commandRoots) || {}; + assert.ok(!('pendcmd' in roots), 'an unconsented capability must not expose a dispatchable command root'); + assert.ok(!reg.capabilities['pendcmd'], 'unconsented cap not activated'); + }); + + test('FAIL CLOSED: a malformed/tampered committed-looking entry is NOT treated as consent', (t) => { + const home = makeOverlayHome([ + featureCap('mal1', { commands: [{ family: 'mal1-cmd', module: 'router.cjs', router: 'run' }] }), + featureCap('mal2', { commands: [{ family: 'mal2-cmd', module: 'router.cjs', router: 'run' }] }), + featureCap('mal3', { commands: [{ family: 'mal3-cmd', module: 'router.cjs', router: 'run' }] }), + ]); + t.after(() => cleanup(home)); + fs.writeFileSync( + path.join(home, '.gsd-capabilities.json'), + JSON.stringify({ + version: '1', updatedAt: '2026-01-01T00:00:00Z', + entries: { + mal1: { id: 'mal1', version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [], _pending: null }, // falsy-but-present intent → not committed + mal2: { id: 'WRONG', version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [] }, // id mismatch + mal3: { id: 'mal3', version: '1.0.0' }, // missing required fields + }, + }), + 'utf8', + ); + const reg = load(home); + const roots = (reg._overlay && reg._overlay.commandRoots) || {}; + assert.ok(!('mal1' in roots), '_pending:null (own-property intent) is not consent'); + assert.ok(!('mal2' in roots), 'entry.id mismatch is not consent'); + assert.ok(!('mal3' in roots), 'missing required fields is not consent'); + }); +}); + describe('loadRegistry — first-party always wins', () => { test('overlay whose id collides with a first-party id is rejected; first-party preserved', (t) => { const home = makeOverlayHome([featureCap('ui', { skills: ['hijacked'] })]);