Skip to content

test(claude-review): pin the gate-passthrough invariant executably - #251

Merged
kyle-sexton merged 2 commits into
mainfrom
test/237-gate-passthrough-invariant
Jul 26, 2026
Merged

test(claude-review): pin the gate-passthrough invariant executably#251
kyle-sexton merged 2 commits into
mainfrom
test/237-gate-passthrough-invariant

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

Closes the last finding from independent review of #248: the gate-passthrough invariant had no executable test.

Passing the check on an infrastructure failure is an operator-ratified non-goal — it is the single invariant this whole design rests on. The review lane reports a verdict, never an outage. Until now it was guarded only by reading the file and by actionlint. A later edit that added a nonzero exit to the outcome step, or dropped continue-on-error from the action step, would flip every Anthropic-side blip into a merge blocker across every consumer repo, and nothing in CI would have caught it.

Asserts, for both reusables:

  • continue-on-error: true is present on the action step
  • every explicit exit in Report review outcome is exit 0
  • neither comment step fails the job

One thing worth calling out

My first draft of this test was itself vacuous. The two comment steps are actions/github-script, not shell — so the exit [1-9] check I originally wrote against them could never fire, no matter how broken they got. It passed, and it proved nothing.

The assertion now targets the primitives that actually fail a JS step (core.setFailed, process.exit), and additionally pins that those steps are still github-script — so if their mechanism ever changes, the test fails loudly with a message saying it must be rewritten, instead of silently going inert again.

