Skip to content

Refuse a rule that matches its own declaration and selects the file it is in - #73

Merged
HackingGate merged 2 commits into
mainfrom
refuse-self-matching-rule
Aug 20, 2026
Merged

Refuse a rule that matches its own declaration and selects the file it is in#73
HackingGate merged 2 commits into
mainfrom
refuse-self-matching-rule

Conversation

@HackingGate

Copy link
Copy Markdown
Owner

Closes #72, taking option B from that issue: refuse at load, name the cause, leave what a rule scans unchanged.

The defect

A policy file is a tracked file, so a rule's own regexp sits inside the corpus it scans. An unanchored literal matches the line it is written on, and the run reports the policy file as violating the rule that file defines:

policy check failed: unanchored-no-dodge
unanchored literal
policy/p4.toml:2:regexp = '(?i)\bYubiKey\b|\bYubico\b|Windows Hello'

A real path, a real line, and nothing in the tree. Now:

policy check error: policy/p4.toml: rule "unanchored-no-dodge" matches its own
declaration ("regexp = '(?i)\bYubiKey\b|\bYubico\b|Windows Hello'"), and it
selects the file that declaration is in. Every run will report this file as
violating this rule, naming a line that is the rule rather than anything in the
tree. Exclude the policy file from this rule with `files.exclude`, narrow
`files.include` to what the rule is about, or anchor the pattern so it cannot
match the key it is written under.

The scope test is the whole design, and it came from measuring

Across 82 policy files and 178 regexp rules, 54 matched their own declaration textually and none selected the file it was written in. An include of ["cmd", "internal"] with glob = ["*.go"] cannot reach policy/. Refusing on the text alone would have failed 54 working rules.

So the check asks scope first, and asks it through selection::selects -- the same two tests from_index applies to every tracked path (under an include prefix, not matched by an exclusion), asked about one path. Not a second reader of files.* that could disagree with the scanner.

include_prefixes is split out of search_roots so both use one definition; search_roots keeps the side effects that belong to a real search (the warning about an include that is not there, the refusal of one outside the tree), which are not true of a caller that only wants to know whether a path is in scope.

Measured blast radius: zero. 82 policy trees scanned with the built binary, none newly refused.

Ordering

After rule.validate. A rule naming two checks is not yet a rule whose pattern means anything, and reporting a self-match on one answers a question the reader has not reached -- that surfaced as 7 test failures when it ran first, and 5 of them went away on the reorder alone.

Own rules only

A bundled set's rule is declared inside this binary; an inherit.paths rule lives in a file this rule may not select. Neither has a declaration in the policy file to match. The the_effective_rules_are_what_inheritance_resolved_to fixture keeps its policy/extra.toml rule untouched for exactly that reason.

Eight fixtures changed, each one an instance

Five wrote an unanchored literal over the whole tree -- regexp = 'SHOUT', regexp = 'hunter2', regexp = "local" -- which is the shape this refuses. Their subjects are provenance, shadowing, redaction and the shim seam; the added files.exclude (the same line the bundled credentials set carries) touches none of them.

That the fixtures reproduced the defect while testing something else is part of the argument for the check.

What this makes deletable

^Sta[t]us: appears in three repositories and the dodge is unnecessary in all three -- the pattern is anchored, so it cannot match regexp = '^Status:...', because that line begins with regexp. Verified. The nine dodges in \bYubi[K]ey\b|\bYubi[c]o\b|... are load-bearing today and become an exclude line instead, which is readable.

This does not suppress anything, so it does not make any of them go away on its own. It makes the moment you need one discoverable rather than silent, which is what option B is.

Verification

  • cargo test -- 549 pass, 0 fail (545 before, 4 added)
  • cargo clippy --all-targets and cargo fmt --check -- clean
  • uphold scan and uphold check pass on this repository
  • 82 fleet policy trees re-scanned with the release binary -- 0 newly refused

New tests: the refusal fires and names all three cures; an anchored pattern loads; a self-matching pattern that cannot reach the policy file still loads (three narrowings -- include, glob, exclude); and declaration_of stops at the next table that is not its own sub-table.

lefthook is not on PATH here, so its hooks did not run on the commit; the gates they invoke were run directly and are listed above.

…t is in

A policy file is a tracked file, so a rule's own `regexp` sits inside the corpus
that rule scans. An unanchored literal therefore matches the line it is written
on, and the run reports the policy file as violating the rule the policy file
defines -- a real path, a real line number, and nothing in the tree. Reading that
finding means working out that the rule is describing itself, and nothing in the
message says so.

WHAT THE MEASUREMENT CHANGED ABOUT THE CHECK.

