feat(#1435): capability matrix (generated + drift-guarded) + trust-model doc consolidation (#1458)

* feat(#1435): capability matrix (generated + drift-guarded) + trust-model doc consolidation

ADR-1244 Phase 6. Adds the capability matrix reference, generated FROM the committed registry so it
can never drift from the actual capability set:
- scripts/gen-capability-matrix.cjs (--write / --check); --check is a CI drift guard.
- tests/capability-matrix-sync.test.cjs (4 tests): drift guard, buildMatrix==committed, every cap
  present, no placeholders.
- docs/reference/capability-matrix.md regenerated from the registry (release-stable: shows engines.gsd,
  omits the lockstep per-cap version that would churn the file every release).
- Consolidated the duplicate trust-model doc: deleted docs/explanation/the-capability-trust-model.md,
  merged its content into capability-trust-model.md, redirected ~10 references; no stale links remain.
- Diataxis verification (now that gsd capability is a real command): corrected the matrix's third-party
  section — the matrix is the first-party catalogue; the overlay-aware view of installed third-party
  capabilities is 'gsd capability list', not this generated file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1435): Added changeset for the capability matrix reference

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1435): address code-review — non-vacuous matrix test + generator polish

- capability-matrix-sync.test.cjs: assert the 'security registers a ship:pre gate' precondition
  unconditionally so the extension-point check can never degrade to a vacuous pass on registry drift.
- gen-capability-matrix.cjs: warn (stderr) on an unknown loop point at generation time; rename
  enginesOf -> fmtEngines for consistency with the other fmt* helpers (output unchanged).
- capability-trust-model.md: point the two how-to links at the real files
  (import-a-capability-from-a-url.md, version-a-capability.md) instead of the bare directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#1435): backfill changeset PR number → #1458

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-19 12:45:26 -04:00
committed by GitHub
parent 34bc096ec2
commit 0d56f544d2
16 changed files with 507 additions and 231 deletions

View File

@@ -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 <family>` 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** (`<projectRoot>/.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 <its-family>` 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.
---

View File

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