Skip to content

fix: converge repeated PR review findings#77

Open
Pigbibi wants to merge 7 commits into
mainfrom
codex/pr-review-convergence
Open

fix: converge repeated PR review findings#77
Pigbibi wants to merge 7 commits into
mainfrom
codex/pr-review-convergence

Conversation

@Pigbibi

@Pigbibi Pigbibi commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • version finding contracts with semantic fingerprints and bound remediation to two rounds
  • review remediation deltas, arbitrate conflicting contracts, and persist head/diff-bound clearance.v1
  • add high-risk vertical-slice preflight plus sanitized fix: classify adapter dispatch outcomes #75/#125 replay fixtures

Validation

  • python3 -m unittest tests.test_run_codex_pr_review (57 tests)
  • python3 -m unittest discover tests (623 tests, 1 skipped)
  • python3 -m ruff check scripts/run_codex_pr_review.py tests/test_run_codex_pr_review.py
  • python3 -m compileall -q scripts tests
  • actionlint .github/workflows/codex_pr_review.yml
  • git diff --check

Safety

  • no label/comment acknowledgement bypasses and no branch-protection changes
  • clearance accepts only trusted review-bot comments and binds repository, PR, head, cumulative diff, contracts, evidence digest, and arbiter identity

Co-Authored-By: Codex <noreply@openai.com>
@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

🤖 Codex PR Review

🚫 Merge blocked: 2 serious issue(s) found in high-risk files

⚖️ Codex Review Arbitration

🚫 block: Both current findings remain valid against the cumulative diff. First, scripts/run_codex_pr_review.py now parses clearance_marker directly from previous_comment and uses it in both the same-head fast path and the post-review blocker-clearing path, but unlike finding history it is never gated by history_source_valid; clearance_workflow_run_is_trusted() only checks Actions run metadata, not that the comment carrying the marker came from the trusted bot/workflow author, so an untrusted comment with a forged clearance.v1 payload can still satisfy the clearance checks. Second, the prior active-history fix is not contradicted, but it is still incomplete: review_scope only changes when active_blocking_history and previous_head_sha != current_head_sha, so same-head reruns stay on initial_review, rerun Codex on unchanged code, and can enter active_history_arbitration_required when the model returns clean. The added docs explicitly say the state machine converges through bounded remediation and that the same head must not repeatedly reopen remediation; the current code still allows a blocker on an unchanged head to be cleared via repeated reruns/arbitration without a new push.

🚫 Blocking Issues

These issues must be fixed before this PR can be merged:

1. 🔴 [CRITICAL] Security in scripts/run_codex_pr_review.py

Trusted clearance markers are accepted even when the source comment is not trusted. parse_clearance_marker() is evaluated from previous_comment, but unlike finding history its result is never gated by history_source_valid before the same-head fast path or the later blocker-clearing path. If find_existing_review_comment() can return a user-authored marker comment, an attacker can forge a base64 clearance.v1 payload pointing at any real successful workflow run for the same repo/head/diff and bypass the review gate. (line 2274)

Suggestion: Treat clearance markers exactly like finding history: ignore them unless the matched comment is from the trusted review bot/workflow author, and thread that trust check through both the early-return clearance path and the post-review decision["blocked"] clearance override.

2. 🟠 [HIGH] Logic in scripts/run_codex_pr_review.py

Active blocking history is only converted into remediation/final_sanity when a new head SHA is pushed. On a rerun of the same head, review_scope stays initial_review, so the system re-asks Codex on unchanged code and can immediately enter the active_history_arbitration_required path if the model happens to return a clean result. That breaks the documented “same head does not reopen remediation” guarantee and lets repeated reruns on identical code eventually clear a blocker without any code change. (line 2268)

Suggestion: When has_active_blocking_history(history) is true and current_head_sha == previous_head_sha, fail closed or short-circuit without rerunning Codex/arbitration. Only allow remediation/final-sanity transitions after a new trusted head is pushed.


Review by Codex PR Review bot • PR

Co-Authored-By: Codex <noreply@openai.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

if not serialized_history_valid or serialized_history_requires_confirmation:
decision = apply_arbitration_failure(
decision, ReviewError("blocking finding history exceeds its safe bound")
)

P2 Badge Preserve human-resolution decisions after serializing history

When a finding exceeds the remediation limit and arbitration confirms it, the code sets next_action to human_contract_resolution and writes a blocked_human_contract history round, but this validation immediately treats that expected status as a history-confirmation failure and overwrites the decision with contract_arbitration. In that limit-reached path, the comment, artifact, and step outputs point automation/humans at another arbitration instead of the required human contract resolution.

ℹ️ 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".

Comment on lines +2636 to +2639
if (
history_requires_confirmation
or (active_blocking_history and not decision["blocked"] and not clearance_marker)
) and not arbitration_cleared:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve active history after a blocking arbitration

