Root cause (confirmed on PR #740, session 33780-7168cbeeba58, marker 12354): merger_lease_adoption.SANCTIONED_PROVENANCE_SOURCES already contained SOURCE_ACQUIRE_MERGER, so the membership check passed, but is_sanctioned_session_lease() branched only for SOURCE_ADOPT and for {SOURCE_ACQUIRE, SOURCE_HEARTBEAT} and fell through to `return False` for a lease minted by gitea_acquire_merger_pr_lease. assess_mutation_lease_gate() therefore appended "in-session lease lacks sanctioned provenance" and gitea_merge_pr fail-closed on the merger's own freshly acquired lease. describe_session_lease_proof() likewise had no branch for that source and reported the lease as lease_proof_kind=unsanctioned. Separately, no merger-role owner-session operation existed to end that lease, so a failed merger lease could only expire. A. Acquired merger provenance is_sanctioned_session_lease() now accepts SOURCE_ACQUIRE_MERGER, but only via the new assess_acquired_merger_lease_integrity(), which fails closed unless the in-session record is complete and self-consistent: comment marker present and matching between provenance and session, non-empty session id, holder identity, merger profile, repository, valid PR number, and a normalized 40-hex candidate_head. describe_session_lease_proof() reports the explicit kind sanctioned_acquire_merger. Manual _SESSION_LEASE seeding, forged or incomplete records, mismatched fields, and unknown provenance all remain rejected; reviewer-acquire, reviewer-heartbeat, and merger-adoption paths are untouched. B. Merger owner-session finalization New native tool gitea_release_merger_pr_lease terminally releases or abandons a merger's own comment-backed lease when the merge does not occur. It is merger-only (gitea.read + gitea.pr.comment + gitea.pr.merge; reviewer profiles lack pr.merge) with an explicit capability-map task release_merger_pr_lease / gitea_release_merger_pr_lease bound to gitea.pr.comment + role merger, and it is listed as role-exclusive in the resolver. assess_merger_lease_finalization() verifies exact session ownership, repository, PR, candidate head vs live head, profile, identity, marker presence on the thread, and the native runtime token fingerprint recorded at acquisition; foreign-session release, reviewer-profile use, and any mismatch fail closed. Finalization appends a terminal lease marker and never edits or deletes ledger history; an already-terminal lease returns already_terminal and posts nothing. The PR, approval, decision lock, branch, and worktree are all preserved. gitea_release_reviewer_pr_lease is unchanged and is not repurposed for merger sessions. "abandoned" is added to reviewer_pr_lease._TERMINAL_PHASES; without it an abandoned marker would fall through the generic non-empty phase branch and keep re-arming the lease as active. Tests: new tests/test_merger_lease_finalization.py (38 tests, 11 subtests) covers all twelve acceptance criteria — provenance sanctioning, explicit proof kind, merge-gate acceptance, manual-seed and unknown-provenance rejection, profile/role/session/head/repository/fingerprint mismatches, owner release, foreign-session refusal, reviewer refusal, append-only idempotent finalization, no surviving lease after finalization, and no regression in adoption or reviewer-lease behavior. The defect was reproduced on clean baselinea8d2087first (is_sanctioned=False, proof_kind=unsanctioned, no finalization path). Validation: targeted lease/capability suites 272 passed. Full suite 3305 passed, 6 skipped, 2 failed — both failures (test_issue_702_review_findings_f1_f6 F1 recovery, reconciler supersession close) reproduce identically on clean baseline mastera8d2087in a throwaway worktree and are pre-existing #737 org/repo-forwarding drift, unrelated to this change. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
449 lines
14 KiB
Python
449 lines
14 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",
|
|
},
|
|
"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])
|