Skip to content

fix(ci): stop the security-evidence guard failing every out-of-scope PR - #2523

Merged
kyle-sexton merged 3 commits into
mainfrom
fix/evidence-guard-out-of-scope-set-e
Aug 12, 2026
Merged

fix(ci): stop the security-evidence guard failing every out-of-scope PR#2523
kyle-sexton merged 3 commits into
mainfrom
fix/evidence-guard-out-of-scope-set-e

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Summary

scripts/verify-security-review-evidence.sh runs under set -euo pipefail and called its scope helper bare:

pr_touches_security_paths "$base_ref"
local in_scope=$?

The helper signals OUT-of-scope by returning non-zero. Under set -e, a bare call with a non-zero return aborts the shell immediately — so the script died on that line, exit 1, no message, and the "diff does not touch security-relevant paths — guard not applicable" branch below was unreachable dead code.

Every out-of-scope pull request FAILED the guard instead of being waved through.

Observed on run 31637054526 (PR #2512): the diff touches only docs/conventions/loop-lane/*.md, security-review / security-review correctly SKIPPED, and security-review-evidence failed beside it with a step log containing nothing but ##[error]Process completed with exit code 1. The empty log is the signature — set -e aborts before any echo can run. Reproduced standalone:

$ cat setE.sh
set -euo pipefail
f() { return 1; }
main() { f "x"; local rc=$?; echo "REACHED with rc=$rc"; }
main
$ bash setE.sh; echo "exit=$?"
exit=1          # "REACHED" never prints

Fix — and why not the one-liner

The obvious fix is pr_touches_security_paths "$base_ref" || in_scope=$?. It clears the red check and introduces something worse, which ShellCheck flags directly via SC2310 — a check this repo enables on purpose in .shellcheckrc ("catches the bash trap where errexit silently stops working inside if/while/&&/||").

The helper runs python3, and a crashing interpreter also exits 1. With out-of-scope encoded as "returns 1", a broken scope check is indistinguishable from a negative one — the guard would skip itself and silently pass a PR it exists to check. Fail-open is the wrong direction for a security guard.

So the two channels are separated instead: the helper prints its verdict (in-scope / out-of-scope) on stdout and reserves a non-zero exit for a genuine fault. The caller consumes it via command substitution, which keeps set -e live for the helper, and treats any unrecognised verdict as a fault rather than a pass.

Verification

Run in this worktree — commands I actually ran, with real output:

  • bash scripts/verify-security-review-evidence.sh.test.sh -> 6 pass, 0 fail, All checks passed.
  • shellcheck --rcfile .shellcheckrc scripts/verify-security-review-evidence.sh scripts/verify-security-review-evidence.sh.test.sh -> clean, with SC2310 no longer firing on the guard (it fired on the || variant, which is how the fail-open hole was found).

New regression cases:

  • a non-zero-returning helper called bare under set -e kills the script — modelled in a separate bash -c process deliberately. A ( … ) subshell will not do: bash suppresses set -e for the whole dynamic extent of a command whose status is being tested, and $( … ) inside [[ … ]] is exactly that context, which made the bug unreproducible in the very harness meant to catch it. My first attempt at this test failed for that reason, not because the fix was wrong.
  • static guards that fail if either older shape (bare call, or ||-suppressed call) returns
  • a static guard that fails if the unrecognised-verdict catch-all is removed

Not verified here: that the three currently-red PRs (#2512, #2504, #2499) go green. They will only pick this up once their branches carry it — the evidence workflow runs from the PR head.

Related

Third distinct defect found in this one file today, which is itself the finding: it infers another workflow's behaviour by reading that workflow's logs. #2517 (grep matched the lane action's echoed source) only became reachable once the cursor[bot] outage was fixed; this one only became reachable once #2517 let execution past the grep.

No linked issue — filing was interrupted; the detail is captured here instead.

@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 54s —— View job


Security review

  • Gather context (PR diff, changed files)
  • Read full changed files for surrounding context
  • Analyze the two changed files for security issues
  • Post findings / summary

Scope

Reviewed both changed files against origin/main:

  • scripts/verify-security-review-evidence.sh (+38/-8)
  • scripts/verify-security-review-evidence.sh.test.sh (+57/-0, test-only)

This is itself a security-guard script (the fail-closed supplement for #2337 that gates the required security-review check), so it's in scope for deep review even though the change is a bug fix rather than new functionality.

Findings

No security issues found.

Specifically checked for the failure modes this class of change tends to introduce:

  • Fail-open risk in the new verdict channel. pr_touches_security_paths now separates the in-scope/out-of-scope verdict (stdout) from fault (exit status), and the catch-all *) branch in the caller (L167-L170) treats any unrecognised stdout as a fault and exits 1, not as a pass. That's the correct direction for a security guard.
  • set -e masking via local. The consumption at L159-L160 correctly splits local scope_verdict from the assignment (scope_verdict="$(...)") rather than combining them (local scope_verdict=$(...)) — the combined form would swallow a nonzero exit from the command substitution behind local's own status (the classic SC2155 trap) and reintroduce a fail-open path if python3 crashed. They avoided it.
  • No new injection surface. base_ref (from GITHUB_BASE_REF) is passed into the embedded Python heredoc as a positional sys.argv value and used only inside a list-form subprocess.run([...]) call, never shell-interpolated — consistent with the pre-existing code, not a regression.
  • Missing-paths-file default preserved. The old contract used return 0 (bare success) to mean "in scope" when PATHS_FILE doesn't exist; the new code emits printf 'in-scope\n'; return 0 — same conservative default (guard applies when there's no scoping config), just moved onto the new verdict channel. No behavior drift.
  • Workflow trust boundary. .github/workflows/claude-security-review.yml (unchanged by this PR) still grants the security-review-evidence job only contents: read / actions: read, passes GITHUB_BASE_REF etc. via env: rather than interpolating the github context directly into the run: block, and this diff doesn't touch permissions or triggers.

The PR's own description of the bug (bare non-zero return under set -e aborting the script, and why the naive ||-suppressed fix would be fail-open per SC2310) matches what's in the diff, and the fix closes it without opening a new hole.

@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: 0d0b8d2c3c

ℹ️ 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 scripts/verify-security-review-evidence.sh
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Last security-reviewed head: 755c27037e14a4a9976d4938f50f7d47dcbddfef. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

kyle-sexton and others added 3 commits August 12, 2026 22:12
`verify-security-review-evidence.sh` runs under `set -euo pipefail` and
called its scope helper bare:

    pr_touches_security_paths "$base_ref"
    local in_scope=$?

The helper signals OUT-of-scope by RETURNING NON-ZERO. Under `set -e` a
bare call with a non-zero return aborts the shell immediately, so the
script died right there — exit 1, no message — and the "guard not
applicable" branch below it was unreachable dead code. Every out-of-scope
pull request FAILED the guard instead of being waved through.

Observed on run 31637054526 (PR #2512): the diff touches only
`docs/conventions/loop-lane/*.md`, the lane correctly SKIPPED, and
`security-review-evidence` failed beside it with a step log containing
nothing but `##[error]Process completed with exit code 1`. The empty log
is the signature — `set -e` aborts before any echo can run.

The obvious one-line fix (`|| in_scope=$?`) clears the red check but
leaves something worse, and ShellCheck says so via SC2310, which this
repo enables deliberately in `.shellcheckrc` ("catches the bash trap
where errexit silently stops working"). The helper runs `python3`, and a
crashing interpreter also exits 1 — so with out-of-scope encoded as
"returns 1", a BROKEN scope check is indistinguishable from a NEGATIVE
one, and the guard would skip itself and silently pass a pull request it
exists to check. Fail-open is the wrong direction for a security guard.

Separate the channels instead: the helper prints its verdict
(`in-scope` / `out-of-scope`) on stdout and reserves a non-zero exit for
a genuine fault. The caller consumes it through command substitution,
which keeps `set -e` live for the helper, and treats any unrecognised
verdict as a fault rather than a pass.

Verified: self-tests 6 pass / 0 fail, including a regression that models
the bare call in a separate `bash -c` process (a `( … )` subshell will
not do — bash suppresses `set -e` for the whole dynamic extent of a
command whose status is tested, so `$( … )` inside `[[ … ]]` makes the
bug unreproducible in the very harness meant to catch it), plus static
guards that fail if either older shape returns. `shellcheck --rcfile
.shellcheckrc` clean on both files, with SC2310 no longer firing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeEvWwrK8KdQTFb5n9CrUP
When origin/$base_ref cannot be resolved, git diff exits non-zero and the
scope helper now surfaces that as a fault instead of printing out-of-scope.
Replace GNU-only \s in the static guard grep with [[:space:]].

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
If origin/$base_ref is missing or git diff otherwise fails, emit stderr and
exit non-zero instead of treating an empty diff as out-of-scope.

Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot force-pushed the fix/evidence-guard-out-of-scope-set-e branch from 0d0b8d2 to 755c270 Compare August 12, 2026 22:12
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cursor[bot]'s task in 1m 36s —— View job


Security review

  • Gather context (PR diff, changed files)
  • Read full changed files for surrounding context
  • Evaluate Codex P1 finding re: git diff failure handling
  • Analyze the two changed files for security issues
  • Post findings / summary

Scope

This PR now sits at HEAD 755c2703 (3 commits): the original stdout/exit-status split, plus two follow-up commits that directly address reviewer feedback on the initial version:

  • scripts/verify-security-review-evidence.sh (+44/-8)
  • scripts/verify-security-review-evidence.sh.test.sh (+57/-0, test-only)

This is the fail-closed supplement for #2337 gating the required security-review check, so it's in scope for deep review despite being a bug fix.

Findings

No security issues found.

Codex's P1 finding on the earlier revision — that an unresolvable origin/$base_ref or a failing git diff would fall through with empty stdout and read as out-of-scope with exit 0, letting the guard skip itself when scope couldn't be determined — is fixed in this HEAD. scripts/verify-security-review-evidence.sh#L102-L107 now checks diff.returncode != 0 explicitly, writes the underlying git error (or a synthesized one) to stderr, and sys.exit(1). That exit status propagates: L166 assigns scope_verdict="$(pr_touches_security_paths "$base_ref")" as a plain assignment, not combined with local (the SC2155 trap) — under set -e a plain assignment's exit status is the substituted command's own status, so a nonzero exit here still aborts the script instead of silently continuing with an empty scope_verdict. Diffed the reviewed commit against current HEAD to confirm; the only other change is a portability fix to the regression test's grep pattern (\s[[:space:]]), test-only and not security-relevant.

Re-checked the rest of the surface from the earlier revision, still holds at this HEAD:

  • Verdict/fault channel separation. Stdout carries in-scope / out-of-scope; a non-zero exit is reserved for genuine faults. The catch-all *) case at L173-L176 treats any unrecognised verdict as a fault (exit 1), never a pass — the correct fail-closed default for a security guard.
  • No new injection surface. base_ref reaches the embedded Python as a sys.argv value and is only used inside list-form subprocess.run([...]), never shell-interpolated.
  • Missing-paths-file default preserved. [[ -f "$PATHS_FILE" ]] || { printf 'in-scope\n'; return 0; } keeps the same conservative default as the old bare return 0 — guard applies when there's no scoping config.
  • Workflow trust boundary unchanged. .github/workflows/claude-security-review.yml isn't touched by this PR; permissions and triggers are unaffected.

The fix closes the fail-open hole described in the PR body without introducing a new one, and the Codex-flagged gap in the diff-failure path is now handled correctly.

@kyle-sexton
kyle-sexton merged commit cef96ac into main Aug 12, 2026
38 checks passed
@kyle-sexton
kyle-sexton deleted the fix/evidence-guard-out-of-scope-set-e branch August 12, 2026 22:22
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cursor[bot]'s task in 3m 58s —— View job


Code review (/review:code-review)

  • Gather context (PR diff, changed files)
  • Read full changed files for surrounding context
  • Analyze the two changed files for correctness/maintainability (security scoped out — separate security-review lane already ran and found no issues)
  • Post findings

Scope

Reviewed both changed files at HEAD 755c2703:

  • scripts/verify-security-review-evidence.sh (+44/-8)
  • scripts/verify-security-review-evidence.sh.test.sh (+57/-0, test-only)

The PR shipped as 3 commits. Commit 3 (755c2703, "fail closed when scope git diff cannot resolve base ref") was pushed in direct response to @chatgpt-codex-connector's P1 finding about the scope-check's git diff subprocess silently reading as out-of-scope on failure. That fix is in place and correct: L102-107 now checks diff.returncode and exits 1 on failure instead of falling through to an empty-diff out-of-scope.

Findings

1 finding — posted inline on scripts/verify-security-review-evidence.sh L102-107:

  • Test-coverage gap on the newest fail-closed branch. The diff.returncode != 0 fail-closed check added in commit 3 has no regression test. Every other behavior change in this PR earned a dedicated test in verify-security-review-evidence.sh.test.sh — the bare-call-under-set -e repro, the ||-suppression static guard, the unrecognised-verdict catch-all — but git diff 777f17c3..755c2703 -- scripts/verify-security-review-evidence.sh.test.sh is empty, so this specific failure mode (the one the block exists to prevent) is currently unverified by the test suite. Not a blocker on an already-merged PR — flagging for a follow-up.

What I verified and didn't flag

  • set -e semantics of the fix (L165-166): splitting local scope_verdict from scope_verdict="$(...)" is correct — a bare assignment (not combined with local, and not inside if/&&/||/[[) does propagate a command substitution's failure through set -e. Matches the PR's own stated reasoning and the SC2310 rationale.
  • The verdict/exit-status separation closes the fail-open hole as described: in-scope/out-of-scope travel on stdout, faults travel on exit status, and the *) catch-all in main() (L173-176) treats any unrecognised verdict as a fault, not a pass.
  • No other SC2310-shaped traps (bare function calls inside if/&&/||) elsewhere in the diff.
  • No injection surface change — base_ref still reaches Python only as a subprocess.run([...]) list argument, never shell-interpolated.

Everything else in scope reads as sound; this was a well-tested fix for a real, reproduced bug.

Comment on lines +102 to +107
if diff.returncode != 0:
sys.stderr.write(
diff.stderr
or f"git diff --name-only origin/{base_ref}...HEAD failed (exit {diff.returncode})\n"
)
sys.exit(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test-coverage gap: this fail-closed branch has no regression test.

This block was added in the third commit (755c270, "fail closed when scope git diff cannot resolve base ref") specifically to close the fail-open hole flagged in review — a git diff failure (unresolvable origin/$base_ref, shallow-fetch merge-base failure, etc.) previously fell through to out-of-scope with exit 0.

Every other behavior change in this PR got a dedicated regression test in verify-security-review-evidence.sh.test.sh (the bare-call-under-set -e repro, the ||-suppression static guard, the unrecognised-verdict catch-all). This branch didn't — diffing the follow-up commit against its parent shows verify-security-review-evidence.sh.test.sh wasn't touched:

git diff 777f17c3..755c2703 -- scripts/verify-security-review-evidence.sh.test.sh
# (empty)

Nothing currently exercises "the Python helper's git diff subprocess fails" and asserts the guard fails closed (nonzero exit, not a silent out-of-scope). Given this is precisely the failure mode this block exists to prevent, and the rest of the PR's own bar is regression coverage for every fail-open path found, this seems worth closing — e.g. a case that runs pr_touches_security_paths (or an equivalent harness) against a base_ref that can't resolve, and asserts nonzero exit.

Not a blocker on an already-merged PR, just flagging for a follow-up.

Comment on lines +102 to +107
if diff.returncode != 0:
sys.stderr.write(
diff.stderr
or f"git diff --name-only origin/{base_ref}...HEAD failed (exit {diff.returncode})\n"
)
sys.exit(1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test-coverage gap: this fail-closed branch has no regression test.

This block was added in the third commit (755c270, "fail closed when scope git diff cannot resolve base ref") specifically to close the fail-open hole @chatgpt-codex-connector flagged — a git diff failure (unresolvable origin/$base_ref, shallow-fetch merge-base failure, etc.) previously fell through to out-of-scope with exit 0.

Every other behavior change in this PR got a dedicated regression test in verify-security-review-evidence.sh.test.sh (the bare-call-under-set -e repro, the ||-suppression static guard, the unrecognised-verdict catch-all). This branch didn't: git diff 777f17c3..755c2703 -- scripts/verify-security-review-evidence.sh.test.sh is empty — the test file wasn't touched by the follow-up commit.

Nothing here currently exercises "the Python helper's git diff subprocess fails" and asserts the guard fails closed (nonzero exit, not a silent out-of-scope). Given this is precisely the failure mode the block exists to prevent, and the PR's own stated bar is regression coverage for every fail-open path found, this seems worth closing — e.g. a case in the test script that runs pr_touches_security_paths (or an equivalent harness) against a base_ref that can't resolve, and asserts nonzero exit.

Not a blocker on a merged PR, just flagging for a follow-up.

@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

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.

2 participants