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:
5
.changeset/plucky-tigers-dart.md
Normal file
5
.changeset/plucky-tigers-dart.md
Normal 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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'] })]);
|
||||
|
||||
Reference in New Issue
Block a user