Skip to content

fix(pii-gate): fail closed when unconfigured, and stop losing matches to SIGPIPE (backend#1409) - #130

Merged
LukasWodka merged 3 commits into
developfrom
fix/1409-pii-gate-fail-closed
Aug 3, 2026
Merged

fix(pii-gate): fail closed when unconfigured, and stop losing matches to SIGPIPE (backend#1409)#130
LukasWodka merged 3 commits into
developfrom
fix/1409-pii-gate-fail-closed

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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-55 before (now :77-85): an unset or empty PII_DENYLIST printed a ::warning:: and exit 0. The secret does not exist org-wide, so pii-gate / pii-check had 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 forgot secrets: inherit.

Defect 2 — the matcher discarded matches (SIGPIPE)

:77 before (now :169-191):

ifprintf'%s'"$HAYSTACK"| grep -iqF -- "$t";then

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. 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):

=== match on its own early line, then padding lines ===
size=100000 rc=141 PIPESTATUS=141 0
size=1000000 rc=141 PIPESTATUS=141 0
=== herestring, same data ===
herestring rc=0 PIPESTATUS=0
=== as the workflow uses it (set -uo pipefail, inside `if`) ===
MATCH LOST rc=141 PIPESTATUS=141 0
herestring: MATCH DETECTED

And end-to-end against the real script, denylisted term in the PR title, ~200KB haystack:

OLD code: "PII gate passed — no denylisted terms found." rc=0 <-- P0
NEW code: "::error::This PR's title, body, or commit ..." rc=1

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. grep exiting >1 is now a refusal; previously it was indistinguishable from a clean scan.

Defect 3 — the Compare API failure was swallowed

:66-67 before (now :112-152): 2>/dev/null || true turned 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_commits vs the returned commits[] length:

  • equal → the whole PR is in hand, scan it
  • 0/0 → a legitimately empty PR, scan title + body and pass (not a refusal — this is the one empty result that is genuinely clean)
  • fewer than total_commits → we are holding a truncated haystack (the API caps commits[] at 250) and refuse rather than call it clean. Escape hatch is the audited override label.
  • count missing or non-numeric → refuse. Guarded with a case glob because [ "$x" -lt "$y" ] on a non-number exits 2, and with no set -e that would have made the if false 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:

  1. A denylist of only commas/whitespace passed every PR having compared nothing — non-empty secret, zero usable terms after trimming: the exact fail-open of an unset secret wearing a populated secret's clothes. Now counted (CHECKED) and refused if zero.
  2. The override label matched on substringgrep -q 'pii-gate-override' against the labels JSON, so a label like discuss-pii-gate-override-policy silently disarmed the gate. Now an exact jq match per label name. An unparseable payload yields "no override", which is the safe direction: the scan still runs.
  3. for term in $DENYLIST glob-expanded terms containing * or ? into filenames. set -f added.
  4. mktemp failure would have left sed reading stdin and hung the job; explicitly guarded.
  5. A failed sed in 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 style on the extracted script — clean.
  • 17-case harness with a stubbed gh (and a stubbed grep that 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

  1. The repro command in #1409 / #1408 does not reproduce.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 returns rc=0 PIPESTATUS=0 0 at 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".
  2. The line numbers were not stale after all. I located everything by content expecting drift, but :51-55, :58, :66-67 and :77 are exact against origin/develop as 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.
  3. :58, the override-label check, has the same pipe-to-grep -q shape as :77 and is not mentioned in the ticket. LABELS is 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:

  • Create the PII_DENYLIST org 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.
  • Make pii-gate / pii-check a required check on the six public repos — per #1409 it is required nowhere, so even armed and red it currently blocks nothing.
  • Add the public-pii-gate caller 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_DENYLIST is 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, and grep exit codes other than 0/1 all now exit 1 with ::error:: messages instead of warnings or silent success.

Matching reliability fixes the pipeline where printf | grep -q could return 141 (SIGPIPE) under pipefail and drop hits on large PRs; the haystack is written once to a temp file and scanned with grep on the file. set -euo pipefail is explicit, denylist terms use grep … || rc=$? so “no match” does not kill the step under errexit, and set -f avoids glob expansion of special characters in terms.

Override and audit: the bypass label is an exactjq match 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.

LukasWodkaand others added 2 commits August 3, 2026 09:05
… 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>
Comment thread.github/workflows/public-pii-gate.yml Outdated
@LukasWodkaLukasWodka self-assigned this Aug 3, 2026
saadqbal
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

Copy link
Copy Markdown
ContributorAuthor

bugbot run

@cursorcursorBot 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.

✅ 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.

@LukasWodka
LukasWodka merged commit 4a7f43e into developAug 3, 2026
2 checks passed
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>
@LukasWodka
LukasWodka deleted the fix/1409-pii-gate-fail-closed branch August 14, 2026 13:53
Sign up for freeto 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.

3 participants

@LukasWodka@saadqbal@divyasinghds