When a remediation review has active blocking history but the primary reviewer returns no blockers, a non-clear arbitration re-blocks the decision; because this condition then excludes decision["blocked"], the code falls through and appends an arbitration_required round with an empty findings list. Since has_active_blocking_history() only considers the latest round active when it has findings, the next run can treat the unresolved blocker as inactive and pass a clean primary review without the required independent clearance.

Useful? React with 👍 / 👎.

Comment thread scripts/run_codex_pr_review.py Outdated
status = latest.get("status", "blocking")
return finding_history_requires_confirmation(history) or (
status == "blocking" and bool(latest.get("findings"))
status in {"blocking", "remediation", "arbitration_required", "blocked_human_contract"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat initial_review as active blocking history

When the first blocking review is recorded, history_status is initial_review, but this active-state check does not include that status. On the next author push, active_blocking_history becomes false, so a clean primary review can clear the PR without the independent source-of-truth clearance required for an outstanding blocker, and the remediation-scoped review is skipped.

Useful? React with 👍 / 👎.

Comment on lines +1598 to +1602
for round_state in reversed(history):
prior = round_state.get("findings")
if not isinstance(prior, list):
continue
for finding in prior:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore cleared rounds when checking contract conflicts

When a previous finding was cleared by arbitration, its round is still stored with status cleared, but this conflict scan walks every historical round without skipping cleared findings. A later new finding on the same contract surface can therefore be marked as a contract conflict against a false-positive finding that was already cleared, disabling normal remediation and reviving history that should no longer block.

Useful? React with 👍 / 👎.

Pigbibi and others added 2 commits July 12, 2026 20:12
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1198ec4f8

ℹ️ 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".

Comment thread scripts/run_codex_pr_review.py Outdated
history_valid=False,
)

clearance_marker_out = clearance_marker_text if clearance_marker and not decision["blocked"] else ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate stale clearance before preserving it

When a previous comment contains a syntactically valid clearance marker that no longer matches the current head/diff, this still copies it into clearance_marker_out whenever the primary review is clean. In an active-history run, that non-empty value makes the later active_blocking_history and not clearance_marker_out guard skip re-blocking, so an arbiter verdict of block can publish “Merge allowed” and even carry forward the stale clearance marker; this happens on a new PR head after an older clearance marker remains in the bot comment.

Useful? React with 👍 / 👎.

Comment on lines +1263 to +1266
run.get("event") == "pull_request_target"
and run.get("conclusion") == "success"
and str(run.get("head_sha") or "").strip().lower()
== str(clearance.get("head_sha") or "").strip().lower()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Compare clearance runs against the PR head

For pull_request_target runs, GitHub executes the workflow on the default-branch commit, while this marker stores the pull request head SHA. This check therefore rejects the successful run that just produced a valid clearance on the next same-head retry, so a persistent false-positive finding cannot be reused from clearance.v1 and the review falls back to blocking/arbitrating again despite an unchanged head and diff.

Useful? React with 👍 / 👎.

Comment thread scripts/run_codex_pr_review.py Outdated
Comment on lines +1658 to +1660
current_fingerprint = str(current.get("fingerprint_v2") or _fingerprint_v2(current))
prior_fingerprint = str(prior.get("fingerprint_v2") or _fingerprint_v2(prior))
if current_fingerprint != prior_fingerprint:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore severity drift when detecting contract conflicts

If the same blocking finding is reclassified between high and critical while keeping the same required behavior, this comparison still treats it as a contract conflict because fingerprint_v2 includes severity. That sends an ordinary severity wording drift into contract_arbitration with auto-fix disabled, and if arbitration capacity is unavailable the PR fails closed even though no behavior reversed or contradicted the prior contract.

Useful? React with 👍 / 👎.

Pigbibi and others added 2 commits July 12, 2026 20:34
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Codex <noreply@openai.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6b74e63bd

ℹ️ 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".

if run_id <= 0:
return False
try:
run = github_request(token, "GET", f"/repos/{repo}/actions/runs/{run_id}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Grant actions read before verifying clearance runs

In the actual workflow this call is made with GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}, but .github/workflows/codex_pr_review.yml only grants contents, id-token, issues, and pull-requests, not actions: read. The workflow-run endpoint needs Actions read permission on private repos, so github_request will 403 and this helper will always return false, causing otherwise valid clearance markers to be ignored on same-head reruns.

Useful? React with 👍 / 👎.

and str(run.get("head_sha") or "").strip().lower()
== str(clearance.get("head_sha") or "").strip().lower()
and str(run_repo.get("full_name") or "") == repo
and workflow_path.startswith(".github/workflows/codex_pr_review.yml@")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Trust reusable-workflow clearances via referenced workflows

For workflow_call consumers, GITHUB_RUN_ID belongs to the caller workflow run, so the run's path is the caller's workflow while this reusable codex_pr_review.yml is recorded under referenced_workflows. Because this workflow explicitly supports consumer repos via workflow_call, checking only the top-level path rejects clearances emitted from those repos and same-head reruns re-review/re-arbitrate instead of reusing the trusted clearance.

Useful? React with 👍 / 👎.

findings = review.get("findings", [])
if not isinstance(findings, list):
findings = []
findings = [_contract_finding(finding) for finding in findings if isinstance(finding, dict)]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve finding line numbers when adding contract fields

When the backend returns a finding with a line, this replaces the whole finding with _contract_finding(), which only keeps severity/category/file/description/suggestion and contract hashes. evaluate_findings() therefore never receives the original line, so _format_finding() can no longer include (line n) in PR comments or artifacts for any finding; keep the original dict and merge the contract fields instead of discarding it.

Useful? React with 👍 / 👎.

Comment on lines +293 to +294
if not isinstance(comparison, dict) or comparison.get("status") != "ahead":
raise ReviewError("remediation head is not a strict descendant of the trusted reviewed head")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fall back when remediation heads diverge

When an author rebases, amends, or force-pushes after a blocking review, the previous reviewed SHA is no longer an ancestor of the new head, so the compare status becomes non-ahead and this raises. The caller then publishes review_retry while preserving the old previous_head_sha, so every rerun hits the same failure and the PR cannot get reviewed again until the branch is rebuilt on top of the abandoned head; fall back to a cumulative review/final sanity pass for diverged heads instead.

Useful? React with 👍 / 👎.

Comment on lines +1691 to +1692
def remediation_round_count(history: list[dict[str, Any]]) -> int:
return sum(1 for round_state in history if round_state.get("status") == "remediation")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Count remediation rounds per contract

This counts every historical remediation round globally, but the new contract state is tracked per finding/contract. If contract A used two remediation rounds and is then fixed while an unrelated contract B becomes the current blocker, B is immediately routed to final sanity/contract arbitration because the old rounds are still counted; filter the count to the current unresolved contract keys so unrelated history does not exhaust B's remediation budget.

Useful? React with 👍 / 👎.

new_head=new_head,
) and not (history_requires_confirmation and not previous_findings)
if confirmation_arbitration_required or history_arbitration_required or repeated_arbitration_required:
if confirmation_arbitration_required or active_history_arbitration_required or repeated_arbitration_required:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Arbitrate contract conflicts when first detected

