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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user