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:
+96
-16
@@ -35,7 +35,7 @@ from mcp_server import ( # noqa: E402
|
||||
gitea_mark_final_review_decision,
|
||||
gitea_authorize_review_correction,
|
||||
init_review_decision_lock,
|
||||
REVIEW_DECISION_FILE,
|
||||
|
||||
gitea_list_issue_comments,
|
||||
gitea_create_issue_comment,
|
||||
)
|
||||
@@ -818,8 +818,8 @@ class TestReviewPR(unittest.TestCase):
|
||||
# mock_api responses: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility)
|
||||
mock_api.side_effect = [
|
||||
{"login": "jcwalker3"}, # /api/v1/user (inventory)
|
||||
{"login": "jcwalker3"}, # /api/v1/user (eligibility)
|
||||
{"state": "open", "head": {"sha": "abc1234"}, "mergeable": True, "user": {"login": "jcwalker3"}}, # /pulls/1
|
||||
{"login": "jcwalker3"}, # /api/v1/user (submit eligibility)
|
||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/1
|
||||
]
|
||||
from mcp_server import init_review_decision_lock
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
@@ -1422,10 +1422,6 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
|
||||
def setUp(self):
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(REVIEW_DECISION_FILE):
|
||||
os.remove(REVIEW_DECISION_FILE)
|
||||
|
||||
def _env(self):
|
||||
return patch.dict(os.environ, {
|
||||
"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
@@ -1520,10 +1516,6 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||
|
||||
def tearDown(self):
|
||||
if os.path.exists(REVIEW_DECISION_FILE):
|
||||
os.remove(REVIEW_DECISION_FILE)
|
||||
|
||||
def _pr(self, author, state="open", sha="abc123", mergeable=True):
|
||||
return {
|
||||
"user": {"login": author},
|
||||
@@ -1783,7 +1775,12 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
lock = _load_review_decision_lock() or {}
|
||||
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
|
||||
_save_review_decision_lock(lock)
|
||||
r = gitea_authorize_review_correction(prior_review_id=42, prior_review_state="approve", reason="typo")
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
)
|
||||
self.assertTrue(r["authorized"])
|
||||
lock = _load_review_decision_lock()
|
||||
self.assertTrue(lock["correction_authorized"])
|
||||
@@ -1793,17 +1790,100 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
lock = _load_review_decision_lock() or {}
|
||||
lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}]
|
||||
_save_review_decision_lock(lock)
|
||||
|
||||
|
||||
# Mismatched ID
|
||||
r = gitea_authorize_review_correction(prior_review_id=99, prior_review_state="approve", reason="typo")
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=99,
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("prior review ID" in x for x in r["reasons"]))
|
||||
|
||||
# Mismatched State
|
||||
r = gitea_authorize_review_correction(prior_review_id=42, prior_review_state="request_changes", reason="typo")
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="request_changes",
|
||||
reason="typo",
|
||||
operator_authorized=True,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("prior review state" in x for x in r["reasons"]))
|
||||
|
||||
def test_authorize_review_correction_requires_operator_auth(self):
|
||||
from mcp_server import _load_review_decision_lock, _save_review_decision_lock
|
||||
lock = _load_review_decision_lock() or {}
|
||||
lock["live_mutations"] = [
|
||||
{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}
|
||||
]
|
||||
_save_review_decision_lock(lock)
|
||||
r = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="typo",
|
||||
operator_authorized=False,
|
||||
)
|
||||
self.assertFalse(r["authorized"])
|
||||
self.assertTrue(any("operator authorization" in x for x in r["reasons"]))
|
||||
|
||||
def test_spoofed_tmp_lock_file_does_not_bypass_gate(self):
|
||||
import json
|
||||
import mcp_server
|
||||
mcp_server._REVIEW_DECISION_LOCK = None
|
||||
spoof_path = "/tmp/gitea_review_decision.lock"
|
||||
with open(spoof_path, "w", encoding="utf-8") as fh:
|
||||
json.dump({"final_review_decision_ready": True}, fh)
|
||||
try:
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertFalse(r["performed"])
|
||||
self.assertTrue(any("review decision lock missing" in x for x in r["reasons"]))
|
||||
finally:
|
||||
if os.path.exists(spoof_path):
|
||||
os.remove(spoof_path)
|
||||
|
||||
def test_mark_final_decision_rejects_remote_mismatch(self):
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
r = gitea_mark_final_review_decision(8, "approve", remote="dadeschools")
|
||||
self.assertFalse(r["marked_ready"])
|
||||
self.assertTrue(any("does not match locked remote" in x for x in r["reasons"]))
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_correction_flow_allows_second_terminal_review(self, _auth, mock_api):
|
||||
mock_api.side_effect = [
|
||||
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 42},
|
||||
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 43},
|
||||
]
|
||||
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
|
||||
"GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"}
|
||||
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
first = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
auth = gitea_authorize_review_correction(
|
||||
prior_review_id=42,
|
||||
prior_review_state="approve",
|
||||
reason="operator approved correcting mistaken approve",
|
||||
operator_authorized=True,
|
||||
)
|
||||
gitea_mark_final_review_decision(8, "request_changes", remote="prgs")
|
||||
second = gitea_submit_pr_review(
|
||||
pr_number=8, action="request_changes", remote="prgs",
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertTrue(first["performed"])
|
||||
self.assertTrue(auth["authorized"])
|
||||
self.assertTrue(second["performed"])
|
||||
|
||||
@patch("mcp_server.api_request")
|
||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||
def test_remote_org_repo_validation_mismatch(self, _auth, mock_api):
|
||||
@@ -1812,7 +1892,7 @@ class TestSubmitPrReview(unittest.TestCase):
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
# Mark decision for specific remote, org, repo
|
||||
gitea_mark_final_review_decision(8, "approve", remote="prgs", org="MyOrg", repo="MyRepo")
|
||||
|
||||
|
||||
# Mismatched remote
|
||||
r = gitea_submit_pr_review(
|
||||
pr_number=8, action="approve", remote="dadeschools", org="MyOrg", repo="MyRepo",
|
||||
|
||||
Reference in New Issue
Block a user