diff --git a/.changeset/noble-goats-chatter.md b/.changeset/noble-goats-chatter.md new file mode 100644 index 000000000..70876ae09 --- /dev/null +++ b/.changeset/noble-goats-chatter.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 1458 +--- +**Capability matrix reference** — a generated catalogue (`docs/reference/capability-matrix.md`) of every first-party capability's role, tier, extension points, hook kinds, and `engines.gsd`, generated from the committed registry and kept honest by a CI drift guard so it can never fall out of sync with the actual capability set. (#1458) diff --git a/CONTEXT.md b/CONTEXT.md index e3fb5eed0..de4402cf4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -188,13 +188,13 @@ ADR-1244 D3 fetch-and-stage seam (`gsd-core/bin/lib/capability-source.cjs`). Pri ADR-1244 D4 per-runtime install manifest (`gsd-core/bin/lib/capability-ledger.cjs`). Leaf module (only `node:fs`/`node:path` plus `shell-command-projection`'s `platformWriteSync`). Records `{ id, version, source, integrity, files[], sharedEdits[{file,marker}] }` per installed capability in `.gsd-capabilities.json` at the runtime config dir root. Exports: `readLedger` (structural-validated, never throws), `writeLedger` (atomic via `platformWriteSync`), `recordInstall` (idempotent, prototype-pollution-guarded), `removeEntry`, and `reconcile` (reports orphans whose `files[]` are missing on disk; hardened against non-string/`..` members; never mutates). Serves as the atomic commit point for Phase-4 upgrade/remove and the reconciliation basis for detecting stale entries after out-of-band deletions. ### Capability Trust Gate -ADR-1244 Phase 4 (D5) PURE policy module (`gsd-core/bin/lib/capability-trust.cjs`). Computes *what* a capability would do and *whether* policy permits it; performs no mutation and no I/O beyond existence-checking declared artifacts. Exports: `discloseExecutableSurfaces(manifest, stagedDir?)` (enumerates the three executable surfaces — `hooks`, command modules, `mcpServers` — and flags `hasExecutable`); `evaluateInstallTrust(args)` (composes source policy + reserved-namespace + engines gate + disclosure into `{ allowed, requiresConsent, disclosure, engines, blockReasons }`); `evaluateSourceAllowed(parsed, strictKnownRegistries)` enforcing `capabilities.strict_known_registries` (unset/null → permissive-with-consent; `[]` → block all external; non-empty → host-based allowlist, never substring); `checkEngines(manifest, hostVersion)` (engines.gsd hard gate via `semverSatisfies` + `compatVersions` graceful-downgrade picking the newest working version); `executableSetChanged(old, new)` (auto-update re-consent trigger); `checkReservedNamespace` (`gsd-`/`gsd-core-`/`anthropic-`). The barrier is consent + integrity + reversibility, NOT a sandbox — see `docs/explanation/the-capability-trust-model.md`. +ADR-1244 Phase 4 (D5) PURE policy module (`gsd-core/bin/lib/capability-trust.cjs`). Computes *what* a capability would do and *whether* policy permits it; performs no mutation and no I/O beyond existence-checking declared artifacts. Exports: `discloseExecutableSurfaces(manifest, stagedDir?)` (enumerates the three executable surfaces — `hooks`, command modules, `mcpServers` — and flags `hasExecutable`); `evaluateInstallTrust(args)` (composes source policy + reserved-namespace + engines gate + disclosure into `{ allowed, requiresConsent, disclosure, engines, blockReasons }`); `evaluateSourceAllowed(parsed, strictKnownRegistries)` enforcing `capabilities.strict_known_registries` (unset/null → permissive-with-consent; `[]` → block all external; non-empty → host-based allowlist, never substring); `checkEngines(manifest, hostVersion)` (engines.gsd hard gate via `semverSatisfies` + `compatVersions` graceful-downgrade picking the newest working version); `executableSetChanged(old, new)` (auto-update re-consent trigger); `checkReservedNamespace` (`gsd-`/`gsd-core-`/`anthropic-`). The barrier is consent + integrity + reversibility, NOT a sandbox — see `docs/explanation/capability-trust-model.md`. ### 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". +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/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 ` 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 630293e65..b224abd1a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -311,7 +311,7 @@ CJS command family routers dispatch through `CommandRoutingHub`. The hub owns th 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. +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/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). diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index d67fa9b40..25837fe6c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1672,7 +1672,7 @@ The check is also run as part of `npm test` via `tests/enh-2789-description-budg 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 …` (equivalently the `gsd ` 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). +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/capability-trust-model.md). --- diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6c1ffcf8c..550dceff3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -552,7 +552,7 @@ Setting the parent object (`agent_skills_security`) directly is not supported; u ## Capability Trust (`capabilities.*`) -Policy for installing and updating third-party capabilities (ADR-1244). These keys govern the trust gate; they have no effect if you only ever use the native first-party capabilities shipped with GSD. They are **policy inputs** read by the `gsd capability` command flow, which passes the resulting decision into the capability lifecycle — `strict_known_registries` gates whether a source may be installed at all; `auto_update` is consulted by the `update`/`outdated` flow (which always re-prompts when a new version's executable surface set changes). The full rationale — including why there is no sandbox — is in [The capability trust model](explanation/the-capability-trust-model.md). +Policy for installing and updating third-party capabilities (ADR-1244). These keys govern the trust gate; they have no effect if you only ever use the native first-party capabilities shipped with GSD. They are **policy inputs** read by the `gsd capability` command flow, which passes the resulting decision into the capability lifecycle — `strict_known_registries` gates whether a source may be installed at all; `auto_update` is consulted by the `update`/`outdated` flow (which always re-prompts when a new version's executable surface set changes). The full rationale — including why there is no sandbox — is in [The capability trust model](explanation/capability-trust-model.md). | Setting | Type | Default | Description | |---------|------|---------|-------------| diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 057198ea4..b6c7fbf38 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -396,7 +396,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations (`capabilities//capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) | | `capability-source.cjs` | Capability source resolver (ADR-1244 D3) — `resolveCapabilitySource(spec, opts)` fetches and stages a capability from local path, git (https/ssh/git transports only), npm pack (no lifecycle scripts), tarball (sha512 integrity verify before extraction), or registry (stub); tar-slip/symlink rejection; atomic staging to `$GSD_HOME/.gsd/capabilities//`; no capability code executes during install | | `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b/6) — composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; exports pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir ]` emitting `{ runtimeConfigDir, capabilities[] }` | -| `capability-trust.cjs` | Capability trust gate (ADR-1244 Phase 4, D5 + compatibility half of D6) — PURE policy module: `discloseExecutableSurfaces` (hooks/command modules/mcpServers), `evaluateInstallTrust` (compose source policy + reserved-namespace + engines gate + disclosure → allowed/requiresConsent/blockReasons), `evaluateSourceAllowed` (`strict_known_registries`: permissive/lockdown/host-allowlist), `checkEngines` (engines.gsd hard gate + `compatVersions` graceful-downgrade), `executableSetChanged` (auto-update re-consent trigger); no sandbox — see `docs/explanation/the-capability-trust-model.md` | +| `capability-trust.cjs` | Capability trust gate (ADR-1244 Phase 4, D5 + compatibility half of D6) — PURE policy module: `discloseExecutableSurfaces` (hooks/command modules/mcpServers), `evaluateInstallTrust` (compose source policy + reserved-namespace + engines gate + disclosure → allowed/requiresConsent/blockReasons), `evaluateSourceAllowed` (`strict_known_registries`: permissive/lockdown/host-allowlist), `checkEngines` (engines.gsd hard gate + `compatVersions` graceful-downgrade), `executableSetChanged` (auto-update re-consent trigger); no sandbox — see `docs/explanation/capability-trust-model.md` | | `capability-validator.cjs` | Shared runtime-callable capability validator (ADR-1244 D2) — extracted from `scripts/gen-capability-registry.cjs` so the build-time generator and the runtime overlay loader share ONE validation implementation (generative-parity guarded); exports `validateCapability`/`validateCrossCapability`/`validateVersionEnvelope`/`validateConsumesGlobal`/… plus the closed-vocabulary sets and `SEMVER_RE` | | `capability-writer.cjs` | Capability State Writer (ADR-1213) — write-side inverse of the resolver; projects desired per-capability enabled/gates onto surface + config substrates, then re-resolves (assert-and-report); exports `setCapabilityState` and I/O handler `cmdCapabilitySet`; command surface: `gsd-tools capability set [--on\|--off] [--gate =]` | | `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` | diff --git a/docs/README.md b/docs/README.md index deeaf7661..2de17199b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,9 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [PLAN.md schema](reference/plan-md.md) — field-by-field reference for `.planning/phases//PLAN.md` - [Planning artifacts](reference/planning-artifacts.md) — all `.planning/` files and their roles - [Review and verification capabilities](reference/review-verification-capabilities.md) — code review, security, and Nyquist capability ownership and hook contracts +- [Capability matrix](reference/capability-matrix.md) — generated catalogue of every capability's role, tier, extension points, hook kinds, and `engines.gsd` +- [Capability manifest](reference/capability-manifest.md) — the full `capability.json` schema and validation rules +- [`gsd capability` command](reference/gsd-capability-command.md) — install / update / remove / list reference for third-party capabilities --- @@ -65,7 +68,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [The phase loop](explanation/the-phase-loop.md) — design rationale for the Discuss → Plan → Execute → Verify → Ship cycle - [Multi-agent orchestration](explanation/multi-agent-orchestration.md) — how subagents are spawned, scoped, and coordinated - [Security model](explanation/security-model.md) — trust boundaries, permissions, and safe automation -- [The capability trust model](explanation/the-capability-trust-model.md) — why third-party capabilities are gated by consent + integrity + reversibility, not a sandbox +- [The capability trust model](explanation/capability-trust-model.md) — why third-party capabilities are gated by consent + integrity + reversibility, not a sandbox - [Architecture](ARCHITECTURE.md) — system architecture, agent model, and data flow - [Discuss modes](workflow-discuss-mode.md) — assumptions mode vs interview mode for `/gsd-discuss-phase` - [Context monitoring](context-monitor.md) — context window monitoring hook architecture diff --git a/docs/explanation/capability-trust-model.md b/docs/explanation/capability-trust-model.md index 23be419fd..56443bd09 100644 --- a/docs/explanation/capability-trust-model.md +++ b/docs/explanation/capability-trust-model.md @@ -3,8 +3,8 @@ > **Explanation** — This document describes *why* GSD draws its trust > boundaries where it does, and *what the trade-offs are*. It is not a > step-by-step guide to installing capabilities; for that, see the how-to -> guides for [importing a capability](../how-to/) and -> [version management](../how-to/). For the decision record, see +> guides for [importing a capability](../how-to/import-a-capability-from-a-url.md) and +> [version management](../how-to/version-a-capability.md). For the decision record, see > [ADR-1244 D5](../adr/1244-capability-ecosystem.md#d5--trust-model-artifact-parity-is-full-trust-posture-is-tiered). > For the capability field reference, see the > [capability matrix](../reference/capability-matrix.md). @@ -172,6 +172,15 @@ What it does not defend against: a malicious capability where the author themselves publishes a bad bundle. The SHA is honest about what you are installing; it says nothing about whether what you are installing is safe. +It also pins **only the top-level bundle**, not an `npm`-sourced capability's +resolved dependency graph. `--ignore-scripts` and copy-only staging stop +install-time execution, but when a command module is later `require()`'d, Node +resolves and runs its transitive dependencies — which the bundle SHA does not +cover (the Wiz / VS Code lesson). For the `npm` source kind, a green integrity +check means "the package tarball is the one you pinned," not "every line of code +that will run is the code you reviewed." Authors who want a stronger guarantee +should vendor their dependencies or ship a lockfile. + ### Auto-update off by default, re-consent on executable-set change When auto-update is enabled for a third-party capability, each update is @@ -200,13 +209,54 @@ rejected at the conformance gate. This prevents impersonation: a malicious actor cannot publish a capability called `gsd-security` and exploit a user's implicit trust in the GSD namespace. -### `strictKnownRegistries` for managed environments +### `capabilities.strict_known_registries` for managed environments Teams or enterprises that want to constrain which capability sources are -permissible can set `strictKnownRegistries` in managed or project config to an -explicit allowlist of URLs or registry names. Setting it to `[]` blocks all -external installs. This gives an administrator a policy lever that operates -before the user even sees a consent prompt. +permissible set `capabilities.strict_known_registries` in config. Its semantics: + +- **unset / `null`** *(default)* — permissive: external installs (git / npm / + tarball) are allowed, each still passing the consent + integrity gate. Local + filesystem installs are always allowed. +- **`[]`** *(explicit empty array)* — lockdown: **all external installs are + blocked**; local-only. +- **non-empty list** — a **host-based** allowlist: only sources whose host + matches an entry (exact host or a subdomain of it — `github.com` matches + `api.github.com` but never `evilgithub.com`; the literal token `npm` permits + the npm source kind). A malformed (non-array) value **fails closed**. + +This gives an administrator a policy lever that operates before the user even +sees a consent prompt. The default is permissive-with-consent (not Obsidian-style +restricted-by-default), because the epic deliberately chose decentralised import +with the consent prompt as the default barrier and lockdown one config key away. + +### Command dispatch: where third-party code runs (1.6.0) + +A capability may declare a **command family** (`commands: [{ family, module, +router }]`); `gsd-tools ` dispatches it by `require()`-ing the router. +This is the one place a third-party capability's own code executes, so it is +gated twice. **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 consented. A bundle merely present on disk with no install record +still contributes its declarative surfaces but is **not** command-dispatchable. +**Confinement:** the router module loads only from the capability's own install +root (bare-`.cjs` basename, `realpath`-confined, rejecting `..` traversal and +symlink escape); a first-party command can never be shadowed by a third-party one. + +#### The project-scope trust boundary + +Capabilities install **globally** (`$GSD_HOME/.gsd/capabilities/`) or +**project-scoped** (`/.gsd/capabilities/`). The consent record is +the ledger — and a project-scope ledger lives **inside the repository**. A repo +you check out can therefore ship a capability bundle *and* a ledger that marks +it committed; running `gsd-tools ` in that repo executes its code. +This is the same boundary that already applies to a repo's build scripts or a +project-scoped capability's hooks, and it is **narrower** — a command never fires +on its own; you have to type it. GSD cannot cryptographically distinguish a +genuine project-local install from a forged project ledger, so: 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. Review repos before running `gsd` commands in them, and prefer global +installs for capabilities you want to trust across projects. --- diff --git a/docs/explanation/the-capability-trust-model.md b/docs/explanation/the-capability-trust-model.md deleted file mode 100644 index ae65aca93..000000000 --- a/docs/explanation/the-capability-trust-model.md +++ /dev/null @@ -1,126 +0,0 @@ -# The capability trust model - -> **Status:** Implemented in ADR-1244 Phase 4 (`gsd-core/bin/lib/capability-trust.cjs`, -> `capability-lifecycle.cjs`). This document is the *honest* explanation of why GSD trusts -> third-party capabilities the way it does — including what it does **not** protect against. - -GSD capabilities can ship the same executable artifacts GSD itself ships — hook scripts, -command modules, and (for third parties) MCP server declarations. That is full first-party -parity, chosen deliberately (epic #1244, alternative 2). Parity means a third-party -capability is, in the limit, **arbitrary code you have invited into your agent runtime**. -This page explains the barrier GSD puts in front of that invitation, and why the barrier is -*consent + integrity + reversibility* rather than a sandbox. - -## Why there is no sandbox (Ask #1) - -ADR-857 D8 originally earned "no sandbox" on a *declarative-only* premise: capabilities were -data, not code. This feature reverses that premise, so the justification must be re-derived on -grounds that survive third-party **code execution**. It is: - -**GSD has no process boundary to interpose.** A capability's executable surfaces all run inside -the host agent runtime's own trust context: - -- **hook scripts** run as the runtime's configured hook commands (your shell, your - permissions); -- **command modules** are `require()`'d directly into the GSD CLI's own Node process; -- **MCP servers** are spawned by the host runtime, not by GSD. - -GSD never forks a sandboxed child to run any of this. It *could* not meaningfully do so: the -runtime loads hooks and MCP servers itself, and a command module is in-process by definition. -A `sandboxTier` enum on capabilities that GSD does not actually enforce would be security -theater — worse than nothing, because it implies a boundary that isn't there. - -The runtime descriptor's existing `sandboxTier` axis (`none` | `codex-agent-sandbox`, -`runtime-config-adapter-registry.cts`) is the **runtime's own** sandbox over everything it -executes — capability code included. That sandbox keeps applying. But it belongs to the -runtime (e.g. Codex's `workspace-write`), not to GSD, and GSD does not extend or simulate it. - -So the barrier GSD owns is the same posture the Obsidian and VS Code marketplaces settled on -after their own supply-chain incidents: - -1. **No execution at install.** Staging is copy-only. `npm` sources use `--ignore-scripts`; - there is no `postinstall`. Installing a capability never runs its code. -2. **Integrity before extraction.** Tarball sources verify a `sha512` digest over the fetched - bytes *before* anything is written to the install root; a mismatch aborts. -3. **Disclosure + explicit consent.** Every executable surface a capability declares is - disclosed at install, and installing one that ships *any* executable surface requires - explicit consent. Declining aborts cleanly, writing nothing. -4. **Namespace reservation.** Third parties cannot claim the `gsd-`, `gsd-core-`, or - `anthropic-` id prefixes, so a hostile capability cannot impersonate a first-party one. -5. **Reversibility.** The ledger records exactly what each install wrote; `remove` deletes - exactly those files and surgically strips exactly the shared-config entries the capability - added, touching nothing the user owns. - -Consent is meaningful precisely because (1)–(2) guarantee nothing has run *yet* when you are -asked, and (4)–(5) guarantee you can fully undo it. That is the trade GSD makes instead of a -sandbox it cannot honestly provide. - -## `strictKnownRegistries` default (Ask #4a) - -`capabilities.strict_known_registries` gates **install itself**, not merely auto-update. - -| Value | Meaning | -| --- | --- | -| unset / `null` *(default)* | External installs (git / npm / tarball / registry) are allowed, each still passing the consent + integrity gate. Local filesystem installs always allowed. | -| `[]` *(explicit empty array)* | **Blocks all external installs** — managed/enterprise lockdown. Local installs only. | -| `["github.com", "registry.example.com"]` | Allowlist — only sources whose host matches an entry (exact host or a subdomain of it) are permitted. | - -The default is **permissive-with-consent**, not Obsidian-style restricted-by-default. The epic -deliberately chose decentralized URL/git distribution over a curated-registry gate (alternative -3), so the consent prompt is the default barrier and full lockdown is one config key away. The -allowlist match is **host-based**, not substring: `github.com` does not match -`evilgithub.com`. - -## The npm transitive-dependency boundary (Ask #4b) - -`integrity` (sha512) pins **only the top-level fetched artifact** — the tarball, or the output -of `npm pack`. It does **not** pin the resolved dependency graph. - -`--ignore-scripts` and copy-only staging stop *install-time* execution (no `postinstall`). But -when a capability's command module is later `require()`'d, Node resolves and executes that -module's **transitive dependency tree**, and sha512 says nothing about those packages — they -can be mutable or compromised independently of the pinned top artifact. This is exactly the -Wiz / VS Code supply-chain lesson. - -For the `npm` source kind, therefore: a green integrity check means "the package tarball is the -one you pinned," **not** "every line of code that will run is the code you reviewed." Authors -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 …`, 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, `/.gsd/capabilities/`). The consent signal is -the ledger, and the project-scope ledger (`/.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 ` 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. diff --git a/docs/how-to/remove-a-capability.md b/docs/how-to/remove-a-capability.md index cfe4725a3..16b7e8745 100644 --- a/docs/how-to/remove-a-capability.md +++ b/docs/how-to/remove-a-capability.md @@ -108,4 +108,4 @@ gsd capability remove --scope project - [How to version and upgrade a capability](version-a-capability.md) - [Develop a Capability for GSD 1.5+](develop-a-capability.md) - [Turn a capability off (and keep it off)](turn-a-capability-off.md) -- [The capability trust model](../explanation/the-capability-trust-model.md) — why removal is surgical and reversible +- [The capability trust model](../explanation/capability-trust-model.md) — why removal is surgical and reversible diff --git a/docs/reference/capability-matrix.md b/docs/reference/capability-matrix.md index d8a14494c..9e95dd878 100644 --- a/docs/reference/capability-matrix.md +++ b/docs/reference/capability-matrix.md @@ -2,14 +2,15 @@ > **Generated file — do not edit by hand.** > This matrix is generated from the capability registry by -> `scripts/gen-capability-matrix.cjs` (introduced in 1.6.0) and kept honest -> by a drift guard in CI. Any manual edit will be overwritten on the next -> generation run. To change a capability's declared metadata, edit the -> corresponding `capabilities//capability.json` and rebuild. +> `scripts/gen-capability-matrix.cjs` and kept honest by a drift guard +> (`tests/capability-matrix-sync.test.cjs` runs `--check`). Any manual edit is +> overwritten on the next generation run. To change a capability's declared +> metadata, edit the corresponding `capabilities//capability.json` and run +> `node scripts/gen-capability-matrix.cjs --write`. See also: [ADR-1244](../adr/1244-capability-ecosystem.md) — [Capability manifest fields](#manifest-field-reference) — -[Trust model explanation](../explanation/capability-trust-model.md) +[The capability trust model](../explanation/capability-trust-model.md) --- @@ -17,96 +18,96 @@ See also: [ADR-1244](../adr/1244-capability-ecosystem.md) — | Column | Description | |---|---| -| **id** | Canonical capability identifier; must be unique across first- and third-party capabilities. Reserved prefixes: `gsd-`, `gsd-core-`, `anthropic-`. | +| **id** | Canonical capability identifier; unique across first- and third-party capabilities. Reserved prefixes: `gsd-`, `gsd-core-`, `anthropic-`. | | **role** | `feature` — extends what the loop does; `runtime` — adapts GSD to a specific AI runtime/IDE. | | **tier** | `core` — always active; `standard` — active when the runtime supports it; `full` — opt-in or runtime-specific. | -| **version** | Semver version of the capability. Values shown are placeholders; the generator stamps exact per-capability versions from `capability.json` at release. | -| **engines.gsd** | Semver range expressing host-version compatibility. A hard gate at install and at load. | -| **extension points** | Loop extension points this capability registers into. See [the phase loop](../explanation/the-phase-loop.md) for the full ordered list. `see capability.json` means the generator would emit the precise set; only well-known registrations are listed here. | -| **hook kinds** | Subset of `step`, `contribution`, `gate` that the capability's hooks use. | +| **engines.gsd** | Semver RANGE expressing host-version compatibility. A hard gate at install and at load. `—` means the capability declares no range. | +| **extension points** | The loop points this capability registers hooks into (from the registry's `byLoopPoint` index). `—` means it registers none (typical for runtime capabilities, whose job is surface emission). | +| **hook kinds** | Which of `step`, `contribution`, `gate` the capability's hooks use. `—` means none. | | **source** | `first-party` — ships with GSD Core; `third-party` — installed from an external source via `gsd capability install`. | +> **On versions.** This matrix intentionally omits a per-capability `version` +> column. First-party capabilities are versioned **in lockstep** with the GSD +> Core package (their `capability.json` `version` always equals the GSD release +> version), so a per-row version would simply repeat the package version and +> churn the committed file on every release. The stable host-compatibility +> signal — `engines.gsd` — is shown instead. A third-party capability's exact +> version is recorded in the per-runtime ledger (`.gsd-capabilities.json`) at +> install time. + --- ## Native (first-party) capabilities First-party capabilities are implicitly trusted: they ship as part of the GSD Core package and are stamped with the package version at release (per -ADR-1244 D6). They are not subject to the consent or integrity-pin flow -applied to third-party capabilities. +ADR-1244 D6). They are not subject to the consent or integrity-pin flow applied +to third-party capabilities. -### Feature capabilities (role: feature) +### Feature capabilities (role: feature) — 16 -Feature capabilities extend what the five-step loop does — contributing -research, planning, execution, verification, or ship artefacts. +Feature capabilities extend what the loop does — contributing research, +planning, execution, verification, or ship artefacts at the loop extension +points. -| id | role | tier | version | engines.gsd | extension points | hook kinds | source | -|---|---|---|---|---|---|---|---| -| `research` | feature | standard | 1.6.0 | `>=1.6.0` | `discuss:pre`, `plan:pre` | step, contribution | first-party | -| `ui` | feature | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `ai-integration` | feature | standard | 1.6.0 | `>=1.6.0` | see capability.json | step, gate | first-party | -| `security` | feature | full | 1.6.0 | `>=1.6.0` | `execute:pre`, `verify:pre` | gate | first-party | -| `code-review` | feature | standard | 1.6.0 | `>=1.6.0` | `verify:pre`, `verify:post` | step, gate | first-party | -| `schema-gate` | feature | standard | 1.6.0 | `>=1.6.0` | `execute:pre` | gate | first-party | -| `pattern-mapper` | feature | standard | 1.6.0 | `>=1.6.0` | see capability.json | contribution | first-party | -| `nyquist` | feature | full | 1.6.0 | `>=1.6.0` | see capability.json | step, gate | first-party | -| `validation` | feature | standard | 1.6.0 | `>=1.6.0` | `verify:pre`, `verify:post` | step, gate | first-party | -| `graphify` | feature | full | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `intel` | feature | standard | 1.6.0 | `>=1.6.0` | see capability.json | step, contribution | first-party | -| `audit` | feature | standard | 1.6.0 | `>=1.6.0` | see capability.json | step, gate | first-party | +| id | role | tier | engines.gsd | extension points | hook kinds | source | +|---|---|---|---|---|---|---| +| `ai-integration` | feature | full | `>=1.6.0` | `plan:pre` | step | first-party | +| `audit` | feature | full | `>=1.6.0` | — | — | first-party | +| `code-review` | feature | full | `>=1.6.0` | `execute:post` | step | first-party | +| `drift` | feature | full | `>=1.6.0` | `execute:wave:post` | gate | first-party | +| `gap-analysis` | feature | standard | `>=1.6.0` | `plan:post` | gate | first-party | +| `graphify` | feature | full | `>=1.6.0` | — | — | first-party | +| `intel` | feature | full | `>=1.6.0` | `plan:pre` | step | first-party | +| `mempalace` | feature | full | `>=1.6.0` | `discuss:pre`, `discuss:post`, `plan:pre`, `plan:post`, `execute:wave:post`, `verify:post`, `ship:post` | step, contribution | first-party | +| `nyquist` | feature | full | `>=1.6.0` | `verify:post` | step | first-party | +| `pattern-mapper` | feature | full | `>=1.6.0` | `plan:pre` | step | first-party | +| `profile-pipeline` | feature | full | `>=1.6.0` | — | — | first-party | +| `research` | feature | standard | `>=1.6.0` | `plan:pre` | step | first-party | +| `schema-gate` | feature | full | `>=1.6.0` | `plan:pre` | contribution | first-party | +| `security` | feature | full | `>=1.6.0` | `plan:pre`, `verify:post`, `ship:pre` | step, contribution, gate | first-party | +| `tdd` | feature | full | `>=1.6.0` | `plan:pre`, `execute:post` | contribution, gate | first-party | +| `ui` | feature | full | `>=1.6.0` | `plan:pre`, `execute:wave:post`, `verify:post` | step, gate | first-party | -> **Note:** version `1.6.0` is the placeholder the generator replaces with the -> actual per-capability `version` field from each `capability.json`. The 12 -> loop extension points available to feature capabilities are, in order: -> `discuss:pre`, `discuss:post`, `plan:pre`, `plan:post`, `execute:pre`, -> `execute:wave:pre`, `execute:wave:post`, `execute:post`, `verify:pre`, -> `verify:post`, `ship:pre`, `ship:post`. A capability registers into the -> subset it needs; registration of all 12 is unusual. - -### Runtime capabilities (role: runtime) +### Runtime capabilities (role: runtime) — 16 Runtime capabilities adapt GSD to a specific AI runtime or IDE — emitting -skills, agents, hooks configuration, and surface files appropriate for that -host environment. +skills, agents, hooks configuration, and surface files for that host. They +typically register no loop hooks (their primary responsibility is surface +emission), so their extension-point and hook-kind cells are `—`. -| id | role | tier | version | engines.gsd | extension points | hook kinds | source | -|---|---|---|---|---|---|---|---| -| `claude` | runtime | core | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `codex` | runtime | core | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `gemini` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `antigravity` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `cline` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `cursor` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `opencode` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `kilo` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `copilot` | runtime | full | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `augment` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `trae` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | -| `qwen` | runtime | standard | 1.6.0 | `>=1.6.0` | see capability.json | step | first-party | - -> **Note:** runtime capabilities typically do not register into the 12 loop -> extension points in the same way feature capabilities do — their primary -> responsibility is surface emission (skills, agents, config). Exact hook -> registrations, where they exist, are emitted by the generator into the -> `extension points` cell. +| id | role | tier | engines.gsd | extension points | hook kinds | source | +|---|---|---|---|---|---|---| +| `antigravity` | runtime | core | `>=1.6.0` | — | — | first-party | +| `augment` | runtime | core | `>=1.6.0` | — | — | first-party | +| `claude` | runtime | core | `>=1.6.0` | — | — | first-party | +| `cline` | runtime | core | `>=1.6.0` | — | — | first-party | +| `codebuddy` | runtime | core | `>=1.6.0` | — | — | first-party | +| `codex` | runtime | core | `>=1.6.0` | — | — | first-party | +| `copilot` | runtime | core | `>=1.6.0` | — | — | first-party | +| `cursor` | runtime | core | `>=1.6.0` | — | — | first-party | +| `gemini` | runtime | core | `>=1.6.0` | — | — | first-party | +| `hermes` | runtime | core | `>=1.6.0` | — | — | first-party | +| `kilo` | runtime | core | `>=1.6.0` | — | — | first-party | +| `kimi` | runtime | core | `>=1.6.0` | — | — | first-party | +| `opencode` | runtime | core | `>=1.6.0` | — | — | first-party | +| `qwen` | runtime | core | `>=1.6.0` | — | — | first-party | +| `trae` | runtime | core | `>=1.6.0` | — | — | first-party | +| `windsurf` | runtime | core | `>=1.6.0` | — | — | first-party | --- ## Third-party capabilities -Once a user installs a third-party capability via `gsd capability install -`, it enters the **runtime registry overlay** (ADR-1244 D2) and appears -in this matrix on their machine alongside native capabilities. Third-party -rows use the same column schema as first-party rows. - -### How a third-party row is produced - -The generator reads the capability's `capability.json` from the per-scope -install root (`~/.gsd/capabilities//` for global installs; -`.gsd/capabilities//` for project-scoped installs), validates it against -the same conformance rules applied to native manifests, and emits a row -identical in shape to the native rows above. The only difference is the -`source` column, which shows `third-party`. +This matrix is the **first-party catalogue**: it is generated from the committed +registry and therefore lists only the capabilities that ship with GSD Core. +Installed third-party capabilities are NOT written into this committed file. Once a +user installs one via `gsd capability install ` it enters the **runtime +registry overlay** (ADR-1244 D2); the overlay-aware view of what is installed on a +given machine is `gsd capability list` (see the +[`gsd capability` command reference](gsd-capability-command.md)), which reports +first-party and installed third-party capabilities together using the same column +fields described below, with `source` = `third-party`. ### Column values for third-party rows @@ -115,41 +116,41 @@ identical in shape to the native rows above. The only difference is the | **id** | As declared in `capability.json`. Must not use reserved prefixes (`gsd-`, `gsd-core-`, `anthropic-`). | | **role** | `feature` or `runtime`, as declared. | | **tier** | `core`, `standard`, or `full`, as declared. | -| **version** | Semver from `capability.json`; the value recorded in the ledger at install time. | | **engines.gsd** | Range from `capability.json`; verified at install and at each load. | -| **extension points** | As declared in `capability.json`. Validated against the known 12 extension-point identifiers. | +| **extension points** | The loop points the capability registers into, validated against the known 12 identifiers. | | **hook kinds** | `step`, `contribution`, and/or `gate` as declared. Disclosed in the consent summary at install. | | **source** | `third-party` | ### Community registry Whether GSD operates or advertises a central community registry of third-party -capabilities is **TBD/TBA** (see [PRD-1244 §8](../prd/1244-capability-ecosystem.md#8-open-questions--decisions-deferred)). -The matrix mechanic and all manifest fields ship in 1.6.0 regardless of that -decision. URL/git/npm/tarball import does not depend on a central registry. +capabilities is **TBD/TBA** (PRD). The matrix mechanic and all manifest fields +ship regardless of that decision; URL/git/npm/tarball import does not depend on +a central registry. --- ## Manifest field reference The fields below are defined in `capability.json` and govern how a capability -appears in this matrix. For the full schema, see [ADR-1244 D1](../adr/1244-capability-ecosystem.md#d1--versioned-capability-manifest). +appears in this matrix. For the full schema, see +[ADR-1244 D1](../adr/1244-capability-ecosystem.md#d1--versioned-capability-manifest) +and the [capability manifest reference](capability-manifest.md). | Field | Required | Type | Purpose | |---|---|---|---| -| `version` | **Yes** | semver string | Capability version. The registry rejects manifests without this field. | +| `version` | **Yes** | semver string | Capability version. The registry rejects manifests without it. | | `engines.gsd` | Recommended | semver range | Host-version compatibility gate. Enforced at install and load. | -| `compatVersions` | No | object: cap-version → min-gsd-version | Graceful downgrade table for sources that enumerate versions (git tags, registry, npm). | -| `integrity` | No | `sha512-` | SHA-512 digest of the capability bundle. Verified before extraction when present; mismatch aborts. | -| `provenance` | No | `{ sourceRepo, commit }` | Source provenance. SHOULD be present for first-party and curated capabilities; populated in CI. | +| `compatVersions` | No | object: cap-version → gsd-range | Graceful-downgrade table for sources that enumerate versions (git tags, registry, npm). | +| `integrity` | No | `sha512-` | SHA-512 digest of the fetched bundle. Verified before extraction when present; mismatch aborts. | +| `provenance` | No | `{ sourceRepo, commit }` | Source provenance; populated in CI for first-party/curated capabilities. | --- ## Related documents - [ADR-1244 — Capability Ecosystem](../adr/1244-capability-ecosystem.md) -- [Capability trust model](../explanation/capability-trust-model.md) — why the trust rules are structured the way they are +- [The capability trust model](../explanation/capability-trust-model.md) — why the trust rules are structured as they are - [The phase loop](../explanation/the-phase-loop.md) — the 12 loop extension points in context -- [ADR-857](../adr/857-capability-system.md) — the original capability architecture; D7 and D8 extended by ADR-1244 -- [ADR-894](../adr/894-capability-declaration-format.md) — capability declaration format -- [ADR-1016](../adr/1016-runtime-capability-descriptor.md) — runtime capability descriptor +- [Capability manifest reference](capability-manifest.md) — the full `capability.json` schema +- [ADR-857](../adr/857-capability-system.md) — the original capability architecture (D7/D8 extended by ADR-1244) diff --git a/scripts/gen-capability-matrix.cjs b/scripts/gen-capability-matrix.cjs new file mode 100644 index 000000000..c32cfee3d --- /dev/null +++ b/scripts/gen-capability-matrix.cjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +'use strict'; + +/** + * gen-capability-matrix.cjs — ADR-1244 Phase 6 (Decision D9). + * + * Generates docs/reference/capability-matrix.md FROM the committed capability + * registry (gsd-core/bin/lib/capability-registry.cjs), so the matrix can never + * drift from the actual capability set. Kept honest by a drift guard + * (tests/capability-matrix-sync.test.cjs runs `--check`). + * + * The matrix is RELEASE-STABLE by design: it does NOT embed each capability's + * exact `version` (which tracks the GSD package version in lockstep and would + * churn the committed file — and trip the drift guard — on every release). It + * shows `engines.gsd` (the stable host-compatibility RANGE) instead, and notes + * the version-lockstep rule in prose. The committed matrix therefore changes + * only on intentional capability edits (add/remove a capability, change its + * tier/role/engines/extension-points/hook-kinds) — never on a version bump. + * + * Usage: + * node scripts/gen-capability-matrix.cjs # print to stdout + * node scripts/gen-capability-matrix.cjs --write # write the committed file + * node scripts/gen-capability-matrix.cjs --check # exit 1 if the committed file is stale + */ + +const fs = require('fs'); +const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const REGISTRY_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'); +const MATRIX_PATH = path.join(ROOT, 'docs', 'reference', 'capability-matrix.md'); + +/** Canonical loop extension points, in order (mirrors the phase loop). */ +const LOOP_POINTS = [ + 'discuss:pre', 'discuss:post', + 'plan:pre', 'plan:post', + 'execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post', + 'verify:pre', 'verify:post', + 'ship:pre', 'ship:post', +]; +const POINT_ORDER = new Map(LOOP_POINTS.map((p, i) => [p, i])); + +/** + * Build a capId → { points:Set, kinds:Set } map from the registry's byLoopPoint + * index — the authoritative record of which loop points each capability registers + * into and with which hook kind (step / contribution / gate). + */ +function extensionsByCapability(registry) { + const out = new Map(); + const byPoint = registry.byLoopPoint || {}; + const KIND = { steps: 'step', contributions: 'contribution', gates: 'gate' }; + for (const point of Object.keys(byPoint)) { + const reg = byPoint[point] || {}; + for (const arrKey of ['steps', 'contributions', 'gates']) { + for (const hook of reg[arrKey] || []) { + const capId = hook && hook.capId; + if (typeof capId !== 'string') continue; + let e = out.get(capId); + if (!e) { e = { points: new Set(), kinds: new Set() }; out.set(capId, e); } + e.points.add(point); + e.kinds.add(KIND[arrKey]); + } + } + } + return out; +} + +function fmtPoints(set) { + if (!set || set.size === 0) return '—'; + for (const p of set) { + // Surface a typo'd/unknown loop point at generation time rather than silently sorting it last. + // The registry validates point names at load, so this should never fire — but if it does, the + // generator (not a confused reader) is where it must be caught. + if (!POINT_ORDER.has(p)) { + process.stderr.write(`gen-capability-matrix: WARNING — unknown loop point "${p}" (not one of the ${LOOP_POINTS.length} canonical points)\n`); + } + } + return [...set] + .sort((a, b) => (POINT_ORDER.has(a) ? POINT_ORDER.get(a) : 99) - (POINT_ORDER.has(b) ? POINT_ORDER.get(b) : 99) || a.localeCompare(b)) + .map((p) => '`' + p + '`') + .join(', '); +} + +function fmtKinds(set) { + if (!set || set.size === 0) return '—'; + const order = { step: 0, contribution: 1, gate: 2 }; + return [...set].sort((a, b) => (order[a] ?? 9) - (order[b] ?? 9)).join(', '); +} + +function fmtEngines(cap) { + const g = cap && cap.engines && cap.engines.gsd; + return typeof g === 'string' && g ? '`' + g + '`' : '—'; +} + +/** Render one capability table (rows sorted by id) for the given role. */ +function renderTable(caps, role, extByCap) { + const rows = caps + .filter((c) => c.role === role) + .sort((a, b) => a.id.localeCompare(b.id)) + .map((c) => { + const ext = extByCap.get(c.id) || { points: null, kinds: null }; + return `| \`${c.id}\` | ${c.role} | ${c.tier || '—'} | ${fmtEngines(c)} | ${fmtPoints(ext.points)} | ${fmtKinds(ext.kinds)} | first-party |`; + }); + return [ + '| id | role | tier | engines.gsd | extension points | hook kinds | source |', + '|---|---|---|---|---|---|---|', + ...rows, + ].join('\n'); +} + +function buildMatrix(registry) { + const caps = Object.values(registry.capabilities || {}); + const extByCap = extensionsByCapability(registry); + const featureTable = renderTable(caps, 'feature', extByCap); + const runtimeTable = renderTable(caps, 'runtime', extByCap); + const featureCount = caps.filter((c) => c.role === 'feature').length; + const runtimeCount = caps.filter((c) => c.role === 'runtime').length; + + return `# Capability matrix reference + +> **Generated file — do not edit by hand.** +> This matrix is generated from the capability registry by +> \`scripts/gen-capability-matrix.cjs\` and kept honest by a drift guard +> (\`tests/capability-matrix-sync.test.cjs\` runs \`--check\`). Any manual edit is +> overwritten on the next generation run. To change a capability's declared +> metadata, edit the corresponding \`capabilities//capability.json\` and run +> \`node scripts/gen-capability-matrix.cjs --write\`. + +See also: [ADR-1244](../adr/1244-capability-ecosystem.md) — +[Capability manifest fields](#manifest-field-reference) — +[The capability trust model](../explanation/capability-trust-model.md) + +--- + +## Column definitions + +| Column | Description | +|---|---| +| **id** | Canonical capability identifier; unique across first- and third-party capabilities. Reserved prefixes: \`gsd-\`, \`gsd-core-\`, \`anthropic-\`. | +| **role** | \`feature\` — extends what the loop does; \`runtime\` — adapts GSD to a specific AI runtime/IDE. | +| **tier** | \`core\` — always active; \`standard\` — active when the runtime supports it; \`full\` — opt-in or runtime-specific. | +| **engines.gsd** | Semver RANGE expressing host-version compatibility. A hard gate at install and at load. \`—\` means the capability declares no range. | +| **extension points** | The loop points this capability registers hooks into (from the registry's \`byLoopPoint\` index). \`—\` means it registers none (typical for runtime capabilities, whose job is surface emission). | +| **hook kinds** | Which of \`step\`, \`contribution\`, \`gate\` the capability's hooks use. \`—\` means none. | +| **source** | \`first-party\` — ships with GSD Core; \`third-party\` — installed from an external source via \`gsd capability install\`. | + +> **On versions.** This matrix intentionally omits a per-capability \`version\` +> column. First-party capabilities are versioned **in lockstep** with the GSD +> Core package (their \`capability.json\` \`version\` always equals the GSD release +> version), so a per-row version would simply repeat the package version and +> churn the committed file on every release. The stable host-compatibility +> signal — \`engines.gsd\` — is shown instead. A third-party capability's exact +> version is recorded in the per-runtime ledger (\`.gsd-capabilities.json\`) at +> install time. + +--- + +## Native (first-party) capabilities + +First-party capabilities are implicitly trusted: they ship as part of the GSD +Core package and are stamped with the package version at release (per +ADR-1244 D6). They are not subject to the consent or integrity-pin flow applied +to third-party capabilities. + +### Feature capabilities (role: feature) — ${featureCount} + +Feature capabilities extend what the loop does — contributing research, +planning, execution, verification, or ship artefacts at the loop extension +points. + +${featureTable} + +### Runtime capabilities (role: runtime) — ${runtimeCount} + +Runtime capabilities adapt GSD to a specific AI runtime or IDE — emitting +skills, agents, hooks configuration, and surface files for that host. They +typically register no loop hooks (their primary responsibility is surface +emission), so their extension-point and hook-kind cells are \`—\`. + +${runtimeTable} + +--- + +## Third-party capabilities + +This matrix is the **first-party catalogue**: it is generated from the committed +registry and therefore lists only the capabilities that ship with GSD Core. +Installed third-party capabilities are NOT written into this committed file. Once a +user installs one via \`gsd capability install \` it enters the **runtime +registry overlay** (ADR-1244 D2); the overlay-aware view of what is installed on a +given machine is \`gsd capability list\` (see the +[\`gsd capability\` command reference](gsd-capability-command.md)), which reports +first-party and installed third-party capabilities together using the same column +fields described below, with \`source\` = \`third-party\`. + +### Column values for third-party rows + +| Column | Value | +|---|---| +| **id** | As declared in \`capability.json\`. Must not use reserved prefixes (\`gsd-\`, \`gsd-core-\`, \`anthropic-\`). | +| **role** | \`feature\` or \`runtime\`, as declared. | +| **tier** | \`core\`, \`standard\`, or \`full\`, as declared. | +| **engines.gsd** | Range from \`capability.json\`; verified at install and at each load. | +| **extension points** | The loop points the capability registers into, validated against the known 12 identifiers. | +| **hook kinds** | \`step\`, \`contribution\`, and/or \`gate\` as declared. Disclosed in the consent summary at install. | +| **source** | \`third-party\` | + +### Community registry + +Whether GSD operates or advertises a central community registry of third-party +capabilities is **TBD/TBA** (PRD). The matrix mechanic and all manifest fields +ship regardless of that decision; URL/git/npm/tarball import does not depend on +a central registry. + +--- + +## Manifest field reference + +The fields below are defined in \`capability.json\` and govern how a capability +appears in this matrix. For the full schema, see +[ADR-1244 D1](../adr/1244-capability-ecosystem.md#d1--versioned-capability-manifest) +and the [capability manifest reference](capability-manifest.md). + +| Field | Required | Type | Purpose | +|---|---|---|---| +| \`version\` | **Yes** | semver string | Capability version. The registry rejects manifests without it. | +| \`engines.gsd\` | Recommended | semver range | Host-version compatibility gate. Enforced at install and load. | +| \`compatVersions\` | No | object: cap-version → gsd-range | Graceful-downgrade table for sources that enumerate versions (git tags, registry, npm). | +| \`integrity\` | No | \`sha512-\` | SHA-512 digest of the fetched bundle. Verified before extraction when present; mismatch aborts. | +| \`provenance\` | No | \`{ sourceRepo, commit }\` | Source provenance; populated in CI for first-party/curated capabilities. | + +--- + +## Related documents + +- [ADR-1244 — Capability Ecosystem](../adr/1244-capability-ecosystem.md) +- [The capability trust model](../explanation/capability-trust-model.md) — why the trust rules are structured as they are +- [The phase loop](../explanation/the-phase-loop.md) — the 12 loop extension points in context +- [Capability manifest reference](capability-manifest.md) — the full \`capability.json\` schema +- [ADR-857](../adr/857-capability-system.md) — the original capability architecture (D7/D8 extended by ADR-1244) +`; +} + +function loadRegistry() { + delete require.cache[require.resolve(REGISTRY_PATH)]; + return require(REGISTRY_PATH); +} + +/** Normalize CRLF→LF + ensure a single trailing newline, for cross-platform compare. */ +function normalize(s) { + return s.replace(/\r\n/g, '\n').replace(/\n+$/, '\n'); +} + +function main() { + const flag = process.argv[2]; + const registry = loadRegistry(); + const content = buildMatrix(registry); + + if (flag === '--check') { + let committed; + try { + committed = fs.readFileSync(MATRIX_PATH, 'utf8'); + } catch { + throw new ExitError(1, `${path.relative(ROOT, MATRIX_PATH)} is missing. Run:\n node scripts/gen-capability-matrix.cjs --write`); + } + if (normalize(committed) !== normalize(content)) { + throw new ExitError(1, `${path.relative(ROOT, MATRIX_PATH)} is stale. Run:\n node scripts/gen-capability-matrix.cjs --write`); + } + console.log(`${path.relative(ROOT, MATRIX_PATH)} is up to date.`); + return; + } + if (flag === '--write') { + fs.mkdirSync(path.dirname(MATRIX_PATH), { recursive: true }); + fs.writeFileSync(MATRIX_PATH, content, 'utf8'); + console.log(`Wrote ${path.relative(ROOT, MATRIX_PATH)}`); + return; + } + process.stdout.write(content); +} + +if (require.main === module) runMain(main); + +module.exports = { buildMatrix, extensionsByCapability }; diff --git a/src/capability-lifecycle.cts b/src/capability-lifecycle.cts index 10a826746..5341bcafd 100644 --- a/src/capability-lifecycle.cts +++ b/src/capability-lifecycle.cts @@ -6,7 +6,7 @@ * from a crash mid-upgrade. The LEDGER WRITE is the commit point for every operation: a crash * before it leaves the prior state fully intact; a crash after it is a completed operation. * - * Trust invariants enforced here (see docs/explanation/the-capability-trust-model.md): + * Trust invariants enforced here (see docs/explanation/capability-trust-model.md): * - install/upgrade never execute capability code (resolver stages copy-only; we only swap * directories and edit JSON); * - executable surfaces are disclosed and consent is required before anything is promoted diff --git a/src/capability-loader.cts b/src/capability-loader.cts index d64f47e80..6c7ca79f3 100644 --- a/src/capability-loader.cts +++ b/src/capability-loader.cts @@ -427,7 +427,7 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry { // 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.) + // as the repo — see docs/explanation/capability-trust-model.md.) if (families.length > 0 && committedIds.has(id)) commandRoots[id] = capDir; } } diff --git a/src/capability-trust.cts b/src/capability-trust.cts index e611006f6..ac0a76451 100644 --- a/src/capability-trust.cts +++ b/src/capability-trust.cts @@ -5,7 +5,7 @@ * never mutates the filesystem and never performs I/O beyond reading staged files to confirm * declared executable artifacts exist. The actual consent decision (yes/no) is passed in by the * caller — GSD has no interactive-prompt layer in lib (the runtime/CLI edge owns that), so the - * gate stays testable and side-effect-free. See docs/explanation/the-capability-trust-model.md. + * gate stays testable and side-effect-free. See docs/explanation/capability-trust-model.md. * * LEAF MODULE — imports ONLY: node:fs, node:path, and ./semver-compare.cjs. * diff --git a/tests/capability-matrix-sync.test.cjs b/tests/capability-matrix-sync.test.cjs new file mode 100644 index 000000000..f35e1f9dc --- /dev/null +++ b/tests/capability-matrix-sync.test.cjs @@ -0,0 +1,59 @@ +'use strict'; + +/** + * capability-matrix-sync.test.cjs — ADR-1244 Phase 6 (D9) drift guard. + * + * Asserts the committed docs/reference/capability-matrix.md is exactly what + * scripts/gen-capability-matrix.cjs would generate from the current registry. + * If a capability is added/removed or its tier/role/engines/extension-points/ + * hook-kinds change without regenerating the matrix, this fails — the same + * pattern that keeps docs/INVENTORY-MANIFEST.json and capability-registry.cjs honest. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const GENERATOR = path.join(ROOT, 'scripts', 'gen-capability-matrix.cjs'); +const MATRIX = path.join(ROOT, 'docs', 'reference', 'capability-matrix.md'); +const { buildMatrix } = require('../scripts/gen-capability-matrix.cjs'); +const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); + +describe('capability-matrix drift guard (ADR-1244 Phase 6)', () => { + test('the committed matrix is in sync with the registry (`gen-capability-matrix.cjs --check` exits 0)', () => { + // execFileSync throws if the generator exits non-zero (i.e. the committed file is stale). + assert.doesNotThrow(() => { + execFileSync(process.execPath, [GENERATOR, '--check'], { cwd: ROOT, stdio: 'pipe' }); + }, 'committed capability-matrix.md is stale — run: node scripts/gen-capability-matrix.cjs --write'); + }); + + test('buildMatrix(registry) equals the committed file byte-for-byte (modulo line endings)', () => { + const generated = buildMatrix(registry).replace(/\r\n/g, '\n').replace(/\n+$/, '\n'); + const committed = fs.readFileSync(MATRIX, 'utf8').replace(/\r\n/g, '\n').replace(/\n+$/, '\n'); + assert.equal(committed, generated); + }); + + test('every first-party capability in the registry appears as a matrix row', () => { + const md = fs.readFileSync(MATRIX, 'utf8'); + for (const cap of Object.values(registry.capabilities)) { + if (cap.role !== 'feature' && cap.role !== 'runtime') continue; + assert.ok(md.includes('`' + cap.id + '`'), `capability ${cap.id} (${cap.role}) must appear in the matrix`); + } + }); + + test('extension points + hook kinds reflect the registry byLoopPoint index (not placeholders)', () => { + const md = fs.readFileSync(MATRIX, 'utf8'); + // The stub used "see capability.json" placeholders — the generated matrix must not. + assert.ok(!md.includes('see capability.json'), 'matrix must show real extension points, not placeholders'); + // `security` registers a gate at ship:pre — a hard architectural invariant. Assert the precondition + // UNCONDITIONALLY (so this never degrades to a vacuous pass if the registry changes), then assert the + // rendered row reflects it. + const shipPreGates = (registry.byLoopPoint['ship:pre'] && registry.byLoopPoint['ship:pre'].gates) || []; + assert.ok(shipPreGates.some((g) => g.capId === 'security'), 'precondition: security registers a ship:pre gate in the registry'); + const securityRow = md.split('\n').find((l) => l.includes('`security`') && l.includes('|')); + assert.ok(securityRow && securityRow.includes('`ship:pre`'), 'security row must list its real ship:pre extension point'); + }); +});