Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc9366c394 |
+93
-165
@@ -517,6 +517,19 @@ def _clear_preflight_capability_state() -> None:
|
||||
_preflight_reviewer_violation_files = []
|
||||
|
||||
|
||||
def _clear_resolved_capability_stamp() -> None:
|
||||
"""#723 AC3: drop only the resolved role/task stamp (denied resolve).
|
||||
|
||||
``record_preflight_check("capability")`` without a role intentionally
|
||||
preserves a prior stamp, so a DENIED resolve must clear it explicitly —
|
||||
otherwise the stale role poisons :func:`_effective_workspace_role` and a
|
||||
later mutation preflight raises instead of failing closed with reasons.
|
||||
"""
|
||||
global _preflight_resolved_role, _preflight_resolved_task
|
||||
_preflight_resolved_role = None
|
||||
_preflight_resolved_task = None
|
||||
|
||||
|
||||
def record_preflight_check(
|
||||
type_name: str,
|
||||
resolved_role: str | None = None,
|
||||
@@ -4096,9 +4109,6 @@ def _evaluate_pr_review_submission(
|
||||
worktree_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared gate chain for live submit and dry-run review tools."""
|
||||
_verify_role_mutation_workspace(
|
||||
remote, worktree_path=worktree_path, task="review_pr"
|
||||
)
|
||||
action = (action or "").strip().lower()
|
||||
workflow_blockers = _review_workflow_load_gate_reasons() if live else []
|
||||
result = {
|
||||
@@ -4116,6 +4126,20 @@ def _evaluate_pr_review_submission(
|
||||
"reasons": [],
|
||||
}
|
||||
reasons = result["reasons"]
|
||||
# #723 AC4: workspace/role-binding failures fail closed with structured
|
||||
# reasons — never escape as a generic internal_error (PR #721: a stamped
|
||||
# 'merger' role raised RuntimeError here and the client saw an
|
||||
# unactionable internal_error after mark_final had already succeeded).
|
||||
try:
|
||||
_verify_role_mutation_workspace(
|
||||
remote, worktree_path=worktree_path, task="review_pr"
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
result["blocker_kind"] = "workspace_role_binding"
|
||||
reasons.append(
|
||||
"workspace/role binding failed (fail closed, #723): " f"{exc}"
|
||||
)
|
||||
return result
|
||||
if workflow_blockers:
|
||||
reasons.extend(workflow_blockers)
|
||||
reasons.extend(review_workflow_load.recovery_handoff_without_replay())
|
||||
@@ -9959,140 +9983,6 @@ def gitea_acquire_reviewer_pr_lease(
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_acquire_merger_pr_lease(
|
||||
pr_number: int,
|
||||
worktree: str,
|
||||
candidate_head: str | None = None,
|
||||
target_branch: str = "master",
|
||||
target_branch_sha: str | None = None,
|
||||
issue_number: int | None = None,
|
||||
session_id: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
repo: str | None = None,
|
||||
) -> dict:
|
||||
"""Acquire a per-PR lease for a merger session when no reviewer lease exists (#718).
|
||||
|
||||
Merger sessions should normally adopt an active reviewer lease using
|
||||
gitea_adopt_merger_pr_lease. However, if no active reviewer lease exists
|
||||
or it has expired, a merger can acquire one directly using this tool.
|
||||
"""
|
||||
read_block = _profile_operation_gate("gitea.read")
|
||||
if read_block:
|
||||
return {
|
||||
"success": False,
|
||||
"acquired": False,
|
||||
"reasons": read_block,
|
||||
"permission_report": _permission_block_report("gitea.read"),
|
||||
}
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
return {
|
||||
"success": False,
|
||||
"acquired": False,
|
||||
"reasons": comment_block,
|
||||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||
}
|
||||
merge_block = _profile_operation_gate("gitea.pr.merge")
|
||||
if merge_block:
|
||||
return {
|
||||
"success": False,
|
||||
"acquired": False,
|
||||
"reasons": merge_block,
|
||||
"permission_report": _permission_block_report("gitea.pr.merge"),
|
||||
}
|
||||
|
||||
try:
|
||||
_verify_role_mutation_workspace(remote, worktree=worktree, task="acquire_merger_pr_lease")
|
||||
except RuntimeError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"acquired": False,
|
||||
"reasons": [str(e)],
|
||||
}
|
||||
|
||||
h, o, r = _resolve(remote, host, org, repo)
|
||||
auth = _auth(h)
|
||||
profile = get_profile()
|
||||
identity = _authenticated_username(h) or profile.get("username") or ""
|
||||
sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id()
|
||||
repo_label = f"{o}/{r}"
|
||||
|
||||
comments = _fetch_pr_comments(
|
||||
pr_number, remote=remote, host=host, org=org, repo=repo)
|
||||
|
||||
pr_live = api_request(
|
||||
"GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {}
|
||||
pr_merged_or_closed = bool(
|
||||
pr_live.get("merged") or pr_live.get("merged_at")
|
||||
) or (str(pr_live.get("state") or "").strip().lower() == "closed")
|
||||
|
||||
assessment = reviewer_pr_lease.assess_acquire_lease(
|
||||
comments,
|
||||
pr_number=pr_number,
|
||||
reviewer_identity=identity,
|
||||
profile=profile.get("profile_name") or "unknown",
|
||||
session_id=sid,
|
||||
repo=repo_label,
|
||||
issue_number=issue_number,
|
||||
worktree=worktree,
|
||||
candidate_head=candidate_head,
|
||||
target_branch=target_branch,
|
||||
target_branch_sha=target_branch_sha,
|
||||
pr_merged_or_closed=pr_merged_or_closed,
|
||||
)
|
||||
if not assessment.get("acquire_allowed"):
|
||||
return {
|
||||
"success": False,
|
||||
"acquired": False,
|
||||
"reasons": assessment.get("reasons") or [],
|
||||
"existing_lease": assessment.get("existing_lease"),
|
||||
"post_merge_moot": assessment.get("post_merge_moot", False),
|
||||
}
|
||||
|
||||
body = assessment["lease_body"]
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
"comment_pr",
|
||||
host=h,
|
||||
remote=remote,
|
||||
org=o,
|
||||
repo=r,
|
||||
pr_number=pr_number,
|
||||
request_metadata={"source": "acquire_merger_pr_lease"},
|
||||
):
|
||||
posted = api_request("POST", comment_url, auth, {"body": body})
|
||||
|
||||
session_lease = reviewer_pr_lease.record_session_lease({
|
||||
"pr_number": pr_number,
|
||||
"issue_number": issue_number,
|
||||
"session_id": sid,
|
||||
"reviewer_identity": identity,
|
||||
"profile": profile.get("profile_name"),
|
||||
"worktree": worktree,
|
||||
"phase": "claimed",
|
||||
"candidate_head": candidate_head,
|
||||
"target_branch": target_branch,
|
||||
"target_branch_sha": target_branch_sha,
|
||||
"repo": repo_label,
|
||||
"comment_id": posted.get("id"),
|
||||
}, lease_provenance=merger_lease_adoption.build_lease_provenance(
|
||||
source=merger_lease_adoption.SOURCE_ACQUIRE_MERGER,
|
||||
comment_id=posted.get("id"),
|
||||
))
|
||||
return {
|
||||
"success": True,
|
||||
"acquired": True,
|
||||
"pr_number": pr_number,
|
||||
"session_id": sid,
|
||||
"comment_id": posted.get("id"),
|
||||
"session_lease": session_lease,
|
||||
"reasons": [],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_adopt_merger_pr_lease(
|
||||
pr_number: int,
|
||||
@@ -12025,8 +11915,16 @@ _RUNTIME_CAPABILITY_TASKS = (
|
||||
def _matching_configured_profiles(
|
||||
config: dict | None,
|
||||
required_permission: str,
|
||||
required_role_kind: str | None = None,
|
||||
) -> list[str]:
|
||||
"""Profile names that allow *required_permission* (redacted metadata only)."""
|
||||
"""Profile names that allow *required_permission* (redacted metadata only).
|
||||
|
||||
#723 AC5: when *required_role_kind* is supplied (role-exclusive tasks),
|
||||
a profile with a declared role must also match it — mirroring the
|
||||
resolver's matching rule so the two lists cannot drift. Profiles without
|
||||
a declared role still match on permission alone, exactly like the
|
||||
resolver.
|
||||
"""
|
||||
if not config or "profiles" not in config:
|
||||
return []
|
||||
matches: list[str] = []
|
||||
@@ -12050,7 +11948,13 @@ def _matching_configured_profiles(
|
||||
ok, _ = gitea_config.check_operation(
|
||||
required_permission, p_allowed_n, p_forbidden_n
|
||||
)
|
||||
if ok:
|
||||
p_role = (p_data.get("role") or "").strip()
|
||||
role_ok = (
|
||||
required_role_kind is None
|
||||
or not p_role
|
||||
or p_role == required_role_kind
|
||||
)
|
||||
if ok and role_ok:
|
||||
matches.append(p_name)
|
||||
return sorted(matches)
|
||||
|
||||
@@ -12059,8 +11963,16 @@ def _build_runtime_task_capabilities(
|
||||
allowed: list[str],
|
||||
forbidden: list[str],
|
||||
config: dict | None,
|
||||
active_role_kind: str | None = None,
|
||||
) -> dict:
|
||||
"""Per-task capability summary for role-aware runtime context (#139)."""
|
||||
"""Per-task capability summary for role-aware runtime context (#139).
|
||||
|
||||
#723 AC5: applies the same role-exclusive filter as
|
||||
``gitea_resolve_task_capability`` when *active_role_kind* is supplied, so
|
||||
runtime context can never report a role-exclusive task as allowed while
|
||||
the resolver fail-closes it in the same session (incident #722). Without
|
||||
an *active_role_kind* the view is permission-only and each entry says so.
|
||||
"""
|
||||
task_entries = []
|
||||
flags: dict[str, bool] = {}
|
||||
flag_keys = {
|
||||
@@ -12073,18 +11985,35 @@ def _build_runtime_task_capabilities(
|
||||
"close_issue": "can_close_issues",
|
||||
"reconcile_already_landed_pr": "can_reconcile_already_landed_prs",
|
||||
}
|
||||
role_filter_applied = active_role_kind is not None
|
||||
for task in _RUNTIME_CAPABILITY_TASKS:
|
||||
permission = task_capability_map.required_permission(task)
|
||||
allowed_here, _ = gitea_config.check_operation(
|
||||
required_role_kind = task_capability_map.required_role(task)
|
||||
role_exclusive = task in task_capability_map.ROLE_EXCLUSIVE_TASKS
|
||||
permission_allowed, _ = gitea_config.check_operation(
|
||||
permission, allowed, forbidden
|
||||
)
|
||||
role_ok = (
|
||||
not role_exclusive
|
||||
or not role_filter_applied
|
||||
or active_role_kind == required_role_kind
|
||||
)
|
||||
allowed_here = permission_allowed and role_ok
|
||||
entry = {
|
||||
"task": task,
|
||||
"required_permission": permission,
|
||||
"required_role_kind": task_capability_map.required_role(task),
|
||||
"required_role_kind": required_role_kind,
|
||||
"role_exclusive": role_exclusive,
|
||||
"capability_view": (
|
||||
"role_filtered" if role_filter_applied else "permission_only"
|
||||
),
|
||||
"allowed_in_current_session": allowed_here,
|
||||
"matching_configured_profiles": _matching_configured_profiles(
|
||||
config, permission
|
||||
config,
|
||||
permission,
|
||||
required_role_kind=(
|
||||
required_role_kind if role_exclusive else None
|
||||
),
|
||||
),
|
||||
}
|
||||
task_entries.append(entry)
|
||||
@@ -12537,7 +12466,10 @@ def gitea_get_runtime_context(
|
||||
)
|
||||
|
||||
session_capabilities = _build_runtime_task_capabilities(
|
||||
allowed, forbidden, config
|
||||
allowed,
|
||||
forbidden,
|
||||
config,
|
||||
active_role_kind=_profile_role_kind(profile),
|
||||
)
|
||||
|
||||
preflight = assess_preflight_status(worktree_path)
|
||||
@@ -13955,26 +13887,9 @@ def gitea_resolve_task_capability(
|
||||
|
||||
required_permission = task_capability_map.required_permission(task)
|
||||
required_role = task_capability_map.required_role(task)
|
||||
role_exclusive_tasks = {
|
||||
"review_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"merge_pr",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"reconciliation_cleanup",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
}
|
||||
# #723 AC5: single shared definition; the runtime-context capability
|
||||
# report applies the same set so the two views cannot drift.
|
||||
role_exclusive_tasks = task_capability_map.ROLE_EXCLUSIVE_TASKS
|
||||
|
||||
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
|
||||
if required_role == "reviewer":
|
||||
@@ -14055,7 +13970,12 @@ def gitea_resolve_task_capability(
|
||||
"exact_safe_next_action": next_safe_action,
|
||||
}
|
||||
|
||||
record_preflight_check("capability", required_role, resolved_task=task)
|
||||
# #723 AC3: record the capability purity baseline now, but DEFER the
|
||||
# resolved-role/task stamp until after the allow/deny decision below. A
|
||||
# denied resolve previously stamped ``_preflight_resolved_role`` anyway
|
||||
# (PR #721: a denied review_pr resolve stamped 'merger', and the later
|
||||
# submit escaped as a generic internal_error instead of failing closed).
|
||||
record_preflight_check("capability")
|
||||
|
||||
# Try automatic dispatch switching
|
||||
_ensure_matching_profile(required_permission, required_role, remote, host)
|
||||
@@ -14090,6 +14010,14 @@ def gitea_resolve_task_capability(
|
||||
permission_allowed_in_current_session and role_matches_current_session
|
||||
)
|
||||
|
||||
# #723 AC3: stamp the resolved role/task only for an ALLOWED resolve; a
|
||||
# denied resolve clears any stale stamp so later mutation preflights key
|
||||
# off the real profile role and fail closed with structured reasons.
|
||||
if allowed_in_current_session:
|
||||
record_preflight_check("capability", required_role, resolved_task=task)
|
||||
else:
|
||||
_clear_resolved_capability_stamp()
|
||||
|
||||
switching = gitea_config.is_runtime_switching_enabled()
|
||||
available_in_session = allowed_in_current_session
|
||||
configured = False
|
||||
|
||||
@@ -18,13 +18,11 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
|
||||
|
||||
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
|
||||
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
|
||||
SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease"
|
||||
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
|
||||
|
||||
SANCTIONED_PROVENANCE_SOURCES = frozenset({
|
||||
SOURCE_ADOPT,
|
||||
SOURCE_ACQUIRE,
|
||||
SOURCE_ACQUIRE_MERGER,
|
||||
SOURCE_HEARTBEAT,
|
||||
})
|
||||
|
||||
@@ -211,10 +209,8 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile(
|
||||
r"(?is)\b("
|
||||
r"gitea_adopt_merger_pr_lease|"
|
||||
r"gitea_acquire_reviewer_pr_lease|"
|
||||
r"gitea_acquire_merger_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|"
|
||||
r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|"
|
||||
r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|"
|
||||
r"adoption_comment_id\s*[:=]|"
|
||||
|
||||
+26
-4
@@ -86,10 +86,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
"acquire_merger_pr_lease": {
|
||||
"permission": "gitea.pr.comment",
|
||||
"role": "merger",
|
||||
},
|
||||
# #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases.
|
||||
# Apply path posts lease release + audit comments (gitea.pr.comment).
|
||||
"cleanup_obsolete_reviewer_comment_lease": {
|
||||
@@ -334,6 +330,32 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# #723 AC1/AC5: tasks where permission alone is NOT enough — the profile's
|
||||
# role kind must also match. Shared by ``gitea_resolve_task_capability`` and
|
||||
# the runtime-context capability report so the two can never disagree about
|
||||
# which tasks are role-exclusive (incident #722: runtime context said
|
||||
# review_pr was allowed while the resolver fail-closed the same session).
|
||||
ROLE_EXCLUSIVE_TASKS: frozenset[str] = frozenset({
|
||||
"review_pr",
|
||||
"approve_pr",
|
||||
"request_changes_pr",
|
||||
"blind_pr_queue_review",
|
||||
"pr_queue_cleanup",
|
||||
"pr-queue-cleanup",
|
||||
"merge_pr",
|
||||
"create_branch",
|
||||
"push_branch",
|
||||
"create_pr",
|
||||
"commit_files",
|
||||
"gitea_commit_files",
|
||||
"address_pr_change_requests",
|
||||
"delete_branch",
|
||||
"cleanup_merged_pr_branch",
|
||||
"reconciliation_cleanup",
|
||||
"work_issue",
|
||||
"work-issue",
|
||||
})
|
||||
|
||||
# Issue-mutating MCP tools and their resolver task keys.
|
||||
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
||||
"gitea_create_issue": "create_issue",
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""#723 AC3/AC4/AC5: denied resolves must not poison the session role stamp,
|
||||
review-submission workspace failures must fail closed with structured
|
||||
reasons, and runtime-context capabilities must apply the resolver's
|
||||
role-exclusive filter.
|
||||
|
||||
Reproduces the PR #721 sequence: a reviewer session resolved a task whose
|
||||
capability-map role had drifted to ``merger``; the denied resolve stamped
|
||||
``_preflight_resolved_role = "merger"`` anyway, ``mark_final`` succeeded, and
|
||||
``gitea_submit_pr_review`` raised RuntimeError out of the tool as a generic
|
||||
``internal_error``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = str(Path(__file__).resolve().parent.parent)
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
import task_capability_map
|
||||
|
||||
|
||||
REVIEWER_PROFILE = {
|
||||
"profile_name": "prgs-reviewer",
|
||||
"role": "reviewer",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.branch.create",
|
||||
"gitea.branch.push",
|
||||
"gitea.repo.commit",
|
||||
"gitea.pr.create",
|
||||
"gitea.pr.merge",
|
||||
],
|
||||
}
|
||||
|
||||
CONFIG = {
|
||||
"profiles": {
|
||||
"prgs-reviewer": {
|
||||
"role": "reviewer",
|
||||
"allowed_operations": REVIEWER_PROFILE["allowed_operations"],
|
||||
"forbidden_operations": REVIEWER_PROFILE["forbidden_operations"],
|
||||
},
|
||||
"prgs-merger": {
|
||||
"role": "merger",
|
||||
"allowed_operations": [
|
||||
"gitea.read",
|
||||
"gitea.pr.merge",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
],
|
||||
"forbidden_operations": [
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.request_changes",
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _reset_preflight():
|
||||
mcp_server._clear_preflight_capability_state()
|
||||
mcp_server._preflight_whoami_called = False
|
||||
mcp_server._preflight_whoami_violation = False
|
||||
|
||||
|
||||
class _ResolveHarness(unittest.TestCase):
|
||||
"""Shared patched-resolver harness."""
|
||||
|
||||
def setUp(self):
|
||||
_reset_preflight()
|
||||
|
||||
def tearDown(self):
|
||||
_reset_preflight()
|
||||
|
||||
def _resolve(self, task, profile=REVIEWER_PROFILE, required_role=None):
|
||||
"""Run gitea_resolve_task_capability with a fixed profile/config."""
|
||||
patches = [
|
||||
patch.object(mcp_server, "get_profile", return_value=profile),
|
||||
patch.object(
|
||||
mcp_server.gitea_config, "load_config", return_value=CONFIG
|
||||
),
|
||||
patch.object(
|
||||
mcp_server, "_authenticated_username", return_value="tester"
|
||||
),
|
||||
patch.object(
|
||||
mcp_server, "_ensure_matching_profile", return_value=None
|
||||
),
|
||||
patch.object(
|
||||
mcp_server, "init_review_decision_lock", return_value=None
|
||||
),
|
||||
]
|
||||
if required_role is not None:
|
||||
patches.append(
|
||||
patch.object(
|
||||
mcp_server.task_capability_map,
|
||||
"required_role",
|
||||
side_effect=lambda t: (
|
||||
required_role
|
||||
if t == task
|
||||
else task_capability_map.TASK_CAPABILITY_MAP[t]["role"]
|
||||
),
|
||||
)
|
||||
)
|
||||
for p in patches:
|
||||
p.__enter__()
|
||||
try:
|
||||
return mcp_server.gitea_resolve_task_capability(
|
||||
task=task, remote="prgs"
|
||||
)
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.__exit__(None, None, None)
|
||||
|
||||
|
||||
class TestAC3DeniedResolveDoesNotStampRole(_ResolveHarness):
|
||||
def test_allowed_resolve_stamps_role(self):
|
||||
result = self._resolve("review_pr")
|
||||
self.assertTrue(result["allowed_in_current_session"], result)
|
||||
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||
self.assertEqual(mcp_server._preflight_resolved_task, "review_pr")
|
||||
|
||||
def test_denied_resolve_does_not_stamp_required_role(self):
|
||||
"""The PR #721 poison: review_pr requiring merger under a reviewer."""
|
||||
result = self._resolve("review_pr", required_role="merger")
|
||||
self.assertFalse(result["allowed_in_current_session"], result)
|
||||
self.assertIsNone(
|
||||
mcp_server._preflight_resolved_role,
|
||||
"denied resolve must not stamp the required role (#723 AC3)",
|
||||
)
|
||||
self.assertIsNone(mcp_server._preflight_resolved_task)
|
||||
|
||||
def test_denied_resolve_clears_prior_stale_stamp(self):
|
||||
allowed = self._resolve("review_pr")
|
||||
self.assertTrue(allowed["allowed_in_current_session"])
|
||||
self.assertEqual(mcp_server._preflight_resolved_role, "reviewer")
|
||||
|
||||
denied = self._resolve("merge_pr") # reviewer profile cannot merge
|
||||
self.assertFalse(denied["allowed_in_current_session"], denied)
|
||||
self.assertIsNone(
|
||||
mcp_server._preflight_resolved_role,
|
||||
"a denied resolve must clear the previous stamp, not keep it",
|
||||
)
|
||||
|
||||
def test_denied_resolve_keeps_effective_role_on_actual_profile(self):
|
||||
with patch.object(
|
||||
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||
):
|
||||
self._resolve("review_pr", required_role="merger")
|
||||
self.assertEqual(
|
||||
mcp_server._effective_workspace_role(), "reviewer"
|
||||
)
|
||||
|
||||
|
||||
class TestAC4SubmissionFailsClosedStructured(unittest.TestCase):
|
||||
def setUp(self):
|
||||
_reset_preflight()
|
||||
|
||||
def tearDown(self):
|
||||
_reset_preflight()
|
||||
|
||||
def test_workspace_binding_failure_returns_reasons_not_raise(self):
|
||||
boom = RuntimeError(
|
||||
"namespace workspace binding blocked: role 'merger' cannot "
|
||||
"mutate from reviewer session workspace"
|
||||
)
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"_verify_role_mutation_workspace",
|
||||
side_effect=boom,
|
||||
), patch.object(
|
||||
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||
):
|
||||
result = mcp_server._evaluate_pr_review_submission(
|
||||
pr_number=721,
|
||||
action="approve",
|
||||
expected_head_sha="80f59b334e6671b08006725292c08a8e8b6c823f",
|
||||
remote="prgs",
|
||||
live=True,
|
||||
final_review_decision_ready=True,
|
||||
)
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||
self.assertTrue(
|
||||
any("workspace/role binding failed" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
self.assertTrue(
|
||||
any("cannot mutate from reviewer session" in r for r in result["reasons"]),
|
||||
"the underlying binding error text must be preserved",
|
||||
)
|
||||
|
||||
def test_stale_runtime_failure_also_structured(self):
|
||||
boom = RuntimeError(
|
||||
"stale-runtime: The active Gitea MCP server process is stale"
|
||||
)
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"_verify_role_mutation_workspace",
|
||||
side_effect=boom,
|
||||
), patch.object(
|
||||
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||
):
|
||||
result = mcp_server._evaluate_pr_review_submission(
|
||||
pr_number=721,
|
||||
action="approve",
|
||||
remote="prgs",
|
||||
live=True,
|
||||
)
|
||||
self.assertFalse(result["performed"])
|
||||
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||
self.assertTrue(
|
||||
any("stale-runtime" in r for r in result["reasons"]),
|
||||
result["reasons"],
|
||||
)
|
||||
|
||||
def test_dry_run_also_fails_closed_structured(self):
|
||||
boom = RuntimeError("binding blocked")
|
||||
with patch.object(
|
||||
mcp_server,
|
||||
"_verify_role_mutation_workspace",
|
||||
side_effect=boom,
|
||||
), patch.object(
|
||||
mcp_server, "get_profile", return_value=REVIEWER_PROFILE
|
||||
):
|
||||
result = mcp_server._evaluate_pr_review_submission(
|
||||
pr_number=721,
|
||||
action="approve",
|
||||
remote="prgs",
|
||||
live=False,
|
||||
)
|
||||
self.assertFalse(result["would_perform"])
|
||||
self.assertEqual(result["blocker_kind"], "workspace_role_binding")
|
||||
|
||||
|
||||
class TestAC5RuntimeContextRoleFilter(unittest.TestCase):
|
||||
def test_role_filtered_view_matches_resolver_denial(self):
|
||||
"""Reviewer session: merge_pr stays denied under the role filter even
|
||||
when the permission is (pathologically) present."""
|
||||
allowed_ops = [
|
||||
"gitea.read",
|
||||
"gitea.pr.review",
|
||||
"gitea.pr.approve",
|
||||
"gitea.pr.request_changes",
|
||||
"gitea.pr.comment",
|
||||
"gitea.issue.comment",
|
||||
"gitea.pr.merge",
|
||||
]
|
||||
caps = mcp_server._build_runtime_task_capabilities(
|
||||
allowed_ops, [], CONFIG, active_role_kind="reviewer"
|
||||
)
|
||||
merge_entry = next(
|
||||
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||
)
|
||||
self.assertTrue(merge_entry["role_exclusive"])
|
||||
self.assertEqual(merge_entry["capability_view"], "role_filtered")
|
||||
self.assertFalse(
|
||||
merge_entry["allowed_in_current_session"],
|
||||
"role-exclusive merge_pr must stay denied for a reviewer role "
|
||||
"even when the permission is present (#723 AC5)",
|
||||
)
|
||||
self.assertFalse(caps["can_merge_prs"])
|
||||
|
||||
def test_role_filter_restricts_matching_profiles(self):
|
||||
caps = mcp_server._build_runtime_task_capabilities(
|
||||
["gitea.read"], [], CONFIG, active_role_kind="author"
|
||||
)
|
||||
review_entry = next(
|
||||
t for t in caps["task_capabilities"] if t["task"] == "review_pr"
|
||||
)
|
||||
self.assertEqual(
|
||||
review_entry["matching_configured_profiles"],
|
||||
["prgs-reviewer"],
|
||||
"role-exclusive review_pr must not list the merger profile",
|
||||
)
|
||||
merge_entry = next(
|
||||
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||
)
|
||||
self.assertEqual(
|
||||
merge_entry["matching_configured_profiles"], ["prgs-merger"]
|
||||
)
|
||||
|
||||
def test_permission_only_view_is_labeled(self):
|
||||
caps = mcp_server._build_runtime_task_capabilities(
|
||||
["gitea.pr.merge", "gitea.read"], [], CONFIG
|
||||
)
|
||||
merge_entry = next(
|
||||
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
|
||||
)
|
||||
self.assertEqual(merge_entry["capability_view"], "permission_only")
|
||||
# Legacy permission-only semantics preserved when no role supplied.
|
||||
self.assertTrue(merge_entry["allowed_in_current_session"])
|
||||
|
||||
def test_incident_722_shape_runtime_context_agrees_with_resolver(self):
|
||||
"""Post-970e68b shape: review_pr requires merger; a reviewer session
|
||||
must see review_pr denied in runtime context, matching the resolver."""
|
||||
with patch.object(
|
||||
mcp_server.task_capability_map,
|
||||
"required_role",
|
||||
side_effect=lambda t: (
|
||||
"merger"
|
||||
if t == "review_pr"
|
||||
else task_capability_map.TASK_CAPABILITY_MAP[t]["role"]
|
||||
),
|
||||
):
|
||||
caps = mcp_server._build_runtime_task_capabilities(
|
||||
REVIEWER_PROFILE["allowed_operations"],
|
||||
REVIEWER_PROFILE["forbidden_operations"],
|
||||
CONFIG,
|
||||
active_role_kind="reviewer",
|
||||
)
|
||||
review_entry = next(
|
||||
t for t in caps["task_capabilities"] if t["task"] == "review_pr"
|
||||
)
|
||||
self.assertFalse(
|
||||
review_entry["allowed_in_current_session"],
|
||||
"runtime context must not report review_pr allowed when the "
|
||||
"resolver would fail-close it (incident #722)",
|
||||
)
|
||||
self.assertFalse(caps["can_review_prs"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Merger lease acquisition tests (#718)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import gitea_mcp_server as mcp_server
|
||||
import reviewer_pr_lease as leases
|
||||
|
||||
|
||||
class TestMergerLeaseAcquireTool(unittest.TestCase):
|
||||
def setUp(self):
|
||||
mcp_server._preflight_whoami_called = True
|
||||
mcp_server._preflight_capability_called = True
|
||||
mcp_server._preflight_resolved_role = "merger"
|
||||
leases.clear_session_lease()
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||
@patch("gitea_mcp_server._resolve", return_value=("dadeschools", "org", "repo"))
|
||||
@patch("gitea_mcp_server.get_profile", return_value={"username": "merger-user", "profile_name": "prgs-merger", "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"], "forbidden_operations": []})
|
||||
@patch("gitea_mcp_server._fetch_pr_comments", return_value=[])
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_lease_success(self, _verify, _comments, _profile, _resolve, _auth, mock_api):
|
||||
"""Merger lease acquisition succeeds when no reviewer lease exists."""
|
||||
# api_request is called for PR state, then for POSTing comment
|
||||
mock_api.side_effect = [
|
||||
{"state": "open"}, # PR is open
|
||||
{"id": 456}, # Posted comment ID
|
||||
]
|
||||
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
session_id="merger-session",
|
||||
)
|
||||
|
||||
self.assertTrue(res["success"])
|
||||
self.assertTrue(res["acquired"])
|
||||
self.assertEqual(res["comment_id"], 456)
|
||||
self.assertEqual(res["session_id"], "merger-session")
|
||||
|
||||
# Verify lease body includes merger identity
|
||||
args, kwargs = mock_api.call_args_list[-1]
|
||||
self.assertEqual(args[0], "POST")
|
||||
body = args[3]["body"]
|
||||
self.assertIn("reviewer_identity: merger-user", body)
|
||||
self.assertIn("profile: prgs-merger", body)
|
||||
self.assertIn("phase: claimed", body)
|
||||
|
||||
# Post-mutation readback: lease is recorded in session
|
||||
session_lease = leases._SESSION_LEASE
|
||||
self.assertIsNotNone(session_lease)
|
||||
self.assertEqual(session_lease["phase"], "claimed")
|
||||
self.assertEqual(session_lease["session_id"], "merger-session")
|
||||
|
||||
# Verify lease provenance uses SOURCE_ACQUIRE_MERGER
|
||||
provenance = session_lease.get("lease_provenance") or {}
|
||||
self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease")
|
||||
|
||||
@patch("gitea_mcp_server.api_request")
|
||||
@patch("gitea_mcp_server._auth", return_value="token pass")
|
||||
@patch("gitea_mcp_server._resolve", return_value=("dadeschools", "org", "repo"))
|
||||
@patch("gitea_mcp_server.get_profile", return_value={"username": "merger-user", "profile_name": "prgs-merger", "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"], "forbidden_operations": []})
|
||||
@patch("gitea_mcp_server._fetch_pr_comments")
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_lease_fails_if_active_exists(self, _verify, _comments, _profile, _resolve, _auth, mock_api):
|
||||
"""Acquisition fails closed if another session holds an active lease."""
|
||||
active_body = leases.format_lease_body(
|
||||
repo="org/repo",
|
||||
pr_number=718,
|
||||
issue_number=None,
|
||||
reviewer_identity="other-user",
|
||||
profile="prgs-reviewer",
|
||||
session_id="other-session",
|
||||
worktree="branches/review-1",
|
||||
phase="claimed",
|
||||
candidate_head="a" * 40,
|
||||
target_branch="master",
|
||||
target_branch_sha="b" * 40,
|
||||
last_activity=datetime.now(timezone.utc),
|
||||
)
|
||||
_comments.return_value = [{"id": 1, "body": active_body, "user": {"login": "other-user"}}]
|
||||
|
||||
# PR is open
|
||||
mock_api.return_value = {"state": "open"}
|
||||
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
session_id="merger-session",
|
||||
)
|
||||
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["acquired"])
|
||||
self.assertIn("already has active reviewer lease", " ".join(res["reasons"]))
|
||||
|
||||
# No POST made
|
||||
post_calls = [c for c in mock_api.call_args_list if c[0][0] == "POST"]
|
||||
self.assertEqual(len(post_calls), 0)
|
||||
|
||||
# No partial state recorded
|
||||
self.assertIsNone(leases._SESSION_LEASE)
|
||||
|
||||
@patch("gitea_mcp_server.get_profile", return_value={"username": "merger-user", "profile_name": "prgs-merger", "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"], "forbidden_operations": []})
|
||||
@patch("gitea_mcp_server._verify_role_mutation_workspace")
|
||||
def test_acquire_merger_lease_fails_workspace_binding(self, _verify, _profile):
|
||||
"""Fails closed if workspace verification throws RuntimeError."""
|
||||
_verify.side_effect = RuntimeError("Branches-only mutation guard")
|
||||
|
||||
with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}):
|
||||
res = mcp_server.gitea_acquire_merger_pr_lease(
|
||||
pr_number=718,
|
||||
worktree="branches/issue-718",
|
||||
session_id="merger-session",
|
||||
)
|
||||
|
||||
self.assertFalse(res["success"])
|
||||
self.assertFalse(res["acquired"])
|
||||
self.assertIn("Branches-only mutation guard", res["reasons"][0])
|
||||
self.assertIsNone(leases._SESSION_LEASE)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,7 +19,12 @@ import unittest
|
||||
|
||||
import gitea_config
|
||||
from role_session_router import MERGER_TASKS, REVIEWER_TASKS
|
||||
from task_capability_map import required_permission, required_role
|
||||
from task_capability_map import (
|
||||
ROLE_EXCLUSIVE_TASKS,
|
||||
TASK_CAPABILITY_MAP,
|
||||
required_permission,
|
||||
required_role,
|
||||
)
|
||||
|
||||
# Canonical role-profile permission shape. Mirrors the configured
|
||||
# author/reviewer/merger/reconciler profiles (profiles.json v2 role split):
|
||||
@@ -201,5 +206,44 @@ class TestMergerBoundary(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestRoleExclusiveSetIntegrity(unittest.TestCase):
|
||||
"""#723 AC1/AC5: the shared role-exclusive set stays coherent."""
|
||||
|
||||
def test_every_role_exclusive_task_exists_in_capability_map(self):
|
||||
for task in sorted(ROLE_EXCLUSIVE_TASKS):
|
||||
with self.subTest(task=task):
|
||||
self.assertIn(
|
||||
task,
|
||||
TASK_CAPABILITY_MAP,
|
||||
f"role-exclusive task {task!r} missing from the "
|
||||
f"capability map — required_role() would raise and the "
|
||||
f"resolver would 500 instead of failing closed",
|
||||
)
|
||||
|
||||
def test_formal_review_tasks_are_role_exclusive(self):
|
||||
for task in FORMAL_REVIEW_TASKS:
|
||||
with self.subTest(task=task):
|
||||
self.assertIn(task, ROLE_EXCLUSIVE_TASKS)
|
||||
|
||||
def test_merge_pr_is_role_exclusive_and_merger_satisfiable(self):
|
||||
"""AC1 merger equivalent: merging must stay possible for a canonical
|
||||
merger profile (permission AND role together)."""
|
||||
self.assertIn("merge_pr", ROLE_EXCLUSIVE_TASKS)
|
||||
self.assertTrue(
|
||||
_profile_satisfies("merger", "merge_pr"),
|
||||
"no canonical merger profile satisfies merge_pr — merging would "
|
||||
"be impossible for every configured profile",
|
||||
)
|
||||
|
||||
def test_reconciler_cleanup_tasks_stay_reconciler_satisfiable(self):
|
||||
for task in ("cleanup_merged_pr_branch", "reconciliation_cleanup"):
|
||||
with self.subTest(task=task):
|
||||
self.assertIn(task, ROLE_EXCLUSIVE_TASKS)
|
||||
self.assertTrue(
|
||||
_profile_satisfies("reconciler", task),
|
||||
f"no canonical reconciler profile satisfies {task!r}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user