Skip to content

feat(review-pr): authorize review_requested via the requester for external contributors#16

Merged
Sayt-0 merged 3 commits into
mainfrom
feat/review-requested-external-contributors
Jun 25, 2026
Merged

feat(review-pr): authorize review_requested via the requester for external contributors#16
Sayt-0 merged 3 commits into
mainfrom
feat/review-requested-external-contributors

Conversation

@Sayt-0

@Sayt-0 Sayt-0 commented Jun 24, 2026

Copy link
Copy Markdown
Member

Summary

Adds the second authorization path the issue describes, so the review workflow handles both triggers feeding the one review pipeline.

Path Trigger Authorized on
Auto-run (existing) pull_request opened/ready/synchronize PR author org membership (org members only)
Review-requested (new) docker-agent requested as reviewer Requester org membership (a maintainer pulls an external contributor PR into review)

Routing already converged both onto the single review job; the missing piece was the authorization, now handled in check-org-membership.

Issue expectations mapped

Expectation Status Where
Auto-run for org member PRs (existing behavior) kept evaluateMembership author path
Review-requested trigger for external contributors added evaluateMembership requester path
Both feed the same review pipeline yes (pre-existing routing) review-pr.yml review job (if lines 161-164)

Security

The requester is never trusted from the trigger artifact (a fork PR controls its own trigger workflow and could otherwise forge a member login):

  • Direct same-repo path: requester is github.event.sender.login (populated by GitHub).
  • Fork / workflow_run path: requester is re-derived server-side from the PR timeline (listEventsForTimeline) using the repo-scoped token.

Safeguards:

  • The timeline lookup is bound to the same pr_number that drives the checkout, so forging pr_number only redirects to a real PR's own code, never attacker-controlled code.
  • The derived requester is re-validated against real org membership.
  • The timeline call runs only when the PR author is not already a member, so member-authored PRs incur no extra API call.

Side fix

Same-repo direct pull_request auto-run previously resolved an empty username (COMMENT_AUTHOR is empty on a pull_request event), so it now resolves the PR author.

Validation

Check Result
typecheck pass
build (tsup) pass
unit tests 484 pass (+15 new)
biome ci pass
actionlint (CI mode) pass

Rollout note

The self-review workflow runs check-org-membership from the pinned released action (DOCKER_AGENT_ACTION_ROOT) and dist/ is gitignored, so this takes effect on the next release plus setup-credentials pin bump. No trigger-workflow or consumer template change is required, because the requester is derived from the timeline rather than the artifact.

…ernal contributors

Add a second authorization path so a maintainer can pull an external
(non-org-member) contributor's PR into the review pipeline by requesting
docker-agent as a reviewer, while auto-run stays limited to org-member PRs.

check-org-membership now decides via evaluateMembership:
- auto-run (pull_request opened/ready/synchronize): authorize the PR author
- review_requested: authorize the requester (a maintainer), not the author

The requester is only taken from trusted sources, never from the fork-written
trigger artifact: github.event.sender.login on the direct same-repo path, or
re-derived from the PR timeline (listEventsForTimeline) on the fork/workflow_run
path. The timeline lookup is bound to the same pr_number that drives the
checkout and the result is re-validated against real org membership, so a forged
requester cannot escalate onto attacker-controlled code. The timeline call runs
only when the author is not already a member.

This also fixes a latent issue where same-repo direct pull_request auto-run
resolved an empty username (it now resolves the PR author).

review-pr.yml passes EVENT_NAME, EVENT_ACTION and REQUESTER to the membership
step. Docs (review-pr README, onboarding skill, AGENTS.md) updated.
@Sayt-0 Sayt-0 force-pushed the feat/review-requested-external-contributors branch from 688b3dd to 6ef9831 Compare June 24, 2026 20:54
@Sayt-0 Sayt-0 enabled auto-merge (squash) June 24, 2026 20:57
@Sayt-0 Sayt-0 requested a review from derekmisler June 24, 2026 20:58

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟡 NEEDS ATTENTION

