Enforce single terminal review decision per PR review run (Issue #211)

Reviewer agents could post probe APPROVE/REQUEST_CHANGES reviews while
testing lock paths, polluting PR audit trails. Add a review decision lock
seeded by gitea_resolve_task_capability(review_pr) that requires
gitea_mark_final_review_decision and final_review_decision_ready=True
before gitea_submit_pr_review performs a live mutation.

Add gitea_dry_run_pr_review for read-only submission validation,
gitea_authorize_review_correction for operator-approved fixes, and
assess_review_mutation_final_report for final-report proof. One live
review mutation per run unless correction is explicitly authorized.
This commit is contained in:
2026-07-05 17:43:36 -04:00
parent 5b9fcaefbf
commit 880fca81da
5 changed files with 556 additions and 71 deletions
+282 -59
View File
@@ -13,6 +13,7 @@ Configuration (mcp_config.json):
"env": {}
}
"""
import json
import os
import re
import sys
@@ -921,6 +922,121 @@ _REVIEW_ACTIONS = {
"request_changes": ("request_changes", "REQUEST_CHANGES"),
}
_TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
REVIEW_DECISION_FILE = "/tmp/gitea_review_decision.lock"
def _load_review_decision_lock():
if not os.path.exists(REVIEW_DECISION_FILE):
return None
try:
with open(REVIEW_DECISION_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
def _save_review_decision_lock(data):
try:
with open(REVIEW_DECISION_FILE, "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception:
pass
def init_review_decision_lock(remote: str | None, task: str | None):
"""Seed read-only-until-ready state for reviewer PR review tasks."""
if task != "review_pr":
return
_save_review_decision_lock({
"task": task,
"remote": remote,
"final_review_decision_ready": False,
"ready_pr_number": None,
"ready_action": None,
"ready_expected_head_sha": None,
"live_mutations": [],
"correction_authorized": False,
"correction_reason": None,
})
def check_review_decision_gate(
pr_number: int,
action: str,
*,
final_review_decision_ready: bool,
) -> list[str]:
"""Fail closed unless validation completed and the final decision is ready."""
reasons = []
lock = _load_review_decision_lock()
if lock is None:
reasons.append(
"review decision lock missing; call gitea_resolve_task_capability "
"for review_pr before live review mutations (fail closed)"
)
return reasons
if not final_review_decision_ready:
reasons.append(
"final_review_decision_ready must be true; validation-phase live "
"review mutations are forbidden — use gitea_dry_run_pr_review "
"instead (fail closed)"
)
return reasons
if not lock.get("final_review_decision_ready"):
reasons.append(
"final review decision not marked ready; call "
"gitea_mark_final_review_decision after validation completes "
"(fail closed)"
)
return reasons
if lock.get("ready_pr_number") != pr_number:
reasons.append(
f"ready PR #{lock.get('ready_pr_number')} does not match "
f"requested PR #{pr_number} (fail closed)"
)
if lock.get("ready_action") != action:
reasons.append(
f"ready action '{lock.get('ready_action')}' does not match "
f"requested action '{action}' (fail closed)"
)
prior = list(lock.get("live_mutations") or [])
if prior and not lock.get("correction_authorized"):
reasons.append(
"live review mutation already recorded in this run; only one live "
"review mutation is allowed unless "
"gitea_authorize_review_correction was invoked (fail closed)"
)
elif (
action in _TERMINAL_REVIEW_ACTIONS
and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior)
and not lock.get("correction_authorized")
):
reasons.append(
"terminal review decision already submitted on this PR in this "
"run; blocked unless an operator-approved correction was "
"authorized (fail closed)"
)
return reasons
def record_live_review_mutation(pr_number: int, action: str):
lock = _load_review_decision_lock() or {}
mutations = list(lock.get("live_mutations") or [])
mutations.append({"pr_number": pr_number, "action": action})
lock["live_mutations"] = mutations
if lock.get("correction_authorized"):
lock["correction_authorized"] = False
lock["correction_reason"] = None
_save_review_decision_lock(lock)
# Patterns scrubbed from any surfaced error text so a credential can never leak.
_SECRET_PREFIXES = ("token ", "Basic ")
@@ -1085,9 +1201,7 @@ def gitea_get_pr_review_feedback(
}
@mcp.tool()
@_audit_pr_result("submit_pr_review")
def gitea_submit_pr_review(
def _evaluate_pr_review_submission(
pr_number: int,
action: str,
body: str = "",
@@ -1096,55 +1210,17 @@ def gitea_submit_pr_review(
host: str | None = None,
org: str | None = None,
repo: str | None = None,
*,
live: bool,
final_review_decision_ready: bool = False,
) -> dict:
"""Gated PR review mutation: comment findings, request changes, or approve.
This is the only tool that submits a Gitea PR *review*. It performs a
mutation **only after every safety gate passes**; if any gate fails it
returns ``performed=False`` and never calls the mutating endpoint.
Gate order (fail-closed at each step):
1. Validate ``action`` is one of 'comment', 'approve', 'request_changes'.
2. Reuse ``gitea_check_pr_eligibility`` (#14), which runs the authenticated
-user lookup, active-profile lookup, PR-author lookup, self-approval
block, and profile-allowed-operation check. ``approve`` requires
eligibility for 'approve', ``request_changes`` requires
'request_changes', and ``comment`` requires 'review'.
3. Redundantly block self-approval (authenticated user == PR author).
4. If ``expected_head_sha`` is supplied and the PR head has moved, abort.
5. Only then POST the review.
Endpoint: ``POST /repos/{owner}/{repo}/pulls/{n}/reviews``. This is the
*formal review* API (it records an APPROVE / COMMENT / REQUEST_CHANGES
review state tied to the head commit), chosen over the plain issue-comment
endpoint (``/issues/{n}/comments``) so that approvals and change requests
carry real review state — a plain comment cannot approve or block a PR.
Merge is intentionally NOT implemented here.
Never returns the token, Authorization header, or any credential material.
Args:
pr_number: Target PR number.
action: 'comment', 'approve', or 'request_changes'.
body: Review body / finding text.
expected_head_sha: Optional. If given and the PR head SHA differs, the
review is refused (guards against reviewing a changed PR).
remote: Known instance — 'dadeschools' or 'prgs'.
host: Override the Gitea host.
org: Override the owner/organization.
repo: Override the repository name.
Returns:
dict describing the attempt: action, whether it was performed, the
authenticated user, profile name, PR author, PR number, head SHA
checked, and the reasons/gates passed or blocked. Never secrets.
"""
"""Shared gate chain for live submit and dry-run review tools."""
action = (action or "").strip().lower()
result = {
"requested_action": action,
"performed": False,
"dry_run": not live,
"would_perform": False,
"authenticated_user": None,
"profile_name": get_profile()["profile_name"],
"pr_author": None,
@@ -1156,7 +1232,6 @@ def gitea_submit_pr_review(
}
reasons = result["reasons"]
# Gate 1 — valid review action (no mutation on unknown action).
if action not in _REVIEW_ACTIONS:
reasons.append(
f"unknown review action '{action}'; expected one of "
@@ -1165,8 +1240,15 @@ def gitea_submit_pr_review(
return result
eligibility_action, event = _REVIEW_ACTIONS[action]
# Gate 2 — reuse #14 eligibility (identity + profile + author + self-approve
# + profile-allowed). This performs only read-only GETs.
if live:
reasons.extend(check_review_decision_gate(
pr_number,
action,
final_review_decision_ready=final_review_decision_ready,
))
if reasons:
return result
elig = gitea_check_pr_eligibility(
pr_number=pr_number,
action=eligibility_action,
@@ -1188,30 +1270,36 @@ def gitea_submit_pr_review(
result["permission_report"] = elig["permission_report"]
return result
# Gate 3 — redundant self-approval block (belt-and-suspenders over #14).
auth_user = result["authenticated_user"]
pr_author = result["pr_author"]
if action == "approve" and auth_user and pr_author and auth_user == pr_author:
reasons.append("self-approval blocked (authenticated user is PR author)")
return result
# Gate 4 — head SHA must match if the caller pinned one.
actual_sha = result["head_sha"]
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
pinned_sha = expected_head_sha
lock = _load_review_decision_lock() or {}
if live and lock.get("ready_expected_head_sha"):
pinned_sha = lock.get("ready_expected_head_sha")
if pinned_sha and actual_sha and pinned_sha != actual_sha:
reasons.append(
"expected head SHA does not match current PR head (fail closed)"
)
return result
if not actual_sha:
# Should be unreachable — eligibility fails closed without a head SHA —
# but never submit a review without a commit to pin it to.
reasons.append("PR head SHA unavailable (fail closed)")
return result
result["would_perform"] = True
if not live:
reasons.append(
f"dry-run only: would submit '{event}' review on PR #{pr_number}; "
"no live mutation performed"
)
return result
# Gate 5 — in-process mutation authority (#199): the last check before
# the mutating POST, using the identity the eligibility gate proved.
# A profile/identity flip or side-channel override between preflight
# and mutation fails closed here.
try:
verify_mutation_authority(remote, host, required_role="reviewer",
active_identity=auth_user)
@@ -1219,22 +1307,151 @@ def gitea_submit_pr_review(
reasons.append(str(e))
return result
# All gates passed — perform the single mutating call.
h, o, r = _resolve(remote, host, org, repo)
review_id = None
try:
auth = _auth(h)
review_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}/reviews"
payload = {"body": body, "event": event, "commit_id": actual_sha}
api_request("POST", review_url, auth, payload)
resp = api_request("POST", review_url, auth, payload)
if isinstance(resp, dict):
review_id = resp.get("id")
except Exception as exc: # noqa: BLE001 — redact before surfacing
reasons.append(f"review submission failed: {_redact(str(exc))}")
return result
record_live_review_mutation(pr_number, action, review_id)
result["performed"] = True
reasons.append(f"all gates passed; submitted '{event}' review on PR #{pr_number}")
return result
@mcp.tool()
def gitea_mark_final_review_decision(
pr_number: int,
action: str,
expected_head_sha: str | None = None,
remote: str = "dadeschools",
) -> dict:
"""Mark validation complete; the final review decision is ready to submit."""
action = (action or "").strip().lower()
lock = _load_review_decision_lock()
if lock is None:
return {
"marked_ready": False,
"reasons": [
"review decision lock missing; resolve review_pr capability first"
],
}
if lock.get("live_mutations"):
return {
"marked_ready": False,
"reasons": [
"cannot mark final decision after a live review mutation was "
"already recorded in this run"
],
}
if action not in _REVIEW_ACTIONS:
return {
"marked_ready": False,
"reasons": [
f"unknown review action '{action}'; expected one of "
f"{sorted(_REVIEW_ACTIONS)}"
],
}
lock["final_review_decision_ready"] = True
lock["ready_pr_number"] = pr_number
lock["ready_action"] = action
lock["ready_expected_head_sha"] = expected_head_sha
_save_review_decision_lock(lock)
return {
"marked_ready": True,
"pr_number": pr_number,
"action": action,
"expected_head_sha": expected_head_sha,
"final_review_decision_ready": True,
"reasons": [],
}
@mcp.tool()
def gitea_authorize_review_correction(reason: str) -> dict:
"""Authorize one operator-approved correction after a mistaken live review."""
reason = (reason or "").strip()
lock = _load_review_decision_lock()
if lock is None:
return {"authorized": False, "reasons": ["review decision lock missing"]}
if not lock.get("live_mutations"):
return {
"authorized": False,
"reasons": ["no prior live review mutation to correct"],
}
if not reason:
return {"authorized": False, "reasons": ["correction reason is required"]}
lock["correction_authorized"] = True
lock["correction_reason"] = reason
_save_review_decision_lock(lock)
return {"authorized": True, "correction_reason": reason, "reasons": []}
@mcp.tool()
def gitea_dry_run_pr_review(
pr_number: int,
action: str,
body: str = "",
expected_head_sha: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
) -> dict:
"""Validate review submission mechanics without a live PR mutation."""
return _evaluate_pr_review_submission(
pr_number=pr_number,
action=action,
body=body,
expected_head_sha=expected_head_sha,
remote=remote,
host=host,
org=org,
repo=repo,
live=False,
)
@mcp.tool()
@_audit_pr_result("submit_pr_review")
def gitea_submit_pr_review(
pr_number: int,
action: str,
body: str = "",
expected_head_sha: str | None = None,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
final_review_decision_ready: bool = False,
) -> dict:
"""Gated PR review mutation: comment findings, request changes, or approve.
Live mutations require ``final_review_decision_ready=True`` and a prior
``gitea_mark_final_review_decision`` call. Use ``gitea_dry_run_pr_review``
during validation instead of probing with live submissions.
"""
return _evaluate_pr_review_submission(
pr_number=pr_number,
action=action,
body=body,
expected_head_sha=expected_head_sha,
remote=remote,
host=host,
org=org,
repo=repo,
live=True,
final_review_decision_ready=final_review_decision_ready,
)
@mcp.tool()
def gitea_edit_pr(
pr_number: int,
@@ -3790,6 +4007,12 @@ def gitea_resolve_task_capability(
"STOP: the active profile cannot perform the requested task; "
"follow exact_safe_next_action instead of improvising.")
if task == "review_pr":
init_review_decision_lock(
remote if remote in REMOTES else None,
task,
)
record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task)
return {