Uh oh!
There was an error while loading. Please reload this page.
fix(pii-gate): fail closed when unconfigured, and stop losing matches to SIGPIPE (backend#1409) - #130
Merged
Merged
Conversation
… to SIGPIPE The gate whose only job is keeping customer names out of public repos was green on six public repos while having evaluated nothing. Three independent fail-open paths (backend#1409), all now refusals. 1. An unset/empty PII_DENYLIST warned and exited 0. The secret does not exist org-wide, so `pii-gate / pii-check` had never compared a single term anywhere. It now exits 1: a guard that cannot check must refuse, not pass. Same message covers a caller that forgot `secrets: inherit`. 2. `printf '%s' "$HAYSTACK" | grep -iqF` discarded matches. `grep -q` exits on its first match and closes the pipe, printf takes SIGPIPE, and under `pipefail` the pipeline returns 141 — so `if` read false and the hit was thrown away. Reproduced on the runner toolchain (bash 5.2.21, GNU grep 3.11): a denylisted term in the PR title with a 200KB haystack gave `rc=141 PIPESTATUS=141 0` and the old script printed "PII gate passed". Now a herestring: no pipe, nothing to break. grep exiting >1 is an operational error and is treated as "did not check", never as "clean". 3. `2>/dev/null || true` on the Compare API turned a 403, a rate limit and a jq error into an empty commit list, and the gate passed on title+body alone. The response is now read into a file, failures are reported with gh's own stderr, and `commits[] < total_commits` (the API's 250 cap) is refused as a truncated haystack rather than scanned and called clean. Three smaller fail-opens found while walking the same error paths: a denylist of only commas passed every PR having compared nothing; the override test matched any label *containing* `pii-gate-override`, so a label like `discuss-pii-gate-override-policy` disarmed the gate; and unquoted word splitting glob-expanded any term containing `*`. The pass message now names how many terms and commits were actually compared, so a green check is distinguishable from a green check that did nothing. Still open, needs org-owner rights (not this PR): create the PII_DENYLIST org secret, make `pii-gate / pii-check` a required check on the six public repos, and add the caller to public `.github`. Refs tracebloc/backend#1409 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hing" Two fail-opens in the previous commit, same class as the ones it fixed. The haystack is staged in a named temp file instead of a `<<<"$HAYSTACK"` herestring. Both avoid the pipe that was discarding matches, but if bash cannot create the herestring temp file the redirection fails, grep never runs, and the status is 1 — the one code that means "no match". Reading a named file, grep returns 2 on any read failure, so a check that did not happen cannot be mistaken for a clean one. It is also written once rather than once per denylist term. The term trim now checks sed exit status. A failed trim yielded an empty $t, which the empty-term guard on the next line skipped silently, so the term went unevaluated while the gate still reported a pass. Refs tracebloc/backend#1409 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
saadqbal
previously approved these changes
Aug 3, 2026
… along
Bugbot on .github#130, High. The term loop ran a bare grep and then read $?,
but Actions invokes `run:` steps as `bash -e {0}`, and `set -uo pipefail` does
not clear that -e. grep returns 1 for "no match", which is the ORDINARY result
here, so errexit terminated the step before rc was ever assigned. The 0/1/*
case could only ever see a match.
The direction is fail-closed, so nothing leaked — but the gate blocked EVERY
clean PR across all six public repos, aborting on the first denylist term that
happened to be absent. It only looked correct while the secret was unset, which
is exactly the state this PR exists to fix: the moment PII_DENYLIST is populated,
the gate would have gone red on everything.
grep is now the left operand of `||`, which exempts it from errexit while still
delivering the status to rc, so 1 ("checked, found nothing") and 2 ("could not
check") stay distinguishable — the whole point of backend#1409.
Also spells out `set -euo pipefail`. errexit was already on; writing it down
stops the next reader inferring it was off, which is how this was written.
Verified by extracting the real loop and running it under `bash -e`:
clean PR, 3 absent terms -> CHECKED=3 HIT=0, completes (was: exit 1)
term present, first slot -> CHECKED=2 HIT=1
term present, last slot -> CHECKED=3 HIT=1
empty/whitespace term -> skipped, CHECKED=2
unreadable haystack -> rc=2, fails closed with the crafted message
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>LukasWodka
commented
Aug 3, 2026
ContributorAuthor
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 72153d0. Configure here.
saadqbal
approved these changes
Aug 3, 2026
Uh oh!
There was an error while loading. Please reload this page.
LukasWodka added a commit
that referenced
this pull request
Aug 5, 2026
…env var The defect-3 case exported a ~150KB PR_BODY. Linux caps a single exec argument or environment string at 128KB (MAX_ARG_STRLEN), so every exec after that export died with E2BIG -- grep, head, tr and even the trap rm. macOS has a larger limit, so it passed locally and failed on the runner: "/usr/bin/grep: Argument list too long". Also unrealistic. GitHub caps a PR body at 65,536 characters, so no real run could produce that env var. Padding now comes from 250 commit messages generated straight into the fixture file, which never travel through argv or the environment -- and that is the faithful shape anyway: backend#1409 describes the haystack as title + body + up to 250 commit messages, title first. The case now asserts the haystack size (80,390 bytes) exceeds the 64KB pipe buffer. Without that, the fixture could shrink and the case would stop reaching the defect while staying green -- a regression test that no longer reproduces its bug is the same class of false comfort #1409 is about. Proof it reaches the defect: reverting the matcher to the pre-#130 pipe form makes this case, and only this case, fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LukasWodka added a commit
that referenced
this pull request
Aug 6, 2026
…ks got impossible advice (backend#1409) (#154) * fix(pii-gate): the override could not rescue an unset secret, and forks got impossible advice (backend#1409) Two defects, plus the first test this gate has ever had. 1 · THE DOCUMENTED ESCAPE HATCH DID NOT WORK The `pii-gate-override` label was checked AFTER the unset-secret refusal, so on a PR where PII_DENYLIST is missing the refusal exited first and the label could not rescue anything. Both this file's own header ("Override a false positive: add the 'pii-gate-override' label") and backend#1409's remediation note ("or apply the pii-gate-override label to an individual PR") advertise it as the per-PR way out of exactly that state. That state is not hypothetical: the secret still does not exist, and today's `.github` promotion (8aabe41, 13:53) armed the fail-closed path, so all seven public repos now carry a red check that the documented workaround cannot clear. An override is a statement that this PR should not be evaluated, so it now precedes every reason the evaluation might refuse. 2 · A FORK PR WAS TOLD TO GO SET A SECRET THAT CANNOT HELP IT GitHub does not pass secrets to `pull_request` runs from a forked repository, so DENYLIST is empty on any fork PR regardless of what the org secret contains. The gate then took the unset-secret path, whose message asks an org admin to run `gh secret set` — advice that cannot work, because this is a property of the event and not of the configuration. Forks now get their own branch with a true reason and a real remedy: a maintainer reads the PR text and applies the override label to record it. Still fails closed. Compared against exactly "true", so an absent head.repo (deleted fork) falls through to the paths below, which refuse on an empty denylist and refuse again on a Compare read they cannot complete — an unknown fork status cannot buy a pass. This also unblocks #1409 defect 2. Making `pii-gate / pii-check` required would have made every fork PR unmergeable; with the fork path explicit, the check can be required for same-repo PRs and deliberately not for forks. Measured today: 0 fork PRs across all 7 public repos, so nothing is affected retroactively. 3 · A SELFTEST, IN THE SHAPE caller-drift ALREADY USES scripts/tests/pii-gate-selftest.sh extracts the gate's `run:` block and executes it against a stubbed `gh`. 15 cases: both new behaviours, the unset-secret and fork refusals, title/body/commit matching, and every fail-closed path (comma-only denylist, truncated commit list, unreadable Compare, missing base SHA, glob-shaped term), plus a regression case for defect 3 — an early match in a ~150KB haystack, which is the shape that used to be discarded via SIGPIPE. Every case asserts the exit status AND a distinguishing phrase, because a fork PR and an unconfigured org both exit 1 and the whole point of this change is that they must not say the same thing. A status-only test would pass while the gate gave impossible advice. Proof the suite bites rather than merely being green: run against the currently live main copy it fails exactly the 4 new cases and passes the other 11, so nothing pre-existing regressed. The test depends on the `run:` block staying free of `${{ }}` interpolation. That is asserted, not assumed — extraction fails loudly if a future edit inlines an expression, rather than silently covering less. Not fixed here, and still open on backend#1409: the secret itself does not exist (a content decision), and the check is required on 0 of 7 public repos. Scanning the diff rather than only PR metadata is backend#1559. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(pii-gate): pad the regression haystack via commits, not a 150KB env var The defect-3 case exported a ~150KB PR_BODY. Linux caps a single exec argument or environment string at 128KB (MAX_ARG_STRLEN), so every exec after that export died with E2BIG -- grep, head, tr and even the trap rm. macOS has a larger limit, so it passed locally and failed on the runner: "/usr/bin/grep: Argument list too long". Also unrealistic. GitHub caps a PR body at 65,536 characters, so no real run could produce that env var. Padding now comes from 250 commit messages generated straight into the fixture file, which never travel through argv or the environment -- and that is the faithful shape anyway: backend#1409 describes the haystack as title + body + up to 250 commit messages, title first. The case now asserts the haystack size (80,390 bytes) exceeds the 64KB pipe buffer. Without that, the fixture could shrink and the case would stop reaching the defect while staying green -- a regression test that no longer reproduces its bug is the same class of false comfort #1409 is about. Proof it reaches the defect: reverting the matcher to the pre-#130 pipe form makes this case, and only this case, fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Code half of
tracebloc/backend#1409. One file:.github/workflows/public-pii-gate.yml.The gate whose only job is keeping customer and partner names out of public repos was reporting green on six public repos while having evaluated nothing. Three independent fail-open paths, all now refusals.
Defect 1 — unconfigured means inactive, and inactive looked like pass
:51-55before (now:77-85): an unset or emptyPII_DENYLISTprinted a::warning::andexit 0. The secret does not exist org-wide, sopii-gate / pii-checkhad never compared a single term in any repo. The warning is invisible in the checks UI — the check was green.Now
exit 1. A guard that cannot read its own denylist must refuse, not pass. The same message covers the other way this goes empty: a caller that forgotsecrets: inherit.Defect 2 — the matcher discarded matches (SIGPIPE)
:77before (now:169-191):grep -qexits on its first match and closes the pipe,printftakes SIGPIPE, and underpipefailthe pipeline returns 141 — soifread false and the hit was thrown away. The haystack is title + body + commit messages with the title first, so an early match on a large PR was the likely case: the gate was least reliable exactly when it mattered most.Reproduction. The command in the ticket does not reproduce — see "corrections" below. What does, on the runner image's toolchain (bash 5.2.21, GNU grep 3.11, Ubuntu 24.04):
And end-to-end against the real script, denylisted term in the PR title, ~200KB haystack:
Fixed by giving grep the haystack without a pipe. One deliberate deviation from the ticket's prescribed fix: the haystack is staged in a named temp file and grepped as
grep -iqF -- "$t" "$HAYSTACK_FILE", rather than the suggested<<<"$HAYSTACK"herestring. A herestring is a temp file, so it fixes the SIGPIPE bug equally — but if bash cannot create it, the redirection fails, grep never runs, and the status is 1, the one code that means "no match". Reading a named file, grep itself returns 2 on any read failure, so "could not check" stays distinguishable from "checked, found nothing". It is also written once instead of once per term.grepexiting >1 is now a refusal; previously it was indistinguishable from a clean scan.Defect 3 — the Compare API failure was swallowed
:66-67before (now:112-152):2>/dev/null || trueturned a 404, a 403, a rate limit and a jq error all into an empty string. The gate then scanned title + body only and reported a pass, with no trace of the failed read.Now: the response goes to a file, the exit status is checked, and a failure prints gh's own stderr and exits 1. Verified — old code on a 403:
PII gate passed,rc=0; new code:::error::Could not read commit messages for aaa...bbb,rc=1.The "distinguish a real failure from an empty result" part is done with
total_commitsvs the returnedcommits[]length:0/0→ a legitimately empty PR, scan title + body and pass (not a refusal — this is the one empty result that is genuinely clean)total_commits→ we are holding a truncated haystack (the API capscommits[]at 250) and refuse rather than call it clean. Escape hatch is the audited override label.caseglob because[ "$x" -lt "$y" ]on a non-number exits 2, and with noset -ethat would have made theiffalse and fallen straight through to a pass.Three more fail-opens found while walking the same error paths
Review on this epic keeps finding "a failure path that reports success", so I walked every path in the file:
CHECKED) and refused if zero.grep -q 'pii-gate-override'against the labels JSON, so a label likediscuss-pii-gate-override-policysilently disarmed the gate. Now an exactjqmatch per label name. An unparseable payload yields "no override", which is the safe direction: the scan still runs.for term in $DENYLISTglob-expanded terms containing*or?into filenames.set -fadded.mktempfailure would have leftsedreading stdin and hung the job; explicitly guarded.sedin the term-trim yielded an empty$t, which the empty-term guard then skipped silently — term unevaluated, gate still green. Now checked.Items 4 and 5 and the named-file change above were found by re-walking my own error paths after the first commit, and are in the second commit.
The pass message now states how many terms were compared against how many commit messages, so a green check is distinguishable from a green check that did nothing. Counts only — the terms themselves are still never printed, so the gate cannot leak what it protects.
Verification
actionlint— clean, whole repo (required check).shellcheck -s bash -S styleon the extracted script — clean.gh(and a stubbedgrepthat exits 2) on Ubuntu 24.04 / bash 5.2.21 / GNU grep 3.11, covering every branch above. All 17 behave as intended; the four cases that are genuinely clean still pass, so this is not a blanket "refuse everything".Corrections to the ticket
haystack="SECRET$big"puts the match and all 200KB of padding on one line, so grep must read to EOF before it can match a line and printf never gets EPIPE. That form returnsrc=0 PIPESTATUS=0 0at 200KB, 1MB and 5MB, on both bash 3.2/BSD grep and bash 5.2/GNU grep. The bug is real, but it needs the match on an early line followed by a newline and enough further lines to fill the 64KB pipe buffer. Worth correcting because the stated repro would read as "not a bug".:51-55,:58,:66-67and:77are exact againstorigin/developas well as@main— the file has not moved between the two. Noting it so the next person does not spend the same time re-deriving them.:58, the override-label check, has the same pipe-to-grep -qshape as:77and is not mentioned in the ticket.LABELSis small so it will not hit the pipe buffer in practice, but it was fixed along with the substring bug above.Out of scope — the remaining half
Needs org-owner rights or a separate ticket, and this PR does nothing about it:
PII_DENYLISTorg secret (--visibility all). Until this exists, this change turns the gate red on every PR in the six public repos — that is the intended, visible state for an unconfigured guard, but it means the secret should land promptly.pii-gate / pii-checka required check on the six public repos — per #1409 it is required nowhere, so even armed and red it currently blocks nothing.public-pii-gatecaller to public.github, the only public repo without one (#1408 P2).Refs tracebloc/backend#1409, evidence in tracebloc/backend#1408 (P0 1).
🤖 Generated with Claude Code
Note
Medium Risk
This only changes a compliance CI gate, but behavior shifts from permissive green checks to hard failures—including every public PR until
PII_DENYLISTis configured—so merge velocity depends on org secret and caller setup.Overview
The public PII gate reusable workflow is reworked so it refuses instead of passing whenever it cannot complete a real scan (backend#1409). Missing or empty
PII_DENYLIST, unreadable Compare API output, truncated commit lists (>250 commits), bad commit counts,mktemp/haystack staging failures, empty denylist after trimming, andgrepexit codes other than 0/1 all nowexit 1with::error::messages instead of warnings or silent success.Matching reliability fixes the pipeline where
printf | grep -qcould return 141 (SIGPIPE) underpipefailand drop hits on large PRs; the haystack is written once to a temp file and scanned withgrepon the file.set -euo pipefailis explicit, denylist terms usegrep … || rc=$?so “no match” does not kill the step under errexit, andset -favoids glob expansion of special characters in terms.Override and audit: the bypass label is an exact
jqmatch on label names (not a substring grep). Success logs now state how many denylist terms and commit messages were checked.Reviewed by Cursor Bugbot for commit 72153d0. Bugbot is set up for automated code reviews on this repo. Configure here.