One LIKELY finding in the new / resolveTrustedRequester authorization logic. The core security model is sound — the workflow routing enforces the fork/same-repo separation — but the TypeScript code relies on that workflow-layer invariant without any in-code assertion, creating a fragile defense-in-depth posture.

): Promise<string> {
const { prSource, eventName, eventAction, trustedRequester } = inputs;
if (prSource === 'event' && eventName === 'pull_request' && eventAction === 'review_requested') {
return trustedRequester;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LIKELY / MEDIUM] resolveTrustedRequester trusts REQUESTER env var without an in-code same-repo guard

On the prSource === 'event' + eventName === 'pull_request' + eventAction === 'review_requested' branch, the function returns inputs.trustedRequester — which comes directly from process.env.REQUESTER (set to github.event.sender.login in the workflow). There is no in-code assertion that this event path is exclusive to same-repo (non-fork) triggers; the trust guarantee is enforced only at the workflow layer (source=trigger is set for fork workflow_run paths, source=event for direct events).

This is safe as long as the workflow routing invariant holds. However, the TypeScript code itself has no internal check, so any future misconfiguration of the workflow (e.g., a consumer that passes PR_SOURCE=event on a fork path) would silently trust an attacker-controlled REQUESTER value and could grant review authorization to a non-member.

Suggested hardening: add an explicit guard in resolveTrustedRequester to make the safety assumption explicit and self-documenting — e.g., throw (or return '') if trustedRequester is empty, or add a comment tying the invariant to the workflow routing. A stronger fix would be to always use resolveReviewRequester (the timeline-based, server-side method) even on the prSource==='event' path, removing any reliance on the env-var value entirely.

…variant

The trusted requester (REQUESTER = github.event.sender.login) is only
authoritative on a direct same-repo pull_request:review_requested event;
the fork/workflow_run path is resolved server-side from the PR timeline.
Make that boundary explicit in code and add a regression test asserting the
env-supplied requester is ignored on any other path.

@derekmisler derekmisler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one real security issue to fix before merging: resolveReviewRequester doesn't track review_request_removed events, so a retracted review request can still authorize a fork PR. fix is small (see inline). one minor suggestion about denial logging subject. the overall approach (trusted triple gate, timeline re-derivation on fork path, fail-closed on API errors) is solid.

Comment thread src/check-org-membership/index.ts Outdated

let requester = '';
for (const ev of events) {
if (ev.event === 'review_requested' && ev.requested_reviewer?.login === reviewerLogin) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: resolveReviewRequester only accumulates review_requested events but never checks for a later review_request_removed event on the same reviewer. if a maintainer requests docker-agent then removes that request, requester still holds the stale login and path 2 in evaluateMembership will grant authorization anyway, on the fork/workflow_run path especially, that's a real access control bypass.

fix: handle both event types in the same pass (timeline is ascending-chronological, so this handles re-request-after-removal correctly too):

let requester = '';
for (const ev of events) {
  if (ev.event === 'review_requested' && ev.requested_reviewer?.login === reviewerLogin) {
    requester = ev.review_requester?.login ?? ev.actor?.login ?? '';
  } else if (ev.event === 'review_request_removed' && ev.requested_reviewer?.login === reviewerLogin) {
    requester = ''; // revoked
  }
}

also worth adding review_request_removed as a possible event value in TimelineReviewRequestedEvent so future contributors see this case at the type level.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6949610.

resolveReviewRequester now replays both event types for the reviewer in one pass: review_requested sets the requester, review_request_removed clears it, so a retracted request no longer authorizes the PR. review_request_removed is added to the (renamed) TimelineReviewEvent type.

One addition beyond the suggested fix: the replay now sorts by created_at rather than relying on the timeline's array order. That order is ascending in practice but not contractually guaranteed, and an out-of-order request/removal pair would otherwise leave a retraction stale, which the org-membership re-check cannot catch because the login is a real member.

New tests:

  • removal clears a prior request
  • re-request after removal wins
  • removal targeting a different reviewer is ignored
  • out-of-order replay still lets the retraction win
  • fork/trigger integration: a retracted request denies an external PR

Comment thread src/check-org-membership/index.ts Outdated
return { isMember: true, subject: requester, via: 'requester' };
}

return { isMember: false, subject: author, via: 'none' };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): when path 2 fires and the requester check fails, this always returns subject: author. but the party that actually failed the membership check was requester, not author (who correctly failed path 1 first). the operator log says ⏭️ Not authorized to review (subject: @ext-author) which points at the wrong person.

return { isMember: false, subject: requester || author, via: 'none' };

small change, but audit logs for auth failures should identify who actually failed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 6949610.

evaluateMembership now returns subject: requester || author on denial, so an authorization failure on path 2 names the requester that actually failed rather than the author who already failed path 1.

Tests updated to assert the corrected subject:

  • direct review_requested denial reports the requester (outsider)
  • fork/trigger denial reports the timeline-derived requester (not-a-member)

