fix: anti-stomp capability auth, inventory wiring, side-effect proof (#604)
Address REQUEST_CHANGES on PR #680: Blocker A — role vs capability agreement - authorization_compatible allows reviewer/merger when they hold the task's required permission (e.g. gitea.issue.comment on comment_issue/mark_issue/ set_issue_labels/lock_issue) without broad role-bypass. - Unauthorized escalation without the permission remains fail closed. - Regression coverage for allowed and denied reviewer/merger cases. Blocker B — MUTATION_TASKS matches runtime wiring - Remove unenforced entries; document dedicated-gate exclusions (mark_final_review_decision, save/resume_review_draft, etc.). - Wire non-closing gitea_edit_pr as edit_pr; acquire lease uses acquire_reviewer_pr_lease task through verify_preflight_purity. - Inventory↔wiring consistency tests replace set-membership-only coverage. Blocker C — entrypoint side-effect ordering - Behavioral tests for merge_pr, submit_pr_review, and comment_issue prove the assessor block aborts before Gitea API mutation and local durable writes. Validation: 43 focused anti-stomp + related suites green; full suite 2639 passed / 6 skipped. Closes #604
This commit is contained in:
@@ -406,6 +406,141 @@ class TestWrongRole(unittest.TestCase):
|
||||
self.assertFalse(asp.roles_compatible("author", "merger"))
|
||||
|
||||
|
||||
class TestCapabilityAuthorizedRoles(unittest.TestCase):
|
||||
"""Blocker A: reviewer/merger holding issue-comment capability may mutate.
|
||||
|
||||
Nominal task role is still ``author`` in task_capability_map, but the
|
||||
shared anti-stomp gate must not WRONG_ROLE-block when the active profile
|
||||
holds ``gitea.issue.comment``. Unauthorized escalation without the
|
||||
permission remains fail closed.
|
||||
"""
|
||||
|
||||
_ISSUE_COMMENT_TASKS = (
|
||||
"comment_issue",
|
||||
"mark_issue",
|
||||
"set_issue_labels",
|
||||
"lock_issue",
|
||||
)
|
||||
_ISSUE_COMMENT_PERM = "gitea.issue.comment"
|
||||
|
||||
def _role_kwargs(self, task, role, *, allowed_ops, permission=None):
|
||||
return _happy_kwargs(
|
||||
task=task,
|
||||
profile_name=f"prgs-{role}",
|
||||
profile_role=role,
|
||||
required_role="author",
|
||||
required_permission=permission if permission is not None else self._ISSUE_COMMENT_PERM,
|
||||
allowed_operations=allowed_ops,
|
||||
workspace_path=f"/repo/branches/{role}-ws",
|
||||
project_root="/repo",
|
||||
current_branch=f"review/{role}-task",
|
||||
)
|
||||
|
||||
def test_reviewer_allowed_with_issue_comment_capability(self):
|
||||
ops = ["gitea.read", "gitea.issue.comment", "gitea.pr.review"]
|
||||
for task in self._ISSUE_COMMENT_TASKS:
|
||||
with self.subTest(task=task):
|
||||
result = asp.assess_anti_stomp_preflight(
|
||||
**self._role_kwargs(task, "reviewer", allowed_ops=ops)
|
||||
)
|
||||
self.assertTrue(result["allowed"], result.get("reasons"))
|
||||
self.assertFalse(result["block"])
|
||||
self.assertTrue(
|
||||
result["checks"]["role"].get("capability_authorized")
|
||||
)
|
||||
|
||||
def test_merger_allowed_with_issue_comment_capability(self):
|
||||
ops = ["gitea.read", "gitea.issue.comment", "gitea.pr.merge"]
|
||||
for task in self._ISSUE_COMMENT_TASKS:
|
||||
with self.subTest(task=task):
|
||||
result = asp.assess_anti_stomp_preflight(
|
||||
**self._role_kwargs(task, "merger", allowed_ops=ops)
|
||||
)
|
||||
self.assertTrue(result["allowed"], result.get("reasons"))
|
||||
self.assertFalse(result["block"])
|
||||
|
||||
def test_reviewer_denied_without_issue_comment_capability(self):
|
||||
# Holds review permission only — not issue comment.
|
||||
ops = ["gitea.read", "gitea.pr.review", "gitea.pr.approve"]
|
||||
for task in self._ISSUE_COMMENT_TASKS:
|
||||
with self.subTest(task=task):
|
||||
result = asp.assess_anti_stomp_preflight(
|
||||
**self._role_kwargs(task, "reviewer", allowed_ops=ops)
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE)
|
||||
|
||||
def test_merger_denied_without_issue_comment_capability(self):
|
||||
ops = ["gitea.read", "gitea.pr.merge"]
|
||||
for task in self._ISSUE_COMMENT_TASKS:
|
||||
with self.subTest(task=task):
|
||||
result = asp.assess_anti_stomp_preflight(
|
||||
**self._role_kwargs(task, "merger", allowed_ops=ops)
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE)
|
||||
|
||||
def test_not_broad_reviewer_to_author_bypass(self):
|
||||
# Reviewer with only issue.comment must not pass create_pr (needs create).
|
||||
result = asp.assess_anti_stomp_preflight(
|
||||
**_happy_kwargs(
|
||||
task="create_pr",
|
||||
profile_name="prgs-reviewer",
|
||||
profile_role="reviewer",
|
||||
required_role="author",
|
||||
required_permission="gitea.pr.create",
|
||||
allowed_operations=["gitea.read", "gitea.issue.comment", "gitea.pr.review"],
|
||||
workspace_path="/repo/branches/review-ws",
|
||||
project_root="/repo",
|
||||
)
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE)
|
||||
|
||||
def test_not_broad_merger_to_author_bypass(self):
|
||||
result = asp.assess_anti_stomp_preflight(
|
||||
**_happy_kwargs(
|
||||
task="create_issue",
|
||||
profile_name="prgs-merger",
|
||||
profile_role="merger",
|
||||
required_role="author",
|
||||
required_permission="gitea.issue.create",
|
||||
allowed_operations=["gitea.read", "gitea.issue.comment", "gitea.pr.merge"],
|
||||
workspace_path="/repo/branches/merge-ws",
|
||||
project_root="/repo",
|
||||
)
|
||||
)
|
||||
self.assertTrue(result["block"])
|
||||
self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE)
|
||||
|
||||
def test_authorization_compatible_helper(self):
|
||||
self.assertTrue(
|
||||
asp.authorization_compatible(
|
||||
"reviewer",
|
||||
"author",
|
||||
required_permission="gitea.issue.comment",
|
||||
allowed_operations=["gitea.issue.comment"],
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
asp.authorization_compatible(
|
||||
"reviewer",
|
||||
"author",
|
||||
required_permission="gitea.issue.comment",
|
||||
allowed_operations=["gitea.pr.review"],
|
||||
)
|
||||
)
|
||||
# Missing ops list fails closed when permission is required and roles differ.
|
||||
self.assertFalse(
|
||||
asp.authorization_compatible(
|
||||
"reviewer",
|
||||
"author",
|
||||
required_permission="gitea.issue.comment",
|
||||
allowed_operations=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowHash(unittest.TestCase):
|
||||
def test_stale_workflow_hash_blocks(self):
|
||||
result = asp.assess_anti_stomp_preflight(
|
||||
@@ -483,7 +618,7 @@ class TestRootCheckoutContamination(unittest.TestCase):
|
||||
|
||||
|
||||
class TestMutationTaskInventory(unittest.TestCase):
|
||||
"""AC1: mutation task set covers issue-listed paths."""
|
||||
"""AC1 + Blocker B: declared inventory matches runtime wiring."""
|
||||
|
||||
def test_issue_required_paths_covered(self):
|
||||
required = {
|
||||
@@ -497,10 +632,74 @@ class TestMutationTaskInventory(unittest.TestCase):
|
||||
"merge_pr",
|
||||
"cleanup_merged_pr_branch",
|
||||
"cleanup_stale_claims",
|
||||
"edit_pr",
|
||||
}
|
||||
missing = required - asp.MUTATION_TASKS
|
||||
self.assertFalse(missing, f"missing mutation tasks: {missing}")
|
||||
|
||||
def test_disputed_helpers_classified_as_dedicated_or_shared(self):
|
||||
"""Blocker B explicit classification for disputed mutation helpers."""
|
||||
# Shared preflight inventory (wired).
|
||||
self.assertIn("edit_pr", asp.MUTATION_TASKS)
|
||||
# Dedicated fail-closed gates — must NOT claim shared preflight.
|
||||
for name in (
|
||||
"mark_final_review_decision",
|
||||
"save_review_draft",
|
||||
"resume_review_draft",
|
||||
):
|
||||
self.assertNotIn(name, asp.MUTATION_TASKS, name)
|
||||
self.assertIn(name, asp.DEDICATED_GATE_MUTATIONS, name)
|
||||
self.assertTrue(asp.DEDICATED_GATE_MUTATIONS[name].strip())
|
||||
|
||||
def test_inventory_and_wiring_consistent(self):
|
||||
"""Every MUTATION_TASKS member is referenced as a live task kwarg.
|
||||
|
||||
Accepts:
|
||||
* task=\"name\" / task='name' on verify_preflight_purity / _run_anti_stomp
|
||||
* review anti_task map values (approve_pr / request_changes_pr / …)
|
||||
* gitea_ alias form when the bare name is also declared
|
||||
"""
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
server_src = pathlib.Path(__file__).resolve().parents[1] / "gitea_mcp_server.py"
|
||||
text = server_src.read_text(encoding="utf-8")
|
||||
|
||||
# Collect string literals used as task= kwargs.
|
||||
task_kw = set(re.findall(r'task\s*=\s*["\']([a-z0-9_]+)["\']', text))
|
||||
# anti_task map values: "approve": "approve_pr",
|
||||
anti_map = set(
|
||||
re.findall(
|
||||
r'"(?:approve|request_changes|comment)"\s*:\s*"([a-z0-9_]+)"',
|
||||
text,
|
||||
)
|
||||
)
|
||||
wired = task_kw | anti_map
|
||||
# Also accept f-string / conditional close_pr|edit_pr style already in task_kw.
|
||||
|
||||
missing = set()
|
||||
for task in asp.MUTATION_TASKS:
|
||||
bare = task.removeprefix("gitea_")
|
||||
if task in wired or bare in wired or f"gitea_{bare}" in wired:
|
||||
continue
|
||||
# Alias pairs: gitea_commit_files ↔ commit_files
|
||||
if task.startswith("gitea_") and bare in asp.MUTATION_TASKS and bare in wired:
|
||||
continue
|
||||
if f"gitea_{task}" in asp.MUTATION_TASKS and task in wired:
|
||||
continue
|
||||
missing.add(task)
|
||||
self.assertFalse(
|
||||
missing,
|
||||
f"MUTATION_TASKS declared but not wired in gitea_mcp_server.py: {sorted(missing)}",
|
||||
)
|
||||
|
||||
# Dedicated exclusions must not appear as shared anti-stomp task kwargs
|
||||
# for the three disputed local helpers (except resume→submit_pr_review).
|
||||
for name in ("mark_final_review_decision", "save_review_draft"):
|
||||
# May appear as string metadata but not as task="..." anti-stomp target.
|
||||
# Allow mention in comments; assert not as task= kwarg.
|
||||
self.assertNotIn(name, task_kw, f"{name} must not be shared-preflight task=")
|
||||
|
||||
|
||||
class TestServerWiring(unittest.TestCase):
|
||||
"""Smoke: MCP server imports anti_stomp and exposes the runner."""
|
||||
@@ -562,5 +761,272 @@ class TestServerWiring(unittest.TestCase):
|
||||
self.assertIn(asp.BLOCKER_STALE_RUNTIME, str(ctx.exception))
|
||||
|
||||
|
||||
class TestEntrypointSideEffectOrdering(unittest.TestCase):
|
||||
"""Blocker C / AC4: anti-stomp aborts before Gitea mutation side effects.
|
||||
|
||||
Invokes real entrypoint functions with external boundaries mocked.
|
||||
Forces the assessor to block and proves api_request POST/merge paths
|
||||
and durable local writes never run.
|
||||
"""
|
||||
|
||||
def _blocked_assessment(self, kind=asp.BLOCKER_FOREIGN_LEASE, next_action="stop"):
|
||||
return {
|
||||
"allowed": False,
|
||||
"block": True,
|
||||
"blockers": [{
|
||||
"kind": kind,
|
||||
"reasons": ["forced block for ordering test"],
|
||||
"exact_next_action": next_action,
|
||||
"detail": {},
|
||||
}],
|
||||
"reasons": ["forced block for ordering test"],
|
||||
"exact_next_action": next_action,
|
||||
"blocker_kind": kind,
|
||||
"checks": {},
|
||||
}
|
||||
|
||||
def test_merge_pr_blocks_before_merge_api(self):
|
||||
import gitea_mcp_server as server
|
||||
|
||||
blocked = self._blocked_assessment(
|
||||
kind=asp.BLOCKER_HEAD_SHA,
|
||||
next_action="re-pin expected_head_sha and retry",
|
||||
)
|
||||
api_mock = mock.Mock(side_effect=AssertionError("Gitea API must not be called"))
|
||||
save_lock = mock.Mock(side_effect=AssertionError("local lock must not mutate"))
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"GITEA_TEST_FORCE_ANTI_STOMP": "1"},
|
||||
clear=False,
|
||||
):
|
||||
with mock.patch.object(
|
||||
server, "_verify_role_mutation_workspace", return_value="/repo/branches/x"
|
||||
), mock.patch.object(
|
||||
server, "_review_workflow_load_gate_reasons", return_value=[]
|
||||
), mock.patch.object(
|
||||
server, "_live_namespace_health_gate", return_value=None
|
||||
), mock.patch.object(
|
||||
server, "terminal_review_hard_stop_reasons", return_value=[]
|
||||
), mock.patch.object(
|
||||
server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={
|
||||
"eligible": True,
|
||||
"authenticated_user": "merger-bot",
|
||||
"profile_name": "prgs-merger",
|
||||
"pr_author": "other-user",
|
||||
"head_sha": HEAD_A,
|
||||
"mergeable": True,
|
||||
"reasons": [],
|
||||
},
|
||||
), mock.patch.object(
|
||||
server, "_reviewer_pr_lease_gate", return_value=[]
|
||||
), mock.patch.object(
|
||||
server, "_pr_work_lease_reviewer_block", return_value={"block": False}
|
||||
), mock.patch.object(
|
||||
server, "get_profile", return_value={
|
||||
"profile_name": "prgs-merger",
|
||||
"allowed_operations": ["gitea.pr.merge", "gitea.read"],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
), mock.patch.object(
|
||||
server, "_role_kind", return_value="merger"
|
||||
), mock.patch.object(
|
||||
asp, "assess_anti_stomp_preflight", return_value=blocked
|
||||
) as assess_mock, mock.patch.object(
|
||||
server, "api_request", api_mock
|
||||
), mock.patch.object(
|
||||
server, "_save_review_decision_lock", save_lock
|
||||
):
|
||||
result = server.gitea_merge_pr(
|
||||
pr_number=680,
|
||||
confirmation="MERGE PR 680",
|
||||
expected_head_sha=HEAD_A,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/repo/branches/merge-680",
|
||||
)
|
||||
|
||||
self.assertFalse(result.get("performed"))
|
||||
self.assertTrue(any("#604" in r or "anti-stomp" in r.lower() or asp.BLOCKER_HEAD_SHA in r
|
||||
for r in (result.get("reasons") or [])),
|
||||
result.get("reasons"))
|
||||
joined = " ".join(result.get("reasons") or [])
|
||||
self.assertIn(asp.BLOCKER_HEAD_SHA, joined)
|
||||
self.assertIn("exact_next_action", joined)
|
||||
assess_mock.assert_called()
|
||||
api_mock.assert_not_called()
|
||||
save_lock.assert_not_called()
|
||||
|
||||
def test_submit_pr_review_blocks_before_review_api(self):
|
||||
import gitea_mcp_server as server
|
||||
|
||||
blocked = self._blocked_assessment(
|
||||
kind=asp.BLOCKER_FOREIGN_LEASE,
|
||||
next_action="stop; do not stomp foreign lease",
|
||||
)
|
||||
api_mock = mock.Mock(side_effect=AssertionError("Gitea review API must not be called"))
|
||||
save_lock = mock.Mock(side_effect=AssertionError("decision lock must not mutate"))
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"GITEA_TEST_FORCE_ANTI_STOMP": "1"},
|
||||
clear=False,
|
||||
):
|
||||
with mock.patch.object(
|
||||
server, "_verify_role_mutation_workspace", return_value="/repo/branches/r"
|
||||
), mock.patch.object(
|
||||
server, "_review_workflow_load_gate_reasons", return_value=[]
|
||||
), mock.patch.object(
|
||||
server, "_live_namespace_health_gate", return_value=None
|
||||
), mock.patch.object(
|
||||
server, "check_review_decision_gate", return_value=[]
|
||||
), mock.patch.object(
|
||||
server,
|
||||
"gitea_check_pr_eligibility",
|
||||
return_value={
|
||||
"eligible": True,
|
||||
"authenticated_user": "reviewer-bot",
|
||||
"profile_name": "prgs-reviewer",
|
||||
"pr_author": "other-user",
|
||||
"head_sha": HEAD_A,
|
||||
"reasons": [],
|
||||
},
|
||||
), mock.patch.object(
|
||||
server, "_reviewer_pr_lease_gate", return_value=[]
|
||||
), mock.patch.object(
|
||||
server, "_pr_work_lease_reviewer_block", return_value={"block": False}
|
||||
), mock.patch.object(
|
||||
server, "_load_review_decision_lock", return_value={
|
||||
"ready_expected_head_sha": HEAD_A,
|
||||
"final_review_decision_ready": True,
|
||||
"ready_pr_number": 680,
|
||||
"ready_action": "request_changes",
|
||||
}
|
||||
), mock.patch.object(
|
||||
server, "get_profile", return_value={
|
||||
"profile_name": "prgs-reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.read",
|
||||
],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
), mock.patch.object(
|
||||
asp, "assess_anti_stomp_preflight", return_value=blocked
|
||||
) as assess_mock, mock.patch.object(
|
||||
server, "api_request", api_mock
|
||||
), mock.patch.object(
|
||||
server, "_save_review_decision_lock", save_lock
|
||||
), mock.patch.object(
|
||||
server, "_submit_pending_pull_review", api_mock
|
||||
):
|
||||
result = server.gitea_submit_pr_review(
|
||||
pr_number=680,
|
||||
action="request_changes",
|
||||
body="needs work",
|
||||
expected_head_sha=HEAD_A,
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
final_review_decision_ready=True,
|
||||
worktree_path="/repo/branches/review-680",
|
||||
)
|
||||
|
||||
self.assertFalse(result.get("performed"))
|
||||
joined = " ".join(result.get("reasons") or [])
|
||||
self.assertIn(asp.BLOCKER_FOREIGN_LEASE, joined)
|
||||
self.assertIn("exact_next_action", joined)
|
||||
assess_mock.assert_called()
|
||||
# Assessor must run for request_changes_pr (anti_task map).
|
||||
call_task = None
|
||||
for c in assess_mock.call_args_list:
|
||||
kwargs = c.kwargs if c.kwargs else {}
|
||||
if "task" in kwargs:
|
||||
call_task = kwargs["task"]
|
||||
elif c.args:
|
||||
call_task = c.args[0] if False else kwargs.get("task")
|
||||
# assess_anti_stomp is called with keyword task=
|
||||
if c.kwargs.get("task"):
|
||||
call_task = c.kwargs["task"]
|
||||
# _run_anti_stomp passes task as first positional to assess via keyword.
|
||||
self.assertTrue(assess_mock.called)
|
||||
api_mock.assert_not_called()
|
||||
save_lock.assert_not_called()
|
||||
|
||||
def test_comment_issue_blocks_before_comment_api(self):
|
||||
"""Representative issue-mutation class for AC4 coverage."""
|
||||
import gitea_mcp_server as server
|
||||
|
||||
blocked = self._blocked_assessment(
|
||||
kind=asp.BLOCKER_WRONG_ROLE,
|
||||
next_action="switch namespace",
|
||||
)
|
||||
api_mock = mock.Mock(side_effect=AssertionError("comment API must not be called"))
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"GITEA_TEST_FORCE_ANTI_STOMP": "1"},
|
||||
clear=False,
|
||||
):
|
||||
with mock.patch.object(
|
||||
server, "verify_preflight_purity", wraps=None
|
||||
) as purity, mock.patch.object(
|
||||
server, "api_request", api_mock
|
||||
):
|
||||
# Drive verify_preflight_purity → _run_anti_stomp by calling purity
|
||||
# path: patch _run_anti_stomp to raise via assessor.
|
||||
def _purity_side_effect(*args, **kwargs):
|
||||
# Call real runner under force so assessor block surfaces.
|
||||
return server._run_anti_stomp_preflight(
|
||||
kwargs.get("task") or (args[2] if len(args) > 2 else None),
|
||||
remote=args[0] if args else kwargs.get("remote"),
|
||||
worktree_path=kwargs.get("worktree_path"),
|
||||
org=kwargs.get("org"),
|
||||
repo=kwargs.get("repo"),
|
||||
raise_on_block=True,
|
||||
)
|
||||
|
||||
purity.side_effect = None
|
||||
with mock.patch.object(
|
||||
asp, "assess_anti_stomp_preflight", return_value=blocked
|
||||
), mock.patch.object(
|
||||
server, "verify_preflight_purity", side_effect=lambda *a, **k: (
|
||||
server._run_anti_stomp_preflight(
|
||||
k.get("task"),
|
||||
remote=a[0] if a else k.get("remote"),
|
||||
worktree_path=k.get("worktree_path"),
|
||||
org=k.get("org"),
|
||||
repo=k.get("repo"),
|
||||
)
|
||||
)
|
||||
), mock.patch.object(
|
||||
server, "_profile_operation_gate", return_value=[]
|
||||
), mock.patch.object(
|
||||
server, "_canonical_comment_gate", return_value={"blocked": False}
|
||||
), mock.patch.object(
|
||||
server, "get_profile", return_value={
|
||||
"profile_name": "prgs-author",
|
||||
"allowed_operations": ["gitea.issue.comment", "gitea.read"],
|
||||
"forbidden_operations": [],
|
||||
}
|
||||
):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
server.gitea_create_issue_comment(
|
||||
issue_number=604,
|
||||
body="handoff",
|
||||
remote="prgs",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
worktree_path="/repo/branches/issue-604",
|
||||
)
|
||||
self.assertIn(asp.BLOCKER_WRONG_ROLE, str(ctx.exception))
|
||||
self.assertIn("exact_next_action", str(ctx.exception))
|
||||
api_mock.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user