fix(mcp): role-exclusive capability invariants and structured fail-closed submit (Closes #723)
Defect A - invariants: - task_capability_map.ROLE_EXCLUSIVE_TASKS is now the single shared definition of role-exclusive tasks; the resolver's inline copy is gone (AC1/AC5 drift surface removed). - tests: every role-exclusive task must exist in the capability map; formal-review tasks stay role-exclusive; canonical merger satisfies merge_pr; canonical reconciler satisfies branch-cleanup tasks (extends the #722 break-glass invariant suite). Defect B - unactionable internal_error: - AC3: gitea_resolve_task_capability records the capability purity baseline first but stamps _preflight_resolved_role/_task only for an ALLOWED resolve; a denied resolve clears any stale stamp (_clear_resolved_capability_stamp) so later mutation preflights key off the real profile role. - AC4: _evaluate_pr_review_submission converts _verify_role_mutation_workspace failures (role binding, stale runtime) into result reasons with blocker_kind=workspace_role_binding instead of letting RuntimeError escape as a generic internal_error. - AC5: _build_runtime_task_capabilities applies the resolver's role-exclusive filter when given the active role kind and labels each entry role_filtered/permission_only; matching_configured_profiles honors declared profile roles for role-exclusive tasks. Validation: focused suites 22 passed (+48 subtests); adjacent resolver/ runtime/review suites 72 passed; full suite 2929 passed, 6 skipped, 221 subtests (single pre-existing Starlette warning, #682). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+93
-31
@@ -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())
|
||||
@@ -11891,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] = []
|
||||
@@ -11916,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)
|
||||
|
||||
@@ -11925,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 = {
|
||||
@@ -11939,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)
|
||||
@@ -12403,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)
|
||||
@@ -13821,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":
|
||||
@@ -13921,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)
|
||||
@@ -13956,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
|
||||
|
||||
Reference in New Issue
Block a user