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:
+3
-3
@@ -330,12 +330,12 @@ class TestGatedToolAudit(_AuditWiringBase):
|
||||
mock_api.side_effect = [
|
||||
{"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 7},
|
||||
]
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
|
||||
GITEA_ALLOWED_OPERATIONS="read,review,approve")
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
gitea_mark_final_review_decision(8, "approve", remote="prgs")
|
||||
r = gitea_submit_pr_review(pr_number=8, action="approve",
|
||||
body="LGTM", remote="prgs",
|
||||
final_review_decision_ready=True)
|
||||
|
||||
@@ -125,8 +125,8 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
|
||||
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
||||
mock_api.side_effect = [
|
||||
{"login": "jcwalker3"}, # /user (inventory)
|
||||
{"login": "jcwalker3"}, # /user (eligibility)
|
||||
{"state": "open", "head": {"sha": "abc1234"}, "mergeable": True, "user": {"login": "jcwalker3"}} # /pulls/9 (eligibility)
|
||||
{"login": "jcwalker3"}, # /user (submit eligibility)
|
||||
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
|
||||
]
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
init_review_decision_lock("prgs", "review_pr")
|
||||
|
||||
+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",
|
||||
|
||||
@@ -122,9 +122,9 @@ class TestPRQueueInventory(unittest.TestCase):
|
||||
# mock_api: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility), 4) /pulls/1/reviews (POST review)
|
||||
mock_api.side_effect = [
|
||||
{"login": "reviewer1"}, # inventory whoami
|
||||
{"login": "reviewer1"}, # eligibility whoami
|
||||
{"state": "open", "head": {"sha": "abc1"}, "mergeable": True, "user": {"login": "other_user"}}, # eligibility PR details
|
||||
{"id": 100} # POST review
|
||||
{"login": "reviewer1"}, # submit eligibility whoami
|
||||
{"user": {"login": "other_user"}, "state": "open", "head": {"sha": "abc1"}, "mergeable": True}, # submit eligibility PR
|
||||
{"id": 100}, # POST review
|
||||
]
|
||||
|
||||
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
|
||||
|
||||
+25
-54
@@ -34,8 +34,7 @@ class TestArgParsing(unittest.TestCase):
|
||||
self.exists_patcher.start()
|
||||
self.addCleanup(self.exists_patcher.stop)
|
||||
|
||||
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
|
||||
def test_missing_pr_number_exits(self, _auth):
|
||||
def test_missing_pr_number_exits(self):
|
||||
with self.assertRaises(SystemExit):
|
||||
review_pr.main([])
|
||||
|
||||
@@ -47,37 +46,21 @@ class TestAPIPayload(unittest.TestCase):
|
||||
self.exists_patcher.start()
|
||||
self.addCleanup(self.exists_patcher.stop)
|
||||
|
||||
@patch("review_pr.api_request")
|
||||
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
|
||||
def test_payload_fields_and_workflow(self, _auth, mock_api):
|
||||
# Setup mock api_request to return PR details, then review response
|
||||
mock_api.side_effect = [FAKE_PR_DATA, {}]
|
||||
def test_review_submission_fails_closed_without_api_call(self):
|
||||
# #211: live review submission must use gated MCP tools, not CLI POST.
|
||||
import io
|
||||
buf = io.StringIO()
|
||||
with patch.object(sys, "stderr", buf):
|
||||
rc = review_pr.main([
|
||||
"--pr-number", "81",
|
||||
"--event", "APPROVE",
|
||||
"--body", "Approved and ready to merge",
|
||||
])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertIn("disabled", buf.getvalue().lower())
|
||||
self.assertIn("gitea_submit_pr_review", buf.getvalue())
|
||||
|
||||
rc = review_pr.main([
|
||||
"--pr-number", "81",
|
||||
"--event", "APPROVE",
|
||||
"--body", "Approved and ready to merge",
|
||||
])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(mock_api.call_count, 2)
|
||||
|
||||
# Verify first call: GET PR
|
||||
first_call_args = mock_api.call_args_list[0]
|
||||
self.assertEqual(first_call_args[0][0], "GET")
|
||||
self.assertEqual(first_call_args[0][1], "https://gitea.dadeschools.net/api/v1/repos/Contractor/Timesheet/pulls/81")
|
||||
|
||||
# Verify second call: POST review
|
||||
second_call_args = mock_api.call_args_list[1]
|
||||
self.assertEqual(second_call_args[0][0], "POST")
|
||||
self.assertEqual(second_call_args[0][1], "https://gitea.dadeschools.net/api/v1/repos/Contractor/Timesheet/pulls/81/reviews")
|
||||
payload = second_call_args[0][3]
|
||||
self.assertEqual(payload["event"], "APPROVE")
|
||||
self.assertEqual(payload["body"], "Approved and ready to merge")
|
||||
self.assertEqual(payload["commit_id"], "abcdef1234567890")
|
||||
|
||||
@patch("review_pr.api_request")
|
||||
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
|
||||
def test_merge_flag_fails_closed_without_api_call(self, _auth, mock_api):
|
||||
def test_merge_flag_fails_closed_without_api_call(self):
|
||||
# --merge is an ungated bypass and is disabled (#16). It must fail
|
||||
# closed BEFORE any API call — no review, no merge.
|
||||
rc = review_pr.main([
|
||||
@@ -88,24 +71,15 @@ class TestAPIPayload(unittest.TestCase):
|
||||
"--merge-method", "squash",
|
||||
])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertEqual(mock_api.call_count, 0)
|
||||
|
||||
def test_merge_flag_message_points_to_gated_workflow(self):
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
import io
|
||||
with patch("review_pr.get_auth_header", return_value=FAKE_CREDS), \
|
||||
patch("review_pr.api_request") as mock_api:
|
||||
buf = io.StringIO()
|
||||
monkeypatch = MonkeyPatch()
|
||||
monkeypatch.setattr(sys, "stderr", buf)
|
||||
try:
|
||||
rc = review_pr.main([
|
||||
"--pr-number", "81", "--event", "APPROVE", "--merge",
|
||||
])
|
||||
finally:
|
||||
monkeypatch.undo()
|
||||
buf = io.StringIO()
|
||||
with patch.object(sys, "stderr", buf):
|
||||
rc = review_pr.main([
|
||||
"--pr-number", "81", "--event", "APPROVE", "--merge",
|
||||
])
|
||||
self.assertEqual(rc, 2)
|
||||
self.assertEqual(mock_api.call_count, 0)
|
||||
msg = buf.getvalue().lower()
|
||||
self.assertIn("disabled", msg)
|
||||
self.assertIn("gitea_merge_pr", msg)
|
||||
@@ -126,21 +100,18 @@ class TestMutationAuthorityLock(unittest.TestCase):
|
||||
])
|
||||
self.assertEqual(rc, 2)
|
||||
msg = buf.getvalue().lower()
|
||||
self.assertIn("disabled within mcp sessions", msg)
|
||||
self.assertIn("disabled", msg)
|
||||
self.assertIn("gitea_submit_pr_review", msg)
|
||||
|
||||
@patch("review_pr.api_request")
|
||||
@patch("review_pr.get_auth_header", return_value=FAKE_CREDS)
|
||||
def test_cli_allowed_without_session_lock(self, _auth, mock_api):
|
||||
# No lock in the environment = direct operator CLI use; the wall
|
||||
# does not apply and the normal flow proceeds.
|
||||
mock_api.side_effect = [FAKE_PR_DATA, {}]
|
||||
def test_cli_disabled_even_without_session_lock(self):
|
||||
# #211: CLI review submission is fail-closed regardless of session lock.
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k != "GITEA_SESSION_PROFILE_LOCK"}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
rc = review_pr.main([
|
||||
"--pr-number", "81", "--event", "APPROVE",
|
||||
])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(rc, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -168,12 +168,16 @@ def _good_live_state(**overrides):
|
||||
|
||||
|
||||
def _good_review_mutation():
|
||||
return {
|
||||
"complete": True,
|
||||
"downgraded": False,
|
||||
"missing_fields": [],
|
||||
"reasons": [],
|
||||
report = (
|
||||
"Review complete on PR #203.\n"
|
||||
"Review mutations: one request_changes on #203.\n"
|
||||
"Review decision: request_changes."
|
||||
)
|
||||
lock = {
|
||||
"live_mutations": [{"pr_number": 203, "action": "request_changes"}],
|
||||
"correction_authorized": False,
|
||||
}
|
||||
return assess_review_mutation_final_report(report, lock)
|
||||
|
||||
|
||||
def _good_role_boundary_179(**overrides):
|
||||
@@ -920,6 +924,32 @@ class TestReviewMutationFinalReport(unittest.TestCase):
|
||||
)
|
||||
self.assertTrue(complete["complete"])
|
||||
|
||||
def test_build_final_report_wires_review_mutation_from_report_text(self):
|
||||
report_text = (
|
||||
"Review complete on PR #203.\n"
|
||||
"Review mutations: one request_changes on #203."
|
||||
)
|
||||
lock = {
|
||||
"live_mutations": [{"pr_number": 203, "action": "request_changes"}],
|
||||
"correction_authorized": False,
|
||||
}
|
||||
final = build_final_report(
|
||||
checkout_proof=_good_checkout(),
|
||||
inventory=_good_inventory(),
|
||||
validation=_good_validation(),
|
||||
contamination=_good_contamination(),
|
||||
identity_eligible=True,
|
||||
merge_performed=False,
|
||||
issue_status_verified=True,
|
||||
capability_evidence=_good_capability_evidence(),
|
||||
sweep=_good_sweep(),
|
||||
live_state=_good_live_state(),
|
||||
role_boundary=_good_role_boundary(),
|
||||
report_text=report_text,
|
||||
review_decision_lock=lock,
|
||||
)
|
||||
self.assertTrue(final["review_mutation_complete"])
|
||||
|
||||
|
||||
class TestPRInventoryTrustGate(unittest.TestCase):
|
||||
"""Issue #194: unit tests for the PR inventory trust gate."""
|
||||
|
||||
Reference in New Issue
Block a user