When conflicting_contract_findings() detects the same contract surface with a changed behavior digest, the code sets next_action=contract_arbitration but this condition never actually invokes the arbiter unless the finding also meets the repeated-finding threshold or has empty current findings. A first-time opposite-contract finding therefore gets stuck publishing an arbitration-required block with no arbitration verdict or clearance path on reruns; include contract_conflicts in the arbitration trigger.

Useful? React with 👍 / 👎.

Co-Authored-By: Codex <noreply@openai.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de8cd1151f

ℹ️ 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".

for token in re.findall(r"[a-z0-9]+", text)
if token not in {"the", "and", "for", "from", "into", "that", "this", "with", "must", "should", "will", "need", "return"}
}
return " ".join(sorted(tokens))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve token order in behavior digests

When two findings for the same contract use the same words or numbers in a different order to require opposite behavior (for example, "copy A to B" vs "copy B to A", or "validate before cache" vs "cache before validate"), this normalization sorts the token set before hashing. Those suggestions produce the same behavior_digest, so conflicting_contract_findings() will not mark the later finding as a contract conflict and can allow normal auto-remediation to reverse the earlier contract.

Useful? React with 👍 / 👎.

Comment on lines +932 to +934
return anchored_subject or _normalize_contract_text(
finding.get("suggestion") or text
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep behavior text out of contract keys

When a finding does not include a backticked or dotted code anchor, this fallback derives the contract subject from the suggestion/description, and _contract_key() hashes that subject. If a later finding on the same plain-English contract changes the required behavior (for example from “return a blocked result” to “raise an error”), it gets a different contract_key instead of just a different behavior_digest, so conflicting_contract_findings() never compares the two rounds and the opposite remediation can remain auto-fixable instead of going to contract arbitration.

Useful? React with 👍 / 👎.

Comment on lines +1673 to +1675
contract_key = str(finding.get("contract_key") or _contract_key(finding))
if contract_key not in resolved_contracts and contract_key not in latest_by_contract:
latest_by_contract[contract_key] = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve prior contracts after arbitration-required rounds

When a contract conflict has been recorded as arbitration_required and the author pushes another head, this reverse scan treats that latest conflicting finding as the contract source and then skips the older finding that held the original required behavior. If the same contradictory finding appears again, conflicting_contract_findings() now sees matching behavior against the arbitration-required round, forgets the original conflict, and can re-enable normal auto-remediation instead of keeping the contract in arbitration.

Useful? React with 👍 / 👎.

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.

1 participant