Skip to content

security: pr_bot core-path classification is blind to file renames — trust-gate and auto-merge gating can be bypassed with git mv #505

Description

@philluiz2323

What happened

src/vouch/pr_bot.py's CORE_GLOBS protects core files (proposals.py,
storage.py, audit.py, server.py, http_server.py, cli.py,
pr_bot.py itself, .github/**, etc.) by exact path match — a change to
any of them must come from the owner, enforced by two workflows:

  • .github/workflows/trust-gate.yml fails an untrusted-author PR that
    touches a core path.
  • .github/workflows/auto-merge.yml / pr-verify.yml never arm native
    auto-merge for a PR classified coreshould_arm_automerge() hard-blocks
    on is_core.

Both build the changed-file list the same way:

gh pr view "$PR" --repo "$REPO" --json files --jq '.files[].path' > changed.txt

gh pr view --json files is backed by the GraphQL PullRequestFile type,
which carries only path / additions / deletions — no previous-filename
field. When GitHub detects a rename (default similarity threshold, same as
plain git mv), only the new path shows up in this list. _match() in
pr_bot.py does exact string equality against CORE_GLOBS:

def _match(path: str, glob: str) -> bool:
    g = glob.lstrip("/")
    if g.endswith("/**"):
        prefix = g[:-3]
        return path == prefix or path.startswith(prefix + "/")
    return path == g

So git mv src/vouch/http_server.py src/vouch/web_server.py (plus any edit
inside it) makes the changed-file list contain only web_server.py
http_server.py never appears — and classify() reports is_core: False.
trust-gate.yml then reports "untrusted author, no core paths touched —
ok" for a PR that substantively rewrote a core file, and if the owner later
applies the auto-merge label (a routine glance, given the bot exists to
avoid re-diffing every PR by hand), the PR is eligible to auto-merge as
klass=code instead of being permanently blocked as klass=core.

What you expected

A PR that renames a CORE_GLOBS path — with or without further edits —
should still classify as core: trust-gate.yml should flag it for an
untrusted author, and should_arm_automerge() should refuse to arm.

Reproduction

from vouch import pr_bot

# what `gh pr view --json files --jq '.files[].path'` produces for a
# GitHub-detected rename of a CORE_GLOBS path:
changed = ["src/vouch/web_server.py"]

print(pr_bot.classify(changed))
# {'is_core': False, 'is_ui': False, 'is_code': True}   <-- should be is_core: True

print(pr_bot.klass(changed))
# 'code'   <-- what auto-merge.yml's `classify --print-klass` step emits

import subprocess, sys
f = "changed.txt"
open(f, "w").write("src/vouch/web_server.py\n")
r = subprocess.run([sys.executable, "-m", "vouch.pr_bot", "core-touched",
                    "--files-file", f])
print(r.returncode)  # 1 == "not core" == what trust-gate.yml checks;
                      # a real `http_server.py` rename produces exactly this.

I confirmed independently (reading the cli/cli Go source backing gh)
that gh pr view --json files has no previousFilename/previous_filename
field anywhere in the PullRequestFile shape — this isn't a hypothetical,
it's the exact command all three workflows (trust-gate.yml,
auto-merge.yml, comment-command.yml) run.

Environment

  • vouch version: test branch @ current HEAD (post-1.3.0, pr-bot merged this week)
  • Python version: 3.11+ (workflow pins 3.12)
  • OS: n/a — this is CI-workflow / GitHub Actions logic, not the CLI
  • Host: n/a

.vouch/ state

Not applicable — this bug is in the repo's own CI trust-gate, not in a KB.

Anything else

pr_bot.py's own docstring states the intent plainly: "the review-gate
core: writes here are the north star... a PR touching any of these needs
the owner's review and is never merged by automation." The rename blind
spot breaks that guarantee for the one detection mechanism (_match)
that's supposed to enforce it, in the newest and most trust-sensitive
module in the repo.

Suggested fix: switch the file-list source from gh pr view --json files
(GraphQL-backed, no rename tracking) to the REST files endpoint
(gh api repos/{owner}/{repo}/pulls/{number}/files --paginate), which does
populate previous_filename on renamed entries, and feed both the new and
previous filename into classify/core-touched so a rename can never drop
a core path off the radar.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions