Repository navigation
fix(audit-required-checks): read every rules page; percent-encode --branch and file names - #303
Conversation
… file names (RED) The validator's rules call reads one page of the branch's rules (30 a page), so a required_status_checks rule past page 1 reads as no required checks, exit 0. --branch and workflow file names go into the URL unencoded: `#` starts a fragment gh never sends, `?` starts the query, and a bad `%` fails inside gh. These cases fail on the current validator (15 of 65 checks, the same on bash 3.2 and bash 5); the fix follows. The shim now reads an endpoint the way gh 2.97.0 and GitHub did (measured 2026-10-16 on topcoder1/dotclaude, and 2026-10-17 on this repo) and pages the rules answer; shim controls s1-s4 pin that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ranch and file names The weekly fleet audit runs this validator across topcoder1 and whois-api-llc, so each gap was a silent pass that hid a phantom required check fleet-wide. - The rules call ran without --paginate, and the endpoint pages its answer 30 rules a page: a required_status_checks rule past page 1 read as no required checks, exit 0. It now pages, every printed page must be an array (`add` would take a null page for nothing), and the shape check runs on the merged list (`jq -e` goes by the last of several values). - --branch and each workflow file name went into the URL as they were: gh never sends a #fragment, `?` starts the query, and a bad `%` fails inside gh. A new uri() percent-encodes them, `/` kept, so ordinary values send the same bytes as before. Error messages name the endpoint as sent. Exit codes and the no-response-bytes rule from #299 are unchanged. Mirrors topcoder1/dotclaude#525 for this copy, which has no --ref. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s {repo} placeholder
From Codex pre-review round 4:
- The failing-page-2 case now carries a canary in page 1 and checks that
none of it reaches the output: gh --paginate prints the pages before the
failure on stdout, and only this case would catch an api_get that echoed
them (mutation checked).
- gh fills {owner}, {repo} and {branch} in an endpoint from the checkout it
runs in (measured 2026-10-17, gh 2.97.0): unencoded, a workflow named
`{repo}.yml` was read as `ci-workflows.yml`. The shim fills them as for
the scheduled audit, and a `{repo}.yml` case pins that braces are encoded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From Codex pre-review round 5: gh 2.97.0 also fills :owner, :repo and :branch where no letter, digit or `_` follows (its placeholderRE, measured 2026-10-17: `contents/:repo.yml` requested `contents/ci-workflows.yml`, `:repository.yml` was sent as it was). The shim fills both forms, and a `:repo.yml` case pins that `:` is encoded (an encoder that left it would pass every other case; mutation checked). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tHub give them From the independent review, both measured 2026-10-17: - A bad % in the rules endpoint's query gets HTTP 400 (`gh: HTTP 400`, no stdout, rc 1); only the contents endpoint drops that parameter. Control s1 had asserted the drop for rules. - rule-pages/ now holds GitHub's own pages, and the shim prints them as gh does: without --paginate the one page as GitHub sent it, and with it joined as gh 2.97.0's paginatedArrayReader joins them (a page with another after it loses its `]`, a later page's `[` becomes `,`), or an array per page. The fixtures had held gh's --paginate bytes, so the old, unpaginated validator saw an unclosed page 1 and looked fail-closed: run as it really was, it exits 1 on the null page and 0 on the failing page 2, and both cases now fail on it (18 of 70, both bashes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The independent review's optional nit: an empty `[]` page joins as ` ]` (gh's paginatedArrayReader); no fixture served one, so a shim that joined it as `,` passed. One more s3 row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The merging logic works correctly for both gh output forms. The analysis is complete:
Pagination ( Shim Branch lookup in No logic bugs found. The test coverage for the new code paths is thorough. No issues found. Diff correctly fixes two silent-pass bugs (unpaginated rules endpoint and unencoded URL values) with well-matched test coverage for both forms of gh --paginate output and all the URL-special characters. Claude Review · workflow run |
…rror, not as no required checks (#305) ## Summary This copy of `scripts/validate-required-checks.sh` checked the rules answer (`GET /repos/<repo>/rules/branches/<branch>`, merged across pages since #303) only for being an array of objects. The weekly "Audit required checks" workflow runs this copy across the fleet. The contexts filter keeps only the rules whose `type` is `required_status_checks`, and reads their checks with `[]?`. So a malformed rule read as no required checks: exit 0 with `no required_status_checks rule on main`. The fleet audit then filed the repo as clean and opened no issue, so whatever check that rule required was never validated: a silent pass. topcoder1/dotclaude#521 (merged 2026-10-16) closed the same gap in dotclaude's copy. This PR ports that check. ## What changed - **The shape check** (`scripts/validate-required-checks.sh`) is now dotclaude's jq program, byte for byte: - every rule has a string `type`; - every `required_status_checks` rule lists its checks as an array of objects, each with a non-empty string `context`. Anything else exits 3 and names the endpoint as it was sent. `[]`, when no rule applies, still exits 0. - **#299's contract is unchanged:** - 0 clean, 1 FINDING only, 2 usage, 3 API error, 4 internal error; - no message quotes the response, because the digest is a PUBLIC issue. dotclaude's copy appends `${RULES_ANSWER:0:200}` to this error; this one names only the endpoint. - **The message** is now dotclaude's: `did not answer a valid list of rules`. "an array of rules" was wrong for an array that holds a bad rule. Four existing assertions take the new wording. - **The CONTEXTS filter is unchanged.** Once the check holds, its `[]?` cannot drop anything. - **`selftest/README.md`:** one added passage. ## Tests All tests are in `selftest/test_audit_required_checks.sh`, new case j. CI runs that file through `selftest/test_workflow_guards.py`, and every expected value is hardcoded. **The fixtures.** Each malformed answer is clean-a's rules answer with one defect, and every `ruleset_source` in it is a canary. There are 14: - no `type`, and a number for it; - a `required_status_checks` rule whose checks are missing (`parameters: {}`), a string, or an empty object, and one whose `parameters` is a string; - a check that is a string; - a check whose context is missing, empty, a number, or an object; - a bad check after a good one, and before one, in the same list; - a bad rule beside a good one. **The assertions:** - Each must exit 3, with stdout and stderr byte-equal (`cmp`) to the header and the one API error line. So no verdict line and no byte of the answer can appear, whichever field it comes from. - A fleet run over clean-a and two malformed repos must exit 3 with two `ERROR: not audited (exit 3: GitHub API error)` blocks naming the endpoint, and no canary. The workflow's own digest step must then open the issue. - Two positive controls, which must still exit 0: a full answer (`pull_request` and `non_fast_forward` rules, a check with `integration_id`), and a rule with an empty list of checks. - The fixture builder fails the run if an edit does not build a JSON array that carries the canary. Without that, a typo would serve an empty answer, which exits 3 with the same message and passes having tested nothing. **RED first.** Commit 83da44c is the tests alone. Against origin/main's validator (5b880ca; the validator is unchanged since 67e3588), 21 of 87 checks fail, the same under `/bin/bash` 3.2.57 and bash 5.3.15: - 6 are the new wording. - 15 are case j: - 9 silent passes, exit 0: no type, numeric type, no list, string list, empty-object list, empty context, bad rule beside a good one, bad check after a good one, and the fleet run reading `All 3 repos clean.`; - 4 false findings, exit 1: phantoms named `null` and `5`, a bad check before a good one (`null`), and an object context, which printed the answer's own bytes into the output; - 2 crashes, exit 4: a check that is a string, and string `parameters`. **GREEN.** 87 of 87 pass under both bashes, with identical verdict lines. - The full suite (`uv run pytest -q`) gave `787 passed, 1 skipped`. It ran before the rebase onto #304, which adds only #304's own files. This test's CI wrapper passes on the rebased head. - shellcheck 0.11.0 is clean on the selftest at style severity, with one `SC2016` disable for the jq variable `$c`. - The validator reports only the 3 notes origin/main already has. - prettier 3.9.10 is clean on the README. **Mutation check.** Each of these, applied to a scratch copy, makes a named case fail: - dropping the `type`, array, string-context or non-empty check; - the old check; - checking only the first check of a list, or only the last; - the shape check's stderr kept, or its redirect order swapped; - echoing 200 bytes of the answer; - leaking the rules' `parameters`, which hold no canary; - a newline-only leak; - the old wording; - exit 0 on a bad shape. The one survivor, dropping the per-check `type == "object"`, is equivalent: a non-object check makes jq error or evaluate to false, and both take the same exit-3 branch. The independent review confirmed this over 14 check values. ## Measured live (read-only) - **Fleet:** the real rules answers of all 13 repos the audit covers pass both the old and the new check. - **This repo, end to end:** the old validator (67e3588) and the new one both exit 0, with byte-identical stdout (23 lines), in default and `--strict` mode. ## Codex pre-review - **Rounds 1 and 2:** native `codex review --base`. No findings. - **Rounds 3 to 6:** an adversarial prompt through `codex exec -s read-only`. Each round listed the findings already declined, so that only new ones counted. - **Round 3:** 2 P1 and 2 P2. - The P1s (declined here): a context with leading or trailing whitespace, or a newline, is altered by the newline-delimited `CONTEXTS` transport and Python's `.strip()`. `" test "` validates as `test`, and `"\n"` reads as no rule, exit 0. Both copies behave the same, and the gap predates this change. The right fix is to carry contexts exactly and report a misconfigured context as a finding, not to reject it here as an API error. That is a follow-up, below. - P2: case j had no whitespace or newline fixtures; these go with that follow-up. - P2: the leak check only grepped for a canary. FIXED with the exact-output assertion. - **Round 4:** 1 P2. `$(cat ...)` drops trailing newlines, so a newline-only leak would pass. FIXED with `cmp`. - **Round 5:** 1 P1, a NUL variant of round 3's transport gap. `"\u0000"` reads as no rule, exit 0. Declined here and folded into the same follow-up. - **Round 6:** `No new findings.` ## Independent review An opus `code-reviewer`, separate from the authoring context, reviewed the first draft. **Verdict: APPROVE WITH NITS.** - It found no validator defect. - It re-ran RED and GREEN on both bashes, and every claimed mutant kill. - It confirmed that the jq program and its comment are byte-identical to dotclaude main, apart from the dropped byte echo. - It found 5 issues, all in the tests and docs. All are fixed in the tests above: - MEDIUM: every bad check was alone in its list, so a first-only or last-only check passed. Two mixed-list fixtures now catch both. - LOW: no fixture made the check itself error, so the stderr redirect went untested. A string `parameters` now does. - LOW: a broken fixture edit would pass vacuously. Now guarded. - LOW: the README and the case-j comment missed the empty-context silent pass, and said "object" where only `{}` read as no checks. - Note: an object context printed the answer's bytes under the old code. Now pinned. **Re-check of the final tests: APPROVE.** It reviewed 8857416, whose files are byte-identical to this branch's. - It re-ran GREEN and RED under both bashes, in a clean environment (`env -i`, `LANG=C` and `C.UTF-8`), and through CI's entry point. That is `pytest selftest/test_workflow_guards.py -k audit_required_checks`: 1 passed, and 1 failed on the old validator. - It killed all 20 non-equivalent mutants, including four leaks no canary can see: the first 20 bytes, a rule's `type`, the body length, and a trailing space. - Its one LOW is optional and outside this diff. Cases a, h and i still check their merge-step errors with canary greps, not byte for byte. While that error branch is shared with the shape check, case j guards it. ## Preflight `bb-preflight.sh topcoder1/ci-workflows claude/validator-strict-rules-shape` was run before the push. - **Before the rebase,** it reported NOT READY on two checks: - the in-flight cap; - a `selftest/README.md` overlap with #304, in another entry. A 3-way merge from #304's real merge base gave 0 conflicts in either order. - **After #304 merged,** this branch was rebased onto 5b880ca, and RED and GREEN were re-run on the rebased commits. bb-preflight now reports **NOT READY on the in-flight cap only.** #302 (a draft) and #269 are open, and this branch overlaps neither ("no file overlap with sibling Claude PRs"). - Every other check is green: working tree, policy gate, required checks reachable, and ruff. ## Known limitations, not changed here - **Contexts the text transport cannot carry exactly:** leading or trailing whitespace, newlines, NUL bytes. See the Codex rounds above. Both copies need the same fix, proposed as its own change. - **A non-string `ruleset_source_type`** on a `required_status_checks` rule makes the filter's `ascii_downcase` error, so the run exits 4 ("validator crashed") instead of 3. That is loud, not silent. dotclaude's copy behaves the same. - **The rules endpoint lists rulesets only.** A check required only by classic branch protection is not seen. No fleet repo requires a check that way alone today. Codex rounds: 6, the cap. Rounds 1 and 2 were native reviews; rounds 3 to 6 were adversarial. Round 6 found nothing new; round 5's only finding was a variant of round 3's declined transport gap. So the loop stopped at the cap, not on two clean rounds in a row. Of the 6 findings, 2 were fixed (both test gaps, each now killing a mutant) and 4 were declined into the follow-up: the transport gap and its test coverage. Auto-merge rationale: manual click-merge. PRs to topcoder1/ci-workflows are always manual merge, and this validator decides the fleet audit's verdicts, which are posted publicly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
This closes two gaps in this repo's copy of
scripts/validate-required-checks.sh. topcoder1/dotclaude#525 (merged 2026-10-16) closed the same two in dotclaude's copy. This is the copy that the weekly "Audit required checks" workflow runs across the fleet, so each gap was a silent pass that could hide a phantom required check in any fleet repo.The rules call read only one page.
GET /repos/<repo>/rules/branches/<branch>returns its rules 30 to a page, and the call had no--paginate. Arequired_status_checksrule after rule 30 was never read. The branch then passed withno required_status_checks rule on mainand exit 0, even when a required context had no producer.--branchand workflow file names went into the URL unencoded. gh parses the endpoint as a URL:#;?starts the query;%that starts no%XXescape fails inside gh;%XXthat is left.As a result,
--branch 'release#2'readrelease's rules, and a workflow namedci#2.ymlwas read asci. This copy has no--ref.What changed
gh api --paginate.addwould treat anullpage as nothing at all, so anullpage would be merged away unseen.jq -cs 'if all(.[]; type == "array") then add else error(…) end'), and the existing shape check runs on the merged list.jq -e, and also for jq's exit status: jq 1.7.1, measured 2026-10-17, still exits 0 after an error on an earlier input. Without the merge, a bad rule on page 1 of 2, printed as an array per page, read as a clean pass.uri()(jq@uri, with/left as it is) encodes--branchin the rules path and each workflow file name in the per-file path.-._~and/send exactly the bytes they did before. Any other character is now sent percent-encoded, and GitHub decodes it once, back to the same value.urifailure there exits 4; inside a command's arguments it would not stop the command.api_get's own messages already did.$API_BODYdirectly, and no raw answer is kept for a message.Measured live (gh 2.97.0)
I re-measured these on 2026-10-17 with read-only GETs on this repo. The selftest shim copies them:
rules/branches/main#xread main's rules.rules/branches/ma%69nread main's rules: GitHub decodes%XX.contents/a%zz.mdfailed inside gh withinvalid URL escape "%zz", rc 1, and sent nothing.--paginatewith?per_page=1printed this repo's 4 rules as one array (gh followedLink4 times).The local-server results come from dotclaude#525 (2026-10-16):
nullpage 2 printed[{"type":"deletion"}null, rc 0;Old against new, live
Old is origin/main at 71322c5; new is this branch. Both ran on this repo, topcoder1/ci-workflows, which is itself in the fleet list, through a
ghwrapper that logged every call's argv.--paginate--strict--branch 'main#x'main's 6 contextsno required_status_checks rule on main#x. One call, torules/branches/main%23xThis repo has 4 rule entries, so paging changes nothing here live. The selftest covers paging. A run on a private fleet repo gave the same verdicts.
Tests
All tests are in
selftest/test_audit_required_checks.sh, which CI runs throughselftest/test_workflow_guards.py. Every expected value is hardcoded.The PATH-shim
ghreads an endpoint the way gh and GitHub did above:{owner}/{repo}/{branch}and:owner/:repo/:branch, as for the scheduled audit's checkout of this repo on main. gh fills them from the checkout it runs in: measured 2026-10-17,contents/{repo}.ymlandcontents/:repo.ymlboth requestedcontents/ci-workflows.yml.%in the path, and decodes%XX.&. A parameter with a bad%gets HTTP 400 from the rules endpoint (measured 2026-10-17); the contents endpoint drops it instead.branch-rules/).--paginate, printed as one array or (pages_apart) as one array per page.rule-pages/), a failing page included. Without--paginateit prints the one page as GitHub sent it. With--paginateit joins them as gh 2.97.0'spaginatedArrayReaderdoes: a page with another after it loses its], and a later page's[becomes,.New cases:
h (paging):
--paginateforms;nullpage 2, in both forms;The bad-rule,
nulland failing-page cases carry canaries, which must not appear in the output.i (encoding):
ci#2.yml,ci?2.yml,50%.yml,{repo}.yml,:repo.ymlandci 2.yml;--branchrelease#2andrel%41se;rel/main#x. The last one also pins that/is sent as it is.s1–s4 (shim controls): these pin what the shim does with the requests origin/main sends (no
--paginate, values as given) and with both--paginateforms. They keep cases h and i able to tell the fix from the bug.RED first. Commit c11a330 is the tests alone: run against origin/main's validator, 15 of its 65 checks fail. The final test file, with the cases the reviews added, fails 18 of 70 there. Both counts are the same under
/bin/bash3.2.57 and bash 5.3.15, and each failure is the reported bug:no required_status_checks rule on main. Thenullpage 2 exited 1 in both forms, a false FINDING from page 1 judged alone. The failing page 2 exited 0 in both forms, a silent pass.#and?readciand got a 404.%.failed inside gh.{repo}and:reporeadci-workflows.ymland got a 404.The 52 that pass on the old code:
GREEN: 70 of 70 checks pass under both bashes, and the pytest wrapper for this file passes on a349673. The full suite (
uv run pytest -q) gave787 passed, 1 skippedon 0a15680; the commits since then change only this selftest and its README. shellcheck 0.11.0 is clean at CI'serrorseverity. At default severity the selftest is clean, and the validator reports only the 3 notes origin/main already has (SC2001, SC2034, SC2086). prettier 3.9.10 is clean on the README.Mutation check (mine; the independent review repeated it). Each of the following, applied to a scratch copy, makes a named case fail:
--paginate;per_page=100instead of--paginate;--paginatewith no merge;addwith no array guard;urion the branch;urion the file name;urithat also encodes/;#;#,?,%and space but not braces, or not:;api_getechoing gh's stdout on failure. Only the failing-page-2 canary catches this one.Codex pre-review
codex review --base origin/main: no findings.codex exec -s read-only. Each round listed the findings already declined, so that only new issues counted.[][]merges to[], exit 0. The old shape check accepted it too, because jq goes by the last value. It needs GitHub to send invalid JSON with HTTP 200, and it cannot be told apart from gh's array-per-page output, which must stay accepted.api_get's command substitution. That capture predates this change (fix(audit-required-checks): fail loudly on API errors, file findings apart #299), and the input needs a raw NUL inside GitHub's JSON.${3%]}strips nothing. It did strip the]under both bashes: the page file it wrote had none. Since 49d23c2 that helper strips nothing, because the fixtures now hold GitHub's own pages and the shim joins them.api_getthat echoed gh's stdout (page 1's rules) would have passed. Page 1 now carries a canary, and this is the only case that catches that echo.{owner},{repo}and{branch}from the checkout it runs in. I measured this, the shim now models it, and a{repo}.ymlcase covers it.paginatedArrayReaderholds back a page's]only when it is the last byte read, so a page body ending in whitespace would merge into invalid JSON. GitHub's rules pages end in]: a live 4-page--paginateof this repo's rules merged into valid JSON. If a page did not, the validator would exit 3 naming the endpoint, which is the fail-closed path.:owner,:repoand:branch(itsplaceholderRE). I measured this and added a:repo.ymlcase.No findings.Independent review
An opus
code-reviewerlane, separate from the authoring context, reviewed 71322c5..d7ee6e5. Verdict: APPROVE with nits. It found no validator defect at any confidence.urifailure: it madeurifail at each call site, on both bashes. Both sites exit 4, and no request goes out with a truncated endpoint. Withuriinlined into an argument instead, the request goes out as.../workflows/and the run exits 3. That is why both call sites are assignments.[...null, page 1 followed by the 403 body, and an empty last page.per_page=100, the Link-following, and 4 pages atper_page=1printed as one array.2>/dev/nullare needed. jq'saddtype error quotes the input.anyforallin the guard,%or?kept, the shape check dropped or run on the raw answer, the merge's stderr kept,uriinlined, and more. The three that survived earlier commits are the ones db68825 and d7ee6e5 closed:api_getechoing gh's stdout, braces left unencoded, and:left unencoded.%in the rules endpoint's query gets HTTP 400; only contents drops the parameter. s1 had asserted the drop.--paginatebytes. The old, unpaginated validator therefore saw an unclosed page 1 and looked fail-closed, when it really exited 1 (nullpage) and 0 (failing page 2).rule-pages/now holds GitHub's own pages, and the shim joins them as gh'spaginatedArrayReaderdoes.fail_withechoand affects no outcome.,is now caught.--branch.typeand no non-empty stringcontext, so a malformed rule of that kind reads as no required checks, exit 0. That is out of scope here; a follow-up session is proposed.Preflight
bb-preflight.sh topcoder1/ci-workflows claude/validator-page-encode:Known limitations, not changed here
main. All 14 fleet repos default tomaintoday (checked 2026-10-17), so a repo added later with another default branch would read as clean.gsub("\\s"; "")in the per-file base64 check is unchanged.Codex rounds: 6, the cap. Round 1 was the native review; rounds 2–6 used the adversarial lane. Round 6, on d7ee6e5, found nothing; the commits after it (README wording, plus the independent review's test-only fixes) were re-checked by the reviewer, since the loop was at its cap. Round 5 did find something, so the loop stopped at the cap, not on two clean rounds in a row. Of the 8 findings, 3 were fixed (all test gaps, each test failing first), 4 were declined for the reasons above, and 1 was false.
Auto-merge rationale: manual click-merge. PRs to topcoder1/ci-workflows are always manual merge, and this validator decides the fleet audit's verdicts, which are posted publicly.
🤖 Generated with Claude Code