fix(#1459): user-owned consent store gates third-party capability activation; env/cwd in disclosure; loader validator parity (#1473)
This commit is contained in:
@@ -279,9 +279,11 @@
|
||||
"audit-command-router.cjs",
|
||||
"audit.cjs",
|
||||
"capability-activation.cjs",
|
||||
"capability-consent.cjs",
|
||||
"capability-ledger.cjs",
|
||||
"capability-lifecycle.cjs",
|
||||
"capability-loader.cjs",
|
||||
"capability-lock.cjs",
|
||||
"capability-registry.cjs",
|
||||
"capability-source.cjs",
|
||||
"capability-state.cjs",
|
||||
|
||||
@@ -390,7 +390,9 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 |
|
||||
| `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers |
|
||||
| `capability-activation.cjs` | Capability activation resolver shared by config validation and capability-state consumers — resolves registry-owned config keys from raw runtime config without re-centralizing migrated settings |
|
||||
| `capability-ledger.cjs` | Per-runtime install ledger (ADR-1244 D4) — atomic read/write of `.gsd-capabilities.json` recording `{ id, version, source, integrity, files[], sharedEdits[] }` per installed capability; exports `readLedger`/`writeLedger`/`recordInstall`/`removeEntry`/`reconcile` (orphan detection); atomic commit point and reconciliation basis for Phase-4 upgrade/remove |
|
||||
| `capability-consent.cjs` | User-owned capability consent store (#1459) — bounded, non-throwing JSON store at `${GSD_HOME\|\|homedir()}/.gsd/consent.json` (NEVER under a repo) keyed by `${realpath(projectRoot)} <id>`; exports `consentStorePath`/`readConsentStore`/`hasProjectConsent` (matches iff integrity AND disclosureSignature both match)/`recordProjectConsent` (atomic+durable write)/`revokeProjectConsent`; the authoritative consent signal that gates PROJECT-scope third-party capability activation so a forged/cloned project ledger no longer activates anything until the user consents on THIS machine |
|
||||
| `capability-lock.cjs` | Shared cross-process lock primitive (#1459 finding 4) — the SINGLE hardened lockfile protocol used by BOTH capability-lifecycle (`.gsd/capabilities/.lock`) and capability-consent (`.consent.lock`); exports `acquireLock(lockPath, opts?)`/`releaseLock(handle)` with pid + process-start-time liveness identity, a hard deadman, and token+inode owner-safe release — NEVER stale-steals a verified-live same-host holder, reclaims only a provably-dead/unverifiable holder, never deadlocks; `opts.maxAttempts`/`opts.waitForFresh` let the consent store serialize genuinely-contended writers; `_setLockProbes`/`_resetLockProbes` are test seams |
|
||||
| `capability-ledger.cjs` | Per-runtime install ledger (ADR-1244 D4) — atomic read/write of `.gsd-capabilities.json` recording `{ id, version, source, integrity, files[], sharedEdits[] }` per installed capability; exports `readLedger`/`writeLedger`/`recordInstall`/`removeEntry`/`reconcile` (orphan detection)/`readSmallRegularFile` (utf8) + `readSmallRegularFileBuffer` (raw bytes, the byte-exact consent-hash reader, #1459 finding 1); atomic commit point and reconciliation basis for Phase-4 upgrade/remove |
|
||||
| `capability-lifecycle.cjs` | Capability lifecycle orchestration (ADR-1244 Phase 4, D5+D6) — composes the source resolver + ledger + trust gate into `installCapability`/`upgradeCapability`/`removeCapability`/`reconcileCapabilities`; ledger write is the commit point; upgrade is atomic stage-then-swap (old set aside, new swapped in, ledger committed, backup dropped) with deterministic crash recovery (`reconcileCapabilities` rolls forward/back to a fully-old-or-fully-new state); remove surgically strips only marker-stamped (`_gsdCapability`) shared-config entries, preserving user hand-edits; never executes capability code |
|
||||
| `capability-loader.cjs` | Runtime Capability Registry overlay (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay read from `$GSD_HOME/.gsd/capabilities/<id>/` (global) and `<projectRoot>/.gsd/capabilities/<id>/` (project); first-party-wins on id/skill/agent/config collisions, reserved-namespace rejection, load-time `engines.gsd` re-gate (skip-with-warning), and gate-kind fail-closed via `_overlay.blockedGates`; composes through the canonical `buildRegistry` so derived views never drift |
|
||||
| `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations (`capabilities/<id>/capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) |
|
||||
|
||||
@@ -100,12 +100,13 @@ Third-party capabilities may ship the **same artifacts** first-party ships (full
|
||||
Hard rules (MUST):
|
||||
|
||||
1. **Install never executes capability code.** Staging is copy-only; no `postinstall`-equivalent. (npm `--ignore-scripts` lesson.)
|
||||
2. **Executable surfaces are disclosed and consented at install.** `hooks`, `mcpServers`, and command modules activate on the *next tool call* — there is no "first use" gate for a hook — so consent must be at install, naming every executable surface. Declining aborts cleanly.
|
||||
2. **Executable surfaces are disclosed and consented at install.** `hooks`, `mcpServers`, and command modules activate on the *next tool call* — there is no "first use" gate for a hook — so consent must be at install, naming every executable surface. The disclosure includes each MCP server's **`env` and `cwd`** (#1459), because an environment variable (e.g. `NODE_OPTIONS=--require evil.js`) can change *what* a command does without touching the command or argv; the disclosure signature folds env/cwd in as stable sorted JSON so any add/change forces re-consent. Declining aborts cleanly.
|
||||
3. **Integrity is verified before extraction** when an `integrity`/SHA is available; mismatch aborts. (npm registry-signature lesson.)
|
||||
4. **Auto-update is OFF by default** for third-party; enabling it still **re-prompts when the executable set changes** between versions. (VS Code stolen-PAT + silent-auto-update lesson.)
|
||||
5. **Modules are `require()`'d only from the capability's own install root** — parent-directory traversal in declared paths is rejected.
|
||||
6. **`gsd-*` (and `gsd-core-*`, `anthropic-*`) ids/prefixes are reserved** — third-party cannot impersonate first-party.
|
||||
7. **`strictKnownRegistries`** (managed/project config) can lock installs to an allowlist; `[]` means no external installs.
|
||||
8. **The consent signal for a project-scope capability is a user-owned consent store, NOT the in-repo ledger** (#1459). The store lives at `${GSD_HOME||homedir()}/.gsd/consent.json` — outside any repository — keyed by `(realpath(projectRoot), id)` and bound to the bundle integrity + disclosure signature. Before activating a project-scope overlay (its declarative loop surfaces **and** its command dispatch) the loader requires a matching record on **this machine**; without it the capability is discovered-but-inactive. This **retracts the prior limitation** that a project-scope ledger living inside the repository was itself the consent — a forged/cloned project ledger could otherwise activate executable + declarative surfaces with no user decision. Global-scope installs (under the user's own home) need no per-project record. `gsd capability trust list`/`revoke` audit and revoke project consents.
|
||||
|
||||
Stated honestly: **there is no sandbox.** Node-level sandboxing is impractical and would defeat full parity. Consent + integrity + reversibility are the barrier. (Obsidian's honest acknowledgment.) **Rationale:** a one-time trust prompt does not make running arbitrary code safe; separating *artifact parity* from *trust posture* is what makes full parity defensible.
|
||||
|
||||
|
||||
@@ -149,9 +149,18 @@ consent window is install, not first use.
|
||||
|
||||
GSD presents a pre-install summary that names every executable surface the
|
||||
capability declares (hooks, MCP servers, command modules), their kinds (`step`,
|
||||
`contribution`, `gate`), and the loop extension points they register into.
|
||||
Declining aborts the install cleanly. Accepting records the consent in the
|
||||
ledger.
|
||||
`contribution`, `gate`), and the loop extension points they register into. For
|
||||
each MCP server the summary also shows the `env` it would be spawned with (each
|
||||
key and its — truncated — value) and the `cwd` it would run in, because an
|
||||
environment variable can change *what* a command does (for example
|
||||
`NODE_OPTIONS=--require /tmp/evil.js`) without touching the command or its
|
||||
arguments. Declining aborts the install cleanly. Accepting records the consent
|
||||
in the user-owned consent store (see "The project-scope trust boundary"), bound
|
||||
to the bundle's integrity and a *disclosure signature* over the executable set
|
||||
(hooks, command modules, and each MCP server's command, argv, env, and cwd). The
|
||||
signature is a stable, key-order-independent encoding, so any later add or change
|
||||
to a surface — including an env or cwd change — deactivates the capability until
|
||||
the user re-consents, while a harmless key reorder does not.
|
||||
|
||||
For non-executable surfaces (skills, agents, workflow files), the disclosure
|
||||
note explains what they do but consent is lighter — they do not execute code.
|
||||
@@ -234,29 +243,43 @@ with the consent prompt as the default barrier and lockdown one config key away.
|
||||
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.
|
||||
gated twice. **Consent:** a third-party family is dispatchable only if the
|
||||
capability is *active* under the activation gate below — for a project-scoped
|
||||
capability that means a **user consent record on this machine**, not merely a
|
||||
ledger entry. A bundle merely present on disk (or a project ledger that marks it
|
||||
committed) but with no on-this-machine consent record is **not** activated at
|
||||
all: no declarative surfaces, no command dispatch. **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.
|
||||
**project-scoped** (`<projectRoot>/.gsd/capabilities/`). The authoritative
|
||||
consent signal is **not** the in-repo ledger but a **user-owned consent store**
|
||||
that lives **outside any repository**, at
|
||||
`${GSD_HOME||homedir()}/.gsd/consent.json`. Each project-scope consent record is
|
||||
keyed by `(realpath(projectRoot), capability id)` and binds the bundle's
|
||||
`integrity` and its disclosure signature; GSD writes one only when *you* install
|
||||
or upgrade that project-scoped capability through the lifecycle on this machine,
|
||||
and removes it when you uninstall.
|
||||
|
||||
Before activating a project-scoped overlay — for **both** its declarative loop
|
||||
surfaces (steps, gates, contributions, federated config) **and** its command
|
||||
dispatch — the loader requires a matching record in this store. With no match the
|
||||
capability is *discovered but inactive*: it shows up in `gsd capability list`
|
||||
with `status: inactive` and a reason, but contributes nothing and runs nothing.
|
||||
|
||||
This closes the previous bypass: a repo you check out could ship a capability
|
||||
bundle *and* a project ledger that marked it committed, and that alone used to
|
||||
activate it. Now a forged or cloned project ledger activates **nothing** until
|
||||
you consent on this machine — and because the consent binds the integrity and
|
||||
the disclosure signature, tampering with the bundle (including changing an MCP
|
||||
server's `env` or `cwd`) deactivates it until you re-consent. A **global**
|
||||
install (under your own home) is trusted without a per-project record, as before.
|
||||
You can audit and revoke project consents with `gsd capability trust list` and
|
||||
`gsd capability trust revoke <id>`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
The `capability` family manages the installation, upgrade, removal, and inspection of GSD capabilities — both first-party (shipped) and third-party overlays. A row for this command also appears in [docs/COMMANDS.md](../COMMANDS.md) (that file is not edited here).
|
||||
|
||||
**Implemented in 1.6.0:** `install`, `update`, `remove`, `list`, `disable`, `enable` (plus the pre-existing `state` and `set` introspection/activation subcommands). **Planned (not yet implemented):** `outdated` — see [Planned subcommands](#planned-subcommands).
|
||||
**Implemented in 1.6.0:** `install`, `update`, `remove`, `list`, `trust`, `disable`, `enable` (plus the pre-existing `state` and `set` introspection/activation subcommands). **Planned (not yet implemented):** `outdated` — see [Planned subcommands](#planned-subcommands).
|
||||
|
||||
---
|
||||
|
||||
@@ -175,7 +175,8 @@ Lists capabilities visible to the current session: first-party capabilities (fro
|
||||
"tier": "core | standard | full | null",
|
||||
"source": "first-party | <recorded source string>",
|
||||
"scope": "first-party | global | project",
|
||||
"status": "active | incompatible",
|
||||
"status": "active | incompatible | inactive",
|
||||
"reason": "string | null",
|
||||
"title": "string | null"
|
||||
}
|
||||
]
|
||||
@@ -185,13 +186,47 @@ Lists capabilities visible to the current session: first-party capabilities (fro
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `active` | Present and (for overlays) compatible with the running GSD version. |
|
||||
| `active` | Present and (for overlays) compatible with the running GSD version and — for project-scope overlays — backed by a user consent record on this machine. |
|
||||
| `incompatible` | An overlay whose `engines.gsd` range does not satisfy the current GSD version; skipped with a warning at load time. |
|
||||
| `inactive` | A **project-scope** overlay that is present on disk (and may have a committed-looking project ledger) but has **no user consent record on this machine** (#1459). It is *discovered but not activated*: it contributes no surfaces and runs nothing. The accompanying `reason` field explains why. Consent it by re-installing through the lifecycle (`gsd capability install … --scope project`). |
|
||||
|
||||
The `reason` field is `null` for active/incompatible rows and carries a short explanation for `inactive` rows.
|
||||
|
||||
> Whether a capability has been turned off via `disable` is reported by `gsd capability state` (the activation-state view), not by `list`.
|
||||
|
||||
---
|
||||
|
||||
### `trust`
|
||||
|
||||
Manage the **user-owned consent store** (#1459) that gates project-scope third-party capability activation. The store lives at `${GSD_HOME||homedir()}/.gsd/consent.json` — **outside any repository** — and records, per `(realpath(projectRoot), capability id)`, the bundle integrity and disclosure signature you consented to **on this machine**. A project-scope overlay is inactive until such a record exists (so a forged or cloned in-repo project ledger activates nothing on its own); installing a project-scope capability through the lifecycle writes the record, and removing it revokes the record.
|
||||
|
||||
**Synopsis**
|
||||
|
||||
```
|
||||
gsd capability trust list [--scope project] [--json]
|
||||
gsd capability trust revoke <id> [--project <path>]
|
||||
```
|
||||
|
||||
**`trust list`** emits a JSON array of the consent records for the current consent home:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "string",
|
||||
"scope": "project",
|
||||
"projectRoot": "/abs/realpath/of/project",
|
||||
"integrity": "sha512-… | (empty)",
|
||||
"consentedAt": "ISO-8601 timestamp"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`--scope` is accepted for symmetry; only `project` records exist today.
|
||||
|
||||
**`trust revoke <id>`** deletes the consent record for `<id>` at the project root. `--project <path>` pins the project root whose consent is revoked (defaults to `realpath(cwd)`). After revoking, the capability — even if its bundle and project ledger remain on disk — lists as `status: inactive` and contributes nothing until you re-consent. `remove` already revokes consent as part of an uninstall; `trust revoke` is the way to withdraw consent **without** uninstalling the bundle.
|
||||
|
||||
---
|
||||
|
||||
## Planned subcommands
|
||||
|
||||
These appear in ADR-1244's command surface but are **not implemented in 1.6.0**. They are documented here so the surface is explicit; invoking them returns the unknown-subcommand error listing the available set.
|
||||
|
||||
Reference in New Issue
Block a user