Skip to content

feat(review-pr): add rate-anomaly safeguard for review requests#13

Merged
Sayt-0 merged 11 commits into
mainfrom
feat/review-request-abuse-safeguards
Jun 26, 2026
Merged

feat(review-pr): add rate-anomaly safeguard for review requests#13
Sayt-0 merged 11 commits into
mainfrom
feat/review-request-abuse-safeguards

Conversation

@Sayt-0

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

Copy link
Copy Markdown
Member

Summary

Adds a rate-anomaly safeguard against abuse of review requests, layered on the
existing per-PR cache lock.

  • src/rate-limit: counts docker-agent review/reply comments posted on a PR
    within a sliding window and, past a threshold, flags a rate anomaly. The
    workflow then skips the expensive review. Fails open, so an API error never
    blocks a legitimate review.
  • Per-PR concurrency: groups on review-pr.yml and self-review-pr-trigger.yml
    collapse same-trigger bursts; the trigger cancels superseded context-capture
    runs so only the latest fans out to a review.

Scope after review

Following review feedback, the PR was narrowed:

  • audit-log removed, deferred to a follow-up. GitHub Actions artifacts are a
    weak audit store (configurable/deletable retention, no query/export); the
    storage backend should be designed separately.
  • check-staleness removed. Reviews already check out refs/pull/N/head
    (current head), so a commit pushed after the request is already included; the
    force-push case is therefore covered. Incremental review (only commits since
    the last review) is the real future want and is tracked separately.
  • check-org-membership unchanged by this PR. The merge of main superseded
    the earlier author-verification commit with the evaluateMembership rewrite
    from feat(review-pr): authorize review_requested via the requester for external contributors #16/docs: explain org-member review flow for external contributor PRs #18, so the merged diff makes no change to that module.

Validation

Check Result
build (tsup) pass
typecheck (tsc) pass
biome ci pass (89 files)
actionlint pass
unit tests 597 pass

Follow-ups (out of scope here)

  • Design a queryable audit-log storage backend.
  • Incremental review: review only commits pushed since the last review.
  • Retire the deprecated /review comment branch.
  • Sanitize @mention comments (like the diff) instead of gating on org membership.

Harden the comment and event triggered PR review pipeline against abuse on
every trigger path (review_requested, /review, @docker-agent mention, automatic
opened/synchronize, and the workflow_run fork path).

- Org membership: verify the PR author on the direct pull_request auto-review
  path (previously an empty comment author was checked) via a new resolveUsername
  helper with a live PR-author lookup fallback; fail closed when no user resolves.
- Audit log: new src/audit-log module emits one structured record per request
  (requester, time, trigger, PR, reviewed SHA, allow/deny/throttle decision) as a
  notice, a job-summary line, and a 90 day JSONL artifact, on allow and deny
  paths, with an inline fallback when setup-credentials is unavailable.
- Rate anomaly: new src/rate-limit module skips the review when too many agent
  review/reply comments land on a PR within a window; per-PR concurrency groups
  collapse same-trigger bursts; the existing cache lock still prevents concurrent
  reviews.
- Force push: new src/check-staleness module compares the requested SHA against
  current head, records the actually reviewed SHA, and posts a notice on rebase.

Documented in SECURITY.md and review-pr/README.md. Unit tests added for all new
modules.
Comment thread .github/workflows/review-pr.yml Fixed
CodeQL (actions/untrusted-checkout-toctou/critical) flagged the new
"Audit review request" run-step because it executed after "Checkout PR head"
checks out untrusted PR code in an issue_comment-privileged workflow. The audit
decision is fully determined before checkout (membership, rate, staleness), so
move the audit and audit-upload steps ahead of the checkout. No post-checkout
run-step is introduced by this change; behavior and recorded fields are
unchanged.
@Sayt-0 Sayt-0 requested a review from derekmisler June 24, 2026 12:10

@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: 🟢 APPROVE

