feat(#1169): wire ship:pre security gate via render-hooks (ADR-857 phase 6)
Part of the ADR-857 phase-6 migration (#1169, epic #857) — not a shipped bug fix. The capability system (registry, render-hooks, gates, capability-state) lives only on next / 1.5.0-rc; npm latest is 1.4.5 and contains none of it, so no released user can hit this. The security capability declares a blocking gates@ship:pre hook (when=workflow.security_enforcement, predicate SECURITY.md.threats_open==0), but ship.md never called `loop render-hooks ship:pre` and had no inline fallback, so the declared gate never fired. Wire it into ship.md preflight_checks via the render-hooks idiom — fail-closed: the ship blocks unless threats_open is exactly 0. Add a phase-6 conformance assertion that every declared hook point has a render-hooks call site; execute:wave:post (ui_safety_gate) stays in KNOWN_UNWIRED pending its unimplemented ui.safety-gate check. Part of #1169. Refs #857. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
</step>
|
||||
|
||||
<step name="push_branch">
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user