diff --git a/gsd-core/workflows/ship.md b/gsd-core/workflows/ship.md index 0f6cb95b9..317abd8bd 100644 --- a/gsd-core/workflows/ship.md +++ b/gsd-core/workflows/ship.md @@ -79,6 +79,32 @@ Verify the work is ready to ship: which gh && gh auth status 2>&1 ``` If `gh` not found or not authenticated: provide setup instructions and exit. + +6. **Security ship gate (capability-driven).** + + Resolve active `ship:pre` gate hooks from the capability registry — the registry evaluates each hook's `when` condition, so do **not** read `workflow.security_enforcement` directly: + + ```bash + SHIP_PRE_HOOKS_JSON=$(gsd_run loop render-hooks ship:pre --raw) + SECURITY_FILE=$(ls "${PHASE_DIR}"/*-SECURITY.md 2>/dev/null | head -1) + ``` + + Read the `activeHooks` array from `SHIP_PRE_HOOKS_JSON` in-context (do NOT pipe it through a shell parser). + + If an active entry exists with `kind == "gate"`, `capId == "security"`, and `blocking == true`, enforce its predicate (`SECURITY.md` frontmatter `threats_open == 0`) before shipping: + + - **`SECURITY_FILE` is empty** → block with `SECURITY_SHIP_GATE_NO_REVIEW`: + ``` + ⚠ Security enforcement is enabled but no SECURITY.md exists for this phase. + Run /gsd:secure-phase {phase} and resolve findings before shipping. + ``` + - **`SECURITY_FILE` exists** → read its frontmatter `threats_open`. The gate passes **only** when `threats_open` is exactly `0`. For any other value — `threats_open` > 0, or a missing / non-numeric / unparsable field — **fail closed and block** with `SECURITY_SHIP_GATE_OPEN_THREATS` (the predicate is strict equality to `0`; never ship on an ambiguous value): + ``` + ⚠ Security ship gate: SECURITY.md does not assert threats_open == 0 (found: {threats_open|unset}). + Resolve open threats (or re-run /gsd:secure-phase {phase}) before shipping. + ``` + + If no active security `ship:pre` gate hook is present (security enforcement off), skip this check silently. diff --git a/tests/phase6-capstone-conformance.test.cjs b/tests/phase6-capstone-conformance.test.cjs index 60069341d..45d547ced 100644 --- a/tests/phase6-capstone-conformance.test.cjs +++ b/tests/phase6-capstone-conformance.test.cjs @@ -105,4 +105,44 @@ describe('ADR-857 Phase 6 capstone conformance (#1139)', () => { assert.ok(baseline[fileName] > 0, `${fileName} baseline must be positive`); } }); + + test('every declared capability hook point has a render-hooks call site in the host loop (#1169)', () => { + // Points whose host call site is intentionally not yet wired, mapped to the + // issue tracking the gap. execute:wave:post (ui.gates / ui_safety_gate) also + // needs its `ui.safety-gate` check implemented before it can be wired — #1169. + const KNOWN_UNWIRED = { 'execute:wave:post': '#1169' }; + + const declaredPoints = new Set(); + for (const cap of Object.values(registry.capabilities)) { + for (const group of ['steps', 'gates', 'contributions']) { + for (const hook of cap[group] || []) { + if (hook.point) declaredPoints.add(hook.point); + } + } + } + + // Scan only the host loop files (not every workflow): a `render-hooks` + // mention in a non-host workflow must not mask a lost host call site. + const callSites = new Set(); + const reCall = /loop render-hooks\s+([a-z:]+)/g; + for (const relativePath of HOST_LOOP_FILES) { + const content = readRepoFile(relativePath); + let m; + while ((m = reCall.exec(content))) callSites.add(m[1]); + } + + for (const point of [...declaredPoints].sort()) { + if (point in KNOWN_UNWIRED) { + assert.ok( + !callSites.has(point), + `${point} is listed in KNOWN_UNWIRED (${KNOWN_UNWIRED[point]}) but now HAS a render-hooks call site — remove it from the allowlist.`, + ); + continue; + } + assert.ok( + callSites.has(point), + `Capability hooks declare point "${point}" but no workflow calls \`gsd_run loop render-hooks ${point}\` — hooks at that point can never fire (#1169). Wire a call site or add the point to KNOWN_UNWIRED with its tracking issue.`, + ); + } + }); }); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json index 354053def..96ff464d4 100644 --- a/tests/workflow-size-baseline.json +++ b/tests/workflow-size-baseline.json @@ -69,7 +69,7 @@ "settings-advanced.md": 39621, "settings-integrations.md": 15801, "settings.md": 32133, - "ship.md": 20896, + "ship.md": 22534, "sketch-wrap-up.md": 14223, "sketch.md": 19960, "spec-phase.md": 23094,