496 lines
16 KiB
Python
496 lines
16 KiB
Python
"""Shared task→permission map for resolver and tool gates (#69).
|
|
|
|
``gitea_resolve_task_capability`` and issue-mutating MCP tools must agree on
|
|
which profile operation each task requires. This module is the single source
|
|
of truth; regression tests assert tool gates cannot drift from it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|
"create_issue": {
|
|
"permission": "gitea.issue.create",
|
|
"role": "author",
|
|
},
|
|
"comment_issue": {
|
|
"permission": "gitea.issue.comment",
|
|
"role": "author",
|
|
},
|
|
"close_issue": {
|
|
"permission": "gitea.issue.close",
|
|
"role": "author",
|
|
},
|
|
"claim_issue": {
|
|
"permission": "gitea.issue.comment",
|
|
"role": "author",
|
|
},
|
|
"mark_issue": {
|
|
"permission": "gitea.issue.comment",
|
|
"role": "author",
|
|
},
|
|
"lock_issue": {
|
|
"permission": "gitea.issue.comment",
|
|
"role": "author",
|
|
},
|
|
"set_issue_labels": {
|
|
"permission": "gitea.issue.comment",
|
|
"role": "author",
|
|
},
|
|
"create_label": {
|
|
"permission": "gitea.issue.comment",
|
|
"role": "author",
|
|
},
|
|
"create_branch": {
|
|
"permission": "gitea.branch.create",
|
|
"role": "author",
|
|
},
|
|
"push_branch": {
|
|
"permission": "gitea.branch.push",
|
|
"role": "author",
|
|
},
|
|
"create_pr": {
|
|
"permission": "gitea.pr.create",
|
|
"role": "author",
|
|
},
|
|
"comment_pr": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "author",
|
|
},
|
|
"close_pr": {
|
|
"permission": "gitea.pr.close",
|
|
"role": "author",
|
|
},
|
|
# Non-closing PR metadata edits (title/body/base). Closing uses close_pr.
|
|
"edit_pr": {
|
|
"permission": "gitea.pr.create",
|
|
"role": "author",
|
|
},
|
|
"gitea_edit_pr": {
|
|
"permission": "gitea.pr.create",
|
|
"role": "author",
|
|
},
|
|
"address_pr_change_requests": {
|
|
"permission": "gitea.branch.push",
|
|
"role": "author",
|
|
},
|
|
# PR synchronization lifecycle: assess is read-only (any role with gitea.read);
|
|
# update-by-merge is author-only and mutates the PR head via Gitea API.
|
|
"assess_pr_sync_status": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_assess_pr_sync_status": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"update_pr_branch_by_merge": {
|
|
"permission": "gitea.branch.push",
|
|
"role": "author",
|
|
},
|
|
"gitea_update_pr_branch_by_merge": {
|
|
"permission": "gitea.branch.push",
|
|
"role": "author",
|
|
},
|
|
"review_pr": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
"submit_pr_review": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
"merge_pr": {
|
|
"permission": "gitea.pr.merge",
|
|
"role": "merger",
|
|
},
|
|
"acquire_reviewer_pr_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "reviewer",
|
|
},
|
|
"gitea_acquire_reviewer_pr_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "reviewer",
|
|
},
|
|
# #695 AC8: controller quarantine of contaminated formal reviews.
|
|
# Apply path posts an append-only forensic audit comment (pr.comment).
|
|
"quarantine_contaminated_review": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "reconciler",
|
|
},
|
|
"gitea_quarantine_contaminated_review": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "reconciler",
|
|
},
|
|
"adopt_merger_pr_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "merger",
|
|
},
|
|
"gitea_adopt_merger_pr_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "merger",
|
|
},
|
|
"acquire_merger_pr_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "merger",
|
|
},
|
|
"gitea_acquire_merger_pr_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "merger",
|
|
},
|
|
# #742: owner-session terminal release/abandon of a merger-held lease when
|
|
# the merge does not occur. Apply path posts an append-only terminal lease
|
|
# marker (gitea.pr.comment); merger-only, never a reviewer path.
|
|
"release_merger_pr_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "merger",
|
|
},
|
|
"gitea_release_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": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "reviewer",
|
|
},
|
|
"gitea_cleanup_obsolete_reviewer_comment_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "reviewer",
|
|
},
|
|
# #745: post-merge moot reviewer-lease cleanup is reconciler-owned. The
|
|
# apply path posts an append-only terminal `phase: released` lease marker
|
|
# (gitea.pr.comment), so holding the comment permission alone must not
|
|
# authorize it — author, reviewer and merger fail closed on the role gate
|
|
# even though their profiles carry gitea.pr.comment. The read-only
|
|
# `apply=false` assessment deliberately stays reachable under gitea.read
|
|
# inside the tool (the same convention as cleanup_stale_review_decision_lock
|
|
# below), so any namespace can diagnose a stuck lease; only apply requires
|
|
# this task plus the reconciler role.
|
|
"cleanup_post_merge_moot_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "reconciler",
|
|
},
|
|
"gitea_cleanup_post_merge_moot_lease": {
|
|
"permission": "gitea.pr.comment",
|
|
"role": "reconciler",
|
|
},
|
|
"blind_pr_queue_review": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
"pr_queue_cleanup": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
"pr-queue-cleanup": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
"request_changes_pr": {
|
|
"permission": "gitea.pr.request_changes",
|
|
"role": "reviewer",
|
|
},
|
|
"approve_pr": {
|
|
"permission": "gitea.pr.approve",
|
|
"role": "reviewer",
|
|
},
|
|
# #594: clear durable #332 decision lock only when last terminal PR is
|
|
# already merged/closed (moot). Apply path requires reviewer review
|
|
# permission; assessment itself uses gitea.read inside the tool.
|
|
"cleanup_stale_review_decision_lock": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
"gitea_cleanup_stale_review_decision_lock": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
# #693: read-only classification of durable decision locks (incl. open PRs).
|
|
"diagnose_review_decision_lock": {
|
|
"permission": "gitea.read",
|
|
"role": "reviewer",
|
|
},
|
|
"gitea_diagnose_review_decision_lock": {
|
|
"permission": "gitea.read",
|
|
"role": "reviewer",
|
|
},
|
|
"authorize_review_correction": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
"gitea_authorize_review_correction": {
|
|
"permission": "gitea.pr.review",
|
|
"role": "reviewer",
|
|
},
|
|
# #709: truthful absence-of-proof recovery (server-side auth + record + consume).
|
|
# Dedicated mutation capability — gitea.read is insufficient (review 434 F1).
|
|
"issue_irrecoverable_provenance_authorization": {
|
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
|
"role": "reconciler",
|
|
},
|
|
"gitea_issue_irrecoverable_provenance_authorization": {
|
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
|
"role": "reconciler",
|
|
},
|
|
"record_irrecoverable_decision_lock_provenance": {
|
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
|
"role": "reconciler",
|
|
},
|
|
"gitea_record_irrecoverable_decision_lock_provenance": {
|
|
"permission": "gitea.decision_lock.irrecoverable_recovery",
|
|
"role": "reconciler",
|
|
},
|
|
"consume_irrecoverable_decision_lock_provenance": {
|
|
"permission": "gitea.pr.merge",
|
|
"role": "merger",
|
|
},
|
|
"gitea_consume_irrecoverable_decision_lock_provenance": {
|
|
"permission": "gitea.pr.merge",
|
|
"role": "merger",
|
|
},
|
|
# #729: delete_branch is reconciler-owned. gitea.branch.delete is granted
|
|
# only to the reconciler profile, so the resolver must classify this task as
|
|
# reconciler (previously "author", which no delete-capable profile held).
|
|
# Raw gitea_delete_branch still redirects reconciler to the guarded
|
|
# gitea_cleanup_merged_pr_branch path (#514/#687); author/reviewer/merger
|
|
# stay denied by both the permission gate and this role gate.
|
|
"delete_branch": {
|
|
"permission": "gitea.branch.delete",
|
|
"role": "reconciler",
|
|
},
|
|
"cleanup_merged_pr_branch": {
|
|
"permission": "gitea.branch.delete",
|
|
"role": "reconciler",
|
|
},
|
|
"commit_files": {
|
|
"permission": "gitea.repo.commit",
|
|
"role": "author",
|
|
},
|
|
"gitea_commit_files": {
|
|
"permission": "gitea.repo.commit",
|
|
"role": "author",
|
|
},
|
|
"reconcile_merged_cleanups": {
|
|
"permission": "gitea.read",
|
|
"role": "reconciler",
|
|
},
|
|
"reconciliation_cleanup": {
|
|
"permission": "gitea.branch.delete",
|
|
"role": "reconciler",
|
|
},
|
|
"work_issue": {
|
|
"permission": "gitea.pr.create",
|
|
"role": "author",
|
|
},
|
|
"work-issue": {
|
|
"permission": "gitea.pr.create",
|
|
"role": "author",
|
|
},
|
|
# #600: controller-owned allocator — any authenticated profile may call;
|
|
# routing enforces role match to selected work. Uses control-plane DB (#613).
|
|
"allocate_next_work": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_allocate_next_work": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
|
|
# #601 first-class lease lifecycle — inspect/list need read; mutations gate on
|
|
# ownership in the control-plane DB (not a separate Gitea write permission).
|
|
"list_workflow_leases": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_list_workflow_leases": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"inspect_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_inspect_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"adopt_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_adopt_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"release_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_release_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"expire_workflow_leases": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_expire_workflow_leases": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"abandon_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_abandon_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"reclaim_expired_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_reclaim_expired_workflow_lease": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
# #612 incident bridge — reconcile uses create_issue for apply;
|
|
# dry-run needs read only. Tools gate apply paths themselves.
|
|
"observability_reconcile_incident": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_observability_reconcile_incident": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"observability_list_projects": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_observability_list_projects": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"observability_link_issue": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"gitea_observability_link_issue": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
|
|
|
|
"reconcile_landed_pr": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"reconcile-landed-pr": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"reconcile_already_landed_pr": {
|
|
"permission": "gitea.pr.close",
|
|
"role": "reconciler",
|
|
},
|
|
# #309: dedicated reconciler path for already-landed open PRs. Exact
|
|
# close capabilities only — never review/approve/request_changes/merge.
|
|
"reconcile_close_landed_pr": {
|
|
"permission": "gitea.pr.close",
|
|
"role": "reconciler",
|
|
},
|
|
"reconcile_close_landed_issue": {
|
|
"permission": "gitea.issue.close",
|
|
"role": "reconciler",
|
|
},
|
|
"reconcile_close_superseded_pr": {
|
|
"permission": "gitea.pr.close",
|
|
"role": "reconciler",
|
|
},
|
|
"reconcile_close_satisfied_issue": {
|
|
"permission": "gitea.issue.close",
|
|
"role": "reconciler",
|
|
},
|
|
"reconcile_create_followup_issue": {
|
|
"permission": "gitea.issue.create",
|
|
"role": "reconciler",
|
|
},
|
|
"post_heartbeat": {
|
|
"permission": "gitea.issue.comment",
|
|
"role": "author",
|
|
},
|
|
"reconcile_issue_claims": {
|
|
"permission": "gitea.read",
|
|
"role": "author",
|
|
},
|
|
"cleanup_stale_claims": {
|
|
"permission": "gitea.issue.comment",
|
|
"role": "author",
|
|
},
|
|
}
|
|
|
|
|
|
# A reviewer lease is the first mutation in the canonical ``review_pr``
|
|
# workflow, so the already-resolved review capability is valid for that one
|
|
# narrower transition. Keep this directed and explicit: lease acquisition
|
|
# does not authorize a review verdict, and reviewer proof never authorizes a
|
|
# merger lease (#763).
|
|
_PREFLIGHT_TASK_TRANSITIONS = frozenset({
|
|
("review_pr", "acquire_reviewer_pr_lease"),
|
|
})
|
|
|
|
|
|
def _canonical_preflight_task(task: str | None) -> str:
|
|
"""Normalize only declared ``gitea_`` aliases for preflight comparison."""
|
|
value = (task or "").strip()
|
|
if value.startswith("gitea_") and value[6:] in TASK_CAPABILITY_MAP:
|
|
return value[6:]
|
|
return value
|
|
|
|
|
|
def preflight_task_matches(
|
|
resolved_task: str | None,
|
|
mutation_task: str | None,
|
|
) -> bool:
|
|
"""Return whether capability proof authorizes this mutation transition."""
|
|
resolved = _canonical_preflight_task(resolved_task)
|
|
mutation = _canonical_preflight_task(mutation_task)
|
|
if not resolved or not mutation:
|
|
return False
|
|
return resolved == mutation or (resolved, mutation) in _PREFLIGHT_TASK_TRANSITIONS
|
|
|
|
# Issue-mutating MCP tools and their resolver task keys.
|
|
ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
|
|
"gitea_create_issue": "create_issue",
|
|
"gitea_close_issue": "close_issue",
|
|
"gitea_create_issue_comment": "comment_issue",
|
|
"gitea_mark_issue": "mark_issue",
|
|
"gitea_set_issue_labels": "set_issue_labels",
|
|
"gitea_create_label": "create_label",
|
|
"gitea_commit_files": "commit_files",
|
|
}
|
|
|
|
|
|
def required_permission(task: str) -> str:
|
|
"""Return the canonical operation a *task* requires (fail closed)."""
|
|
try:
|
|
return TASK_CAPABILITY_MAP[task]["permission"]
|
|
except KeyError as exc:
|
|
raise KeyError(f"Unknown task/action: {task!r} (fail closed)") from exc
|
|
|
|
|
|
def required_role(task: str) -> str:
|
|
"""Return author/reviewer role kind for *task* (fail closed)."""
|
|
try:
|
|
return TASK_CAPABILITY_MAP[task]["role"]
|
|
except KeyError as exc:
|
|
raise KeyError(f"Unknown task/action: {task!r} (fail closed)") from exc
|
|
|
|
|
|
def tool_required_permission(tool_name: str) -> str:
|
|
"""Return the operation an issue-mutating tool must gate on."""
|
|
return required_permission(ISSUE_MUTATION_TOOL_TASKS[tool_name])
|