fix(review): harden #211 decision lock and CLI gate per review blockers

- Bind review decision state to in-process session (pid + profile lock); drop /tmp file path.
- Fail closed on review_pr.py CLI; route live reviews through gated MCP tools only.
- Require operator_authorized on review correction; allow re-mark after correction.
- Validate remote/org/repo on mark and submit; wire review mutation proof into build_final_report.
- Add security regression tests for spoofed locks, correction flow, and CLI bypass.

Refs #211
This commit is contained in:
2026-07-05 20:16:04 -04:00
parent 7489107768
commit 16f977fde0
9 changed files with 272 additions and 172 deletions
+85 -3
View File
@@ -924,7 +924,8 @@ _REVIEW_ACTIONS = {
_TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
REVIEW_DECISION_FILE = "/tmp/gitea_review_decision.lock"
# In-process only (#211): never persist to /tmp — host-global files are
# spoofable by any local process and go stale across sessions.
_REVIEW_DECISION_LOCK: dict | None = None
@@ -938,13 +939,41 @@ def _save_review_decision_lock(data):
_REVIEW_DECISION_LOCK = data
def _review_decision_session_reasons(lock: dict | None) -> list[str]:
"""Reject locks that do not belong to this MCP process/session."""
if lock is None:
return []
reasons = []
if lock.get("session_pid") != os.getpid():
reasons.append(
"review decision lock was created in a different process "
"(fail closed)"
)
env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
stored_lock = (lock.get("session_profile_lock") or "").strip()
if env_lock and stored_lock and env_lock != stored_lock:
reasons.append(
"review decision lock session profile lock mismatch (fail closed)"
)
return reasons
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
profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip()
session_lock = (
(os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip()
or profile_name
)
_save_review_decision_lock({
"task": task,
"remote": remote,
"session_pid": os.getpid(),
"session_profile": profile_name,
"session_profile_lock": session_lock,
"final_review_decision_ready": False,
"ready_pr_number": None,
"ready_action": None,
@@ -977,6 +1006,13 @@ def check_review_decision_gate(
)
return reasons
reasons.extend(_review_decision_session_reasons(lock))
if reasons:
return reasons
if remote in REMOTES:
_, org, repo = _resolve(remote, None, org, repo)
if not final_review_decision_ready:
reasons.append(
"final_review_decision_ready must be true; validation-phase live "
@@ -1367,12 +1403,46 @@ def gitea_mark_final_review_decision(
"review decision lock missing; resolve review_pr capability first"
],
}
if lock.get("live_mutations"):
session_reasons = _review_decision_session_reasons(lock)
if session_reasons:
return {"marked_ready": False, "reasons": session_reasons}
if remote != lock.get("remote"):
return {
"marked_ready": False,
"reasons": [
f"requested remote '{remote}' does not match locked remote "
f"'{lock.get('remote')}' (fail closed)"
],
}
try:
_, resolved_org, resolved_repo = _resolve(remote, None, org, repo)
except ValueError as exc:
return {"marked_ready": False, "reasons": [str(exc)]}
if org is not None and org != resolved_org:
return {
"marked_ready": False,
"reasons": [
f"requested org '{org}' does not match resolved org "
f"'{resolved_org}' (fail closed)"
],
}
if repo is not None and repo != resolved_repo:
return {
"marked_ready": False,
"reasons": [
f"requested repo '{repo}' does not match resolved repo "
f"'{resolved_repo}' (fail closed)"
],
}
org = resolved_org
repo = resolved_repo
if lock.get("live_mutations") and not lock.get("correction_authorized"):
return {
"marked_ready": False,
"reasons": [
"cannot mark final decision after a live review mutation was "
"already recorded in this run"
"already recorded in this run unless an operator-approved "
"correction was authorized"
],
}
if action not in _REVIEW_ACTIONS:
@@ -1409,6 +1479,7 @@ def gitea_authorize_review_correction(
prior_review_id: int,
prior_review_state: str,
reason: str,
operator_authorized: bool = False,
) -> dict:
"""Authorize one operator-approved correction after a mistaken live review."""
reason = (reason or "").strip()
@@ -1416,6 +1487,17 @@ def gitea_authorize_review_correction(
lock = _load_review_decision_lock()
if lock is None:
return {"authorized": False, "reasons": ["review decision lock missing"]}
session_reasons = _review_decision_session_reasons(lock)
if session_reasons:
return {"authorized": False, "reasons": session_reasons}
if not operator_authorized:
return {
"authorized": False,
"reasons": [
"operator authorization is required for review correction "
"(fail closed)"
],
}
prior = list(lock.get("live_mutations") or [])
if not prior:
return {