Sayt-0 added a commit that referenced this pull request Jun 25, 2026
- CONTRIBUTING.md: org-member PRs are not auto-reviewed on open or
  ready-for-review. The self-review trigger fires only on
  review_requested, so org members also request a review explicitly;
  the only difference from external contributors is approving the
  workflow run.
- review-pr/README.md: add a visible note that the requester-authorized
  path depends on the check-org-membership update from PR #16; without
  it, membership is checked against the PR author and the review is
  silently skipped.
- review-pr/README.md: caveat the check run with "if checks: write is
  granted" to match the rest of the doc (the Create check run step uses
  continue-on-error).
…orized

resolveReviewRequester replayed only review_requested timeline events, so a
review request a maintainer later removed left a stale requester that still
authorized a fork/workflow_run PR (access control bypass raised in review).

Replay review_requested and review_request_removed for the reviewer in
chronological order, sorting by created_at instead of relying on the timeline
endpoint's non-guaranteed ordering, so the latest still-effective state wins.
On denial, evaluateMembership now reports the requester that failed path 2
rather than the PR author.

@derekmisler derekmisler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix is correct. the review_request_removed handling is clean, the created_at sort to avoid relying on undocumented array ordering is a nice proactive addition, and the denial subject now points at the right party. good work addressing this quickly.

@Sayt-0 Sayt-0 merged commit 6af5898 into main Jun 25, 2026
10 checks passed
@Sayt-0 Sayt-0 deleted the feat/review-requested-external-contributors branch June 25, 2026 16:22
Sayt-0 added a commit that referenced this pull request Jun 25, 2026
> [!IMPORTANT]
> **Merge #16 first.** This PR documents the requester-authorized review
flow for external and fork contributor PRs, which only works once #16
(`feat(review-pr): authorize review_requested via the requester for
external contributors`) lands. Merging this ahead of #16 would describe
a flow that does not yet function and would briefly contradict the
on-main defense-in-depth text that #16 updates.

## Summary

Documents the UX for org members handling external and fork contributor
PRs across three docs. The flow, with no special commands or workflow
inputs:

1. An org member approves the workflow run (GitHub gates Actions on PRs
from first-time and external contributors).
2. An org member requests a review from `docker-agent` via the native
review-request UI (PR sidebar, Reviewers).

| File | Change |
| --- | --- |
| `review-pr/README.md` | New `External and fork contributor PRs`
section with the full two-step flow |
| `CONTRIBUTING.md` | New `Automated PR Review` section (org members vs
external and fork contributors) |
| `README.md` | One-line pointer and link in the PR Review Workflow
section |

## Alignment with open PRs

| PR | Relationship |
| --- | --- |
| #16 | Source of the documented behavior. The review is authorized by
the requesting org member, not the PR author. This PR's wording matches
that model. |
| #13 | Compatible. The "only triage or write access can request a
reviewer" phrasing matches #13's GitHub-native enforcement framing. |

## Conflict avoidance

Changes are additive. The new section in `review-pr/README.md` is
inserted before `Customizing`, outside the regions #16 and #13 edit (the
"What you get" trigger table and the defense-in-depth blockquote), so it
merges cleanly against both.

## Validation

| Check | Result |
| --- | --- |
| Merges cleanly against #16 and #13 (additive, non-overlapping regions)
| yes |
| Em-dash, double-hyphen, or emoji in added prose | none |
| Cross-file anchor links resolve (`#external-and-fork-contributor-prs`)
| yes |
| Markdown lists, links, and backticks valid | yes |
Sayt-0 added a commit that referenced this pull request Jun 25, 2026
Reconcile the abuse safeguards with the requester-based authorization
rework that landed on main (PRs #16, #17, #18).

check-org-membership: take main's evaluateMembership model. It subsumes
this branch's resolveUsername helper (the directly wired pull_request
auto-review path now verifies the PR author via a live API lookup) and
adds the review_requested requester path so an org member can pull an
external contributor's PR into review.

review-pr.yml: provide EVENT_NAME, EVENT_ACTION and REQUESTER to the
membership step; drop the PR_AUTHOR env that main's code never reads.

tsup.config.ts: keep both the rate-limit (this branch) and
score-confidence (main) entry points.

review-pr/README.md: keep main's requester-model wording and preserve
this branch's audit, throttle and force-push safeguards.

SECURITY.md: update section 1 to the requester-based model and remove
the stale resolveUsername and PR_AUTHOR references.
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.

3 participants