Skip to content

feat(classify): case-fold blocked/sensitive matching in risk-paths.yml#114

Merged
topcoder1 merged 2 commits into
mainfrom
fix/classify-nocase
Jul 15, 2026
Merged

feat(classify): case-fold blocked/sensitive matching in risk-paths.yml#114
topcoder1 merged 2 commits into
mainfrom
fix/classify-nocase

Conversation

@topcoder1

Copy link
Copy Markdown
Owner

What

classify.mjs now matches blocked: / sensitive: patterns case-insensitively. Companion to whois-api-llc/wxa-jake-ai#877.

Why

minimatch defaults to case-sensitive, so a lowercase-authored pattern silently misses real-world case variants:

path class (today)
docs/SECRETS.md trivial ← auto-merge eligible
docs/secrets.md blocked

**/secrets* in wxa-jake-ai's blocked: never matched docs/SECRETS.md, so the production secrets rotation runbook — shell snippets pasted against prod hosts — fell through to docs/** and landed in trivial: ("zero runtime impact"). wxa-jake-ai#875 had to be held as a draft to dodge it.

The invariant

Folding case may only ever ADD gating, never remove it.

Everything below follows from that one line.

Why the fold is asymmetric (blocked/sensitive only)

Applying nocase uniformly is a fail-open, and this is the part worth reading. A path matching nothing today gets the deliberately-strict standard fallback. Folding the safe classes hands it to an auto-merge-eligible class instead: with safe_test: ['tests/**'], a PR adding Tests/release.py would classify safe_test rather than standard. On GitHub's case-sensitive filesystem that's a genuinely distinct path, not the same file recased — a lowercase pattern has no business claiming it.

Class precedence does not protect against this: it only breaks ties when a blocked/sensitive pattern also matches, and here none does.

And there's no upside being given up — folding the safe classes only makes them more lenient, which is precisely the direction we don't want. The asymmetry buys the entire safety benefit at zero cost.

Caught by codex round-1 (P2). Worth being explicit: the fleet audit below could not have found this. It scans files that already exist; this vector is about files a future PR introduces. Measuring the static tree and concluding "safe" was the flaw.

Why negation now fails closed

! inverts the match, so folding removes gating. Verified directly:

minimatch("FOO", "!foo", {nocase:false}) => true    ← gated
minimatch("FOO", "!foo", {nocase:true})  => false   ← NOT gated
minimatch("src/A.MD", "src/!(*.md)", {nocase:false}) => true
minimatch("src/A.MD", "src/!(*.md)", {nocase:true})  => false

This mirrors the bracket guard from #107 exactly, including its rationale: zero of the 45 fleet repos use negation in a gating class, so strictness costs nothing today and stops the footgun from ever being introduced. Caught by codex round-2 (P2).

Fleet evidence

Every blob in all 45 repos carrying a risk-paths.yml18,604 files — classified twice, fold off vs on:

repos scanned: 45 | upgrades: 2 | downgrades: 0

UPGRADE  trivial -> blocked   whois-api-llc/wxa-jake-ai: docs/SECRETS.md
UPGRADE  trivial -> blocked   topcoder1/inbox_superpilot: docs/SECRETS_ROTATION.md

Zero downgrades. Both upgrades are real secrets docs currently sitting at risk:trivial. Re-verified under the narrowed fold after the codex fix: same 2 upgrades, still zero downgrades — the narrowing costs nothing.

Enumeration used the REST contents API per repo across both orgs (topcoder1 is a user account, not an org), not code search — a fine-grained PAT returns silently-empty search results, which would have produced a falsely-clean verdict. Zero unreadable repos.

Latent (no live hit, but present fleet-wide): Dockerfile in 44/45 repos and the .env family — a committed DOCKERFILE would classify standard today. This closes those too.

Scope limit — read this

This does NOT fix .github/CODEOWNERS. GitHub matches that itself, and also case-sensitively:

"CODEOWNERS paths are case sensitive, because GitHub uses a case sensitive file system."

So **/secrets* in CODEOWNERS missed docs/SECRETS.md too, and the file had no code owner — a second dead gate that no upstream change can reach. Repos relying on a lowercase glob to own an uppercase path need an exact-case CODEOWNERS line. wxa-jake-ai#877 does this; inbox_superpilot still needs it.

Tests

selftest/test_classify_nocase.sh — 21 cases pinning the fix, the safe-class asymmetry, and the negation guard. Registered in test_workflow_guards.py so tests-runner.yml enforces it in CI.

Verified the test actually tests something: with the fold reverted, 6 of 12 original cases fail, including docs/SECRETS.md → trivial. The existing test_classify_bracket_guard.sh still passes (5/5).

Rollout

pr-classify.yml fetches classify.mjs from main at run time, so this takes effect fleet-wide on merge. The #783/#107 merge-order trap does not apply: #107 needed ordering because it fails closed; this doesn't, and either order lands in the same state. wxa-jake-ai#877 is independent — it fixes that repo's CODEOWNERS half, which this PR cannot.