The PR introduces three new modules (src/audit-log, src/rate-limit, src/check-staleness) and wires them into the review workflow with a clear security-first design. The changes are well-structured and the logic holds up under scrutiny.

What was reviewed:

  • src/audit-log/index.ts — JSONL artifact, job summary, audit record emission: no data-loss paths in the CLI call chain
  • src/rate-limit/index.ts — comment counting with since/updated_at vs. created_at: the explicit client-side created_at filter correctly compensates for GitHub's updated_at-based since parameter; no under-count scenario is possible since updated_at ≥ created_at always holds on GitHub
  • src/check-staleness/index.ts — SHA comparison and stale-post logic: correct
  • src/check-org-membership/index.tsresolveUsername fallback on non-event paths: the PR-author fallback on trigger/input paths is intentional design; both paths are gated by trusted callers who control the PR number, so there is no bypass
  • .github/workflows/review-pr.yml — rate check ordering, concurrency groups, needs: dependencies: correctly ordered; rate check precedes check creation as documented
  • The fail-open behavior for the rate-limit step and the audit decision logging are consistent with the documented design

No confirmed or likely bugs were found in the changed code.

@Sayt-0 Sayt-0 enabled auto-merge (squash) June 24, 2026 20:59
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 |
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.

@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.

i'm sorry, i forgot to hit "submit" yesterday. some of these comments may be out-of-date now, though

Comment on lines +83 to +95
# Rate-anomaly safeguard: serialize same-trigger bursts on a PR (e.g. rapid
# force-pushes firing repeated auto-reviews) instead of running N in parallel.
# The group is keyed per PR AND per trigger intent: the comment id (or event
# name) suffix keeps distinct comments/replies in distinct groups, so a quick
# conversational reply is never queued behind a 45-minute review. cancel-in-
# progress is false so an in-flight review/reply is never killed mid-post.
# Per-PR request *frequency* is enforced by the rate-limit check below, and the
# in-action cache lock (review-pr/action.yml) prevents concurrent reviews; the
# workflow_run/fork path (PR number only in the artifact) falls back to a per-run
# group, where those two mechanisms still bound abuse.
concurrency:
group: pr-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr-number || github.run_id }}-${{ github.event.comment.id || github.event_name }}
cancel-in-progress: false

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.

This actually already exists, but doesn't use GH's concurrency, and only 1 review-per-PR can run within a window of time: https://github.com/docker/docker-agent-action/blob/main/review-pr/action.yml#L142-L146

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.

Correct that a per-PR collapse already exists via the cache lock. The new concurrency: group is additive rather than equivalent: it is keyed per (PR + trigger intent), queues instead of skipping, applies before a runner spins up, and has no race window (the lock has a documented one). The trigger workflow also uses cancel-in-progress to drop superseded context-capture runs. If relying on the lock plus the rate-limit check is preferred, the group can be dropped; it is left in place for now as a cheap, race-free pre-resource layer.

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.

makes sense, thanks for the explanation!

Comment thread src/check-staleness/index.ts Outdated
Comment on lines +5 to +6
* check-staleness — detect when a PR was force-pushed / rebased between the
* moment a review was requested and the moment it actually runs.

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.

i'm not totally sure if this is an actual problem, because i think we want the reviewer to review the whole PR. realistically, this change would lead to this user experience:

  • user requests review
  • review starts
  • user pushes new commit
  • review runs against everything before that commit
  • reviewer posts stale review
  • user re-requests review to include the last commit

so we're kind of forcing a user to run extra reviews. i think it's fine if the user pushed a commit after triggering a review, and the reviewer includes it in the reviewed diff

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.

eventually, what's more important, is that we're running reviews only against commits that were pushed after the last review, so we're no re-reviewing the same diff over and over again.

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.

Removed in 7f5e3c3. Reviews check out refs/pull/N/head, so a commit pushed after the request is already part of the reviewed diff; the stale-pre-push concern did not apply, and the module only emitted a notice plus one audit field. The incremental-review goal (review only commits pushed since the last review) is separate work and is tracked as a follow-up.

