Skip to content

feat(public-repo-hygiene): scan the tracked path string, not just file contents - #231

Merged
mattmillerai merged 2 commits into
mainfrom
matt/be-9399-hygiene-scan-tracked-path
Aug 26, 2026
Merged

feat(public-repo-hygiene): scan the tracked path string, not just file contents#231
mattmillerai merged 2 commits into
mainfrom
matt/be-9399-hygiene-scan-tracked-path

Conversation

@mattmillerai

@mattmillerai mattmillerai commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

ELI-5

The leak checker reads what is inside your files. It never read the file names. So a public repo could contain a directory literally called docs/Comfy-Org/<a-private-repo>/, with a completely empty file in it, and the check went green — while GitHub happily showed that private repo's name to anyone browsing the repository. This PR makes the checker read the tracked path as well as the contents, using the exact same matcher so every allowlist and every false-positive suppressor applies to both.

What changed

_file_findings's per-line body is factored out into a shared _line_findings(location, line, ticket_allowlist, owner_span), and run_checks now runs it over the tracked path itself — right after the exclusion check, before check_file. Deliberately one matcher, not two: TICKET_ALLOWLIST / TICKET_ALLOWED_PREFIXES, the --ticket-allow extension, the model-host and markdown-link-label suppressors, the homoglyph handling and the .git/period strips all reach the new surface for free, and there is no second place to forget an allowlist entry.

Details worth knowing at review time:

  • Every non-excluded entry gets its path scanned, including the ones check_file declines (binary, non-UTF-8, submodule gitlink, LFS stub, FIFO, unreadable). The path is published in the tree whatever the entry type. It does not make such an entry count as SCANNED: — that number is files read as text.
  • owner_span is always None on a path. A path string is not a CODEOWNERS owner line, even the path of a CODEOWNERS file. I passed the precomputed span rather than an is_codeowners flag because deriving the span also needs the line number (the BOM-at-offset-0 rule), and a line number means nothing on a path — owner_span=None reads as the fact it is, where lineno=1 would have been a lie in the signature. Same behaviour either way.
  • exclude_paths: covers the new surface, because the existing _is_excluded continue sits above the path scan. An excluded path is not scanned as a path either, and still reports its skipped-file count so the hole stays named in the log.
  • Findings are labelled <path> (tracked path): rather than <path>:<lineno>: — "rename the file" and "edit the file" are different fixes. Merged into the same per-run MAX_FINDINGS_TOTAL cap.
  • No regex/boundary change. / is not in REPO_REF_RE's left lookbehind, so docs/Comfy-Org/x/y.md matches while aComfy-Org/x does not; \b fires at both / and -, so notes/TEAM-1234/plan.md and TEAM-1234-notes.md both match. Same token rules as content, verified empirically rather than assumed.
  • One behaviour change outside the plan, and why. _emit now lists findings before the zero-coverage verdict. Until this PR, findings implied scanned >= 1; now a repo of nothing but binaries can leak in its own file listing while scanned stays 0, and the old ordering returned exit 2 having printed the finding nowhere — sending the operator after a configuration problem instead of the leak. Exit codes are byte-for-byte unchanged (still 2 there); only the output is added. Pinned by test_a_path_finding_is_listed_even_when_nothing_was_scanned.

Rollout is the ordinary one: callers pin workflows_ref by SHA, so the new surface reaches them through the normal bump-public-repo-hygiene-callers.yml flow.

False-positive risk — measured, not asserted

Every tracked path of nine Comfy-Org public repos, fed through the real _line_findings:

repo tracked paths path findings
github-workflows 63 0
ComfyUI 957 0
ComfyUI_frontend 4150 0
comfy-cli 317 0
comfy-api-proxy 47 0
litegraph.js 171 0
comfy-typescript-sdk 60 0
comfy-python-sdk 58 0
docs 5592 0
total 11,415 0

Enabling this reddens no caller CI. This repo's own working tree (176 tracked paths, and it is deliberately not self-enrolled) also produces 0 path findings. Overhead is ~4 µs per path (47 ms across 11,187 paths), against a content scan that reads up to 5 MiB per file.

And the portion this does not cover, swept with the same tool: the checker reads the work tree, so a path that once existed and was renamed or deleted stays in every clone and is still not examined — nor are branch and tag names. Across the same nine repos that is 3,337 history-only paths, which likewise produce 0 would-be findings today. Real gap, empirically empty, now written down as a known limitation rather than left implicit. (Caveat: measured over the refs present in the local clones, which may not carry every remote branch.)