Auto-merge rationale: MANUAL MERGE — PRs to topcoder1/ci-workflows are always manual-merge per policy, and this one changes the risk classifier for all 45 consuming repos. Opened as a draft deliberately.

Codex pre-review: codex review --uncommitted (v0.144.1). Round 1 → P2 (uniform fold fail-open) fixed. Round 2 → P2 (negation non-monotonic) fixed. Rounds 3 and 4 → zero findings, two consecutive clean rounds. Codex rounds: 4.

HUMAN_READABLE_SUMMARY: Our PR risk classifier compared file paths letter-for-letter including capitals, so a rule written as secrets failed to catch a file named SECRETS.md — and a production secrets runbook was being treated as a harmless doc that could merge itself. This makes the safety rules ignore capitalization, but only for the rules that restrict things, never the ones that wave things through — so the change can only ever make the gate stricter, never looser.

🤖 Generated with Claude Code

minimatch defaults to case-SENSITIVE, so a lowercase-authored pattern
silently misses real-world case variants. '**/secrets*' in wxa-jake-ai's
`blocked:` matched docs/secrets.md but NOT docs/SECRETS.md — so the
production secrets ROTATION RUNBOOK, whose shell snippets are pasted against
prod hosts, fell through to `docs/**` and classified risk:trivial ("zero
runtime impact"), auto-merge-eligible. wxa-jake-ai#875 had to be opened as a
draft to dodge it. Repo-side exact-case gate: wxa-jake-ai#877.

The invariant: folding case may only ever ADD gating, never remove it.

That is why the fold is restricted to blocked/sensitive rather than applied
uniformly. Folding the safe/trivial classes is a fail-OPEN: a path matching
nothing gets the deliberately-strict `standard` fallback, and folding would
hand it to an auto-merge-eligible class instead. With `safe_test:
['tests/**']`, a PR adding 'Tests/release.py' would classify safe_test rather
than standard — and on GitHub's case-sensitive filesystem that is a genuinely
DISTINCT path, not the same file recased. Class precedence does not help:
it only breaks ties when a blocked/sensitive pattern also matches, and there
none does. Folding the safe classes has no upside either — its only effect is
to make them more lenient. (Codex round-1 P2; the fleet audit below could not
have caught it, since it scans files that already exist and this vector is
about files a future PR introduces.)

Negation now fails closed in the folded classes, mirroring the bracket guard
in #107: '!' inverts the match, so folding REMOVES gating — minimatch('FOO',
'!foo') is true but false under nocase; 'src/!(*.md)' vs 'src/A.MD' likewise.
Zero fleet repos use negation in a gating class, so — exactly as with brackets
— strictness costs nothing today and stops the footgun being introduced.
(Codex round-2 P2.)

Fleet audit before shipping, 2026-07-14: every blob in all 45 repos carrying a
risk-paths.yml (18,604 files) classified twice, fold off vs on. ZERO
downgrades. Exactly 2 upgrades, both real secrets docs, both trivial ->
blocked: wxa-jake-ai docs/SECRETS.md and inbox_superpilot
docs/SECRETS_ROTATION.md. Re-verified under the narrowed fold: same 2
upgrades, still zero downgrades.

Does NOT fix .github/CODEOWNERS, which GitHub matches itself and also
case-sensitively. Repos relying on a lowercase glob to own an uppercase path
still need an exact-case CODEOWNERS line (see wxa-jake-ai#877).

selftest/test_classify_nocase.sh pins the fix, the safe-class asymmetry, and
the negation guard; verified to fail without the change.

Auto-Merge-Risk: high

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Coverage Floor — mode: enforce

metric value
measured 100.0%
floor (current) 99.0%
target 100.0%
last bumped 2026-05-12

@topcoder1
topcoder1 marked this pull request as ready for review July 15, 2026 07:10
@github-actions github-actions Bot added the risk:sensitive Risk class: sensitive label Jul 15, 2026
@github-actions

Copy link
Copy Markdown

Codex review

no regressions found
no regressions found

The `prettier (markdown)` check formats *and* to _and_. Caught by CI on #114.

Auto-Merge-Risk: high

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Codex review

regression: .github/scripts/classify.mjs:170 - no test exercises case-folded matching for the sensitive class
regression: .github/scripts/classify.mjs:170 - no test exercises case-folded matching for the sensitive class

@claude

claude Bot commented Jul 15, 2026

Copy link
Copy Markdown

No issues found. The case-fold is applied only to the two highest-priority classes (blocked/sensitive); since nocase only ever adds matches and classify() returns the first match in priority order, the change can only upgrade gating, never downgrade — the invariant holds. Negation guard is correctly scoped and tests cover both halves plus fail-closed negation.

@topcoder1
topcoder1 merged commit 6db1902 into main Jul 15, 2026
13 checks passed
@topcoder1
topcoder1 deleted the fix/classify-nocase branch July 15, 2026 07:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:sensitive Risk class: sensitive

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant