fix(#2009): load-failed capability gates fail open with a loud warning

Previously a capability that failed to LOAD (e.g. incompatible engines.gsd) but
declared a gate-kind loop hook caused the loop resolver to inject a BLOCKING
synthetic gate (blocking:true, onError:halt) at every declared point, halting
every ship:pre / verify:post project-wide over an unrelated load error, with no
remediation surfaced.

Per maintainer decision (#2009) it now fails OPEN: no gate is injected (the loop
proceeds; --active-cap correctly reports the failed cap inactive) and a loud
warning is emitted — to stderr (the channel host workflows/agents actually see)
and in the envelope 'warnings' array — naming the load reason and the exact
'gsd capability remove <id>' remediation. The loader still records blockedGates;
only the consequence changes from block to warn.

Security (review): capId and reason originate from a third-party manifest /
directory name. capId is validated against the canonical kebab-case id shape
before it is placed in the runnable remediation command (withheld otherwise);
reason is stripped of control chars and backticks. This closes an argument/
prompt-injection vector in the surfaced message.

Docs updated to the fail-open-warning posture (ARCHITECTURE, INVENTORY,
CONFIGURATION, README, capability-overlay-model). Also removes a dead 'before'
import surfaced by lint in the issue-2045 test.
This commit is contained in:
Tom Boucher
2026-07-07 19:38:01 -04:00
parent c82355972d
commit 46f8d3814c
8 changed files with 123 additions and 53 deletions

View File

@@ -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;