gitea_cleanup_post_merge_moot_lease (#515) posts a terminal `phase: released` lease marker — a durable mutation of the PR lease ledger — but had no entry in the canonical task-capability map and no role binding. Entry gated on gitea.read, apply gated only on gitea.pr.comment, so any profile holding the comment permission (author, reviewer, merger) reached the mutation path, while the reconciler could not satisfy the operator-required resolve-exact-task -> mutation sequence because no cleanup task was resolvable at all. The apply path also called verify_preflight_purity(remote) with no task/org/repo, skipping the resolved-task, canonical-root and explicit-target checks its siblings perform, and passed request org/repo straight into _resolve. Capability map and router: - Map `cleanup_post_merge_moot_lease` and the tool-name alias `gitea_cleanup_post_merge_moot_lease` to gitea.pr.comment + reconciler. Both names carry an identical contract; unknown names keep failing closed on the map's KeyError. - Add both to role_session_router RECONCILER_TASKS and TASK_REQUIRED_ROLE so the map and the router cannot disagree (the #723 defect-A class). Tool enforcement (apply path only): - Require the session to have resolved exactly the cleanup task; resolving a different task, including a sibling reconciler task, does not authorize it. - Require the reconciler role, checked independently of the permission gate. - Require gitea.pr.comment. - Validate explicit org/repo against the canonical repository identity derived from the session binding, so request parameters can never redirect the mutation, and forward worktree_path/task/org/repo to verify_preflight_purity for canonical-root, workspace and anti-stomp binding (#733/#739). - Require matching dry-run evidence proving lease_moot and cleanup_allowed for the same PR, lease session, candidate head and lease marker id, with optional caller expectations checked against the live lease. New post_merge_moot_lease_gate module holds the pure authorization logic and an append-only dry-run ledger: entries are only ever appended, lookup is newest-wins, and dry_run_history hands out copies. Live, non-moot, superseded, mismatched, malformed and foreign-repository leases all fail closed; already-terminal cleanup stays idempotent. The read-only apply=false assessment deliberately stays reachable under gitea.read with no role gate, matching gitea_cleanup_stale_review_decision_lock and gitea_cleanup_obsolete_reviewer_comment_lease, so an operator can diagnose a stuck lease from any attached namespace. This choice is documented in the map, the tool docstring and docs/gitea-execution-profiles.md, and is directly tested. _delete_branch_repository_binding_block is generalized into _repository_binding_block(required_permission=...) and retained as a thin delete-path alias so #733/#739 coverage keeps exercising its permission label. Tests: new tests/test_issue_745_moot_lease_reconciler_gate.py (39 tests, 35 subtests) covers the map/router contract, alias parity, unknown-name rejection, role and task gates, dry-run/apply sequencing, superseded and malformed leases, repository binding, ledger append-only behavior, idempotency, and asserts no production PR/session/marker is referenced. tests/test_post_merge_moot_lease.py is updated from the permission-only model to reconciler + dry-run evidence, with a new negative test pinning that a merger can no longer apply. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
466 lines
15 KiB
Python
466 lines
15 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",
|
|
},
|
|
}
|
|
|
|
# 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])
|