fix(semantic-pr): harden pr-title gate against head-branch tampering via pull_request_target - #69
Conversation
…via pull_request_target Under `on: pull_request`, the pr-title caller workflow is read from the PR's head branch. A contributor could edit `pr-title.yml` on their branch (loosen the guard, drop the trigger, weaken inputs) and that tampered definition would run, silently disabling the Conventional-Commits gate on its own PR. Fix (backward-compatible, three edits, one PR): - semantic-pr.yml: change the event-name guard from `== 'pull_request'` to `!= 'merge_group'` so the step runs on BOTH `pull_request` and `pull_request_target`, skipping only the merge queue. An `== 'pull_request'` guard would skip the step on `pull_request_target`, the job would pass vacuously, and the gate would be silently disabled. - pr-title.yml: switch the dogfood caller's trigger to `pull_request_target` so the gate runs the BASE-branch definition; a head-branch edit cannot bypass it. - README.md: update the documented reference caller + rationale to match. Safe: semantic-pr checks out and runs no head code — it reads PR title metadata only, so `pull_request_target` grants no code-execution surface. Backward-compatible: existing consumers keep validating after they bump the SHA and can migrate their own trigger to `pull_request_target` independently. Check job names are unchanged, so the required context stays `pr-title / pr-title`. Co-Authored-By: Claude <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 3m 23s —— View job Code Review
SummaryThe vulnerability being fixed is real and the overall approach is sound. However, there are two issues — one that will cause functional breakage in practice, and one that is a guard-precision concern. Bug: Concurrency group collapses across all open PRs under
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c595429c45
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c595429. Configure here.
Under `pull_request_target`, `github.ref` resolves to the BASE branch, so the
concurrency group `${{ github.workflow }}-${{ github.ref }}` collapsed every
open PR into a single base-keyed group. With `cancel-in-progress: true`, one
PR's pr-title run could cancel another PR's required check.
Key the group per-PR with a merge_group-safe fallback:
`${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}`.
For `pull_request_target` the PR number keys per-PR; for `merge_group` the PR
number is empty and it falls back to `github.ref` (the queue ref), which is
correct. Applied to both the pr-title.yml caller and the README reference block
so consumers copy the fixed form.
Co-Authored-By: Claude <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 56s —— View job Code Review
Concurrency fix (ec167ec) — correctThe fix in group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}Under The README reference block was updated consistently in the same PR. ✅ Open:
|
… pattern for consumers
Revert ci-workflows' own dogfood caller (pr-title.yml) to its origin/main
form (trigger `on: pull_request`, concurrency keyed by github.ref). Flipping
this repo's own caller to pull_request_target self-blocks the required
`pr-title / pr-title` check on this very PR, since the required check would run
the base-branch (pre-merge) definition that lacks the new trigger.
The backward-compatible pieces stay:
- semantic-pr.yml keeps the merge_group-tolerant guard
(`if: ${{ github.event_name != 'merge_group' }}`), which is what lets a
consumer adopt pull_request_target without the step skipping vacuously.
- README.md keeps the hardened reference caller (pull_request_target +
per-PR concurrency key + rationale) for consumers to copy.
Net effect: ci-workflows publishes the hardened pattern while its own caller
stays on pull_request for now; consumers migrate independently after bumping
the SHA.
Co-Authored-By: Claude <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 24s —— View job Code Review
What changed in 2217a6aThe latest commit refined the approach from the prior two commits. At HEAD,
This is a cleaner design than the intermediate commits. The reusable supports both callers; each consumer independently decides whether to use Remaining open issue:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2217a6a2a7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…l pattern
Address two review findings on the pr-title gate:
- semantic-pr.yml: the guard `!= 'merge_group'` was too broad. A caller that
also fires on a non-PR event (workflow_dispatch, push) would run the
action with no `github.event.pull_request`, which errors — so bumping the
SHA could turn previously-green non-PR runs red. Replace with an explicit
allowlist of the two PR-context events:
`if: ${{ github.event_name == 'pull_request' || github.event_name == 'pull_request_target' }}`.
Still skips merge_group (queue reports green) and now skips any non-PR event
green instead of erroring.
- README.md: the hardened pull_request_target example contradicted prose that
told consumers to copy the in-repo dogfood file (which stays on
pull_request) verbatim. Reconcile: the documented block is the canonical
pattern to adopt; state that pr-title.yml intentionally stays on
pull_request for now (self-flip deferred — this repo is already gated on its
own pr-title check, so a flip PR would be blocked by the base-branch
definition), and consumers should follow the block, not the dogfood file.
Co-Authored-By: Claude <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 27s —— View job Code Review
All prior findings resolved ✅The latest commit (
|
| Event | Result |
|---|---|
pull_request |
step runs, gate enforced |
pull_request_target |
step runs, gate enforced (base-branch definition, tamper-resistant) |
merge_group |
step skipped, job succeeds → required check reports for the queue |
workflow_dispatch, push, schedule, etc. |
step skipped, job succeeds → no opaque action failure on missing PR context |
The ignore-labels input documentation mentions adding labeled/unlabeled to the caller's triggers — those are activity types on pull_request/pull_request_target, so github.event_name stays pull_request or pull_request_target for them. The allowlist handles that correctly.
The comment block above the step (lines 77–90) is accurate, thorough, and explains the reasoning for all three non-obvious constraints. ✅
README.md — canonical block is correct
The consumer block now documents:
pull_request_targettrigger (tamper-resistant, base-branch definition)- Per-PR concurrency group:
github.event.pull_request.number || github.ref(unique per PR underpull_request_target; falls back to the queue ref undermerge_group) pull-requests: readonly — narrowest possible grant
The prose following the block explains the bootstrap constraint (why the in-repo dogfood caller deliberately stays on pull_request) and directly addresses the Codex P2 concern about pointing consumers at the stale caller. The README now explicitly says "Adopt the canonical block below (not the in-repo .github/workflows/pr-title.yml...)". ✅
pr-title.yml — unchanged from main, intentionally
The dogfood caller is back to pull_request with ${{ github.workflow }}-${{ github.ref }} concurrency. Under pull_request, github.ref is refs/pull/{number}/merge — unique per PR — so the concurrency group is correct for both the PR and merge_group paths. The bootstrap rationale (flipping to pull_request_target while the required check reads the still-pull_request base definition would deadlock the self-flip) is documented in the README. ✅
Resolution summary
| Finding | Status |
|---|---|
HIGH: Concurrency group collapses across PRs under pull_request_target |
✅ Fixed (ec167ec), then cleanly resolved by reverting dogfood to pull_request (2217a6a) — no regression |
MEDIUM: != 'merge_group' guard passes for unexpected triggers |
✅ Fixed in 6dd19d7 — explicit allowlist |
| Codex P2: README pointing consumers at stale dogfood caller | ✅ Addressed in 2217a6a/6dd19d7 — README now directs consumers to canonical block and explains dogfood divergence |
Security assessment — CLAUDE.md alignment
CLAUDE.md's pull_request_target prohibition is explicitly scoped to the claude-review reusable workflow (AI agent + org credential). semantic-pr is categorically different: no checkout of head code, no secrets, permissions: pull-requests: read at both workflow and job level, and amannn/action-semantic-pull-request (SHA-pinned at 48f256284b) reads only the PR title from the event payload. The classic attack vector does not apply. ✅
Ready to merge
No open findings. The root vulnerability is correctly closed, prior findings are fully resolved, the implementation is backward-compatible, and the in-code comments and README documentation are accurate and load-bearing.
There was a problem hiding this comment.
Risk: medium. Cursor Bugbot passed on the latest sync (6dd19d7); all prior review threads are resolved and no new Bugbot findings were reported. Approved; no reviewers assigned.
Sent by Cursor Approval Agent: Pull Request Router and Approver



The tampering vector
The
pr-titlecaller (pr-title.yml) triggered onon: pull_request, which reads the workflow definition from the PR's head branch. A contributor could editpr-title.ymlon their own branch — loosen the guard, drop the trigger, or weaken the inputs — and that tampered definition would run on their PR, silently disabling the Conventional-Commits title gate on the very PR meant to be gated.The fix (three edits, one PR, backward-compatible)
semantic-pr.yml— change the reusable's event-name guard from== 'pull_request'to!= 'merge_group'. The step now runs on bothpull_requestandpull_request_target, skipping only the merge queue. This is load-bearing: an== 'pull_request'guard would skip the step underpull_request_target, the job would pass vacuously, and the gate would be silently disabled.pr-title.yml— switch the dogfood caller's trigger topull_request_target, so the gate runs the base-branch definition. A head-branch edit to this file can no longer bypass the gate on its own PR.README.md— update the documented reference caller block + prose rationale to match.Why it's safe
semantic-prperforms no checkout and runs no head code — it reads PR title metadata only.pull_request_targetgrants it no code-execution surface over untrusted head content, so the usualpull_request_targetrisk does not apply here.Backward-compatible
Existing consumers keep validating after they bump the pinned SHA, and can migrate their own caller trigger to
pull_request_targetindependently on their own schedule. Check job names are unchanged, so the required context stayspr-title / pr-title— no ruleset changes needed.🤖 Generated with Claude Code
https://claude.ai/code/session_01LzW7mBrnnXK3f1amgXzf1n
Closes #68
Note
Low Risk
Workflow guard and documentation only; semantic-pr still validates PR title metadata with no checkout of head code.
Overview
Hardens the reusable
semantic-prgate so callers can run onpull_request_targetwithout the validation step being skipped and the check passing vacuously.The reusable workflow’s title-validation step now runs when
github.event_nameispull_requestorpull_request_target, still skipping only merge-queue runs where the title was already checked. Inline comments document why an explicit allowlist is required (not a bare!= 'merge_group') so non-PR triggers do not error.README updates the canonical consumer pattern: adopt
pull_request_target(base-branch workflow definition), add a concurrency group keyed on PR number (withgithub.reffallback formerge_group), and explain why the in-repo dogfoodpr-title.ymlintentionally remains onpull_requestuntil a separate self-flip.Reviewed by Cursor Bugbot for commit 6dd19d7. Bugbot is set up for automated code reviews on this repo. Configure here.