Verification

  • python3 -m unittest discover -s .github/public-repo-hygiene/tests -p 'test_*.py'178 passed, 0 failed (1 skipped, pre-existing; 165 → 178, +13 new). The pre-existing 165 pass unchanged, which is the zero-new-false-positive claim.
  • Mutation check on the new tests: with the path scan neutered, 7 of the 13 go red (the other 6 are clean-path negatives that pass either way by construction).
  • Every run: step of .github/workflows/test-public-repo-hygiene.yml extracted and executed locally — all 13 smoke steps pass, exit 0.
  • python3 .github/workflow-pins/check_workflow_pins.py — OK, 11 workflows, 0 exempt.
  • python3 .github/agents-md-integrity/check_agents_md.py --root . — passed (1 pre-existing CODEOWNERS warning).
  • bash .github/bump-callers/tests/test_paths_contract.sh — 70 passed, 0 failed.
  • Neighbouring Python suites re-run to catch cross-area breakage: agents-md-integrity 46, cursor-review 305, groom 365, refresh-reviewers 48, workflow-pins 272 — all OK.

New tests (13): a private repo name in a path fails; an allowlisted repo name in a path passes; ticket-shaped directory component and filename prefix both fail; built-in and caller-extended acronyms in a path pass; the (tracked path) vs :<lineno> labels distinguish the two surfaces when a name appears in both; aComfy-Org/x does not flag (left-boundary pin); an internal-marker host in a path flags; the npm-scope and model-host suppressors carry over; the nested-model-host over-flag is pinned; exclude_paths: suppresses and still counts; a path finding fires on a binary entry and on a dangling symlink whose body is never read; and the zero-coverage output case above.

Residual

  • .github/lint/README.md KNOWN LIMITATION 8 is not updated. That file does not exist on main — it arrives with PR ci(lint): fail on org repo literals not on a committed allowlist #224, which is still open. Its limitation-8 sentence "neither checker scans the tracked path itself" becomes stale the moment this merges, and ci(lint): fail on org repo literals not on a committed allowlist #224's branch will need that sentence corrected on rebase. I could not edit it here without touching another open PR's branch, so it is called out rather than done. Whichever of the two lands second owns the fix.
  • A model-host mirror path over-flags (root or nested, since review round 1). models/hf.co/Comfy-Org/<model>/config.json is reported while the same mirror at the tree root is suppressed: MODEL_HOST_PREFIX_RE's left anchor rejects a preceding /, and every segment of a path has one. This is the over-flag direction (a leak guard should be wrong that way), one exclude_paths: entry clears it, and the shape occurs zero times across the 11,415 paths measured — so it is pinned by a test and documented as a known limitation rather than narrowed. Narrowing it would mean loosening a suppressor, which fails open; that is a deliberate non-goal here and would want its own change with its own evidence.
  • Repository history and refs remain unscanned, per the 3,337-path sweep above. Scrubbing a name out of the tree does not scrub it out of the history that still carries it. Out of scope for a checker whose contract is git ls-files, but it is the honest remaining hole and is now stated in both READMEs.
  • Caller repos are not bumped by this PR. The new surface reaches them only when bump-public-repo-hygiene-callers.yml moves their pinned SHA. Nothing here does that, and no caller CI changes until it happens.
  • Unexercised artifact: the originating investigation's findings write-up is not reachable from this environment, so the 11,415-path figure was re-derived independently here rather than taken on trust — it reproduces exactly, which is the closest thing to exercising it that was available.

Provenance

  • Authored by: agent-work loop
  • Verified: public-repo-hygiene unittest 184 passed / 0 failed (1 skipped) after review round 1 (178 → 184); check_workflow_pins.py OK (11 workflows); check_agents_md.py passed (1 pre-existing warning); local path-surface re-sweep over every public clone on the host, 0 findings on public repos
  • Deviations: _line_findings takes a precomputed owner_span rather than an is_codeowners flag plus a line number (same behaviour, no fake lineno=1 on a path — rationale above); _emit output ordering changed so a finding is never swallowed by the zero-coverage verdict (exit codes unchanged); .github/lint/README.md left alone because PR ci(lint): fail on org repo literals not on a committed allowlist #224 has not merged. Review round 1 (cursor-review panel, 9 threads): the two URL-syntax suppressors (model-host prefix, markdown link label) are now gated OFF the path surface via url_suppressors=False — a path has no authority, so they failed open there; a vendored mirror therefore over-flags at the root as well as nested. A surrogateescape code point (Cs) now continues a name in _nonascii_tail. Path findings are capped and PARTIAL-counted through _path_findings. Documented rather than changed: Comfy-Org/ComfyUI.md over-flags, lowercase ticket ids in paths miss, category-2 hosts need a following /, exit 2 may carry findings, and exclude_paths: costs contents coverage.

…e contents

A tracked entry publishes three strings, and the checker only read two of
them. A tree holding `docs/Comfy-Org/<a-private-repo>/placeholder.md` names
that repo to anyone who browses or clones it — and the run reported clean,
because the reference regexes were applied to file CONTENTS and symlink
target strings but never to the tracked PATH.

Factor the per-line matcher out of `_file_findings` into a shared
`_line_findings(location, line, ticket_allowlist, owner_span)` and run it over
`rel` in the scan loop, right after the exclusion check and before
`check_file`. Deliberately ONE matcher, not two: the allowlists, the
model-host and markdown-label suppressors and both caller-side knobs
(`ticket_allowlist:`, `exclude_paths:`) reach the new surface for free, and
there is no second place to forget an allowlist entry.

- Runs for EVERY non-excluded entry, including the ones `check_file` declines
  (binary, non-UTF-8, gitlink, FIFO, unreadable) — the path is published
  whatever the entry type — and does not make such an entry count as SCANNED.
- `owner_span` is always None on a path: a path string is not a CODEOWNERS
  owner line, even the path OF a CODEOWNERS file.
- Findings are labelled `<path> (tracked path):` rather than
  `<path>:<lineno>:`, because "rename the file" and "edit the file" are
  different fixes. Merged under the same per-run MAX_FINDINGS_TOTAL cap.
- No boundary change: `/` is not in the repo-reference lookbehind, so
  `docs/Comfy-Org/x/y.md` matches while `aComfy-Org/x` does not, and `\b`
  fires at `/` and `-`.
- `_emit` now lists findings BEFORE the zero-coverage verdict, since the two
  can co-occur for the first time: a repo of nothing but binaries can leak in
  its own file listing while `scanned` stays 0. The exit code is unchanged
  (still 2 there); the finding is no longer swallowed.

False-positive risk measured, not assumed: all 11,415 tracked paths of nine
Comfy-Org public repos through the real machinery produce zero findings, and
the existing 165-test corpus stays green unchanged. Overhead is ~4 us per
path.
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 36 minutes.

View limit details

Limit details: You’ve used the included review currently available. Your 122 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 90eddd1e-5045-4930-a266-7e096a1c88d7

📥 Commits

Reviewing files that changed from the base of the PR and between 27e23ac and 8dccd72.

📒 Files selected for processing (6)
  • .github/public-repo-hygiene/README.md
  • .github/public-repo-hygiene/check_public_repo_hygiene.py
  • .github/public-repo-hygiene/tests/test_check_public_repo_hygiene.py
  • .github/workflows/public-repo-hygiene.yml
  • README.md
  • docs/callers/public-repo-hygiene.md

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

@mattmillerai mattmillerai added the agent-coded Authored by the agent-work loop label Aug 26, 2026
@mattmillerai
mattmillerai marked this pull request as ready for review August 26, 2026 04:41
@mattmillerai mattmillerai added the cursor-review Multi-model cursor review label Aug 26, 2026

@github-actions github-actions Bot 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.

🔍 Cursor Review — Consolidated panel

Triggered by @mattmillerai.

Found 9 finding(s).

Severity Count
🟠 High 1
🟡 Medium 4
🟢 Low 4

Panel: 8/8 reviewers contributed findings.

Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py
Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py
Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py
Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py Outdated
Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py Outdated
Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py
Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py
Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py
Comment thread .github/public-repo-hygiene/check_public_repo_hygiene.py
…rface, cap path findings, read surrogates as name (BE-9399)

Review round 1 of the tracked-path scan:

- The model-host prefix and markdown-link-label suppressors read URL /
  markdown syntax a path does not have, so inheriting them let a tree park
  a private name under an hf.co/ directory and stay green. Gated via
  _line_findings(url_suppressors=False) on the path surface; allowlists,
  ticket knobs, the npm-scope crossing and the homoglyph handling still
  reach both surfaces. A vendored mirror now over-flags at the root as
  well as nested (fail-closed, documented, corpus count still zero).
- A lone surrogate (category Cs, from surrogateescape-decoded paths) is
  now a name continuation in _nonascii_tail, so Comfy-Org/ComfyUI\xff-x
  can no longer clear as the bare allowlisted prefix.
- Path findings go through _path_findings with the same per-file cap,
  PARTIAL_FINDINGS accounting and warning as contents; a file counts once
  per partial kind whichever surface earned it.
- Documented rather than changed: an allowlisted name with a file
  extension over-flags (stripping would fail open), lowercase ticket ids
  in a path are a miss, category-2 hosts need a following slash, and exit
  2 may now carry findings. exclude_paths' contents-coverage cost is
  stated in both docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mattmillerai
mattmillerai merged commit ef57cf8 into main Aug 26, 2026
5 checks passed
@mattmillerai
mattmillerai deleted the matt/be-9399-hygiene-scan-tracked-path branch August 26, 2026 05:54
mattmillerai added a commit that referenced this pull request Aug 26, 2026
…9652) (#232)

PR text is a leak surface with no guard at all. It is published on a public repo
the moment it is typed, and no file scan can ever see it -- the checker walks
tracked files, and a PR title and body are neither.

Both motivating leaks are real and happened on a public PR of THIS repo: an
internal collaboration-tool permalink pasted into a description (exposing a
workspace and a channel id), and a non-public org repo named in a body. Neither
is caught anywhere today.

This adds PR title and description as a third surface, alongside a file's
contents and (since #231) its tracked path. All three go through the one
`_line_findings` matcher rather than a fork of it, for the reason #231 already
gives: a second matcher is a second place to forget an allowlist entry.

CATEGORIES 2 AND 3 ONLY -- NEVER CATEGORY 1

The ticket category is deliberately excluded from this surface, and that is a
policy decision, not an oversight. This org's commit convention REQUIRES a
`(BE-####)` Linear suffix on the PR title, so a ticket id in a public PR title
is deliberate org-wide practice rather than a leak: half of this repo's own
recent merged PR titles carry one. Scanning for it here would fail roughly
every second PR on every enrolled repo, and a required check that fires on
CORRECT behaviour does not get fixed -- it gets switched off, taking the two
categories that catch real leaks with it. A test pins this, in both directions:
the same string is still flagged in a tracked file.

TWO PROPERTIES THAT ARE SECURITY, NOT STYLE

Read from the EVENT, never from a workflow input. A caller that could supply the
text being judged could supply different text -- the same reasoning that loads
the checker from `workflows_ref` rather than the caller's checkout.

Passed to the checker as FILE PATHS, never as argv values, and interpolated only
into an `env:` value, never into a `run:` body. A PR title and body are
attacker-controlled strings; `${{ ... }}` inside a shell script is textual
substitution before the shell sees it, so `$(...)` in a PR title would execute
on the runner. The workflow writes them from `env:` to RUNNER_TEMP -- outside
the scanned tree -- and passes `--scan-text 'LABEL=PATH'`.

Surface findings are reported BEFORE file findings so `MAX_FINDINGS_TOTAL`
cannot truncate away the highest-signal, cheapest-to-fix ones. A surface named
but unreadable FAILS (exit 2) rather than scanning an empty string: a green run
over text nobody read is the silent pass this checker exists to deny.

TWO LIMITATIONS, DOCUMENTED RATHER THAN IMPLIED

A repo named WITHOUT the org prefix is not caught on any surface -- a bare name
in prose is indistinguishable from an ordinary word without a list of non-public
names, and that list is exactly what cannot live in a public repo. Of the three
real leaks above, this catches two.

PR text is only re-scanned when the caller's `on:` includes `edited`; GitHub's
default `pull_request` types do not fire on a title or body edit, so without it
a PR can go green and then have a link pasted in. The reusable cannot enforce
this -- `on:` belongs to the caller and a workflow cannot read the trigger types
it was configured with -- so the caller guide now carries it, with the `types:`
line marked load-bearing in the copy-pasteable example.

Verified: 196 tests pass (184 upstream + 12 new), workflow-pins lint clean,
actionlint reports only the two pre-existing findings on this file, and the
checker was run end to end against the real leaked PR text.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent-coded Authored by the agent-work loop cursor-review Multi-model cursor review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants