Clarify and enforce reviewer-vs-merger role boundaries (#483)

This commit is contained in:
2026-07-08 02:12:26 -04:00
parent 7af73e1539
commit a863928661
12 changed files with 294 additions and 55 deletions
+56 -24
View File
@@ -137,13 +137,13 @@ def verify_mutation_authority(remote: str | None, host: str | None = None,
)
# Reviewer/author role pivot boundary: only an authorized pivot
# (recorded by gitea_activate_profile) may cross author reviewer.
if (required_role == "reviewer"
# (recorded by gitea_activate_profile) may cross author -> reviewer/merger.
if (required_role in ("reviewer", "merger")
and "author" in str(data.get("initial_profile")).lower()
and "reviewer" in str(active_profile).lower()):
and ("reviewer" in str(active_profile).lower() or "merger" in str(active_profile).lower())):
if not data.get("role_pivot_authorized"):
raise RuntimeError(
"Attempted reviewer mutation from author session without "
f"Attempted {required_role} mutation from author session without "
"authorized role pivot (fail closed)"
)
@@ -418,7 +418,7 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None):
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
"""#274: author mutations must run from a branches/ session worktree."""
if _preflight_resolved_role == "reviewer":
if _preflight_resolved_role in ("reviewer", "reconciler", "merger"):
return
ctx = _resolve_author_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
@@ -1872,13 +1872,23 @@ def gitea_check_pr_eligibility(
# ("gitea.pr.merge") always match each other and never cross services.
allowed = profile["allowed_operations"]
forbidden = profile["forbidden_operations"]
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
active_role = _role_kind(allowed, forbidden)
if action == "merge" and active_role == "reviewer":
op_ok = False
op_reason = "reviewer-cannot-merge"
else:
op_ok, op_reason = gitea_config.check_operation(action, allowed, forbidden)
if not op_ok:
if op_reason == "no-allowed-operations":
reasons.append(
"profile has no configured allowed operations (fail closed)")
elif op_reason == "forbidden":
reasons.append(f"profile forbids '{action}'")
elif op_reason == "reviewer-cannot-merge":
reasons.append(
"Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head."
)
elif op_reason == "invalid-forbidden-entry":
reasons.append(
"profile has an unrecognized forbidden operation entry "
@@ -1902,21 +1912,23 @@ def gitea_check_pr_eligibility(
missing_perm)
req_profile = None
role_noun = "reviewer"
if action in ("approve", "request_changes", "review"):
req_profile = "A profile with reviewer role permissions (allowing approve/merge/review, and forbidding author operations)"
req_profile = "A profile with reviewer role permissions (allowing approve/review, and forbidding author operations)"
elif action == "merge":
req_profile = "A profile with reviewer role permissions and explicit merge permission"
req_profile = "A profile with merger role permissions (allowing merge, and forbidding author operations)"
role_noun = "merger"
result["required_profile"] = req_profile
switching_supported = gitea_config.is_runtime_switching_enabled()
if switching_supported:
result["fixable_by_profile_switch"] = True
result["requires_different_namespace"] = False
safe_step = f"Switch to a reviewer profile by calling gitea_activate_profile with a profile that allows {action}."
safe_step = f"Switch to a {role_noun} profile by calling gitea_activate_profile with a profile that allows {action}."
else:
result["fixable_by_profile_switch"] = False
result["requires_different_namespace"] = True
safe_step = "Switch to the reviewer MCP session (e.g. gitea-reviewer) which has reviewer permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a reviewer profile."
safe_step = f"Switch to the {role_noun} MCP session (e.g. gitea-{role_noun}) which has {role_noun} permissions configured, or ask the operator to update GITEA_MCP_PROFILE to a {role_noun} profile."
result["safe_next_step"] = safe_step
return result
@@ -3419,6 +3431,12 @@ def gitea_merge_pr(
available. Never secrets.
"""
verify_preflight_purity(remote)
allowed = get_profile()["allowed_operations"]
forbidden = get_profile()["forbidden_operations"]
active_role = _role_kind(allowed, forbidden)
if active_role == "reviewer":
raise RuntimeError("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.")
do = (do or "").strip().lower()
result = {
"performed": False,
@@ -3434,6 +3452,10 @@ def gitea_merge_pr(
"merge_result": None,
"merge_commit": None,
"reasons": [],
"active_profile": get_profile()["profile_name"],
"role_kind": active_role,
"merge_capability_source": "profile" if "gitea.pr.merge" in allowed else "config",
"explicit_operator_auth": confirmation,
}
reasons = result["reasons"]
@@ -3599,7 +3621,7 @@ def gitea_merge_pr(
# A profile/identity flip or side-channel override between preflight
# and merge fails closed here.
try:
verify_mutation_authority(remote, host, required_role="reviewer",
verify_mutation_authority(remote, host, required_role="merger",
active_identity=auth_user)
except RuntimeError as e:
reasons.append(str(e))
@@ -5214,26 +5236,28 @@ def _role_kind(allowed, forbidden) -> str:
"""Classify the active profile from its normalized operations.
'reconciler' can close already-landed PRs without review/author powers;
'author' can create PRs / push branches; 'reviewer' can approve/merge;
'reconciler' can close already-landed PRs via ``gitea.pr.close`` without
review/author powers; 'mixed' can do both (a config smell the
reviewer-deadlock invariant forbids it in v2 configs); 'limited' can do
neither.
'author' can create PRs / push branches; 'reviewer' can approve;
'merger' can merge PRs;
'mixed' can do multiple (a config smell); 'limited' can do neither.
"""
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
return "reconciler"
def can(op):
return gitea_config.check_operation(op, allowed, forbidden)[0]
review = can("gitea.pr.approve") or can("gitea.pr.merge")
can_merge = can("gitea.pr.merge")
can_approve = can("gitea.pr.approve")
author = can("gitea.pr.create") or can("gitea.branch.push")
reconciler = (
can("gitea.pr.close")
and not review
and not can_approve
and not can_merge
and not author
)
if review and author:
if (can_approve or can_merge) and author:
return "mixed"
if review:
if can_merge:
return "merger"
if can_approve:
return "reviewer"
if reconciler:
return "reconciler"
@@ -5749,12 +5773,20 @@ def mcp_get_control_plane_guide(
"gitea.issue.comment (separate from gitea.pr.comment).")
elif role == "reviewer":
guidance.append(
"Reviewer profile: review/approve/merge may proceed ONLY after "
"Reviewer profile: review/approve may proceed ONLY after "
"gitea_check_pr_eligibility passes and the PR head SHA is "
"pinned (expected_head_sha); the PR author must be a different "
"user, and merging additionally requires explicit operator "
"authorization plus the 'MERGE PR <n>' confirmation. "
"PR comments do not imply issue comments.")
"user. Reviewer profiles cannot merge PRs. "
"PR comments do not imply issue comments. "
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
elif role == "merger":
guidance.append(
"Merger profile: merge may proceed ONLY after formal approval at "
"current head, gitea_check_pr_eligibility passes, and the PR head "
"SHA is pinned (expected_head_sha); the PR author must be a different "
"user, and merging requires explicit operator authorization plus the "
"'MERGE PR <n>' confirmation. "
"Review and merge are separate workflow roles. A reviewer approval is not merge authorization.")
elif role == "mixed":
guidance.append(
"WARNING: this profile allows both authoring and "