fix(cleanup): harden readback scope and ownership fail-closed (#687)

Require branch-scoped post-delete not-found (not generic/repo/host 404),
emit consistent top-level cleanup fields, fail closed on ownership inventory
errors, never auto-reclaim expired control-plane leases, include active
comment-backed reviewer leases, apply the same gates to reconcile_merged_cleanups,
and match ownership with normalized host identity.
This commit is contained in:
2026-07-16 01:21:18 -04:00
parent 137426f7ad
commit 1c37e62014
3 changed files with 1008 additions and 289 deletions
+368 -172
View File
@@ -6026,13 +6026,15 @@ def gitea_cleanup_merged_pr_branch(
"""Delete a merged PR source branch through the guarded MCP path (#514)."""
gate_reasons = _profile_operation_gate("gitea.branch.delete")
if gate_reasons:
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": gate_reasons,
"permission_report": _permission_block_report("gitea.branch.delete"),
}
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
required_permission="gitea.branch.delete",
reasons=gate_reasons,
permission_report=_permission_block_report("gitea.branch.delete"),
)
profile = get_profile()
active_role = _profile_role_kind(profile)
@@ -6040,29 +6042,33 @@ def gitea_cleanup_merged_pr_branch(
# Author/reviewer/merger must not reach this path even if they somehow
# hold gitea.branch.delete.
if active_role != "reconciler":
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"required_role_kind": "reconciler",
"active_role_kind": active_role,
"reasons": [
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
required_permission="gitea.branch.delete",
required_role_kind="reconciler",
active_role_kind=active_role,
reasons=[
f"profile role '{active_role}' is not authorized for merged "
"branch cleanup; required role is reconciler (fail closed)"
],
"permission_report": _permission_block_report("gitea.branch.delete"),
}
permission_report=_permission_block_report("gitea.branch.delete"),
)
if worktree_path is None or "/branches/" not in os.path.realpath(worktree_path):
return {
"success": False,
"performed": False,
"required_permission": "gitea.branch.delete",
"reasons": [
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
required_permission="gitea.branch.delete",
reasons=[
"merged branch cleanup requires an explicit branches/ worktree "
"path; root checkout branch ref mutation is blocked (fail closed)"
],
}
)
verify_preflight_purity(
remote,
@@ -6080,16 +6086,18 @@ def gitea_cleanup_merged_pr_branch(
target_branch = (pr.get("base") or {}).get("ref") or "master"
if branch and branch != pr_head.get("ref"):
return {
"success": False,
"performed": False,
"pr_number": pr_number,
"branch": branch,
"reasons": [
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
pr_number=pr_number,
branch=branch,
reasons=[
f"requested branch '{branch}' does not match PR head "
f"'{pr_head.get('ref')}'"
],
}
)
open_prs = api_get_all(f"{base}/pulls?state=open", auth)
open_heads = {
@@ -6119,50 +6127,74 @@ def gitea_cleanup_merged_pr_branch(
confirmation=confirmation,
)
if not assessment["safe_to_delete"]:
return {
"success": False,
"performed": False,
"pr_number": pr_number,
"branch": head_branch,
"assessment": assessment,
"reasons": assessment["block_reasons"],
}
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
pr_number=pr_number,
branch=head_branch,
assessment=assessment,
reasons=assessment["block_reasons"],
)
# #687: active ownership protection (sessions/leases/worktree bindings).
# Do not rely only on the caller worktree living under branches/.
ownership_records = _collect_branch_ownership_records(
ownership_bundle = _collect_branch_ownership_records(
remote=remote,
host=h,
org=o,
repo=r,
branch=head_branch,
pr_number=pr_number,
project_root=PROJECT_ROOT,
auth=auth,
base_api=base,
)
ownership_records = ownership_bundle.get("records") or []
if ownership_bundle.get("inventory_error"):
# O1: control-plane / ownership inventory failure fails closed.
ownership_records = list(ownership_records) + [
{
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
"status": "unknown",
"remote": remote,
"host": h,
"org": o,
"repo": r,
"branch": head_branch,
"reclaim_allowed": False,
"role": "inventory",
}
]
ownership = branch_cleanup_guard.assess_active_branch_ownership(
remote=remote,
org=o,
repo=r,
branch=head_branch,
host=h,
records=ownership_records,
)
if ownership.get("block"):
return {
"success": False,
"performed": False,
"pr_number": pr_number,
"branch": head_branch,
"assessment": assessment,
"ownership": {
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=False,
delete_acknowledged=False,
verified_absent=False,
pr_number=pr_number,
branch=head_branch,
assessment=assessment,
ownership={
"block": True,
"blocking_categories": ownership.get("blocking_categories") or [],
"reasons": ownership.get("reasons") or [],
"blocker_kind": ownership.get("blocker_kind"),
"checked": True,
},
"reasons": ownership.get("reasons") or [
"active ownership protects the target branch"
],
"blocker_kind": "active_branch_ownership",
}
reasons=ownership.get("reasons")
or ["active ownership protects the target branch"],
blocker_kind="active_branch_ownership",
)
import urllib.parse
@@ -6188,19 +6220,20 @@ def gitea_cleanup_merged_pr_branch(
api_request("DELETE", url, auth)
# #687: authoritative post-delete readback. DELETE success alone is not
# enough — only an authoritative not-found proves deletion.
# enough — only branch-scoped not-found proves deletion (R1).
readback = _probe_remote_branch(h, o, r, auth, head_branch)
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(readback)
verified_absent = bool(readback_assessment.get("verified_absent"))
request_metadata["post_delete_readback"] = {
"status": (readback_assessment.get("readback") or {}).get("status"),
"verified_absent": bool(
(readback_assessment.get("readback") or {}).get("verified_absent")
),
"verified_absent": verified_absent,
"error_class": (readback_assessment.get("readback") or {}).get(
"error_class"
),
"not_found_scope": (readback_assessment.get("readback") or {}).get(
"not_found_scope"
),
}
# Emit a second audit row capturing readback evidence (no secrets).
if gitea_audit.audit_enabled():
_audit(
"cleanup_merged_pr_branch_readback",
@@ -6220,53 +6253,60 @@ def gitea_cleanup_merged_pr_branch(
)
if not readback_assessment.get("ok"):
return {
"success": False,
"performed": True,
"delete_acknowledged": True,
"pr_number": pr_number,
"branch": head_branch,
"assessment": assessment,
"ownership": {
return branch_cleanup_guard.cleanup_result_envelope(
success=False,
performed=True,
delete_acknowledged=True,
verified_absent=False,
pr_number=pr_number,
branch=head_branch,
assessment=assessment,
ownership={
"block": False,
"blocking_categories": [],
"checked": True,
},
"readback": readback_assessment.get("readback"),
"reasons": readback_assessment.get("reasons") or [
"post-delete branch readback could not verify deletion"
],
"blocker_kind": readback_assessment.get("blocker_kind")
readback=readback_assessment.get("readback"),
reasons=readback_assessment.get("reasons")
or ["post-delete branch readback could not verify deletion"],
blocker_kind=readback_assessment.get("blocker_kind")
or "post_delete_readback_failed",
"message": (
message=(
f"DELETE accepted for '{head_branch}' but post-delete readback "
"did not verify absence"
),
}
)
return {
"success": True,
"performed": True,
"pr_number": pr_number,
"branch": head_branch,
"message": (
return branch_cleanup_guard.cleanup_result_envelope(
success=True,
performed=True,
delete_acknowledged=True,
verified_absent=True,
pr_number=pr_number,
branch=head_branch,
message=(
f"Merged PR #{pr_number} source branch '{head_branch}' deleted "
"and verified absent via post-delete readback."
"and verified absent via branch-scoped post-delete readback."
),
"assessment": assessment,
"ownership": {
assessment=assessment,
ownership={
"block": False,
"blocking_categories": [],
"checked": True,
},
"readback": readback_assessment.get("readback"),
}
readback=readback_assessment.get("readback"),
)
def _probe_remote_branch(
h: str, o: str, r: str, auth: str, branch: str
) -> dict:
"""GET a remote branch and return a secret-free structured readback."""
"""GET a remote branch and return a secret-free structured readback.
R1: On HTTP 404, re-probe the repository endpoint. Only when the repo is
still reachable is the 404 treated as branch-scoped absence. Generic,
repository-scoped, or host-level 404 never sets verified_absent.
"""
import urllib.parse
encoded = urllib.parse.quote(branch, safe="")
@@ -6275,23 +6315,56 @@ def _probe_remote_branch(
api_request("GET", url, auth)
return branch_cleanup_guard.classify_branch_readback_http_status(200)
except Exception as exc:
return branch_cleanup_guard.classify_branch_readback_exception(exc)
status = branch_cleanup_guard._extract_http_status(exc)
if status != 404:
return branch_cleanup_guard.classify_branch_readback_exception(exc)
# Distinguish branch vs repository/host 404 via repo reachability.
repo_url = repo_api_url(h, o, r)
try:
api_request("GET", repo_url, auth)
# Repo reachable → branch-scoped not-found.
return branch_cleanup_guard.classify_branch_readback_http_status(
404,
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_BRANCH,
)
except Exception as repo_exc:
repo_status = branch_cleanup_guard._extract_http_status(repo_exc)
if repo_status == 404:
return branch_cleanup_guard.classify_branch_readback_http_status(
404,
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_REPOSITORY,
)
if repo_status in (401, 407):
return branch_cleanup_guard.classify_branch_readback_http_status(
401
)
if repo_status == 403:
return branch_cleanup_guard.classify_branch_readback_http_status(
403
)
# Host/transport/unknown: not branch-verified.
return branch_cleanup_guard.classify_branch_readback_http_status(
404,
not_found_scope=branch_cleanup_guard.NOT_FOUND_SCOPE_UNKNOWN,
)
def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool:
"""True when the remote branch exists; False on authoritative 404.
"""True when the remote branch exists; False on authoritative branch 404.
Authentication, authorization, transport, and unexpected failures are
re-raised (or returned as structured failures by callers that use
``_probe_remote_branch``) rather than treated as absence.
Authentication, authorization, transport, and ambiguous 404 failures are
raised rather than treated as absence.
"""
probe = _probe_remote_branch(h, o, r, auth, branch)
status = probe.get("status")
if status == branch_cleanup_guard.READBACK_NOT_FOUND:
if (
status == branch_cleanup_guard.READBACK_NOT_FOUND
and probe.get("verified_absent")
):
return False
if status == branch_cleanup_guard.READBACK_EXISTS:
return True
# Preserve structured failure modes for callers that need them.
error_class = probe.get("error_class") or "unexpected"
raise RuntimeError(
f"remote branch probe failed ({error_class}/{status})"
@@ -6301,23 +6374,44 @@ def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> boo
def _collect_branch_ownership_records(
*,
remote: str,
host: str,
org: str,
repo: str,
branch: str,
pr_number: int | None,
project_root: str,
) -> list[dict]:
auth: str | None = None,
base_api: str | None = None,
) -> dict:
"""Gather canonical ownership records for *branch* (secret-free).
Sources:
- author issue locks (task-session / lease files)
- control-plane leases (author/reviewer/merger/controller/reconciler)
- comment-backed active reviewer leases (O3)
- local worktree bindings checked out to the branch
Returns ``{"records": [...], "inventory_error": bool}``. Control-plane
inventory errors set inventory_error=True so callers fail closed (O1).
"""
records: list[dict] = []
inventory_error = False
target_branch = (branch or "").strip()
if not target_branch:
return records
return {"records": records, "inventory_error": False}
host_n = branch_cleanup_guard.normalize_host(host)
def _base_rec(**kwargs):
rec = {
"remote": remote,
"host": host_n or host,
"org": org,
"repo": repo,
"branch": target_branch,
}
rec.update(kwargs)
return rec
# --- Author issue locks (session + lease) ---
try:
@@ -6331,6 +6425,12 @@ def _collect_branch_ownership_records(
or str(lock.get("repo") or "") != str(repo)
):
continue
# Host identity when present on the lock
lock_host = branch_cleanup_guard.normalize_host(
lock.get("host") or lock.get("host_name")
)
if host_n and lock_host and host_n != lock_host:
continue
if str(lock.get("branch_name") or "").strip() != target_branch:
continue
freshness = issue_lock_store.assess_lock_freshness(lock)
@@ -6339,58 +6439,53 @@ def _collect_branch_ownership_records(
if freshness.get("live"):
status = "active"
records.append(
{
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
"status": status,
"remote": remote,
"org": org,
"repo": repo,
"branch": target_branch,
"reclaim_allowed": bool(reclaim.get("reclaim_allowed")),
"role": "author",
}
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
status=status,
reclaim_allowed=bool(reclaim.get("reclaim_allowed")),
role="author",
)
)
# Session pointer binding (same record, distinct category when live)
if freshness.get("live"):
records.append(
{
"category": (
_base_rec(
category=(
branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION
),
"status": "active",
"remote": remote,
"org": org,
"repo": repo,
"branch": target_branch,
"reclaim_allowed": False,
"role": "author",
}
status="active",
reclaim_allowed=False,
role="author",
)
)
except Exception:
# Fail closed: if lock inventory cannot be read, invent a sticky block.
inventory_error = True
records.append(
{
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
"status": "unknown",
"remote": remote,
"org": org,
"repo": repo,
"branch": target_branch,
"reclaim_allowed": False,
"role": "author",
}
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE,
status="unknown",
reclaim_allowed=False,
role="author",
)
)
# --- Control-plane leases (role-tagged) ---
try:
db = control_plane_db.get_db() if hasattr(control_plane_db, "get_db") else None
db = None
if hasattr(control_plane_db, "get_db"):
try:
db = control_plane_db.get_db()
except Exception:
db = None
if db is None and hasattr(control_plane_db, "ControlPlaneDB"):
# Best-effort default path used by runtime tools.
try:
db = control_plane_db.ControlPlaneDB()
except TypeError:
except Exception:
db = None
if db is not None:
inventory_error = True
if db is None:
# O1: unavailable control-plane inventory fails closed.
inventory_error = True
else:
listed = lease_lifecycle.list_active_leases(
db,
remote=remote,
@@ -6416,11 +6511,14 @@ def _collect_branch_ownership_records(
if not (matches_branch or matches_pr):
continue
if matches_pr and not lease_branch:
# PR-scoped lease without explicit branch still protects
# the merged PR source branch being cleaned.
lease_branch = target_branch
if lease_branch != target_branch:
continue
lease_host = branch_cleanup_guard.normalize_host(
lease.get("host") or lease.get("host_name")
)
if host_n and lease_host and host_n != lease_host:
continue
fr = lease.get("freshness") or lease_lifecycle.classify_lease_freshness(
lease
)
@@ -6431,11 +6529,7 @@ def _collect_branch_ownership_records(
)
role = str(lease.get("role") or "unknown")
category = branch_cleanup_guard.ownership_category_for_role(role)
reclaim_allowed = freshness_status in {
"released",
"abandoned",
"expired",
} and freshness_status != "active"
# O2: expired never auto-receives reclaim_allowed=True.
if freshness_status == "active":
status = "active"
reclaim_allowed = False
@@ -6446,27 +6540,61 @@ def _collect_branch_ownership_records(
isinstance(fr, dict) and fr.get("expired_by_time")
):
status = "expired"
# Sticky if session still alive with worktree — unknown here
# defaults to reclaimable when status is expired by time.
reclaim_allowed = True
reclaim_allowed = False # O2 fail closed
else:
status = freshness_status
reclaim_allowed = False
records.append(
{
"category": category,
"status": status,
"remote": remote,
"org": org,
"repo": repo,
"branch": target_branch,
"reclaim_allowed": reclaim_allowed,
"role": role,
}
_base_rec(
category=category,
status=status,
reclaim_allowed=reclaim_allowed,
role=role,
host=lease_host or host_n or host,
)
)
except Exception:
# Soft: control-plane may be unavailable in unit tests / offline.
pass
# O1: fail closed on control-plane inventory errors.
inventory_error = True
records.append(
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
status="unknown",
reclaim_allowed=False,
role="control_plane",
)
)
# --- O3: comment-backed active reviewer leases ---
if pr_number is not None and auth and base_api:
try:
comments = api_get_all(
f"{base_api}/issues/{int(pr_number)}/comments", auth
)
active = reviewer_pr_lease.find_active_reviewer_lease(
comments, pr_number=int(pr_number)
)
if active:
records.append(
_base_rec(
category=(
branch_cleanup_guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE
),
status="active",
reclaim_allowed=False,
role="reviewer",
)
)
except Exception:
inventory_error = True
records.append(
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR,
status="unknown",
reclaim_allowed=False,
role="reviewer_comment_lease",
)
)
# --- Worktree bindings checked out to the target branch ---
try:
@@ -6475,34 +6603,28 @@ def _collect_branch_ownership_records(
if wt_branch != target_branch:
continue
records.append(
{
"category": (
_base_rec(
category=(
branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING
),
"status": "active",
"remote": remote,
"org": org,
"repo": repo,
"branch": target_branch,
"reclaim_allowed": False,
"role": "worktree",
}
status="active",
reclaim_allowed=False,
role="worktree",
)
)
except Exception:
inventory_error = True
records.append(
{
"category": branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
"status": "unknown",
"remote": remote,
"org": org,
"repo": repo,
"branch": target_branch,
"reclaim_allowed": False,
"role": "worktree",
}
_base_rec(
category=branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING,
status="unknown",
reclaim_allowed=False,
role="worktree",
)
)
return records
return {"records": records, "inventory_error": inventory_error}
@mcp.tool()
@@ -6689,6 +6811,66 @@ def gitea_reconcile_merged_cleanups(
if remote_assessment.get("safe_to_delete_remote"):
import urllib.parse
pr_num = entry.get("pr_number")
try:
pr_num_int = int(pr_num) if pr_num is not None else None
except (TypeError, ValueError):
pr_num_int = None
ownership_bundle = _collect_branch_ownership_records(
remote=remote,
host=h,
org=o,
repo=r,
branch=head_branch,
pr_number=pr_num_int,
project_root=PROJECT_ROOT,
auth=auth,
base_api=base,
)
ownership_records = list(ownership_bundle.get("records") or [])
if ownership_bundle.get("inventory_error"):
ownership_records.append(
{
"category": (
branch_cleanup_guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR
),
"status": "unknown",
"remote": remote,
"host": h,
"org": o,
"repo": r,
"branch": head_branch,
"reclaim_allowed": False,
"role": "inventory",
}
)
ownership = branch_cleanup_guard.assess_active_branch_ownership(
remote=remote,
org=o,
repo=r,
branch=head_branch,
host=h,
records=ownership_records,
)
if ownership.get("block"):
actions.append(
{
"action": "delete_remote_branch",
"branch": head_branch,
"success": False,
"performed": False,
"delete_acknowledged": False,
"verified_absent": False,
"blocker_kind": "active_branch_ownership",
"reasons": ownership.get("reasons") or [],
"blocking_categories": ownership.get(
"blocking_categories"
)
or [],
}
)
continue
encoded = urllib.parse.quote(head_branch, safe="")
url = f"{base}/branches/{encoded}"
with _audited(
@@ -6698,14 +6880,28 @@ def gitea_reconcile_merged_cleanups(
org=o,
repo=r,
target_branch=head_branch,
request_metadata={"branch": head_branch, "source": "reconcile_merged_cleanups"},
request_metadata={
"branch": head_branch,
"source": "reconcile_merged_cleanups",
"ownership_checked": True,
},
):
api_request("DELETE", url, auth)
readback = _probe_remote_branch(h, o, r, auth, head_branch)
readback_assessment = branch_cleanup_guard.assess_post_delete_readback(
readback
)
verified = bool(readback_assessment.get("verified_absent"))
actions.append(
{
"action": "delete_remote_branch",
"branch": head_branch,
"success": True,
"success": bool(readback_assessment.get("ok")),
"performed": True,
"delete_acknowledged": True,
"verified_absent": verified,
"readback": readback_assessment.get("readback"),
"reasons": readback_assessment.get("reasons") or [],
}
)