6 lines
11 KiB
Markdown
6 lines
11 KiB
Markdown
---
|
|
type: Fixed
|
|
pr: 1473
|
|
---
|
|
**Capability trust model was bypassable for project-scope third-party capabilities (#1459).** The consent signal for a project-scope capability was its in-repo project ledger — but a project ledger is repo-plantable, so cloning or forging a repository activated that capability's executable surfaces (hooks, MCP servers) AND its declarative loop surfaces (steps, gates, contributions, federated config) AND its command dispatch with **no user decision on the machine running it**. The fix moves the authoritative consent signal off the repo tree into a new **user-owned consent store** at `${GSD_HOME||homedir()}/.gsd/consent.json` (new leaf module `src/capability-consent.cts`): a bounded, non-throwing, atomically-written store keyed by `(realpath(projectRoot), capability id)`. The security binding is a **recomputed full-bundle content hash** (`bundleContentHash` — a `sha512` over *every* regular file in the bundle, manifest AND artifacts AND identity, symlinks and non-regular entries rejected, bounded), **not** the repo-plantable ledger `integrity` (which is `''` for path/git/dir installs — a degenerate `'' === ''`) and **not** the executable-only disclosure signature (which is constant for a declarative-only capability, so a repo-write attacker could swap `capability.json` for a malicious gate/contribution while consent still matched). The loader **recomputes** the bundle content hash at load and activates a project-scope overlay — declarative surfaces and command dispatch alike — only when it matches the consent record on **this** machine; any tamper (a swapped declarative manifest, an edited hook script, an empty-integrity local install) changes the hash and leaves the capability *discovered but inactive* (`gsd capability list` reports `status: inactive` with a reason). Global-scope overlays (under the user's own home) remain trusted without a per-project record. The lifecycle records consent (bound to the installed bundle's content hash) on a consented project install/upgrade and revokes it on remove, deriving the project root through one canonical helper (`consentProjectRoot`) shared by the install record site, the loader lookup, and `trust revoke`, so an install from a sub-directory is not immediately inactive. The disclosure signature now also covers each MCP server's `transport`/`url`/`headers` (non-stdio endpoints), `env`, `cwd`, and the *raw* args array, plus a command module's `router`, and every surface line is JSON-encoded (no delimiter-injection collisions) — so a swapped remote endpoint, header, environment (e.g. `NODE_OPTIONS=--require evil.js`), entry point, or non-string arg forces re-consent. The consent store serializes concurrent cross-project writes under a lockfile (no lost updates), enforces its record cap at write time, uses a collision-safe on-disk key for paths containing spaces, and tolerates a vanished directory on the durability fsync. New CLI: `gsd capability trust list` and `gsd capability trust revoke <id> [--project <path>]`. The loader's per-scope ledger read now goes through the shared bounded fd reader (a repo-planted FIFO ledger can no longer hang the loader) and reuses the ledger's shared `isValidLedgerEntry` validator for committed-entry parity. Integration hardening: the overlay consumers (`capability-state`, `loop-resolver`, the federated config-loader/config-schema) now thread the consent home (`GSD_HOME`) explicitly to the loader so a consented project capability is never looked up at the wrong home; the loader's discovered-but-inactive warning carries a structural `kind: 'unconsented'` discriminant that `gsd capability list` filters on (rather than matching the reason prose); a reconcile rollback that deletes a project-scope bundle also revokes its now-stale consent so an identical re-drop stays inactive; `installCapability`/`upgradeCapability` warn on stderr when a project install supplies no consent store and when the consent-store write fails (the install still succeeds — a consent-store IO error never fails an otherwise-successful install); `gsd capability trust list` now exposes the stored `disclosureSignature` and `contentHash` for diffing; and when `GSD_HOME` resolves equal to a genuine project root an in-repo bundle still requires a consent record (it is not deduped as trusted-global). Convergence hardening: the content-hash canonicalization — the security binding itself — is now **injective and lossless**. It length-FRAMES every component (an entry count, then per entry a type tag, the uint32 path length + path bytes, and for files the uint64 content length + the **raw** content bytes read via a new raw-`Buffer` reader, never a lossy UTF-8 decode) so neither a `NUL` embedded in file content can fake a file boundary (the old `relpath + NUL + content + NUL` framing was non-injective) nor can two binary artifacts that differ only in invalid-UTF-8 bytes collide on `U+FFFD`; empty directories are bound via typed directory markers so adding/removing one changes the hash. `recordProjectConsent`/`revokeProjectConsent` now **throw** rather than perform an unlocked read-modify-write when the consent-store lock cannot be acquired (the lifecycle already treats a consent-write failure as non-fatal and warns, so an install still succeeds). The consent lock and the lifecycle lock are now ONE shared hardened primitive (`src/capability-lock.cts`) — process-start-time liveness identity + a hard deadman — so the consent lock can no longer stale-steal a slow-but-live writer (the old mtime-only 60 s steal could). Finally, the MCP disclosure signature now folds in a stable hash of the **full** server config object the writer persists (not only the whitelisted fields), so an upgrade that changes any host-honored field outside the whitelist (a future `envFile`/`workingDir`/launch option) still forces re-consent. A final convergence pass closes four residual gaps: (1) the loader's overlay-root dedup and the CB-3 "project root == global home ⇒ require consent" comparison are now keyed on `fs.realpathSync` (fail-safe to `path.resolve`), so a **symlinked `GSD_HOME` aliasing the project root** can no longer slip an in-repo bundle into the trusted-global slot — it still requires a consent record; (2) the loader reads `capability.json` through the shared **bounded** fd reader (regular-file + size cap) instead of a raw `fs.readFileSync`, so a project-planted FIFO/device or oversized manifest skips the overlay (warning) rather than hanging or OOM-ing the loop; (3) the **PATH** component of the content hash is now hashed from raw directory-entry **bytes** (a `{ encoding: 'buffer' }` walk, separator normalized at the byte level), so two bundle files whose names differ only in invalid-UTF-8 bytes (which a string decode would collapse to `U+FFFD`) no longer collide; and (4) the `gsd capability trust revoke` CLI now catches the consent-store lock-acquire failure and emits a clean, actionable error instead of surfacing a raw stack. A further convergence pass closes three more residual gaps and documents one irreducible limit: (1) the loader's user-owned consent gate now runs **before** the heavy pre-activation work (`materializeHookFragments` and cross-capability validation) for a project-scope overlay, so a forged in-repo bundle whose `fragment.path` points at an in-bundle FIFO/oversized file is skipped (unconsented → inactive) **without** ever reading that fragment — closing a pre-consent hang/OOM (the gate's decision is unchanged; only the work-ordering moved), and as defense-in-depth `materializeHookFragments` now reads each fragment body through the shared **bounded** fd reader (regular-file + size cap) so a FIFO/device/oversized fragment becomes an un-materializable-fragment validation error rather than a blocking read on any scope; (2) `gsd capability list` now reads each project `capability.json` through the same bounded reader instead of a raw `fs.readFileSync`, so a project-planted FIFO/device or oversized manifest omits that entry's metadata and exits cleanly rather than hanging/OOM-ing the list; (3) the loader's `canonicalDir` realpath **failure** is now strictly fail-safe — a candidate that would be classified trusted-`global` but whose `realpathSync` throws (a race/odd-FS, e.g. a symlinked `GSD_HOME` aliasing the project root) is reclassified conservatively to consent-required `project`, so it can no longer park an aliased project tree in the trusted-global slot (a non-existent global dir still resolves to a harmless no-op scan). Finally, an honest in-code comment at the loader consent gate documents the **irreducible filesystem-primitive TOCTOU residual**: the content hash binds the bundle at verification time, but a local writer racing between that verification and the capability's later execution can still mutate the bundle files — closing this window would require fd-pinned execution or an atomic content snapshot (native support not available at this layer); any persisted tamper is still caught on the next load (mirrors the #1462 lock-release residual — a documented real limit, not a dismissal). A final deep-convergence pass closes three more gaps: (1) the realpath fail-safe is now **two-sided** — a global overlay root is trusted (consent-free) ONLY when `realpath(global)` AND `realpath(project)` BOTH succeed AND resolve to DIFFERENT physical paths; the prior one-sided rule (demote only a realpath-failed *global* candidate) still let a **symlinked `GSD_HOME` aliasing the project root** bypass consent when the GLOBAL candidate realpathed fine but the PROJECT candidate's realpath failed (the keys never collided, so the in-repo bundle stayed in the no-consent global slot), so distinctness that cannot be proven (either side throws, or both resolve equal) now demotes the global to consent-required `project` whenever a genuine project root exists — while a genuinely non-existent project overlay (ENOENT) or a distinct real global root still stays trusted; (2) `bundleContentHash` now **bounds the enumeration itself** — it streams each directory via `fs.opendirSync` + `readSync` and throws the moment a cumulative entry counter exceeds the cap, BEFORE collecting/sorting a whole directory, so a malicious unconsented bundle with a huge single directory (or a deep tree) can no longer force unbounded memory/CPU before fail-closing (the cap is cumulative across the recursive walk; determinism is preserved by sorting the bounded set); and (3) a project `remove` no longer silently swallows the revoke-on-lock-failure throw — `revokeProjectConsent` throws on a consent-lock failure (a stale consent record a byte-identical re-drop could reactivate against), so `removeCapability` now surfaces it via a stderr warning naming the record AND a `consentRevokeFailed`/`consentRevokeWarning` flag on the result, which the CLI reports as a non-clean removal (telling the user to run `gsd capability trust revoke`).
|