Comment thread src/rate-limit/index.ts
* rate-limit — detect (and let the workflow prevent) abnormally frequent review
* activity on a single pull request.
*
* The existing per-PR cache lock (review-pr/action.yml) only stops *concurrent*

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.

that's not totally true, there's a window of time where concurrent reviews are prevented. we can increase that window of time easily enough (people found it annoying, though).

Comment thread src/audit-log/index.ts Outdated

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.

this is an interesting idea! i don't think storing them in GH is the right approach, though (i can't remember if artifacts have 90-day or 7-day time limits). this feels like something we'd want to store somewhere we can easily query. we should exclude this idea from this PR and talk about it more in depth for a later PR.

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.

Removed from this PR in 7f5e3c3 and deferred to a follow-up, so the storage backend (a queryable store rather than Actions artifacts) can be designed separately. The src/audit-log module, its two workflow steps, the tsup entry, and the SECURITY.md / README sections are gone. Nothing in the merge-gating path depended on it.

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.

we should talk through this one some more, because i think i'm missing something. we just want to allow docker org members to request reviews from docker-agent, regardless of the PR author, right? so we don't actually have to check docker org membership status of the PR author (probably ever; we might be able to remove the current implementation where we prevent reviews completely if the PR author is not a member of the docker org).

auto-reviews already can't run unless the author isn't a member of the docker org (i think security already prevents all CI checks, including this one, from running for outside contributors).

also, since /review is deprecated, i don't think we need to support that specific comment flow anymore. what i think is more important is sanitizing comments like we do with the diff, so don't have to check org membership status at all and the agent can reply to anyone

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.

This PR makes no change to src/check-org-membership: the merge of main superseded the earlier author-verification commit with the evaluateMembership rewrite from #16 / #18, so the merged diff does not touch this file. The points raised are good follow-ups, tracked separately: retire the deprecated /review branch, and sanitize @mention comments instead of gating replies on org membership. One nuance for that discussion: on the automatic pull_request path the PR-author org check is currently the sole gate (the native "only triage/write can request a reviewer" control only covers the review_requested path), so dropping it warrants confirming the auto-review threat model first.

Per review feedback, defer the audit log to a follow-up (GitHub artifacts are a
weak audit store; the storage backend should be designed separately) and remove
the force-push staleness check (reviews already run against refs/pull/N/head, so
the latest commit is included; incremental review is the real future want and is
tracked separately). Keeps the rate-anomaly safeguard (rate-limit module +
per-PR concurrency groups); no merge-gating step depended on the removed modules.
@Sayt-0 Sayt-0 changed the title feat(review-pr): add abuse safeguards for review requests feat(review-pr): add rate-anomaly safeguard for review requests Jun 26, 2026
@Sayt-0

Sayt-0 commented Jun 26, 2026

Copy link
Copy Markdown
Member Author

Follow-up on the review. Scope narrowed and the branch brought up to date with main (clean merge, no conflicts).

Changes in response to the review:

@Sayt-0 Sayt-0 requested a review from derekmisler June 26, 2026 11:59

@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

The rate-anomaly safeguard is well-structured overall: the sliding-window logic is correct, the fail-open behavior is properly realized, and the workflow correctly consumes the anomalous output. Two issues found in the new code:

  1. [MEDIUM] src/rate-limit/index.ts — Full PR Reviews (/pulls/{pr}/reviews endpoint) are not counted; only issue comments and inline diff-thread comments are queried. Since the bot posts reviews via the Reviews API, the rate counter stays at 0 regardless of review frequency.
  2. [LOW] .github/workflows/review-pr.yml — The rate check is gated on is_review != 'false', leaving the reply-to-mention job (a separate job) with no throttle. May be intentional scope deferral, but worth an explicit comment if so.

