fix(mcp): bind post-merge moot-lease cleanup to the reconciler capability (Closes #745)
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]>
This commit is contained in:
+143
-11
@@ -1829,6 +1829,7 @@ def _seed_session_context(
|
||||
import issue_work_duplicate_gate # noqa: E402
|
||||
import issue_workflow_labels # noqa: E402
|
||||
import reviewer_pr_lease # noqa: E402
|
||||
import post_merge_moot_lease_gate # noqa: E402 # #745 reconciler cleanup gate
|
||||
import merger_lease_adoption # noqa: E402
|
||||
import merged_cleanup_reconcile # noqa: E402
|
||||
import worktree_cleanup_audit # noqa: E402
|
||||
@@ -8859,13 +8860,14 @@ def gitea_review_pr(
|
||||
return out
|
||||
|
||||
|
||||
def _delete_branch_repository_binding_block(
|
||||
def _repository_binding_block(
|
||||
remote: str | None,
|
||||
*,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
required_permission: str = "gitea.branch.delete",
|
||||
) -> dict | None:
|
||||
"""#733: validate an explicit delete target against the workspace binding.
|
||||
"""#733: validate an explicit mutation target against the workspace binding.
|
||||
|
||||
The trusted repository identity comes only from the verified,
|
||||
workspace-aligned git remote (never ``REMOTES`` defaults, never
|
||||
@@ -8898,7 +8900,7 @@ def _delete_branch_repository_binding_block(
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"required_permission": required_permission,
|
||||
"reasons": canonical_reasons,
|
||||
"blocker_kind": "repository_binding",
|
||||
}
|
||||
@@ -8917,9 +8919,9 @@ def _delete_branch_repository_binding_block(
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"required_permission": required_permission,
|
||||
"reasons": list(override.get("reasons") or [
|
||||
"delete target repository does not match the workspace "
|
||||
"target repository does not match the workspace "
|
||||
"binding (fail closed)"
|
||||
]),
|
||||
"blocker_kind": "repository_binding",
|
||||
@@ -8929,17 +8931,51 @@ def _delete_branch_repository_binding_block(
|
||||
return {
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"required_permission": "gitea.branch.delete",
|
||||
"required_permission": required_permission,
|
||||
"reasons": [
|
||||
"repository binding unverified: no workspace repository "
|
||||
"identity could be established to corroborate the explicit "
|
||||
"delete target (fail closed)"
|
||||
"target (fail closed)"
|
||||
],
|
||||
"blocker_kind": "repository_binding",
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _delete_branch_repository_binding_block(
|
||||
remote: str | None,
|
||||
*,
|
||||
org: str | None,
|
||||
repo: str | None,
|
||||
) -> dict | None:
|
||||
"""Delete-branch view of :func:`_repository_binding_block` (#733).
|
||||
|
||||
Retained as the named entry point for the delete path so #733/#739
|
||||
regression coverage keeps exercising the exact permission label that
|
||||
``gitea_delete_branch`` reports.
|
||||
"""
|
||||
return _repository_binding_block(
|
||||
remote, org=org, repo=repo,
|
||||
required_permission="gitea.branch.delete",
|
||||
)
|
||||
|
||||
|
||||
def _bound_repository_slug(remote: str | None) -> str | None:
|
||||
"""Canonical repository slug for the active session, or None (#745).
|
||||
|
||||
Same trust order as ``_repository_binding_block``: the configured canonical
|
||||
root when the namespace declares one, otherwise the workspace-derived
|
||||
identity. Request parameters are never consulted, so they cannot redirect a
|
||||
mutation at a foreign repository.
|
||||
"""
|
||||
canonical_slug, canonical_reasons = _canonical_repository_slug(
|
||||
get_profile(), remote
|
||||
)
|
||||
if canonical_reasons:
|
||||
return None
|
||||
return canonical_slug or _workspace_repository_slug(remote)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_delete_branch(
|
||||
branch: str,
|
||||
@@ -12352,6 +12388,10 @@ def gitea_diagnose_reviewer_pr_lease_handoff(
|
||||
def gitea_cleanup_post_merge_moot_lease(
|
||||
pr_number: int,
|
||||
apply: bool = False,
|
||||
expected_session_id: str | None = None,
|
||||
expected_candidate_head: str | None = None,
|
||||
expected_lease_comment_id: int | None = None,
|
||||
worktree_path: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
host: str | None = None,
|
||||
org: str | None = None,
|
||||
@@ -12367,14 +12407,37 @@ def gitea_cleanup_post_merge_moot_lease(
|
||||
an append-only comment that neutralises the moot lease without deleting any
|
||||
other session's comment.
|
||||
|
||||
Role binding (#745). The two modes are gated differently, on purpose:
|
||||
|
||||
* ``apply=false`` (assessment) stays reachable under ``gitea.read`` for
|
||||
**any** role, matching ``gitea_cleanup_stale_review_decision_lock`` and
|
||||
``gitea_cleanup_obsolete_reviewer_comment_lease``, so an operator can
|
||||
diagnose a stuck lease from whichever namespace is attached. It performs
|
||||
no mutation and records append-only dry-run evidence in-session.
|
||||
* ``apply=true`` (mutation) additionally requires, in order: the session to
|
||||
have resolved exactly ``cleanup_post_merge_moot_lease``; the
|
||||
``reconciler`` role; ``gitea.pr.comment``; a validated repository /
|
||||
canonical-root / workspace binding; and matching dry-run evidence proving
|
||||
``lease_moot`` and ``cleanup_allowed`` for the same PR, lease session,
|
||||
candidate head and lease marker. Author, reviewer and merger fail closed
|
||||
here even though their profiles carry ``gitea.pr.comment``.
|
||||
|
||||
Args:
|
||||
pr_number: The PR whose lingering lease to assess/clean.
|
||||
apply: When false (default) report only (read-only). When true, post the
|
||||
terminal released marker if — and only if — cleanup is allowed.
|
||||
terminal released marker if — and only if — cleanup is authorized.
|
||||
expected_session_id: Optional lease session the caller expects; a
|
||||
mismatch against the live lease fails closed.
|
||||
expected_candidate_head: Optional leased head the caller expects; a
|
||||
mismatch against the live lease fails closed.
|
||||
expected_lease_comment_id: Optional lease marker id the caller expects;
|
||||
a mismatch against the live lease fails closed.
|
||||
worktree_path: Reconciler worktree to bind the apply-path preflight to.
|
||||
remote: Known instance — 'dadeschools' or 'prgs'.
|
||||
host: Override the Gitea host.
|
||||
org: Override the owner/organization.
|
||||
repo: Override the repository name.
|
||||
org: Override the owner/organization. Validated against the canonical
|
||||
repository identity; it can never redirect the mutation.
|
||||
repo: Override the repository name. Validated as above.
|
||||
|
||||
Returns:
|
||||
dict reporting PR merged/closed state, merge_commit_sha, linked-issue
|
||||
@@ -12434,14 +12497,62 @@ def gitea_cleanup_post_merge_moot_lease(
|
||||
"reasons": assessment.get("reasons") or [],
|
||||
}
|
||||
|
||||
# The canonical repository identity comes from the session binding only —
|
||||
# never from the org/repo request parameters (#745 requirement 10).
|
||||
repository_slug = _bound_repository_slug(remote)
|
||||
report["repository_slug"] = repository_slug
|
||||
report["required_task"] = post_merge_moot_lease_gate.CLEANUP_TASK
|
||||
report["required_role_kind"] = post_merge_moot_lease_gate.REQUIRED_ROLE
|
||||
|
||||
if not apply:
|
||||
# Append-only dry-run evidence. Recorded for every assessment, allowed
|
||||
# or not, so a later apply can prove the lease it saw is the lease that
|
||||
# is still there. Prior entries are never rewritten.
|
||||
evidence = post_merge_moot_lease_gate.record_dry_run(
|
||||
pr_number=pr_number,
|
||||
repository_slug=repository_slug,
|
||||
lease_moot=bool(assessment.get("is_moot")),
|
||||
cleanup_allowed=bool(assessment.get("cleanup_allowed")),
|
||||
session_id=active.get("session_id"),
|
||||
candidate_head=active.get("candidate_head"),
|
||||
lease_comment_id=active.get("comment_id"),
|
||||
)
|
||||
report["dry_run_evidence"] = evidence
|
||||
return report
|
||||
|
||||
# ---------------------------------------------------------------- apply --
|
||||
# Preserve the existing dry-run safety assessment: a lease that is not moot
|
||||
# (open PR, already-terminal, absent) is refused here exactly as before, and
|
||||
# no mutation is attempted.
|
||||
if not assessment.get("cleanup_allowed"):
|
||||
report["cleanup_skipped_reason"] = (
|
||||
assessment.get("reasons") or ["cleanup not allowed"]
|
||||
)
|
||||
return report
|
||||
|
||||
# 1 + 2 + 6: exact resolved cleanup task, reconciler role, and matching
|
||||
# append-only dry-run evidence. Permission alone is deliberately not enough.
|
||||
authorization = post_merge_moot_lease_gate.assess_apply_authorization(
|
||||
pr_number=pr_number,
|
||||
repository_slug=repository_slug,
|
||||
resolved_task=_preflight_resolved_task,
|
||||
active_role_kind=_profile_role_kind(get_profile()),
|
||||
assessment=assessment,
|
||||
evidence=post_merge_moot_lease_gate.latest_dry_run(
|
||||
pr_number=pr_number, repository_slug=repository_slug
|
||||
),
|
||||
expected_session_id=expected_session_id,
|
||||
expected_candidate_head=expected_candidate_head,
|
||||
expected_lease_comment_id=expected_lease_comment_id,
|
||||
)
|
||||
report["authorization"] = authorization
|
||||
if not authorization["allowed"]:
|
||||
report["success"] = False
|
||||
report["reasons"] = authorization["reasons"]
|
||||
report["blocker_kind"] = authorization["blocker_kind"]
|
||||
return report
|
||||
|
||||
# 3: the dedicated mutation permission.
|
||||
comment_block = _profile_operation_gate("gitea.pr.comment")
|
||||
if comment_block:
|
||||
report["success"] = False
|
||||
@@ -12449,7 +12560,28 @@ def gitea_cleanup_post_merge_moot_lease(
|
||||
report["permission_report"] = _permission_block_report("gitea.pr.comment")
|
||||
return report
|
||||
|
||||
verify_preflight_purity(remote)
|
||||
# 4: explicit target must agree with the canonical repository identity
|
||||
# before any preflight or mutation (#733/#739).
|
||||
repo_binding_block = _repository_binding_block(
|
||||
remote, org=org, repo=repo,
|
||||
required_permission="gitea.pr.comment",
|
||||
)
|
||||
if repo_binding_block:
|
||||
report["success"] = False
|
||||
report["reasons"] = repo_binding_block["reasons"]
|
||||
report["blocker_kind"] = repo_binding_block["blocker_kind"]
|
||||
return report
|
||||
|
||||
# 4 (cont.): canonical root, workspace binding and the resolved-task match
|
||||
# are enforced by the shared preflight, with the explicit target forwarded
|
||||
# so the #604 anti-stomp resolution validates the targeted repository.
|
||||
verify_preflight_purity(
|
||||
remote,
|
||||
worktree_path=worktree_path,
|
||||
task=post_merge_moot_lease_gate.CLEANUP_TASK,
|
||||
org=org,
|
||||
repo=repo,
|
||||
)
|
||||
body = assessment["release_body"]
|
||||
comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments"
|
||||
with _audited(
|
||||
|
||||
Reference in New Issue
Block a user