Skip to content

Guard Against a Review Reply That Defers Its Thread Resolve #757

Description

@ptr727

What happened

On #756, a Copilot review thread was replied to and left unresolved for about seven minutes, across a push and a re-request, and was resolved only on a later pass. The maintainer read the pull request inside that window and saw an answered finding with an open conversation, which is indistinguishable from a finding nobody triaged.

Timeline, all on 2026-08-16 UTC:

Time Event
03:20:42 Round 1 review 4945287818 lands on acf70a4, one inline thread on host-setup/bootstrap.sh
03:22:14 Reply posted to that thread, fix pushed as 4a9abe4, round 2 requested
03:25:47 Round 2 review lands on the new head
~03:29 Thread PRRT_kwDOQ5caqM6ZkWcG finally resolved

The gate noticed even though a human did not have to: the pull request's ruleset requires conversation resolution, so mergeStateStatus read BLOCKED throughout, for a reason no field names.

Root cause

Not a defect in scripts/pr_review.py, because the script was never run. The reply was hand-rolled as a POST to the review comment replies endpoint, and the resolve as a separate resolveReviewThread mutation issued on a later pass. Splitting the two into separate acts is what creates the window.

.github/copilot-instructions.md already says to reach for the helper first, and the helper already closes this by construction:

scripts/pr_review.py reply <N> --repo <owner>/<name> --match "<words from the finding>" --body "<answer>" --resolve queries the thread id itself and passes it straight to the mutation.

The same paragraph also records why another sentence will not fix it:

That rule is known and read by the agents that break it anyway, three times so far, so the shape is what changes rather than the wording.

This is the fourth. The wording is not the problem. The hand-run GraphQL form documented below that paragraph is the path an agent falls into, since it presents reply and resolve as two snippets, which an agent then runs as two decisions.

Suggested fix

Extend host-setup/agent-safety/gh-write-guard.py with a third denial class, alongside the discarded-output and literal-node-id rules it already carries. Deny, from the Bash PreToolUse matcher:

  • a resolveReviewThread mutation issued directly through gh api graphql
  • a POST to the pull request review comment replies endpoint

The refusal names scripts/pr_review.py reply --match ... --body ... --resolve as the path to take instead, the way the existing rules name what to do rather than only what is refused.

This mirrors the precedent exactly. The discarded-output rule exists because a mutation whose result is thrown away is a class of mistake no amount of documentation stopped, and it fired correctly during this same session on a requestReviews call carrying a > /dev/null tail. The reply-without-resolve split is the same shape of mistake with the same history.

Two properties worth keeping in the implementation:

  • Deny the mechanism, not the intent. Reading threads, requesting reviews, and posting an issue-level comment stay untouched. Only the two calls the helper supersedes are refused.
  • Leave an escape. A cross-owner pull request, which pr_review.py refuses outright, still needs the hand-run form, so the guard permits it on the same GH_WRITE_GUARD_ALLOW grant the cross-owner write rule already reads.

Alternative considered and rejected

Stating "do not defer the resolve to a later pass" in pr-review-conduct and in the runbook. It is the fifth restatement of a rule already stated in three places, and the file itself records that restating it has not worked.

A second, smaller finding in the same file

Filing this issue was itself denied by gh-write-guard.py, as a cross-owner write to an owner named .... The command was an gh issue create --repo ptr727/ProjectTemplate whose --body prose quoted an API path in the abbreviated form a reader expects, and the guard's target extraction read that path out of the body text as the write's target.

The deny was correct in shape and wrong in fact, and the body had to move to --body-file to get the issue filed. Worth handling while the file is open, since a guard that fires on prose describing a path teaches an agent to route around it, and routing around a guard is the behavior least wanted here. Target extraction should read only an argument in target position, never text carried by --body, --title, --field body, or a heredoc feeding one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions