11 KiB
type, pr
| type | pr |
|---|---|
| Fixed | 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).