Merge pull request 'fix: enforce merger role boundaries (Closes #539)' (#596) from fix/issue-539-role-boundary-hard-stops into master

This commit was merged in pull request #596.
This commit is contained in:
2026-07-09 17:07:54 -05:00
4 changed files with 175 additions and 9 deletions
+63 -6
View File
@@ -222,6 +222,21 @@ def _effective_workspace_role() -> str:
)
def _profile_role_kind(profile: dict) -> str:
"""Resolve a profile's declared role before inferring from permissions."""
role = (profile.get("role") or profile.get("role_kind") or "").strip()
if role:
return role
profile_name = (profile.get("profile_name") or "").strip().lower()
for candidate in ("reconciler", "merger", "reviewer", "author"):
if candidate in profile_name:
return candidate
return _role_kind(
profile.get("allowed_operations") or [],
profile.get("forbidden_operations") or [],
)
def _actual_profile_role() -> str:
"""Resolve the workspace role from the ACTIVE PROFILE alone (#540).
@@ -234,10 +249,7 @@ def _actual_profile_role() -> str:
``author`` here, so author blocking is preserved.
"""
profile = get_profile()
role = _role_kind(
profile.get("allowed_operations") or [],
profile.get("forbidden_operations") or [],
)
role = _profile_role_kind(profile)
return nwb.normalize_role_kind(
role,
profile_name=profile.get("profile_name"),
@@ -10255,6 +10267,24 @@ 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",
"work_issue",
"work-issue",
}
infra_assessment = role_session_router.assess_infra_stop(PROJECT_ROOT)
if required_role == "reviewer":
@@ -10349,11 +10379,26 @@ def gitea_resolve_task_capability(
# Load active permissions
active_allowed = profile.get("allowed_operations") or []
active_forbidden = profile.get("forbidden_operations") or []
active_role_kind = _profile_role_kind(profile)
# Check if allowed in current session
allowed_in_current_session, _ = gitea_config.check_operation(
permission_allowed_in_current_session, _ = gitea_config.check_operation(
required_permission, active_allowed, active_forbidden
)
role_matches_current_session = (
task not in role_exclusive_tasks
or active_role_kind == required_role
)
role_mismatch_reason = None
if not role_matches_current_session:
role_mismatch_reason = (
f"Active profile role '{active_role_kind}' cannot perform "
f"{required_role} task '{task}' even if nearby permissions are "
"present (fail closed)."
)
allowed_in_current_session = (
permission_allowed_in_current_session and role_matches_current_session
)
switching = gitea_config.is_runtime_switching_enabled()
available_in_session = allowed_in_current_session
@@ -10380,7 +10425,13 @@ def gitea_resolve_task_capability(
except Exception:
pass
ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n)
if ok:
p_role = (p_data.get("role") or "").strip()
p_role_ok = (
task not in role_exclusive_tasks
or not p_role
or p_role == required_role
)
if ok and p_role_ok:
matching_profiles.append(p_name)
configured = len(matching_profiles) > 0
@@ -10408,6 +10459,8 @@ def gitea_resolve_task_capability(
reason_msg = (
f"No profile configured with permission '{required_permission}'."
)
elif role_mismatch_reason:
reason_msg = role_mismatch_reason
different_namespace_required = False
next_safe_action = "None; ready for operations."
@@ -10439,6 +10492,8 @@ def gitea_resolve_task_capability(
# into author-side mutations.
stop_required = not allowed_in_current_session
task_role_guidance = []
if role_mismatch_reason:
task_role_guidance.append(f"STOP: {role_mismatch_reason}")
if required_role == "reviewer":
if allowed_in_current_session:
task_role_guidance.append(
@@ -10484,7 +10539,9 @@ def gitea_resolve_task_capability(
"required_role_kind": required_role,
"active_profile": profile["profile_name"],
"active_identity": username,
"active_role_kind": active_role_kind,
"active_profile_allowed_operations": active_allowed,
"active_profile_permission_allowed": permission_allowed_in_current_session,
"allowed_in_current_session": allowed_in_current_session,
"available_in_session": available_in_session,
"configured": configured,