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:
2026-07-17 01:00:12 -04:00
co-authored by Claude Opus 4.8
parent 67e4a2b5e9
commit bc9366c394
4 changed files with 501 additions and 32 deletions
+93 -31
View File
@@ -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