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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions