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:
@@ -317,6 +317,44 @@ Least-privilege constraints:
|
||||
canonical names such as `gitea.pr.close` (never bare `pr.close` /
|
||||
`issue.close`, which the production normalizer rejects or drops).
|
||||
|
||||
### Post-merge moot-lease cleanup ownership (`gitea.pr.comment`)
|
||||
|
||||
Neutralising a reviewer lease left behind on an already-merged/closed PR is
|
||||
reconciliation work too. `task_capability_map` maps
|
||||
`cleanup_post_merge_moot_lease` — and its tool-name alias
|
||||
`gitea_cleanup_post_merge_moot_lease` — to role `reconciler` with permission
|
||||
`gitea.pr.comment` (#745). Both names carry the **same** contract.
|
||||
|
||||
The permission alone is deliberately not sufficient: author, reviewer and
|
||||
merger profiles all hold `gitea.pr.comment` for ordinary PR discussion, so the
|
||||
role gate — not the permission gate — is what keeps the terminal lease marker
|
||||
reconciler-owned.
|
||||
|
||||
`gitea_cleanup_post_merge_moot_lease` splits its two modes on purpose:
|
||||
|
||||
- **`apply=false` (assessment) requires only `gitea.read`, with no role gate.**
|
||||
This matches `gitea_cleanup_stale_review_decision_lock` and
|
||||
`gitea_cleanup_obsolete_reviewer_comment_lease`, whose assessment paths are
|
||||
likewise read-gated, so an operator can diagnose a stuck lease from whichever
|
||||
namespace happens to be attached without switching roles. The dry run
|
||||
performs no mutation and records append-only evidence in-session.
|
||||
- **`apply=true` (mutation) requires all of the following**, in order: the
|
||||
session must have resolved exactly `cleanup_post_merge_moot_lease` (resolving
|
||||
any other task — including a sibling reconciler task — does not authorize
|
||||
it); the active role must be `reconciler`; the profile must hold
|
||||
`gitea.pr.comment`; the explicit `org`/`repo` must agree with the canonical
|
||||
repository identity, which is derived from the session binding and can never
|
||||
be overridden by request parameters; and matching dry-run evidence must show
|
||||
`lease_moot`, `cleanup_allowed`, and the same PR, lease session, candidate
|
||||
head and lease marker id that are live at apply time.
|
||||
|
||||
Everything else fails closed: a live lease on an open PR, an already-terminal
|
||||
(idempotent) lease, a lease superseded between the dry run and the apply, a
|
||||
malformed lease missing session/head/marker, and any foreign-repository target.
|
||||
The cleanup only ever appends a terminal `phase: released` marker
|
||||
(`blocker: post-merge-moot`) — it never edits or deletes another session's
|
||||
comment, and it never merges or adopts a lease.
|
||||
|
||||
Launch a static `gitea-reconciler` MCP namespace with
|
||||
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
|
||||
`reconciler_profile.assess_reconciler_profile` (#304). Use the
|
||||
|
||||
Reference in New Issue
Block a user