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

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