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

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