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:
+85
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user