Comment thread src/rate-limit/index.ts Outdated
since,
per_page: 100,
});
const reviewComments: CommentLike[] = await octokit.paginate(

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.

[MEDIUM] detectRateAnomaly() skips /pulls/{pr}/reviews — full PR reviews are not counted toward the rate limit

The new detectRateAnomaly() function queries two endpoints:

  • issues.listComments — issue-level (top-level PR) comments
  • pulls.listReviewComments — inline diff-thread comments

It does not query pulls.listReviews (/pulls/{pr}/reviews), which is the endpoint for full PR Review objects — the kind that carry the <!-- docker-agent-review --> marker in their body field. This is how the docker-agent-action bot posts its code reviews (a single Review object with inline comments attached).

As a result, every full review the bot posts goes uncounted. An org member can request 20 reviews in a 10-minute window, the rate counter stays at 0, and the anomaly threshold is never reached.

Fix: add a third paginated fetch for PR Reviews and merge the results before counting:

const prReviews: CommentLike[] = await octokit.paginate(octokit.rest.pulls.listReviews, {
  owner: opts.owner,
  repo: opts.repo,
  pull_number: opts.prNumber,
  per_page: 100,
});

const count = [...issueComments, ...reviewComments, ...prReviews].filter((c) =>
  isAgentReviewComment(c, opts.botLogin, windowStartMs),
).length;

Note: Octokit's CommentLike interface already fits the shape of Review objects (they have user, body, and submitted_at). You may need to remap submitted_at → created_at or extend CommentLike to accept either field.

# a burst above the threshold is flagged and the expensive review is skipped.
# Runs BEFORE "Create check run" so a throttled request creates no check run
# (a skipped review must not surface as a green "PR Review" check).
- name: Check rate anomaly

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.

[LOW] Check rate anomaly is scoped to review requests only — @docker-agent mention floods are unthrottled

The Check rate anomaly step has:

if: |
  steps.membership.outputs.is_member == 'true' &&
  steps.command.outputs.is_review != 'false' &&
  steps.draft.outputs.skip != 'true'

When an @docker-agent mention arrives (not a /review command), steps.command.outputs.is_review is 'false', so the rate check is skipped. The reply-to-mention job (line ~785) is a separate job with no rate-limit integration at all.

This means an org member can flood @docker-agent mentions on a PR and drive up LLM inference costs with no throttle. The PR title scopes itself to "review requests", so this may be an intentional deferral — if so, a comment in the workflow noting the known gap would be helpful. If unintentional, the reply-to-mention job should also consult dist/rate-limit.js with its own window/threshold (likely a looser one, since mention replies are cheaper than full reviews).

The rate-anomaly safeguard only queried issues.listComments and
pulls.listReviewComments, but the bot posts every review through the
Reviews API with no inline marker on the body. Zero-finding APPROVEs and
the timeout/error/LGTM fallbacks therefore counted as 0, and a findings
review counted as N (one per inline comment) instead of 1, so the counter
measured the wrong unit.

Count one unit per LLM run instead:

- Reviews are counted from pulls.listReviews by bot author with a
  non-empty body. The non-empty-body check skips the empty-body review
  entries GitHub creates for standalone inline comments and replies, so a
  review run is counted exactly once and an inline reply is not
  double-counted.
- Replies are counted from the comment endpoints on the -reply markers
  only; the -review finding markers are dropped because that review is
  already counted via the Reviews API.
- Bot author matching now accepts both "docker-agent" and
  "docker-agent[bot]".

Document the deliberate scope of the gate: it guards the review run only.
The conversational reply jobs (reply-to-feedback, reply-to-mention) stay
unthrottled (org-gated, per-PR serialized, and trigger-bound to a human
comment), though their replies still feed the window count. Update
SECURITY.md and the reusable-workflow README to match.
@Sayt-0 Sayt-0 requested a review from docker-agent June 26, 2026 13:45

@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: 🟢 APPROVE

The rate-anomaly safeguard is well-designed and correctly implements a fail-open safety model. The core sliding-window logic in detectRateAnomaly is sound, the bot-identity matching covers both docker-agent and docker-agent[bot] token variants, pagination is used on all three API endpoints, and the <!-- docker-agent-review --> marker is correctly excluded from REPLY_MARKERS to avoid double-counting inline finding comments as replies.

Fail-open correctness confirmed: All error paths — missing token, invalid repo slug, and uncaught exceptions — call core.setOutput('anomalous', 'false') before returning, ensuring API failures never block a legitimate review. The workflow's steps.rate.outputs.anomalous != 'true' gate is correctly a negative check so an empty/unset output also fails open.

Minor observation (no action required): The two early-return guards in main() (lines ~195–205 of src/rate-limit/index.ts) set anomalous and count but omit window and threshold outputs. The workflow only reads anomalous, so there is no functional impact — these outputs would just appear empty in the Actions log on those rarely-hit paths. Worth a follow-up if richer diagnostics are desired.

Concurrency groups: The per-PR concurrency group on review-pr.yml correctly serialises same-PR bursts. The acknowledged github.run_id fallback for the workflow_run/fork path is an acceptable known limitation.

No bugs introduced by this PR. Ship it.

@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.

solid feature overall. the fail-open design, marker-based counting, and the distinction between review runs and reply runs are all the right calls. two real issues in the exported detectRateAnomaly (missing input guards that main() handles but the library export doesn't) plus a handful of non-blocking suggestions, mostly around the listReviews pagination and test coverage for main().

Comment thread src/rate-limit/index.ts Outdated
// `since` filters the comment endpoints server-side by updated_at; the
// per-comment created_at check below enforces the precise window. paginate()
// handles busy PRs.
const issueComments: CommentLike[] = await octokit.paginate(octokit.rest.issues.listComments, {

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): these three octokit.paginate calls are awaited sequentially, so on a busy PR you're waiting for each one to finish before starting the next. since they're independent, Promise.all gets them running in parallel and cuts wall-clock time by ~2/3:

const [issueComments, reviewComments, reviews] = await Promise.all([
  octokit.paginate(octokit.rest.issues.listComments, { owner, repo, issue_number: prNumber, since, per_page: 100 }),
  octokit.paginate(octokit.rest.pulls.listReviewComments, { owner, repo, pull_number: prNumber, since, per_page: 100 }),
  octokit.paginate(octokit.rest.pulls.listReviews, { owner, repo, pull_number: prNumber, per_page: 100 }),
]);

not blocking given continue-on-error: true, but worth picking up.

Comment thread src/rate-limit/index.ts Outdated
// submitted_at below. This is the only endpoint where a review run is
// observable: the bot posts every review (findings, zero-finding APPROVE, and
// the timeout/error/LGTM fallbacks) here with no inline marker on the body.
const reviews: ReviewLike[] = await octokit.paginate(octokit.rest.pulls.listReviews, {

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): listComments and listReviewComments accept since so they're server-side filtered to the window. listReviews has no since parameter (you noted this in the comment), so octokit.paginate fetches every review ever posted on the PR. on a long-lived, heavily-reviewed PR that could be hundreds of pages of API calls, which risks exhausting the job timer and falling through to the fail-open path, i.e. the rate safeguard silently disappears on the exact PRs most likely to need it.

a cleaner approach: use octokit.paginate.iterator and break once you've passed the window boundary. GitHub returns reviews oldest-first, so once you see items older than windowStartMs you can stop (or just cap at a reasonable max like 500 reviews before bailing out):

const reviews: ReviewLike[] = [];
for await (const page of octokit.paginate.iterator(octokit.rest.pulls.listReviews, {
  owner: opts.owner, repo: opts.repo, pull_number: opts.prNumber, per_page: 100,
})) {
  reviews.push(...page.data);
  // all items on this page are older than the window: no point fetching more
  if (page.data.every(r => !r.submitted_at || Date.parse(r.submitted_at) < windowStartMs)) break;
}

(double-check the sort order in the GitHub docs before assuming direction, but it is oldest-first)

Comment thread src/rate-limit/index.ts
): Promise<RateAnomalyResult> {
const octokit = new Octokit({ auth: token });
const now = opts.nowMs ?? Date.now();
const windowStartMs = now - opts.windowSeconds * 1000;

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: detectRateAnomaly is an exported function, so it can be called as a library without going through main(). the CLI path is safe because parsePositiveInt rejects NaN, 0, and negatives. but the exported function has no guard, so if opts.windowSeconds is NaN, Infinity, or a negative number:

windowStartMs = now - NaN * 1000  // NaN
new Date(NaN).toISOString()       // throws: RangeError: Invalid time value

that propagates to the caller uncaught. a simple guard at the top of detectRateAnomaly covers it:

if (!Number.isFinite(opts.windowSeconds) || opts.windowSeconds <= 0) {
  throw new RangeError(`windowSeconds must be a positive finite number, got ${opts.windowSeconds}`);
}
if (opts.threshold <= 0) {
  throw new RangeError(`threshold must be a positive integer, got ${opts.threshold}`);
}

the threshold <= 0 case is also worth guarding here (see the comment on the anomalous line below): count >= 0 is always true, so threshold: 0 permanently throttles everything.

Comment thread src/rate-limit/index.ts

return {
count,
anomalous: count >= opts.threshold,

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: count >= opts.threshold, when threshold is 0, 0 >= 0 is true, so every call is flagged anomalous and all reviews are blocked. main() protects against this via parsePositiveInt (which rejects 0 and falls back to 8), but any direct caller of detectRateAnomaly with threshold: 0 (misconfiguration, bad test setup, etc.) will silently permanently throttle the bot.

the guard I mentioned on line 127 covers this too, so it's one small addition in one place.

Comment thread src/rate-limit/index.ts
// Fail open: a missing token or unparseable PR number must not block reviews.
if (!token || !Number.isInteger(prNumber) || prNumber <= 0) {
core.warning('rate-limit: missing token or PR number — skipping rate check (fail-open)');
core.setOutput('anomalous', 'false');

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): the jsdoc at the top of the file documents four outputs: anomalous, count, window, threshold. but all three early-exit / fail-open paths (here, the invalid-repo check ~10 lines down, and the catch block) only emit anomalous and count. window and threshold are left as empty strings.

today's callers only read anomalous so nothing breaks. but it's inconsistent with your own spec, and anyone who tries to read steps.rate.outputs.threshold in a debug step or a future consumer will get an empty string and be confused.

easy fix: set all four in every exit path. you have windowSeconds and threshold already parsed from env vars at this point, so it's just:

core.setOutput('window', String(windowSeconds));
core.setOutput('threshold', String(threshold));

@@ -0,0 +1,212 @@
// Copyright The Docker Agent Action authors

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): the coverage of detectRateAnomaly here is really thorough, good work on that. one gap though: main() (the actual CLI entry point) has zero test coverage. that means all of this is untested:

  • env-var name correctness (RATE_MAX_REQUESTS vs RATE_THRESHOLD, etc.)
  • parsePositiveInt fallback behavior
  • all three setOutput paths (success / missing-token / invalid-repo / api-error)
  • the core.warning / core.info log messages
  • the early-return fail-open conditions

a mistake in any of these ships silently. vi.stubEnv makes it pretty painless:

describe('main()', () => {
  it('fails open and emits anomalous=false when token is missing', async () => {
    vi.stubEnv('GITHUB_TOKEN', '');
    vi.stubEnv('GH_TOKEN', '');
    vi.stubEnv('RATE_PR_NUMBER', '5');
    // call main(), assert core.setOutput called with ('anomalous', 'false')
  });

  it('emits all four outputs on a successful run', async () => {
    vi.stubEnv('GITHUB_TOKEN', 'tok');
    vi.stubEnv('GITHUB_REPOSITORY', 'docker/repo');
    vi.stubEnv('RATE_PR_NUMBER', '5');
    // mock detectRateAnomaly, assert window + threshold outputs are set
  });
});

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.

quick follow-up: CI caught two issues in the new commit.

unit test failure (emits all four outputs on a successful anomalous run): the test is hitting a fail-open path instead of the success path, outputting anomalous=false and count=0. the most likely cause is that vi.clearAllMocks() in beforeEach is clearing the mockImplementation set by routePaginate from a previous test run, or the CI runner environment has GITHUB_REPOSITORY set to docker/docker-agent-action (which is a valid owner/repo string, so slashIdx >= 0) and routePaginate is returning the wrong dataset for that repo path. worth running pnpm test locally with --reporter=verbose to see exactly which fail-open branch is firing. also try adding vi.resetAllMocks() in beforeEach (instead of clearAllMocks) to make sure mock implementations don't leak between tests.

lint error (src/rate-limit/index.ts:171): biome flags the non-null assertion (r as ReviewLike).submitted_at!. since the check on line 170 already guards !(r as ReviewLike).submitted_at, the ! on line 171 is redundant. replace it with ?? '' or restructure slightly:

const submittedAt = (r as ReviewLike).submitted_at;
!(submittedAt) || Date.parse(submittedAt) < windowStartMs,

lint error (src/rate-limit/__tests__/rate-limit.test.ts): biome wants the chained .rejects.toThrow(RangeError) calls split across multiple lines. run pnpm biome format --write src/rate-limit/__tests__/rate-limit.test.ts and it'll fix the formatting automatically.

# Distinct review comments stay independent (the comment id keys them into their
# own group) so a real reply is never dropped by another comment on the same PR.
concurrency:
group: pr-review-trigger-${{ github.event.pull_request.number }}-${{ github.event.comment.id || 'review-request' }}

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.

nitpick (super non-blocking): if github.event.pull_request.number is somehow absent (low probability, but possible if the event shape changes), the group key collapses to pr-review-trigger--<comment.id>, which could let two different PRs land in the same group. a || github.run_id at the end is a cheap safety net:

group: pr-review-trigger-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.comment.id || 'review-request' }}

Sayt-0 added 2 commits June 26, 2026 17:57
- add input guards to detectRateAnomaly for windowSeconds and threshold
  (NaN/Infinity/non-positive values now throw RangeError instead of
  propagating silently or permanently throttling all reviews)
- parallelize the three paginate calls with Promise.all
- replace paginate() with paginate.iterator + early break for listReviews
  to avoid unbounded API calls on heavily-reviewed PRs
- emit all four outputs (anomalous, count, window, threshold) in every
  fail-open path (missing token, invalid repo, API error catch block)
- export main() and add test coverage: input guard tests, and six
  main() tests covering env-var parsing, all fail-open paths, and
  the four-output invariant
- add github.run_id fallback to self-review-pr-trigger concurrency key
The Lint job failed on a forbidden non-null assertion in the
listReviews early-stop predicate and on biome formatting of the long
RangeError throw. Extract submitted_at into a local so the predicate
needs no '!', and let biome reflow the affected lines.

The Unit Tests job failed on the main() anomalous-run case: main()
reads the clock through Date.now() (no injectable nowMs), so the mocked
replies timestamped relative to the fixed NOW fell outside the window
against the real clock, yielding count 0. Pin the system clock to NOW
with fake timers in the main() describe so the fixtures stay inside the
sliding window.

@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.

all issues addressed and ci is green. approving!

@Sayt-0 Sayt-0 merged commit 64c526a into main Jun 26, 2026
8 checks passed
@Sayt-0 Sayt-0 deleted the feat/review-request-abuse-safeguards branch June 26, 2026 16:05
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.

4 participants