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>
8.1 KiB
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:
- No execution at install. Staging is copy-only.
npmsources use--ignore-scripts; there is nopostinstall. Installing a capability never runs its code. - Integrity before extraction. Tarball sources verify a
sha512digest over the fetched bytes before anything is written to the install root; a mismatch aborts. - 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.
- Namespace reservation. Third parties cannot claim the
gsd-,gsd-core-, oranthropic-id prefixes, so a hostile capability cannot impersonate a first-party one. - Reversibility. The ledger records exactly what each install wrote;
removedeletes 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:
- 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. - Confinement. The router module is loaded from the capability's own install root — a bare
.cjsbasename,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 inbin/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
gsdcommands in them, and prefer global installs for capabilities you want to trust across projects.