Test plan

  • node --test .github/scripts/*.test.cjs273 passing, 0 failing.

  • bash .github/scripts/classify-infra-failure.test.sh — 23 cases passing.

  • Mutation-verified, all four caught:

    Mutation Result
    exit 1 added to the outcome step caught
    continue-on-error removed from the action step caught
    core.setFailed(...) added to the failure-comment step caught
    process.exit(1) added to the failure-comment step caught

    Each mutation was applied to the real workflow, the suite run, and the file restored from a byte snapshot — confirmed byte-identical afterwards, with the baseline suite green.

Related

Follow-up to #248 and #249. Refs #237, #228.

No linked issue: this closes a SUGGESTION-tier finding from review of #248, small enough that filing an issue to immediately close it would be noise. The finding and its provenance are recorded above.

Passing the check on an infrastructure failure is an operator-ratified
non-goal, and it was the one invariant in this design with no executable
guard — it held only by code reading and actionlint. A later edit adding a
nonzero exit to the outcome step, or dropping continue-on-error from the
action step, would turn every Anthropic-side blip into a merge blocker
across every consumer repo, and CI would not have caught it.

Asserts, for both reusables: continue-on-error is present on the action
step; every explicit exit in the outcome step is `exit 0`; and neither
comment step fails the job.

The comment steps are actions/github-script, so a shell `exit` check on
them would never fire — the assertion targets `core.setFailed` and
`process.exit` instead, and additionally pins that they are still
github-script steps, so the check fails loudly rather than silently going
inert if their mechanism changes.

Mutation-verified, all four caught: an `exit 1` in the outcome step, a
removed continue-on-error, a `core.setFailed` in the comment step, and a
`process.exit` in the comment step.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 23s —— View job


Review

  • Load context and understand the request
  • Read the changed test file
  • Read the workflow files under test
  • Analyze test coverage, correctness, and alignment with CLAUDE.md
  • Post findings

The test is sound. Each assertion pins the correct primitive, the mutation-testing list in the PR description accounts for all four failure paths, and the design rationale in the inline comments is accurate. One minor nit and a few notes follow.

Minor: stepSource has an off-by-one

const rest = workflow.slice(start + 1);

workflow.slice(start + 1) starts one character into the matched step header, so the returned string begins with 5 leading spaces on the - name: line instead of 6. This has no effect on any current assertion — all checks use multiline mode with their own anchors on content inside the step — but a future assertion targeting the - name: boundary characters directly would need to account for the missing space. Replacing with workflow.slice(start) would make the extraction exact. Lowest-priority nit; raise or skip.

The two structural choices in the new test deserve recognition

Pinning the mechanism, not just the behavior (lines 114–118). The uses: actions/github-script@ check is the self-invalidating guard: if someone converts either comment step from a github-script step to a shell run: step, the assertion fails with a message that explicitly says "this assertion must be rewritten for its new failure mechanism." That's the right pattern — the test stays intentionally fragile to mechanism change so it cannot silently go inert again (the failure mode the PR description names for the original draft).

Targeting JS failure primitives, not shell exit (lines 119–129). core.setFailed( and process.exit( are the two ways a github-script step can produce a nonzero conclusion. A shell exit 1 inside the script: block has no effect on the step's outcome. The PR description is clear-eyed about why the original draft was vacuous; the fix is correct.

Assertions verified against the actual workflows

  • continue-on-error: true — present at line 325 of claude-review.yml and line 396 of claude-security-review.yml. ✓
  • exit in Report review outcome — exactly one exit 0 in each; no nonzero exit code present. ✓
  • core.setFailed / process.exit — absent from both comment steps in both workflows. ✓

Overall: LGTM. The one nit above is not blocking.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5fe8f37c60

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/scripts/classify-infra-failure-render.test.cjs
…he job

Review of the invariant test found the invariant itself was untrue. Both
comment steps are actions/github-script and both await GitHub API calls —
listComments, then createComment or updateComment. github-script turns an
unhandled rejection into a failed step on its own, with no explicit
core.setFailed or process.exit involved, and neither step carried
continue-on-error. So a rejected API call while *reporting* an outage
turned the infra-failure path red, which is exactly what pass-through
exists to prevent.

The first version of the test asserted only the explicit failure calls,
so it certified an invariant that the code did not hold. Adds
continue-on-error to both steps in both reusables, and asserts it, so the
rejection path is covered rather than assumed.

Reporting an outage must never become one; tidying a stale comment on a
successful review must not turn it red either.

Mutation-verified, all four caught: dropping continue-on-error from the
failure-comment step and from the clear-stale step, in each of the two
reusables.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 1m 36s —— View job


Review

  • Load context
  • Read changed files
  • Review workflow changes (claude-review.yml, claude-security-review.yml)
  • Review test changes (classify-infra-failure-render.test.cjs)
  • Post findings

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Both findings addressed.

@chatgpt-codex-connector's P2 was right, and it was more than a test gap — the invariant itself was untrue.

Both comment steps are actions/github-script and both await GitHub API calls (listComments, then createComment/updateComment). github-script turns an unhandled rejection into a failed step on its own, with no core.setFailed or process.exit involved, and neither step carried continue-on-error. A rejected API call while reporting an outage therefore turned the infra-failure path red — precisely what pass-through exists to prevent.

My first version of the test asserted only the explicit failure primitives, so it certified an invariant the code did not hold. That is the same vacuity class this PR was written to eliminate, reintroduced one layer up.

Fixed at the source rather than by narrowing the claim: continue-on-error: true added to both comment steps in both reusables, and asserted, so the rejection path is covered rather than assumed.

Mutation-verified, all four caught — dropping continue-on-error from the failure-comment step and from the clear-stale step, in each of the two reusables. Plus the four from the original commit (an exit 1 in the outcome step, a removed continue-on-error on the action step, a core.setFailed, and a process.exit).

Also fixed the biome format failure on the test file.

State: 273 node tests and 23 shell cases passing, generated blocks in sync (--check exits 0).

@claude

claude Bot commented Jul 26, 2026

Copy link
Copy Markdown

Claude encountered an error after 1m 32s —— View job


Security Review

  • Read changed files
  • Analyze workflow changes for security implications
  • Analyze test file for injection surfaces
  • Post findings

No security issues found.

Scope of changes: continue-on-error: true added to two actions/github-script steps in each of claude-review.yml and claude-security-review.yml (the failure-notification and stale-comment-clearing steps), plus test assertions in the .cjs test file verifying those invariants.

Workflow changes — security analysis:

The four affected steps are purely comment-management operations (list/create/update/delete on github.rest.issues). They hold pull-requests: write via the inherited job grant; they don't access secrets, don't influence the review verdict, and don't write to GITHUB_OUTPUT. Adding continue-on-error: true to them has no effect on the job's security posture — it only prevents a rejected GitHub API call from propagating a failure upward to the job conclusion, which is the exact invariant the PR documents as intentional design.

The two env vars threaded into the failure-notification step's script: block deserve a look:

  • REVIEW_CLASS — one of four fixed tokens (auth | rate-limit | overloaded | other), written by jq -r '.class // "other"' from a structured projection. Cannot carry adversarial content.
  • REVIEW_DETAIL — compact JSON from a jq projection that explicitly strips result (model-authored free text) and errors[] (raw SDK stacks); only typed numeric and boolean fields, plus the class token, survive the filter (per the block comment at line 382). The projected value is embedded in a Markdown backtick code span, so even if unexpected content reached it, it cannot break the Markdown structure.

Test file — security analysis:

classify-infra-failure-render.test.cjs reads workflow files off disk with fs.readFileSync and performs string matching against the returned content. No user input is processed, no external data is fetched, no subprocesses are spawned by the new test logic. The stepSource helper is pure string manipulation over trusted local files. No injection surface.

Deferred to zizmor: supply-chain / action-pin hygiene, dangerous trigger patterns, template injection in run: blocks, and excessive permissions — all within zizmor's static advisory lane; nothing here changes those surfaces.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@github-actions

Copy link
Copy Markdown

Warning

Automated security review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@github-actions

Copy link
Copy Markdown

Warning

Automated review did not complete — this is an infrastructure failure, not a review verdict.

Treat any Claude comment on this PR (including a placeholder like "I'll analyze this and get back to you") as incomplete, not "no findings."

Re-running the job, or pushing a new commit, will retry the review.

@kyle-sexton
kyle-sexton merged commit 1b8f658 into main Jul 26, 2026
41 checks passed
@kyle-sexton
kyle-sexton deleted the test/237-gate-passthrough-invariant branch July 26, 2026 15:24
kyle-sexton added a commit that referenced this pull request Jul 26, 2026
… review could not run (#269)

🤖 Agent-authored (autonomous babysit lane, fable-autopilot) implementing
the operator-adjudicated decision on #266.

## Summary

Implements the adjudicated decision on #266 (3-0, Option A): the
security lane's
required check now **fails closed** when a PR was in scope and the
review could not
run at all.

The check exists to prove a security pass RAN — ADR 0002's #509
addendum: *"proves
the pass ran; it does not gate on the verdict."* It was reporting
`success` when the
SDK call failed, certifying an execution that never happened. `success`,
`neutral`
and `skipped` all satisfy a required check, so `failure` is the only
conclusion that
can express "in scope and did not run".

**Scale, measured on `claude-code-plugins` before this change:** 55 of
129 in-scope
merges (42.6%) since 2026-07-25 landed on `main` with the check green
and no security
pass at the merge head. Classified by the workflow's own `Comment on
genuine review
failure` step conclusion, not by a duration heuristic.

### What lands

1. **Conclusion mapping** keyed on `review-outcome.outputs.review_failed
== 'true'`.
That signal is precise against all three legitimate no-verdict paths, so
none of
   them can be caught by it:
   - out-of-scope PRs skip at job level;
- **skip-actors** (`dependabot[bot]`, `melodic-standards-sync[bot]`)
also skip at
     job level — ADR 0002's operator-ratified exception is untouched;
   - a superseded head skips the outcome step, leaving the output unset.
2. **One bounded retry** with a 60s backoff, absorbing the sporadic-429
class
(run `30217744377`: 25s failure, 3m17s clean verdict on manual re-run).
Ships
alongside the mapping, explicitly *not* as a substitute for it —
sustained
multi-hour blackouts dominate the measured failures and no in-job
backoff survives
   those.
3. **Fork guard**, and **non-`pull_request` runs stay on the
pass-through path** —
   see the flagged consequences below.
4. **Narrowing amendment** to #228's non-goal, in the workflow's POSTURE
header and in
the render test: pass-through stays ratified for advisory lanes, whose
rationale
reasons from merge-irrelevance (`review / review` "gates nothing whether
it reports
green or red"); required execution-evidence contexts fail closed. The
shared
invariant test is split along exactly that line, so both directions are
pinned.

### ⚠️ Flagged consequence — fork PRs now skip the job

Fork-triggered runs get no secrets, so the review can never succeed;
without a guard
the new mapping would pin **every fork PR red permanently**, for a cause
no push can
fix. They now skip at job level.

A skipped job is name-stable and a ruleset reads it as **success** — so
on a consumer
where this check is required, a fork PR satisfies the sole required
security context
with no review having run. That is the same accepted property as the
skip-actors
exception, but note the difference in provenance: **skip-actors was
explicitly
operator-ratified in ADR 0002's step-3 addendum, and this one has not
been.** Calling
it out rather than burying it in a workflow comment. Human review of
fork changes to
security-sensitive surfaces is the compensating control.

The exemption is scoped to `github.event_name == 'pull_request'`, which
is a **security
control, not a style choice** — see the third flagged item below.

### ⚠️ Second flagged consequence — only `pull_request` runs fail closed

Caught by independent review of the first commit, which had reddened
these:

The pinned action **cannot serve `merge_group` at all** — it is in
neither
`ENTITY_EVENT_NAMES` nor `AUTOMATION_EVENT_NAMES`, so
`parseGitHubContext` throws
`Unsupported event type` — and `track_progress` (hardcoded on here)
rejects every
non-PR event. Both throw for a cause no head change can fix and no retry
can clear.

Had that shipped, a consumer following **this workflow's own CONSUMER
CONTRACT**, which
tells them to add `merge_group:`, would have wedged its merge queue on a
permanently red
required check — carrying no explanation, because the comment steps are
PR-gated too.

Only a `pull_request` run gates a merge, so only a `pull_request` run
fails closed.
Everything else keeps the historical pass-through: still annotated,
still classified,
not fatal. The tradeoff is explicit: a genuine infra failure on a
`workflow_dispatch`
invocation stays green, exactly as it does today.

### ⚠️ Third flagged consequence — the fork guard must stay scoped to
`pull_request`

Caught by Codex review of the first commit (P1), and missed by two prior
passes:

Written as a bare origin test, the fork exemption **also matched a fork
PR arriving via
`pull_request_target` or `workflow_run`** — the two privileged triggers
this workflow
exists to reject. Those runs were skipped at job level *before* `Reject
privileged
triggers` could hard-fail them, so a consumer's dangerous
misconfiguration would have
surfaced as a **green skipped required check**: the tripwire silently
disarmed by the
guard added to keep fork PRs from being pinned red, producing exactly
the
unreviewed-but-green state this PR exists to eliminate.

Scoping to the event name sends every non-`pull_request` event to the
tripwire first.
The regression test asserts both halves as a single clause, so the fork
test cannot be
reintroduced without its scope.

### Known sharp edge — turn-budget exhaustion

A review that exhausts `--max-turns` counts as "no verdict" and now
fails closed, and
unlike a rate limit **no re-run clears it** — the remedy is a smaller
diff or a higher
`--max-turns`. That is semantically right (no verdict was produced) but
it is a real
way for a large security-relevant PR to be blocked, so the PR comment
now names that
remedy instead of telling the author to re-run. Flagging it rather than
reclassifying:
turning it into a pass-through class would reopen a silent evidence gap,
which is a
posture decision, not an implementation detail.

## Test plan

New suite `.github/scripts/claude-security-review-fail-closed.test.cjs`
(13 tests).
It **executes the real outcome step** — extracted from the workflow and
run under
`bash` — rather than grepping the source for `exit 1`, so what is pinned
is the exit
code a replayed payload actually produces.

- `node --test .github/scripts/*.test.cjs` — **287 passing, 0 failing**
(was 273).
- `bash .github/scripts/classify-infra-failure.test.sh` — all cases
passing.
- `actionlint .github/workflows/claude-security-review.yml` — clean.
- `shellcheck -S style` on both new/changed `run:` blocks — clean.
- `biome ci --config-path=fixtures/typescript/good/biome.json
.github/scripts` — clean.

**Acceptance criterion 4 — replay of run `30217744377`'s shape:** its
payload
(`subtype: success`, `is_error: true`, `api_error_status: 429`, one
turn, $0) is fed
through the real outcome step. Asserted: exit status **1**,
`review_failed=true`,
`failure_class=rate-limit`, and the `::error::` annotation still
emitted. Two
companion tests assert a completed review still exits 0, and that an
unreadable
execution file degrades to class `other` while still failing closed.

Log hygiene is re-pinned on the new failing path: the replay payload
carries a canary
string in the model-authored `result` field, and the test asserts it
reaches neither
stdout nor any step output.

**Mutation-verified — all 14 applied to the real workflow at the FINAL
branch state,
suite re-run per mutation, files restored from byte snapshots (tree
confirmed clean
afterwards):**

| Mutation | Result |
| --- | --- |
| `exit 1` removed from the outcome step (the #266 defect itself) |
caught |
| fork guard removed from the job condition | caught |
| skip-actors exception removed | caught |
| retry inputs diverged from the first attempt | caught |
| outcome step points back at attempt 1, ignoring a successful retry |
caught |
| `continue-on-error` removed from the retry | caught |
| **advisory lane** (`claude-review.yml`) made to fail closed | caught |
| resolve step never selects the first attempt (would redden every CLEAN
review) | caught |
| resolve step discards a successful retry | caught |
| non-`pull_request` pass-through removed (merge-queue wedge) | caught |
| fork guard unscoped from `pull_request` (disarms the
privileged-trigger tripwire) | caught |
| `IS_PULL_REQUEST` env line deleted (fail-OPEN: reverts #266 on every
event) | caught |
| `IS_PULL_REQUEST` rewired to a wrong expression | caught |
| guard reverted to the fail-open sense (`!= "true"`) | caught |

The advisory-lane mutation matters most: it proves the narrowing
amendment is enforced
in both directions, not just relaxed for the security lane.

The last four were added as the review findings landed. The resolve-step
mutation is
the one independent review used to demonstrate the original test gap —
it passed all 8
tests before, and is caught now.

### Not covered by automated tests

The end-to-end behaviour of a real rate-limited run against live branch
protection is
not reproducible in CI. This repo dogfoods the lane via
`claude-security-review-self.yml`,
so this PR exercises the new code on itself; `security-review` is not a
required context
here and is not in `ci-status`'s `needs:`, so a red review cannot
self-block this merge.

### Review

Independently reviewed in a fresh context with the rationale withheld,
so the audit
targeted the diff rather than the narrative. It found one CRITICAL (the
`merge_group`
wedge) and three IMPORTANT issues, all fixed in the second commit.
**Codex review
independently found a P1 that both of those passes missed** — the fork
guard disarming
the privileged-trigger tripwire — fixed in the third commit. A round-2
pass over that
fix then found a **fail-OPEN blind spot it had introduced** (an unset
`IS_PULL_REQUEST`
would have reverted #266 on every event), fixed in the fourth commit by
inverting the
guard's sense so the safe default is closed. The
`outcome`-vs-`conclusion`, output-survives-`exit 1`, and
skipped-step-context semantics
were verified against the runner source and official docs rather than
assumed.

### Deployment note — merging this does not yet close the gap on
`claude-code-plugins`

Consumers pin this reusable by SHA. `claude-code-plugins` currently pins
`e2951077a7b43c09fc5a8dee4da52ba6f0fb39ed`, which predates this change,
so the measured
42.6% bypass on that repo persists until its pin is bumped through the
ordinary
Dependabot/SHA-bump path. Merging here fixes the reusable; the consumer
bump is what
deploys it.

## Related

Closes #266.

- Decision comment (the spec this implements):
#266 (comment)
- #228 — the pass-through non-goal this narrows rather than contradicts.
- #251 — the executable gate-passthrough invariant whose shared
assertion is split here.
- ADR 0002 (`melodic-software/claude-code-plugins`,
`docs/adr/0002-*.md`) — the #509
addendum defining the required check as execution evidence, and the
step-3 addendum
  recording the skip-actor exception.

**Spun out separately (github-iac, not touched here)** per the
decision's prerequisite 4:
the break-glass `bypass_actors` grant on `security-review-gate` that
makes a sustained
blackout an explicit, logged, attributable override instead of a hard
stop — and the
app-pinning question for required contexts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant