diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 760409bcd..59ef61380 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -381,7 +381,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts; emitted by `scripts/gen-loop-host-contract.cjs` from workflow markers (ADR-894 §3); consumed by `gen-capability-registry.cjs` | -| `capability-loader.cjs` | Runtime registry overlay loader (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay of third-party capability manifests read from global `$GSD_HOME/.gsd/capabilities/` and project `/.gsd/capabilities/`; first-party always wins; load-time `engines.gsd` re-gate skips incompatible overlays with a warning; gate-kind hooks on skipped capabilities fail CLOSED | +| `capability-loader.cjs` | Runtime registry overlay loader (ADR-1244 D2) — `loadRegistry({ includeInstalled })` composes the frozen first-party registry with a validated installed overlay of third-party capability manifests read from global `$GSD_HOME/.gsd/capabilities/` and project `/.gsd/capabilities/`; first-party always wins; load-time `engines.gsd` re-gate skips incompatible overlays with a warning; gate-kind hooks on skipped capabilities fail OPEN — no gate is injected; a loud warning (stderr + envelope `warnings`) names the load failure and the `gsd capability remove ` remediation (#2009) | | `capability-registry.cjs` | Generated central Capability Registry — role-partitioned index of all co-located capability declarations; emitted by `scripts/gen-capability-registry.cjs` (ADR-894 §5) | | `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c registry-consuming query; consumes resolved Capability State, filters `byLoopPoint` by capability enablement plus config activation, renders active hooks as markdown, emits `{ point, activeHooks, rendered }` envelope; `gsd-tools loop render-hooks [--config-dir ]` | | `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b/6; composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, I/O `cmdCapabilityState`, and convenience predicate `isCapabilityActive(capId, cwd)`; `gsd-tools capability state [--config-dir ]` emits `{ runtimeConfigDir, capabilities[] }` where each entry carries `enabled` (installed && surfaced) and `active` (enabled && configActivation via the capability's `activationKey`; absent key → active===enabled) | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f1c4357a8..eb6f36b6a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -769,9 +769,9 @@ Installed overlay capabilities are merged via the same `buildRegistry` pipeline Each overlay manifest may declare an `engines.gsd` semver range. At load time GSD evaluates this range against the running GSD version. An overlay that does not satisfy the range is **skipped with a warning** — it is never loaded and never crashes the loop. Manifests without an `engines.gsd` field are accepted unconditionally. -### Gate-kind fail-closed policy +### Gate-kind fail-open policy (#2009) -If a skipped overlay capability declared a `gate`-kind loop hook, the loop resolver **injects a blocking gate** at that hook point (fail CLOSED). Skipped capabilities whose hooks are `step` or `contribution` kind skip open — the loop proceeds without them. +If a skipped or load-failed overlay capability (for example, one whose `engines.gsd` range is incompatible) declared a `gate`-kind loop hook, the loop resolver does **not** inject a gate at that hook point (fail OPEN): the loop proceeds. Instead it emits a loud warning — to stderr and in the `loop render-hooks` envelope's `warnings` array — naming the load-failure reason and the exact remediation, `gsd capability remove `, so the operator is loudly told how to clear it. Skipped capabilities whose hooks are `step` or `contribution` kind skip open too, as before — the loop proceeds without them. ### Overlay config federation diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 8743b4305..6562c72c1 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -405,7 +405,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `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//` (global) and `/.gsd/capabilities//` (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-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//` (global) and `/.gsd/capabilities//` (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-open via `_overlay.blockedGates` — a loud warning (stderr + envelope `warnings`) naming the load failure and `gsd capability remove ` remediation; no gate injected (#2009); 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//capability.json`); emitted by `scripts/gen-capability-registry.cjs --write` (ADR-894 §5) | | `capability-source.cjs` | Capability source resolver (ADR-1244 D3) — `resolveCapabilitySource(spec, opts)` fetches and stages a capability from local path, git (https/ssh/git transports only), npm pack (no lifecycle scripts), tarball (sha512 integrity verify before extraction), or registry (stub); tar-slip/symlink rejection; atomic staging to `$GSD_HOME/.gsd/capabilities//`; no capability code executes during install | | `capability-state.cjs` | Unified capability-state resolver (ADR-857 phase 4b/6) — composes install profile, runtime surface, and config activation into one per-capability view consumed by workflow hook rendering; exports pure `resolveCapabilityState`, reusable `resolveCapabilityRuntimeState`, and I/O handler `cmdCapabilityState`; command surface: `gsd-tools capability state [--config-dir ]` emitting `{ runtimeConfigDir, capabilities[] }` | diff --git a/docs/README.md b/docs/README.md index f05fce2cd..c78509ffa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -72,7 +72,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Multi-agent orchestration](explanation/multi-agent-orchestration.md) — how subagents are spawned, scoped, and coordinated - [Security model](explanation/security-model.md) — trust boundaries, permissions, and safe automation - [The capability trust model](explanation/capability-trust-model.md) — why third-party capabilities are gated by consent + integrity + reversibility, not a sandbox -- [How overlay capabilities compose](explanation/capability-overlay-model.md) — why first-party always wins and how the loader resolves precedence, conflicts, and fail-closed gates +- [How overlay capabilities compose](explanation/capability-overlay-model.md) — why first-party always wins and how the loader resolves precedence, conflicts, and fail-open load-failure warnings - [Architecture](ARCHITECTURE.md) — system architecture, agent model, and data flow - [Discuss modes](workflow-discuss-mode.md) — assumptions mode vs interview mode for `/gsd-discuss-phase` - [Context monitoring](context-monitor.md) — context window monitoring hook architecture diff --git a/docs/explanation/capability-overlay-model.md b/docs/explanation/capability-overlay-model.md index a39326644..b1d95f956 100644 --- a/docs/explanation/capability-overlay-model.md +++ b/docs/explanation/capability-overlay-model.md @@ -181,9 +181,10 @@ candidate is processed. A single broken overlay cannot poison the rest of the se --- -## The one place where skipping is dangerous: gates +## The one place where a skip must be loud: gates -Skipping a broken overlay is the safe default for most surfaces — but not for *gates*. +Skipping a broken overlay is the safe default for every surface — including gates, though +gates get special treatment. A capability's loop hooks come in three kinds: @@ -195,21 +196,28 @@ For steps and contributions, skipping a capability means the loop simply proceed **without** that addition. That is *fail-open*, and it is correct: the loop is missing an optional step, not doing something unsafe. -A gate is the opposite. The whole purpose of a gate is to *stop* the loop when a -condition is not met — a deploy gate, a house-style verification gate, a safety check. If -GSD skipped a broken gate-declaring capability and proceeded, it would behave exactly as -if the gate had *passed* — silently waving through the very thing the gate existed to -block. That is a fail-open on a security-relevant control, and it is unacceptable. +A gate looks different at first glance. The whole purpose of a gate is to *stop* the loop +when a condition is not met — a deploy gate, a house-style verification gate, a safety +check. Silently skipping a broken gate-declaring capability and proceeding as if the gate +had *passed* would wave through the very thing the gate existed to block, with no signal +to the operator at all. -So composition treats gates asymmetrically from steps and contributions. When a -capability that declares a gate is skipped, GSD records its gate points in -`_overlay.incompatibleGateCapIds` and `_overlay.blockedGates`, and the loop resolver -**injects a synthetic blocking gate** at each of those extension points. The loop -**fails closed**: rather than proceed as if the gate passed, it halts with a message -naming the skipped capability and why its gate could not be evaluated. +So, per the maintainer decision on [#2009](https://github.com/open-gsd/gsd-core/issues/2009), +composition treats gates like steps and contributions for control flow — the loop always +proceeds — but never silently. When a capability that declares a gate is skipped, GSD +records its gate points in `_overlay.incompatibleGateCapIds` and `_overlay.blockedGates`, +and the loop resolver **injects no gate** at each of those extension points. The loop +**fails open**, but loudly: it emits a warning through two channels — stderr (the channel +host workflows/agents see when they run `gsd_run loop render-hooks `) and the +`loop render-hooks` JSON envelope's top-level `warnings` array. The warning names the +skipped capability, why it could not be loaded (for example, an incompatible +`engines.gsd` range), and the exact remediation — `gsd capability remove ` — so the +operator sees the missing control on every pass through the loop until they act on it, +instead of the loop halting project-wide over a single incompatible overlay. The discriminator is therefore *not* "is this overlay broken?" but "what does failing -to load it mean?" — and for a gate, failing to load it means you must not proceed. +to load it mean?" — and for a gate, failing to load it means the operator must be told, +unmistakably, until they resolve it. --- @@ -230,10 +238,12 @@ details make this safe rather than merely convenient: behind a path that a runtime dispatcher might `require()` a command module from. - Every dropped overlay's **gates are recorded as blocked** — using the same extraction as the per-candidate path — so a gate-declaring overlay that vanishes in the fallback - still **fails closed**, never open. + still **surfaces a loud warning** (stderr + envelope `warnings`) at its gate points + rather than vanishing silently (#2009). The principle is the same at every layer: when GSD cannot compose an overlay, it removes -the overlay's *additions* but never weakens a *control*. +the overlay's *additions* but never silences a *control* — a missing gate always +surfaces, even though, per #2009, it no longer blocks the loop. --- @@ -262,16 +272,20 @@ The overlay model rests on a few rules applied consistently: - **First-party always wins** every collision — id, skill/agent stem, config key, command family, reserved prefix. An overlay can only add, never override. - A bad overlay is **skipped, not crashed** — the loop always gets a usable registry. -- Skipping **fails open** for steps and contributions (a missing optional addition) but - **fails closed** for gates (a missing control must block, not pass). +- Skipping **fails open** for steps and contributions (a missing optional addition) and, + per [#2009](https://github.com/open-gsd/gsd-core/issues/2009), **fails open** for gates + too (a missing control, no gate injected) — but loudly, via a warning (stderr + the + envelope's `warnings` array) that names the load failure and its + `gsd capability remove ` remediation. - A whole-set compose failure **falls back to first-party**, clearing command roots and - still blocking dropped gates. + still surfacing dropped gates as loud warnings. - One canonical builder materialises both first-party and overlay views, so an accepted overlay has true parity with a shipped capability. Every one of these choices answers the same question — *what does it mean if this -composition step fails?* — and resolves it in favour of first-party authority and a -fail-closed security posture. +composition step fails?* — and resolves it in favour of first-party authority and, +per #2009, a loud fail-open posture: never silent, never a project-wide halt over a +single incompatible overlay. --- diff --git a/src/capability-loader.cts b/src/capability-loader.cts index 673570770..2ba118ccc 100644 --- a/src/capability-loader.cts +++ b/src/capability-loader.cts @@ -17,10 +17,12 @@ * `gsd-core-` / `anthropic-` id prefix) is rejected. * - Load-time re-gate (default-resilient): an overlay that fails validation or * whose `engines.gsd` does not satisfy the running GSD version is SKIPPED - * with a warning — it never crashes the loop. EXCEPTION (per-hook-kind - * policy): a skipped capability that declares a `gate` is recorded in - * `_overlay.incompatibleGateCapIds` so the loop resolver can fail CLOSED for - * that gate rather than silently proceeding as if it had passed. + * with a warning — it never crashes the loop. A skipped capability that + * declares a `gate` is additionally recorded in + * `_overlay.incompatibleGateCapIds` / `_overlay.blockedGates` so the loop + * resolver can surface a loud fail-OPEN advisory for that gate (#2009): the + * un-evaluable gate is skipped (not enforced) with a remediation message, + * rather than silently vanishing. * * The merged registry is materialized by the canonical `buildRegistry` * (re-exported from the generator, which ships) over a cap-map reconstructed @@ -845,12 +847,12 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry { // to load. Clear the map (the first-party base never lists overlay commandRoots — first-party // command modules ship in bin/lib/, not via _overlay.commandRoots). meta.commandRoots = {}; - // #1461 OVL-2 fail-CLOSED on compose failure (HIGH): the fallback DROPS every accepted overlay, - // so any accepted overlay that DECLARED a gate would have its gate silently vanish → a blocking - // gate FAILS OPEN, violating ADR-1244 (a skipped capability declaring a gate must FAIL CLOSED). + // #1461 OVL-2 (HIGH): on compose failure the fallback DROPS every accepted overlay, so any + // accepted overlay that DECLARED a gate would have its gate silently vanish with no trace. // Record each dropped gate-declaring overlay's gate as blocked using the SAME extraction the - // per-candidate `skip()` closure uses (gatePointsOf), so loop-resolver injects the synthetic - // blocking gate at each declared point exactly as it would for a per-candidate skip. + // per-candidate `skip()` closure uses (gatePointsOf), so loop-resolver surfaces the loud + // fail-OPEN advisory (#2009) at each declared point exactly as it would for a per-candidate + // skip — the gate does not silently disappear. for (const cap of overlayCaps) { const gatePoints = gatePointsOf(cap); if (gatePoints.length === 0) continue; diff --git a/src/loop-resolver.cts b/src/loop-resolver.cts index 1b0d3b1aa..ff86175a7 100644 --- a/src/loop-resolver.cts +++ b/src/loop-resolver.cts @@ -454,6 +454,27 @@ function renderLoopHooks(resolved: ResolveLoopHooksResult): string { * Missing value → coreError + non-zero exit. * Unknown/inactive capId → `false` (not an error). */ +// #2009: a capability id surfaced inside the runnable `gsd capability remove ` +// remediation must match the canonical kebab-case id shape (identical to +// capability-consent.cts / capability-ledger.cts) before it is embedded — a raw +// overlay directory name is attacker-controlled and can carry shell/markdown +// metacharacters (backticks, ';', '|', '$()'). An id that fails this check is +// withheld and no runnable command is rendered for it. +const LOAD_FAIL_CAP_ID_RE = /^[a-z][a-z0-9-]*$/; + +// #2009: neutralize control chars, newlines, and backticks from a third-party +// load-failure reason so a malicious manifest cannot break out of the warning +// line or inject markdown / prompt content into the surfaced message. +function sanitizeLoadFailReason(reason: unknown): string { + const cleaned = String(reason) + // Strip C0 control chars, DEL, and backticks; collapse remaining whitespace. + .replace(/[\x00-\x1F\x7F`]/g, ' ') + .replace(/\s+/g, " ") + .trim() + .slice(0, 300); + return cleaned || '(no reason given)'; +} + function cmdLoopRenderHooks( cwd: string, point: string, @@ -519,26 +540,55 @@ function cmdLoopRenderHooks( return; } - // ── ADR-1244 D2 fail-closed gate injection ──────────────────────────────────── - // For every skipped overlay capability that declared a gate at this point, - // inject a synthetic BLOCKING gate into the resolved output so the loop HALTS - // rather than silently proceeding as if the gate had passed. step/contribution - // overlays that were skipped are left open (skip-open is correct for them). + // ── ADR-1244 D2: load-failed capability gates FAIL OPEN with a loud warning ──── + // Decision (#2009): a capability that failed to LOAD must not block the loop. + // The prior behavior injected a BLOCKING synthetic gate (blocking:true, + // onError:'halt') at every point where the skipped cap declared a gate, so a + // single incompatible capability halted every ship:pre / verify:post + // project-wide for a load error unrelated to what the gate checked — with no + // remediation surfaced. We now fail OPEN: no gate is injected (the loop proceeds + // and `--active-cap ` correctly reports it inactive), and a loud + // warning is emitted instead — to STDERR (which the operator, or the agent + // running the command, actually sees regardless of how the host workflow + // consumes stdout) AND in the envelope's `warnings` channel for structured + // consumers. The warning names the load reason and the exact + // `gsd capability remove ` remediation so the operator can clear the broken + // capability. blockedGates is still recorded by the loader; only the consequence + // changes from block to warn. step/contribution overlays were already skip-open. + // + // The gate injection was dropped rather than made non-blocking because no host + // workflow generically surfaces an arbitrary gate's message at ship:pre / + // verify:post (consumers dispatch on specific capIds / ref.skills), and the + // generic gate consumers expect an object-shaped `check`, not a prose string — + // so an injected advisory gate would be silently dropped or mis-dispatched. A + // stderr warning is the channel that is actually surfaced. (See #2009 review.) const overlayMeta = (registry as { _overlay?: { blockedGates?: Array<{ point: string; capId: string; reason: string }> } })['_overlay']; + const loadFailWarnings: string[] = []; if (overlayMeta && Array.isArray(overlayMeta.blockedGates)) { for (const blocked of overlayMeta.blockedGates) { - if (blocked.point === point) { - const syntheticGate: ActiveHook = { - capId: blocked.capId, - kind: 'gate', - blocking: true, - onError: 'halt', - check: `capability "${blocked.capId}" was skipped at load (${blocked.reason}); its gate at ${point} cannot be evaluated — failing closed`, - }; - resolved.activeHooks.push(syntheticGate); - } + if (blocked.point !== point) continue; + // Security (#2009 review): capId/reason come from a third-party manifest or + // directory name. Validate capId before embedding it in the runnable + // remediation command; withhold it (no runnable command) if it is not a + // canonical id. Strip control chars/backticks from reason. + const idValid = LOAD_FAIL_CAP_ID_RE.test(String(blocked.capId)); + const capLabel = idValid + ? `"${blocked.capId}"` + : 'with an invalid id (withheld) under .gsd/capabilities/'; + const remediation = idValid + ? `Run \`gsd capability remove ${blocked.capId}\` to remove it, or fix the load error.` + : 'Remove the offending capability directory under .gsd/capabilities/, or fix the load error.'; + loadFailWarnings.push( + `capability ${capLabel} failed to load (${sanitizeLoadFailReason(blocked.reason)}); ` + + `its gate at ${point} is SKIPPED and NOT enforced (failing open). ${remediation}`, + ); } } + // Emit loudly to stderr in EVERY output mode (including --active-cap), so a + // skipped gate is never silently invisible to the operator/agent. + for (const w of loadFailWarnings) { + process.stderr.write(`gsd: warning — ${w}\n`); + } // --active-cap mode: print exactly 'true' or 'false' with no envelope if (activeCapId !== undefined) { @@ -558,8 +608,12 @@ function cmdLoopRenderHooks( activeHooks: resolved.activeHooks, rendered, }; - if (state.warnings && state.warnings.length > 0) { - envelope.warnings = state.warnings; + // Surface capability-state warnings and the #2009 load-failure fail-open + // warnings together in the structured `warnings` channel (in addition to the + // stderr emission above, which is the channel host workflows actually see). + const combinedWarnings = [...(state.warnings || []), ...loadFailWarnings]; + if (combinedWarnings.length > 0) { + envelope.warnings = combinedWarnings; } coreOutput(envelope, raw); diff --git a/tests/issue-2045-third-party-skills-surface.test.cjs b/tests/issue-2045-third-party-skills-surface.test.cjs index 09917e620..3e5ccd1cb 100644 --- a/tests/issue-2045-third-party-skills-surface.test.cjs +++ b/tests/issue-2045-third-party-skills-surface.test.cjs @@ -29,7 +29,7 @@ * AC5: first-party caps unaffected; writer still rejects truly-unknown ids. */ -const { describe, test, before, after } = require('node:test'); +const { describe, test, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os');