From c44e8239c1553391f96a57bd6a87975d8bd169d2 Mon Sep 17 00:00:00 2001 From: Quintin Botes Date: Sat, 11 Jul 2026 00:49:20 +0200 Subject: [PATCH] fix(action): fail closed when the gate can't be evaluated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Action failed OPEN — the most dangerous false-green of the audit set, since a green check can unblock a merge: - A crashed/absent `warden report aggregate` (subprocess throw, missing binary, unparseable stdout) defaulted the check to PASS "No aggregated results available." → now BLOCK "aggregate failed — gate not evaluated: ". - `normalizeGate` coerced a missing or unrecognized gate decision to PASS (`String(g.decision ?? 'PASS')`) → now only an explicit, recognized PASS/WARN/BLOCK is trusted; anything else fails closed to BLOCK. A gate we could not evaluate must never read as a green merge signal. TDD: 3 red→green tests (unknown decision → BLOCK, missing decision → BLOCK, aggregate crash → BLOCK) plus a guard that an explicit PASS still passes. Bundle refreshed. Full barrier green: 1327 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++++ packages/github-action/dist/index.js | 23 +++++++++--------- packages/github-action/src/parse.test.ts | 17 ++++++++++++++ packages/github-action/src/parse.ts | 30 ++++++++++++++++-------- packages/github-action/src/run.test.ts | 28 ++++++++++++++++++++++ packages/github-action/src/run.ts | 6 ++++- 6 files changed, 86 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17375e9..e907b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). when given no decisions to combine. - The CUJ gate `WARN`s a touched journey whose tests didn't run this change (`NOT_TESTED`) instead of reporting it "healthy" against a DEGRADED/BROKEN baseline. +- **The GitHub Action now fails _closed_.** A crashed/absent `warden report aggregate` step, or an + aggregate report with a missing/unrecognized gate decision, previously defaulted the merge check + to `PASS` — a broken gate could post a green check and unblock a merge. Both now resolve to + `BLOCK`; only an explicit, recognized `PASS`/`WARN`/`BLOCK` is trusted. ## [0.4.0] — 2026-07-10 · "Dogfood" diff --git a/packages/github-action/dist/index.js b/packages/github-action/dist/index.js index 39ca5a1..0d776d9 100644 --- a/packages/github-action/dist/index.js +++ b/packages/github-action/dist/index.js @@ -36522,18 +36522,17 @@ function parseGithubOutput(stdout) { return out; } function normalizeGate(raw, fallbackReason) { - if (raw && typeof raw === "object") { - const g = raw; - const decision2 = String(g.decision ?? "PASS").toUpperCase(); - return { - decision: decision2 === "BLOCK" || decision2 === "WARN" ? decision2 : "PASS", - reason: typeof g.reason === "string" ? g.reason : "" - }; + const isObject = Boolean(raw) && typeof raw === "object"; + const g = isObject ? raw : void 0; + const rawDecision = g ? g.decision : raw; + const reason = g ? typeof g.reason === "string" ? g.reason : "" : typeof fallbackReason === "string" ? fallbackReason : ""; + const decision = String(rawDecision ?? "").toUpperCase(); + if (decision === "PASS" || decision === "WARN" || decision === "BLOCK") { + return { decision, reason }; } - const decision = String(raw ?? "PASS").toUpperCase(); return { - decision: decision === "BLOCK" || decision === "WARN" ? decision : "PASS", - reason: typeof fallbackReason === "string" ? fallbackReason : "" + decision: "BLOCK", + reason: reason || "unrecognized or missing gate decision \u2014 failing closed" }; } function parseAggregateReport(stdout) { @@ -36730,7 +36729,9 @@ async function run(deps = {}) { report = await aggregate(exec2, { reportsDir, prNumber: pr.number, ...execOpts }); } catch (err) { core.warning(`Warden: aggregate failed: ${errMsg2(err)}`); - report = { gate: { decision: "PASS", reason: "No aggregated results available." } }; + report = { + gate: { decision: "BLOCK", reason: `aggregate failed \u2014 gate not evaluated: ${errMsg2(err)}` } + }; } const gate = report.gate.decision; const reportPath = report.reportPath ?? path6.join(reportsDir, "warden-ctrf.json"); diff --git a/packages/github-action/src/parse.test.ts b/packages/github-action/src/parse.test.ts index 210e951..5df5f95 100644 --- a/packages/github-action/src/parse.test.ts +++ b/packages/github-action/src/parse.test.ts @@ -47,4 +47,21 @@ describe('parseAggregateReport', () => { it('throws a WardenError when there is no JSON', () => { expect(() => parseAggregateReport('no json here')).toThrow(WardenError); }); + + it('fails closed (BLOCK) on an unrecognized gate decision', () => { + const report = parseAggregateReport(JSON.stringify({ gate: { decision: 'WEIRD' } })); + expect(report.gate.decision).toBe('BLOCK'); + }); + + it('fails closed (BLOCK) when the gate decision is missing', () => { + const report = parseAggregateReport(JSON.stringify({ gate: { reason: 'x' } })); + expect(report.gate.decision).toBe('BLOCK'); + }); + + it('still honors an explicit PASS decision', () => { + const report = parseAggregateReport( + JSON.stringify({ gate: { decision: 'PASS', reason: 'ok' } }), + ); + expect(report.gate).toEqual({ decision: 'PASS', reason: 'ok' }); + }); }); diff --git a/packages/github-action/src/parse.ts b/packages/github-action/src/parse.ts index 7f75d65..9d63a18 100644 --- a/packages/github-action/src/parse.ts +++ b/packages/github-action/src/parse.ts @@ -54,19 +54,29 @@ export interface AggregateReport { markdown?: string; } +/** + * Only an explicit, recognized PASS/WARN/BLOCK is trusted. A missing or unrecognized decision + * fails **closed** to BLOCK — a gate report we can't read must never read as a green merge signal. + */ function normalizeGate(raw: unknown, fallbackReason: unknown): AggregateReport['gate'] { - if (raw && typeof raw === 'object') { - const g = raw as { decision?: unknown; reason?: unknown }; - const decision = String(g.decision ?? 'PASS').toUpperCase(); - return { - decision: (decision === 'BLOCK' || decision === 'WARN' ? decision : 'PASS') as GateVerdict, - reason: typeof g.reason === 'string' ? g.reason : '', - }; + const isObject = Boolean(raw) && typeof raw === 'object'; + const g = isObject ? (raw as { decision?: unknown; reason?: unknown }) : undefined; + const rawDecision = g ? g.decision : raw; + const reason = g + ? typeof g.reason === 'string' + ? g.reason + : '' + : typeof fallbackReason === 'string' + ? fallbackReason + : ''; + + const decision = String(rawDecision ?? '').toUpperCase(); + if (decision === 'PASS' || decision === 'WARN' || decision === 'BLOCK') { + return { decision: decision as GateVerdict, reason }; } - const decision = String(raw ?? 'PASS').toUpperCase(); return { - decision: (decision === 'BLOCK' || decision === 'WARN' ? decision : 'PASS') as GateVerdict, - reason: typeof fallbackReason === 'string' ? fallbackReason : '', + decision: 'BLOCK', + reason: reason || 'unrecognized or missing gate decision — failing closed', }; } diff --git a/packages/github-action/src/run.test.ts b/packages/github-action/src/run.test.ts index 639c584..d3f65f8 100644 --- a/packages/github-action/src/run.test.ts +++ b/packages/github-action/src/run.test.ts @@ -231,6 +231,34 @@ describe('run', () => { expect(runCall).toBeDefined(); }); + it('fails closed (BLOCK) when the aggregate step crashes', async () => { + const exec: ExecFn = (_command, args) => { + if (args.includes('analyze')) { + return Promise.resolve({ + stdout: 'test_tags=@x\nrisk_score=1\nrun_full_suite=false\n', + stderr: '', + }); + } + if (args.includes('aggregate')) { + return Promise.reject(new Error('aggregate boom')); + } + return Promise.resolve({ stdout: '{}', stderr: '' }); + }; + + await run({ + core, + octokit: octo.octokit, + exec, + env: { GITHUB_REPOSITORY: 'acme/shop' }, + eventPath: '/event.json', + fs: fakeFs(PR_EVENT), + }); + + // A gate that could not be evaluated must never read as green. + expect(core.outputs.gate).toBe('BLOCK'); + expect(core.failed.length).toBeGreaterThan(0); + }); + it('skips the AI agent when risk is below the threshold and passes the gate', async () => { const passReport = { gate: { decision: 'PASS', reason: 'All exit criteria met' }, diff --git a/packages/github-action/src/run.ts b/packages/github-action/src/run.ts index 69d64fd..95778e0 100644 --- a/packages/github-action/src/run.ts +++ b/packages/github-action/src/run.ts @@ -158,8 +158,12 @@ export async function run(deps: ActionDeps = {}): Promise { try { report = await aggregate(exec, { reportsDir, prNumber: pr.number, ...execOpts }); } catch (err) { + // Fail closed: a gate that could not be evaluated must not post a green check that unblocks + // the merge. Surface the crash as a BLOCK rather than defaulting to PASS. core.warning(`Warden: aggregate failed: ${errMsg(err)}`); - report = { gate: { decision: 'PASS', reason: 'No aggregated results available.' } }; + report = { + gate: { decision: 'BLOCK', reason: `aggregate failed — gate not evaluated: ${errMsg(err)}` }, + }; } const gate = report.gate.decision; const reportPath = report.reportPath ?? path.join(reportsDir, 'warden-ctrf.json');