Over 82 policy files and 178 `regexp` rules in one workspace, 54 rules matched
their own declaration textually and NONE of them selected the file it was
written in: an `include` of `["cmd", "internal"]` with a `glob` of `["*.go"]`
cannot reach `policy/`. Refusing on the text alone would have failed 54 rules
that work. So the scope test comes first, and it is the same test `from_index`
applies to every tracked path rather than a second reader of `files.*` -- see
`selection::selects`, which is that test asked about one path.

With the scope test, the count of rules in those 82 trees that this refuses is
zero. Nothing that passes today starts failing.

WHY IT IS A LOAD-TIME REFUSAL AND NOT A REPORT.

At run time this is a violation like any other, and the tier that produces it
cannot say what it is. `validate_shims` gives the argument for its own pair and
it holds unchanged here: a load-time refusal is the only place either can be
seen at all. The message names three cures, because a refusal that names none is
a wall: exclude the policy file, narrow the include, or anchor the pattern.

Ordered last, after `rule.validate`. A rule naming two checks is not yet a rule
whose pattern means anything, and reporting a self-match on one answers a
question the reader has not reached.

Own rules only. A bundled set's rule is declared inside this binary and an
`inherit.paths` rule in a file this rule may not select; neither has a
declaration in the policy file to match.

WHAT THE FLEET WAS DOING INSTEAD, WHICH IS WHY THIS IS WORTH A REFUSAL.

Three workarounds have spread with nothing recording any of them as the answer:
a narrow `include` that happens to miss the policy dir, an explicit
`exclude = ["**/policy/principles.toml"]`, and breaking the literal with a
one-character class -- `\bYubi[K]ey\b`, `Yubi[c]o`, `Windows Hell[o]`,
`Touch I[D]`, `Bitward[e]n`, nine of them in a single pattern that is unreadable
because of the workaround rather than because of the problem.

The third is also applied where it does nothing. `^Sta[t]us:` appears in three
repositories and the dodge is unnecessary in all three: the pattern is anchored,
so it cannot match `regexp = '^Status:...'` -- that line begins with `regexp`.
A defensive edit that changes nothing, transcribed three times, is what "the
engine gives no signal either way" looks like from outside.

EIGHT TEST FIXTURES CHANGED, AND EACH ONE IS AN INSTANCE.

Five wrote an unanchored literal over the whole tree -- `regexp = 'SHOUT'`,
`regexp = 'hunter2'`, `regexp = "local"` -- which is exactly the shape this
refuses. Their subjects are provenance, shadowing, redaction and the shim seam,
none of which the added `files.exclude` or narrowed `include` touches. Three more
in `config.rs` needed nothing once the check moved after `rule.validate`.

549 tests pass, clippy and fmt clean, and `uphold scan` and `uphold check` pass
on this repository.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@HackingGate, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b6f058a9-2fd9-4f01-bb6d-3e68538389de

📥 Commits

Reviewing files that changed from the base of the PR and between b312fbc and ebf9fdc.

📒 Files selected for processing (5)
  • src/config.rs
  • src/selection.rs
  • tests/base_sets_cli.rs
  • tests/scan_cli.rs
  • tests/test_review.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

codecov-commenter commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.74797% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.34%. Comparing base (b312fbc) to head (ebf9fdc).

Files with missing lines Patch % Lines
src/config.rs 95.95% 4 Missing ⚠️

❌ Your patch status has failed because the patch coverage (96.74%) is below the target coverage (100.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #73      +/-   ##
==========================================
+ Coverage   90.27%   90.34%   +0.07%     
==========================================
  Files          35       35              
  Lines       11025    11147     +122     
==========================================
+ Hits         9953    10071     +118     
- Misses       1072     1076       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… other eight

`tests/test_review.py` builds a policy carrying `regexp = 'TODO'` over the whole
tree. That is the shape the new load-time refusal catches: the literal appears on
its own `regexp` line, so the rule reports the file it is declared in.

The test is about a claim naming a rule that does not exist, and it asserted
exit 1 -- a policy violation. A load refusal is a `Fatal` and exits 2, the same
as every other validator in `load`, so the assertion failed on the code rather
than on anything it was testing.

Found by the three hook jobs in CI, which run this repository's own lefthook
commands and were the only jobs that reached this suite. Every consumer job
passed, which is the half that matters: nothing outside this repository changed
behaviour.
@HackingGate
HackingGate merged commit 79b6081 into main Aug 20, 2026
12 checks passed
@HackingGate
HackingGate deleted the refuse-self-matching-rule branch August 20, 2026 15:41
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.

A rule's own regexp is inside the corpus it scans, and three different workarounds have quietly spread instead of an answer

2 participants