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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-18 23:37:43 -04:00
committed by GitHub
parent c26947808a
commit 1abebbf4fd
9 changed files with 576 additions and 14 deletions

View File

@@ -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 <family>` 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)

View File

@@ -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 <point>` which emits `{ point, activeHooks[], rendered }`. Activation is driven by `when` (dotted config key resolved against `loadConfig`), with inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard. The first phase-6 cutovers wiring workflows to this query have landed — ui-phase at `plan:pre` and ui-review at `verify:post` (in `plan-phase.md`/`autonomous.md`); further per-feature cutovers are ongoing.

View File

@@ -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.

View File

@@ -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 <family> …` (equivalently the `gsd <family>` 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)

View File

@@ -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 <family> …`, 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, `<projectRoot>/.gsd/capabilities/`). The consent signal is
the ledger, and the project-scope ledger (`<projectRoot>/.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 <that-family>` 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.

View File

@@ -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 };

View File

@@ -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 → <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-)/;
@@ -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 `<scope>/.gsd/capabilities`,
* so its ledger is `<scope>/.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 `<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.
*/
function pendingOverlayIds(rootDir: string): Set<string> {
const out = new Set<string>();
/**
* 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<string, unknown>;
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<string>; committed: Set<string> } {
const pending = new Set<string>();
const committed = new Set<string>();
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<string, unknown>)['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<string, unknown>)) {
if (entry && typeof entry === 'object' && (entry as Record<string, unknown>)['_pending']) out.add(id);
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(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<string, string> = {};
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

View File

@@ -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');
});
});

View File

@@ -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/<id> 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'] })]);