diff --git a/.env.example b/.env.example index 4777fac..89fef58 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,27 @@ GITEA_AUDIT_LOG=/path/to/gitea-mcp-audit.log # only — never the token value. Surfaced by gitea_get_profile. GITEA_TOKEN_SOURCE=GITEA_TOKEN +# ── Optional self-hosted Sentry observability (#606) ──────────────────────── +# Emits runtime errors, fail-closed workflow blockers, lease/terminal-lock/ +# stale-runtime collisions, and watchdog cron check-ins to a SELF-HOSTED Sentry +# (https://sentry.prgs.cc/) — never Sentry Cloud. Gitea stays the source of +# truth; Sentry is observe-only. OFF by default: with MCP_SENTRY_ENABLED unset +# or SENTRY_DSN empty, nothing is initialised and no events are sent. +# +# Master gate. Truthy = 1/true/yes/on. Both this AND SENTRY_DSN are required. +MCP_SENTRY_ENABLED=0 +# DSN for the self-hosted project (create a `gitea-tools-mcp` project in +# https://sentry.prgs.cc/ and copy its DSN). Never commit a real DSN. +SENTRY_DSN= +# Deployment environment tag (local/dev/prod). Defaults to "development". +SENTRY_ENVIRONMENT=development +# Optional release identifier (e.g. a git SHA or version string). +SENTRY_RELEASE= +# Performance-trace sample rate, 0.0–1.0 (clamped). Default 0.0 (traces off). +MCP_SENTRY_TRACES_SAMPLE_RATE=0.0 +# Set to 1 to forward Python logs to Sentry as structured logs. Default off. +MCP_SENTRY_ENABLE_LOGS=0 + # Optional canonical runtime-profile config (#19). Instead of the fields above, # point every LLM launcher at ONE JSON file of named profiles and select one. # Secrets are referenced (keychain id / env var name), never inlined. See @@ -55,3 +76,12 @@ GITEA_MCP_PROFILE=prgs # GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456 # GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456 # GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override + +# Durable HMAC key for irrecoverable decision-lock provenance artifacts (#709 F4). +# REQUIRED in production native MCP processes that mint or verify recovery +# authorization (reconciler + merger must share the same key). Hex (preferred), +# base64, or utf-8 literal (>=16 bytes). Never generate an ephemeral per-process +# key — cross-process / post-restart verification would fail closed. +# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +# Optional key version string bound into the signature (for rotation). +# GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION=v1 diff --git a/anti_stomp_preflight.py b/anti_stomp_preflight.py new file mode 100644 index 0000000..83f1dae --- /dev/null +++ b/anti_stomp_preflight.py @@ -0,0 +1,807 @@ +"""Common anti-stomp preflight for every MCP mutation tool (#604). + +Even with leases, mutation tools need a **shared** preflight that prevents +stale sessions, wrong worktrees, wrong repos, old prompts, terminal locks, +foreign leases, contaminated approvals, and root-checkout mutations from +slipping through. + +This module is the pure assessment core. Callers (MCP mutation entrypoints) +gather live facts and pass them in — nothing here performs git, network, or +durable-state I/O. Existing happy-path guards remain authoritative; this +module composes them into one typed, fail-closed result. + +Required checks (issue #604): + +* repo/org verification +* profile/role verification +* root checkout clean and not used for mutation (when role requires branches/) +* worktree under branches when required +* active lease ownership (when lease is required for the mutation) +* terminal lock status (when applicable) +* expected head SHA (when pinned) +* stale runtime status +* workflow hash (when a workflow load is required) +* source contamination status +* no manual state/mtime/source-file bypass + +Failure returns a typed blocker and an exact next action. There is **no** +agent-facing bypass flag. +""" + +from __future__ import annotations + +from typing import Any + +import author_mutation_worktree +import master_parity_gate +import remote_repo_guard +import root_checkout_guard +import stable_branch_push_guard + +# ── public constants ────────────────────────────────────────────────────────── + +# Typed blocker kinds returned in the structured result. +BLOCKER_WRONG_REPO = "wrong_repo" +BLOCKER_WRONG_ROLE = "wrong_role" +BLOCKER_ROOT_CHECKOUT = "root_checkout_mutation" +BLOCKER_WRONG_WORKTREE = "wrong_worktree" +BLOCKER_FOREIGN_LEASE = "foreign_lease" +BLOCKER_TERMINAL_LOCK = "terminal_lock" +BLOCKER_HEAD_SHA = "head_sha_mismatch" +BLOCKER_STALE_RUNTIME = "stale_runtime" +BLOCKER_WORKFLOW_HASH = "workflow_hash" +BLOCKER_SOURCE_CONTAMINATION = "source_contamination" +BLOCKER_MANUAL_BYPASS = "manual_bypass" + +BLOCKER_KINDS = frozenset({ + BLOCKER_WRONG_REPO, + BLOCKER_WRONG_ROLE, + BLOCKER_ROOT_CHECKOUT, + BLOCKER_WRONG_WORKTREE, + BLOCKER_FOREIGN_LEASE, + BLOCKER_TERMINAL_LOCK, + BLOCKER_HEAD_SHA, + BLOCKER_STALE_RUNTIME, + BLOCKER_WORKFLOW_HASH, + BLOCKER_SOURCE_CONTAMINATION, + BLOCKER_MANUAL_BYPASS, +}) + +# Mutation tasks that must invoke the shared anti-stomp preflight before +# acting (issue #604 AC1). Every member must appear as a live task= kwarg +# to verify_preflight_purity / _run_anti_stomp_preflight (or the review +# anti_task map) in gitea_mcp_server.py. Inventory↔wiring tests fail if +# a declared task is not wired. +MUTATION_TASKS = frozenset({ + "create_issue", + "comment_issue", + "close_issue", + "mark_issue", + "lock_issue", + "set_issue_labels", + "create_label", + "create_pr", + "close_pr", + "edit_pr", + "commit_files", + "gitea_commit_files", + "delete_branch", + "cleanup_merged_pr_branch", + "cleanup_stale_claims", + "reconcile_merged_cleanups", + "reconcile_already_landed_pr", + "reconcile_close_superseded_pr", + "post_heartbeat", + "acquire_reviewer_pr_lease", + "gitea_acquire_reviewer_pr_lease", + "adopt_merger_pr_lease", + "review_pr", + "submit_pr_review", + "approve_pr", + "request_changes_pr", + "merge_pr", +}) + +# Intentionally excluded from MUTATION_TASKS. Each has a dedicated +# fail-closed gate that preserves decision-lock ownership, workflow-hash, +# head-SHA, and repository consistency. Do not re-add without either +# wiring shared preflight or updating this rationale (#604 Blocker B). +DEDICATED_GATE_MUTATIONS: dict[str, str] = { + "mark_final_review_decision": ( + "Local review-decision lock only. Enforced by session lock ownership, " + "workflow-hash, expected head SHA, PR work lease, eligibility, and " + "terminal-lock gates. No Gitea review POST; shared anti-stomp would " + "duplicate without side-effect-ordering value." + ), + "save_review_draft": ( + "Local session-state draft save with live PR head/base consistency " + "checks. Not a Gitea review/merge mutation; dedicated head-SHA and " + "worktree resolution gates apply." + ), + "resume_review_draft": ( + "Live-state resume with terminal lock, lease ownership, head/base SHA, " + "and parity checks. When submit=True, delegates to gitea_submit_pr_review " + "which runs shared anti-stomp before the Gitea review POST." + ), + "cleanup_stale_review_decision_lock": ( + "Moot-lock cleanup with identity match, live PR merged/closed proof, " + "and reviewer capability gate (#594). Distinct from shared anti-stomp." + ), + "gitea_cleanup_stale_review_decision_lock": ( + "Alias of cleanup_stale_review_decision_lock; dedicated #594 path." + ), + "heartbeat_reviewer_pr_lease": ( + "Lease heartbeat posts via verify_preflight_purity(task='review_pr') " + "plus in-session lease ownership checks; not a separate mutation class." + ), + "release_reviewer_pr_lease": ( + "Lease release uses workspace binding + ownership checks; posts only " + "when the session owns the active lease." + ), +} + +# Roles that must mutate from a branches/ worktree (not the control checkout). +_BRANCHES_REQUIRED_ROLES = frozenset({"author"}) + +# Reviewer/merger mutations that require an owned lease + head pinning when +# the caller supplies those facts. +_LEASE_AWARE_TASKS = frozenset({ + "review_pr", + "submit_pr_review", + "approve_pr", + "request_changes_pr", + "merge_pr", + "acquire_reviewer_pr_lease", + "gitea_acquire_reviewer_pr_lease", + "adopt_merger_pr_lease", +}) + +_NEXT_ACTIONS: dict[str, str] = { + BLOCKER_WRONG_REPO: ( + "Pass explicit org= and repo= matching the local git remote " + "(e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools) and retry." + ), + BLOCKER_WRONG_ROLE: ( + "Call gitea_resolve_task_capability, switch to the required " + "namespace/profile, re-verify with gitea_whoami, then retry." + ), + BLOCKER_ROOT_CHECKOUT: ( + "Leave the root control checkout on clean master; create or switch " + "to a session-owned worktree under branches/ and retry the mutation " + "with worktree_path set." + ), + BLOCKER_WRONG_WORKTREE: ( + "Create or bind a session-owned worktree under branches/, set " + "worktree_path (or GITEA_ACTIVE_WORKTREE), and retry." + ), + BLOCKER_FOREIGN_LEASE: ( + "Stop. Do not stomp a foreign lease. Wait for expiry, request " + "takeover through the sanctioned path, or hand off to the owner." + ), + BLOCKER_TERMINAL_LOCK: ( + "Stop. A terminal review-decision lock is active for this head. " + "Do not re-approve/merge; follow the #332/#620 recovery path." + ), + BLOCKER_HEAD_SHA: ( + "Re-fetch the live PR head with gitea_view_pr, re-pin " + "expected_head_sha to the current head, re-validate, then retry." + ), + BLOCKER_STALE_RUNTIME: ( + "Restart the Gitea MCP server so it reloads master's capability " + "gates, re-run gitea_whoami + gitea_resolve_task_capability, then retry." + ), + BLOCKER_WORKFLOW_HASH: ( + "Reload the canonical workflow via gitea_load_review_workflow, then " + "rerun the full review-merge workflow from inventory (no approve/merge replay)." + ), + BLOCKER_SOURCE_CONTAMINATION: ( + "Stop. Session is source-contaminated. Hand off to a reconciler for " + "audit/clear; do not clear markers by deleting session-state files." + ), + BLOCKER_MANUAL_BYPASS: ( + "Stop. Manual state/mtime/source-file bypass is forbidden. Use " + "sanctioned MCP tools only; never delete or rewrite session-state " + "or lock files by hand." + ), +} + + +def is_mutation_task(task: str | None) -> bool: + """True when *task* is in the #604 mutation set (normalized).""" + name = (task or "").strip().lower().removeprefix("gitea_") + if not name: + return False + if name in MUTATION_TASKS: + return True + # Accept gitea_ prefixed membership for aliases already in the set. + return f"gitea_{name}" in MUTATION_TASKS + + +def roles_compatible(active_role: str | None, required_role: str | None) -> bool: + """Whether *active_role* may perform a task that maps to *required_role*. + + Exact match always passes. Reconcilers may run author-class mutations + (comment/close/cleanup) that the capability map stamps as ``author`` — + matching the long-standing reconciler exemption for control-checkout + work. No other cross-role substitutions are allowed. + + Capability-authorized multi-role tasks (e.g. reviewer holding + ``gitea.issue.comment`` for ``comment_issue``) are handled by + :func:`authorization_compatible`, not by expanding this role matrix. + """ + active = (active_role or "").strip().lower() + required = (required_role or "").strip().lower() + if not active or not required: + return True + if active == required: + return True + if active == "reconciler" and required in {"author", "reconciler"}: + return True + return False + + +def authorization_compatible( + active_role: str | None, + required_role: str | None, + *, + required_permission: str | None = None, + allowed_operations: Any = None, +) -> bool: + """Whether the active session may run a task given role *and* capability. + + Order: + + 1. :func:`roles_compatible` (exact role or narrow reconciler→author). + 2. Capability possession: when *required_permission* is non-empty and + present in *allowed_operations*, allow even if the nominal task role + differs (e.g. reviewer/merger with ``gitea.issue.comment`` on + ``comment_issue`` / ``mark_issue`` / ``set_issue_labels`` / + ``lock_issue``). + + Fail closed otherwise. This is **not** a broad reviewer→author or + merger→author role rewrite: without the specific permission the check + still denies. Missing *allowed_operations* when a permission is required + also fails closed (callers must supply the live profile op list). + """ + if roles_compatible(active_role, required_role): + return True + perm = (required_permission or "").strip() + if not perm: + return False + if allowed_operations is None: + return False + try: + ops = {str(o).strip() for o in allowed_operations if o is not None} + except TypeError: + return False + return perm in ops + + +def _blocker( + kind: str, + reasons: list[str], + *, + exact_next_action: str | None = None, + detail: dict | None = None, +) -> dict[str, Any]: + if kind not in BLOCKER_KINDS: + raise ValueError(f"unknown anti-stomp blocker kind: {kind!r}") + return { + "kind": kind, + "reasons": list(reasons), + "exact_next_action": exact_next_action or _NEXT_ACTIONS[kind], + "detail": dict(detail or {}), + } + + +def _allowed_result(checks: dict[str, Any]) -> dict[str, Any]: + return { + "allowed": True, + "block": False, + "blockers": [], + "reasons": [], + "exact_next_action": "proceed", + "blocker_kind": None, + "checks": checks, + } + + +def _blocked_result( + blockers: list[dict[str, Any]], + checks: dict[str, Any], +) -> dict[str, Any]: + primary = blockers[0] + all_reasons: list[str] = [] + for b in blockers: + for r in b.get("reasons") or []: + if r not in all_reasons: + all_reasons.append(r) + return { + "allowed": False, + "block": True, + "blockers": blockers, + "reasons": all_reasons, + "exact_next_action": primary["exact_next_action"], + "blocker_kind": primary["kind"], + "checks": checks, + } + + +def assess_anti_stomp_preflight( + *, + task: str | None = None, + # repo/org + remote: str | None = None, + resolved_org: str | None = None, + resolved_repo: str | None = None, + local_remote_url: str | None = None, + org_explicit: bool = False, + repo_explicit: bool = False, + check_repo: bool = True, + # profile/role + capability + profile_name: str | None = None, + profile_role: str | None = None, + required_role: str | None = None, + required_permission: str | None = None, + allowed_operations: Any = None, + check_role: bool = True, + # root checkout + worktree + workspace_path: str | None = None, + project_root: str | None = None, + current_branch: str | None = None, + root_head_sha: str | None = None, + root_porcelain: str | None = None, + remote_master_sha: str | None = None, + check_root_checkout: bool = True, + check_worktree: bool = True, + # stale runtime (master parity) + startup_head: str | None = None, + current_code_head: str | None = None, + check_stale_runtime: bool = True, + # lease ownership + lease_required: bool = False, + foreign_lease: bool | None = None, + lease_owner_session: str | None = None, + active_session_id: str | None = None, + lease_owner_identity: str | None = None, + active_identity: str | None = None, + lease_reasons: list[str] | None = None, + # terminal lock + terminal_lock_blocks: bool | None = None, + terminal_lock_reasons: list[str] | None = None, + # expected head SHA + require_head_sha: bool = False, + expected_head_sha: str | None = None, + live_head_sha: str | None = None, + # workflow hash + workflow_hash_valid: bool | None = None, + workflow_hash_reasons: list[str] | None = None, + # source contamination (stable-branch push / approval contamination) + source_contaminated: bool | None = None, + contamination_reasons: list[str] | None = None, + # manual bypass + manual_bypass_attempted: bool = False, + manual_bypass_reasons: list[str] | None = None, +) -> dict[str, Any]: + """Fail-closed common anti-stomp assessment (pure). + + Only checks for which facts are supplied (or which are required by the + mutation category) are evaluated. Unsupplied optional facts are recorded + as ``skipped`` so callers can see coverage without inventing defaults. + + Returns a structured dict with ``allowed``/``block``, typed ``blockers``, + aggregated ``reasons``, ``exact_next_action``, and per-check ``checks``. + """ + task_name = (task or "").strip() + role = (profile_role or "").strip().lower() or None + req_role = (required_role or "").strip().lower() or None + req_perm = (required_permission or "").strip() or None + checks: dict[str, Any] = { + "task": task_name or None, + "profile_name": profile_name, + "profile_role": role, + "required_role": req_role, + "required_permission": req_perm, + } + blockers: list[dict[str, Any]] = [] + + # ── manual bypass (always first; never skippable when attempted) ───────── + if manual_bypass_attempted: + reasons = list(manual_bypass_reasons or [ + "manual state/mtime/source-file bypass attempt detected (fail closed)" + ]) + blockers.append(_blocker(BLOCKER_MANUAL_BYPASS, reasons)) + checks["manual_bypass"] = {"block": True, "reasons": reasons} + else: + checks["manual_bypass"] = {"block": False, "skipped": False} + + # ── repo/org ───────────────────────────────────────────────────────────── + if check_repo and resolved_org is not None and resolved_repo is not None: + repo_assessment = remote_repo_guard.assess_remote_repo_match( + remote=remote or "", + resolved_org=resolved_org, + resolved_repo=resolved_repo, + local_remote_url=local_remote_url, + org_explicit=org_explicit, + repo_explicit=repo_explicit, + ) + checks["repo"] = { + "block": bool(repo_assessment.get("block")), + "reasons": list(repo_assessment.get("reasons") or []), + "resolved_org": resolved_org, + "resolved_repo": resolved_repo, + } + if repo_assessment.get("block"): + blockers.append( + _blocker( + BLOCKER_WRONG_REPO, + list(repo_assessment.get("reasons") or ["repo/org mismatch"]), + detail={ + "resolved_org": resolved_org, + "resolved_repo": resolved_repo, + "local_remote_url": local_remote_url, + }, + ) + ) + else: + checks["repo"] = {"block": False, "skipped": True} + + # ── profile/role + capability ──────────────────────────────────────────── + # Role match OR possession of the task's required permission (Blocker A). + # Reviewer/merger may run issue-comment-class tasks when they hold + # gitea.issue.comment; unauthorized escalation without the permission + # still fails closed. + if check_role and req_role and role: + authorized = authorization_compatible( + role, + req_role, + required_permission=req_perm, + allowed_operations=allowed_operations, + ) + if not authorized: + perm_clause = ( + f", required_permission='{req_perm}'" if req_perm else "" + ) + reasons = [ + f"active profile role '{role}' is not authorized for task " + f"'{task_name or '(unknown)'}' (required_role='{req_role}'" + f"{perm_clause}; profile={profile_name or '(unknown)'})" + ] + checks["role"] = { + "block": True, + "reasons": reasons, + "required_permission": req_perm, + "capability_authorized": False, + } + blockers.append( + _blocker( + BLOCKER_WRONG_ROLE, + reasons, + detail={ + "profile_name": profile_name, + "profile_role": role, + "required_role": req_role, + "required_permission": req_perm, + }, + ) + ) + else: + checks["role"] = { + "block": False, + "reasons": [], + "required_permission": req_perm, + "capability_authorized": bool( + req_perm + and allowed_operations is not None + and req_perm in { + str(o).strip() for o in (allowed_operations or []) + if o is not None + } + and not roles_compatible(role, req_role) + ), + } + else: + checks["role"] = {"block": False, "skipped": True} + + # ── stale runtime (master parity) ──────────────────────────────────────── + if check_stale_runtime and ( + startup_head is not None or current_code_head is not None + ): + parity = master_parity_gate.assess_master_parity( + {"startup_head": startup_head}, + current_code_head, + ) + stale_reasons = master_parity_gate.parity_block_reasons(parity) + checks["stale_runtime"] = { + "block": bool(stale_reasons), + "reasons": list(stale_reasons), + "startup_head": startup_head, + "current_code_head": current_code_head, + "stale": bool(parity.get("stale")), + } + if stale_reasons: + blockers.append( + _blocker( + BLOCKER_STALE_RUNTIME, + list(stale_reasons), + detail={ + "startup_head": startup_head, + "current_code_head": current_code_head, + }, + ) + ) + else: + checks["stale_runtime"] = {"block": False, "skipped": True} + + # ── root checkout ──────────────────────────────────────────────────────── + if ( + check_root_checkout + and workspace_path is not None + and project_root is not None + and root_porcelain is not None + ): + root_assessment = root_checkout_guard.assess_root_checkout_guard( + workspace_path=workspace_path, + canonical_repo_root=project_root, + current_branch=current_branch, + head_sha=root_head_sha, + porcelain_status=root_porcelain, + remote_master_sha=remote_master_sha, + resolved_role=req_role or role, + actual_role=role, + ) + checks["root_checkout"] = { + "block": bool(root_assessment.get("block")), + "reasons": list(root_assessment.get("reasons") or []), + } + if root_assessment.get("block"): + blockers.append( + _blocker( + BLOCKER_ROOT_CHECKOUT, + list(root_assessment.get("reasons") or [ + "control checkout is not clean master" + ]), + detail={ + "workspace_path": workspace_path, + "project_root": project_root, + "current_branch": current_branch, + }, + ) + ) + else: + checks["root_checkout"] = {"block": False, "skipped": True} + + # ── worktree under branches (author) ───────────────────────────────────── + worktree_role = role or req_role + if ( + check_worktree + and workspace_path is not None + and project_root is not None + and worktree_role in _BRANCHES_REQUIRED_ROLES + ): + wt = author_mutation_worktree.assess_author_mutation_worktree( + workspace_path=workspace_path, + project_root=project_root, + current_branch=current_branch, + ) + checks["worktree"] = { + "block": bool(wt.get("block")), + "reasons": list(wt.get("reasons") or []), + "under_branches": wt.get("under_branches"), + } + if wt.get("block"): + blockers.append( + _blocker( + BLOCKER_WRONG_WORKTREE, + list(wt.get("reasons") or [ + "mutation worktree is not under branches/" + ]), + detail={ + "workspace_path": workspace_path, + "project_root": project_root, + }, + ) + ) + else: + checks["worktree"] = { + "block": False, + "skipped": worktree_role not in _BRANCHES_REQUIRED_ROLES + or workspace_path is None, + } + + # ── foreign lease ──────────────────────────────────────────────────────── + lease_needed = lease_required or ( + task_name.removeprefix("gitea_") in { + t.removeprefix("gitea_") for t in _LEASE_AWARE_TASKS + } + and foreign_lease is not None + ) + if lease_needed or foreign_lease is True: + is_foreign = bool(foreign_lease) + if foreign_lease is None and lease_required: + # Ownership must be proven when lease_required; missing ownership + # facts fail closed. + owner_ok = ( + (not lease_owner_session and not active_session_id) + or ( + lease_owner_session + and active_session_id + and lease_owner_session == active_session_id + ) + ) + identity_ok = ( + (not lease_owner_identity and not active_identity) + or ( + lease_owner_identity + and active_identity + and lease_owner_identity == active_identity + ) + ) + if not owner_ok or not identity_ok: + is_foreign = True + reasons = list(lease_reasons or []) + if is_foreign and not reasons: + reasons = [ + "active lease is owned by a foreign session or identity " + "(anti-stomp fail closed)" + ] + checks["lease"] = { + "block": is_foreign, + "reasons": reasons if is_foreign else [], + "lease_owner_session": lease_owner_session, + "active_session_id": active_session_id, + } + if is_foreign: + blockers.append( + _blocker(BLOCKER_FOREIGN_LEASE, reasons) + ) + else: + checks["lease"] = {"block": False, "skipped": True} + + # ── terminal lock ──────────────────────────────────────────────────────── + if terminal_lock_blocks is not None: + reasons = list(terminal_lock_reasons or []) + if terminal_lock_blocks and not reasons: + reasons = [ + "terminal review-decision lock is active for this head " + "(anti-stomp fail closed)" + ] + checks["terminal_lock"] = { + "block": bool(terminal_lock_blocks), + "reasons": reasons if terminal_lock_blocks else [], + } + if terminal_lock_blocks: + blockers.append(_blocker(BLOCKER_TERMINAL_LOCK, reasons)) + else: + checks["terminal_lock"] = {"block": False, "skipped": True} + + # ── expected head SHA ──────────────────────────────────────────────────── + if require_head_sha or ( + expected_head_sha is not None and live_head_sha is not None + ): + exp = (expected_head_sha or "").strip().lower() + live = (live_head_sha or "").strip().lower() + mismatch = False + reasons: list[str] = [] + if require_head_sha and not exp: + mismatch = True + reasons.append( + "expected_head_sha is required before this mutation " + "(anti-stomp fail closed)" + ) + elif exp and live and exp != live: + mismatch = True + reasons.append( + f"expected_head_sha '{exp}' does not match live PR head " + f"'{live}' (anti-stomp fail closed; stale prompt data)" + ) + elif require_head_sha and exp and not live: + mismatch = True + reasons.append( + "live PR head SHA could not be determined to corroborate " + "expected_head_sha (anti-stomp fail closed)" + ) + checks["head_sha"] = { + "block": mismatch, + "reasons": reasons, + "expected_head_sha": expected_head_sha, + "live_head_sha": live_head_sha, + } + if mismatch: + blockers.append(_blocker(BLOCKER_HEAD_SHA, reasons)) + else: + checks["head_sha"] = {"block": False, "skipped": True} + + # ── workflow hash ──────────────────────────────────────────────────────── + if workflow_hash_valid is not None: + reasons = list(workflow_hash_reasons or []) + if not workflow_hash_valid and not reasons: + reasons = [ + "workflow load proof missing or hash stale " + "(anti-stomp fail closed)" + ] + checks["workflow_hash"] = { + "block": not bool(workflow_hash_valid), + "reasons": reasons if not workflow_hash_valid else [], + } + if not workflow_hash_valid: + blockers.append(_blocker(BLOCKER_WORKFLOW_HASH, reasons)) + else: + checks["workflow_hash"] = {"block": False, "skipped": True} + + # ── source contamination ───────────────────────────────────────────────── + if source_contaminated is not None: + reasons = list(contamination_reasons or []) + if source_contaminated and not reasons: + reasons = [ + "session is source-contaminated (stable-branch push or " + "contaminated approval path); anti-stomp fail closed" + ] + checks["source_contamination"] = { + "block": bool(source_contaminated), + "reasons": reasons if source_contaminated else [], + } + if source_contaminated: + blockers.append( + _blocker(BLOCKER_SOURCE_CONTAMINATION, reasons) + ) + else: + checks["source_contamination"] = {"block": False, "skipped": True} + + if blockers: + return _blocked_result(blockers, checks) + return _allowed_result(checks) + + +def format_anti_stomp_error(assessment: dict[str, Any]) -> str: + """Single RuntimeError message for MCP mutation gates.""" + kind = assessment.get("blocker_kind") or "unknown" + reasons = "; ".join( + assessment.get("reasons") + or ["anti-stomp preflight blocked the mutation"] + ) + next_action = assessment.get("exact_next_action") or "stop and diagnose" + return ( + f"Anti-stomp preflight (#604) blocked mutation " + f"[{kind}]: {reasons}. " + f"exact_next_action: {next_action}" + ) + + +def block_response( + assessment: dict[str, Any], + **extra_fields: Any, +) -> dict[str, Any]: + """Structured tool-return payload for a blocked mutation.""" + payload = { + "success": False, + "performed": False, + "blocked": True, + "anti_stomp": True, + "blocker_kind": assessment.get("blocker_kind"), + "blockers": list(assessment.get("blockers") or []), + "reasons": list(assessment.get("reasons") or []), + "exact_next_action": assessment.get("exact_next_action"), + "checks": assessment.get("checks") or {}, + } + payload.update(extra_fields) + return payload + + +def contamination_from_stable_marker( + marker: dict | None, + *, + task: str | None, + actual_role: str | None, +) -> tuple[bool, list[str]]: + """Derive source-contamination facts from a #671 durable marker.""" + if not marker: + return False, [] + gate = stable_branch_push_guard.assess_contamination_gate( + marker, + task=task, + actual_role=actual_role, + ) + if gate.get("block"): + return True, list(gate.get("reasons") or []) + return False, [] diff --git a/branch_cleanup_guard.py b/branch_cleanup_guard.py index 66d2708..64db84a 100644 --- a/branch_cleanup_guard.py +++ b/branch_cleanup_guard.py @@ -7,6 +7,19 @@ from typing import Any PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) +# Evidence / preservation branches must never be removed by cleanup tools +# (e.g. chore/issue-681-preserve-review-session-wip). +_PRESERVATION_MARKERS = ("preserve", "preservation", "evidence") + + +def is_preservation_or_evidence_branch(branch: str | None) -> bool: + """Return True when *branch* is a preservation/evidence ref that must stay.""" + if not branch: + return False + name = str(branch).lower() + return any(marker in name for marker in _PRESERVATION_MARKERS) + + _RAW_BRANCH_DELETE_PATTERNS = ( re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I), re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I), @@ -72,6 +85,11 @@ def assess_merged_pr_branch_cleanup( reasons.append("PR head branch is missing") if head_branch in protected: reasons.append(f"branch '{head_branch}' is protected") + if is_preservation_or_evidence_branch(head_branch): + reasons.append( + f"branch '{head_branch}' is a preservation/evidence branch and " + "cannot be deleted through merged-PR cleanup" + ) if head_branch in open_pr_heads: reasons.append("an open PR still references this head branch") if head_on_target is False: @@ -96,3 +114,490 @@ def assess_merged_pr_branch_cleanup( "block_reasons": reasons, "recommended_action": "delete_remote_branch" if safe else "keep_remote_branch", } + + +# --------------------------------------------------------------------------- +# #687 remediation: post-delete readback + active ownership protection +# --------------------------------------------------------------------------- + +READBACK_NOT_FOUND = "not_found" +READBACK_EXISTS = "exists" +READBACK_AUTHENTICATION = "authentication_error" +READBACK_AUTHORIZATION = "authorization_error" +READBACK_TRANSPORT = "transport_error" +READBACK_UNEXPECTED = "unexpected_response" +READBACK_AMBIGUOUS_404 = "ambiguous_not_found" + +# Scope of a 404: only branch-scoped absence may set verified_absent. +NOT_FOUND_SCOPE_BRANCH = "branch" +NOT_FOUND_SCOPE_REPOSITORY = "repository" +NOT_FOUND_SCOPE_HOST = "host" +NOT_FOUND_SCOPE_UNKNOWN = "unknown" + +ERROR_CLASS_AUTHENTICATION = "authentication" +ERROR_CLASS_AUTHORIZATION = "authorization" +ERROR_CLASS_TRANSPORT = "transport" +ERROR_CLASS_UNEXPECTED = "unexpected" + +OWNERSHIP_CATEGORY_AUTHOR_SESSION = "author_session" +OWNERSHIP_CATEGORY_AUTHOR_LEASE = "author_lease" +OWNERSHIP_CATEGORY_REVIEWER_LEASE = "reviewer_lease" +OWNERSHIP_CATEGORY_MERGER_LEASE = "merger_lease" +OWNERSHIP_CATEGORY_CONTROLLER_LEASE = "controller_lease" +OWNERSHIP_CATEGORY_RECONCILER_LEASE = "reconciler_lease" +OWNERSHIP_CATEGORY_WORKTREE_BINDING = "worktree_binding" +OWNERSHIP_CATEGORY_INVENTORY_ERROR = "ownership_inventory_error" + +_ROLE_TO_OWNERSHIP_CATEGORY = { + "author": OWNERSHIP_CATEGORY_AUTHOR_LEASE, + "reviewer": OWNERSHIP_CATEGORY_REVIEWER_LEASE, + "merger": OWNERSHIP_CATEGORY_MERGER_LEASE, + "controller": OWNERSHIP_CATEGORY_CONTROLLER_LEASE, + "reconciler": OWNERSHIP_CATEGORY_RECONCILER_LEASE, +} + +_ACTIVE_OWNERSHIP_STATUSES = frozenset( + {"active", "live", "claimed", "in_progress", "working", "pushing", "pushed"} +) +_TERMINAL_OWNERSHIP_STATUSES = frozenset( + {"released", "abandoned", "done", "blocked", "terminal", "closed"} +) +_EXPIRED_STATUSES = frozenset({"expired"}) +_STALE_STATUSES = frozenset({"stale", "stale_dead_process", "stale_missing_worktree"}) + + +def _norm_str(value: Any) -> str: + return str(value or "").strip() + + +def normalize_host(host: str | None) -> str: + """Normalize a host identity for ownership matching (no credentials).""" + text = _norm_str(host).lower() + for prefix in ("https://", "http://"): + if text.startswith(prefix): + text = text[len(prefix) :] + # Drop path/query if a full URL slipped through. + text = text.split("/", 1)[0] + text = text.split("?", 1)[0] + return text.rstrip(".") + + +def ownership_category_for_role(role: str | None) -> str: + """Map a role kind to a non-secret ownership category label.""" + key = _norm_str(role).lower() + return _ROLE_TO_OWNERSHIP_CATEGORY.get(key, f"{key or 'unknown'}_lease") + + +def classify_branch_readback_http_status( + status_code: int | None, + *, + not_found_scope: str | None = None, +) -> dict[str, Any]: + """Classify a GET-branch HTTP status into a secret-free readback result. + + R1: A bare/generic/repository/wrong-host 404 never yields + ``verified_absent=True``. Only an authoritative *branch-scoped* not-found + (``not_found_scope='branch'``) may verify deletion. + """ + if status_code == 404: + scope = _norm_str(not_found_scope).lower() or NOT_FOUND_SCOPE_UNKNOWN + if scope == NOT_FOUND_SCOPE_BRANCH: + return { + "status": READBACK_NOT_FOUND, + "error_class": None, + "verified_absent": True, + "branch_present": False, + "not_found_scope": NOT_FOUND_SCOPE_BRANCH, + "reasons": [], + } + # repository / host / unknown / generic 404 — not verified absence + reason = { + NOT_FOUND_SCOPE_REPOSITORY: ( + "post-delete readback 404 is repository-scoped, not branch absence" + ), + NOT_FOUND_SCOPE_HOST: ( + "post-delete readback 404 is host-scoped, not branch absence" + ), + }.get( + scope, + "post-delete readback 404 is ambiguous (not branch-scoped); " + "cannot verify absence", + ) + return { + "status": READBACK_AMBIGUOUS_404, + "error_class": ERROR_CLASS_UNEXPECTED, + "verified_absent": False, + "branch_present": None, + "not_found_scope": scope, + "reasons": [reason], + } + if status_code in (401, 407): + return { + "status": READBACK_AUTHENTICATION, + "error_class": ERROR_CLASS_AUTHENTICATION, + "verified_absent": False, + "branch_present": None, + "reasons": ["post-delete branch readback authentication failed"], + } + if status_code == 403: + return { + "status": READBACK_AUTHORIZATION, + "error_class": ERROR_CLASS_AUTHORIZATION, + "verified_absent": False, + "branch_present": None, + "reasons": ["post-delete branch readback authorization failed"], + } + if status_code is not None and 200 <= int(status_code) < 300: + return { + "status": READBACK_EXISTS, + "error_class": None, + "verified_absent": False, + "branch_present": True, + "reasons": ["post-delete readback found branch still present"], + } + if status_code is not None and int(status_code) >= 500: + return { + "status": READBACK_TRANSPORT, + "error_class": ERROR_CLASS_TRANSPORT, + "verified_absent": False, + "branch_present": None, + "reasons": ["post-delete branch readback transport/upstream failure"], + } + return { + "status": READBACK_UNEXPECTED, + "error_class": ERROR_CLASS_UNEXPECTED, + "verified_absent": False, + "branch_present": None, + "reasons": ["post-delete branch readback returned an unexpected response"], + "http_status": status_code, + } + + +def _extract_http_status(exc: BaseException) -> int | None: + """Extract an HTTP status code from an exception chain (secret-free).""" + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and id(current) not in seen: + seen.add(id(current)) + for attr in ("code", "status", "status_code"): + value = getattr(current, attr, None) + if isinstance(value, int) and 100 <= value <= 599: + return value + text = str(current) if current is not None else "" + match = re.match(r"HTTP\s+(\d{3})\b", text) + if match: + return int(match.group(1)) + current = current.__cause__ or current.__context__ + return None + + +def classify_branch_readback_exception( + exc: BaseException, + *, + not_found_scope: str | None = None, +) -> dict[str, Any]: + """Classify a GET-branch exception without leaking credentials or bodies. + + R1: substring ``404`` / ``not found`` alone never becomes verified_absent. + Callers must pass ``not_found_scope='branch'`` only after authoritative + proof that the repository/host is still reachable and the 404 is branch-level. + """ + status_code = _extract_http_status(exc) + if status_code is None: + lower = str(exc).lower() if exc is not None else "" + if any( + token in lower + for token in ( + "timed out", + "timeout", + "connection", + "network", + "temporarily unavailable", + "name or service not known", + "nodename nor servname", + ) + ): + return { + "status": READBACK_TRANSPORT, + "error_class": ERROR_CLASS_TRANSPORT, + "verified_absent": False, + "branch_present": None, + "reasons": [ + "post-delete branch readback transport/upstream failure" + ], + } + if "unauthorized" in lower: + status_code = 401 + elif "forbidden" in lower: + status_code = 403 + elif "404" in lower or "not found" in lower: + # Ambiguous: do NOT treat as branch absence without scope proof. + status_code = 404 + if not_found_scope is None: + not_found_scope = NOT_FOUND_SCOPE_UNKNOWN + + if status_code is not None: + return classify_branch_readback_http_status( + status_code, not_found_scope=not_found_scope + ) + + return { + "status": READBACK_UNEXPECTED, + "error_class": ERROR_CLASS_UNEXPECTED, + "verified_absent": False, + "branch_present": None, + "reasons": ["post-delete branch readback returned an unexpected response"], + } + + +def assess_post_delete_readback(readback: dict[str, Any] | None) -> dict[str, Any]: + """Decide whether DELETE success may be reported after branch readback. + + Success requires authoritative branch-scoped not-found + (``verified_absent=True``). DELETE HTTP success alone is never enough. + Generic/repository/host 404 cannot verify absence (R1). + """ + payload = dict(readback or {}) + status = _norm_str(payload.get("status")) or READBACK_UNEXPECTED + # Only explicit verified_absent flag counts — never infer from status alone + # when status is a bare not_found without branch scope proof. + verified = bool(payload.get("verified_absent")) is True + if verified and status == READBACK_NOT_FOUND: + return { + "ok": True, + "success": True, + "verified_absent": True, + "readback": { + "status": READBACK_NOT_FOUND, + "verified_absent": True, + "branch_present": False, + "error_class": None, + "not_found_scope": NOT_FOUND_SCOPE_BRANCH, + }, + "reasons": [], + } + + reasons = list(payload.get("reasons") or []) + if not reasons: + if status == READBACK_EXISTS: + reasons = ["post-delete readback found branch still present"] + elif status == READBACK_AUTHENTICATION: + reasons = ["post-delete branch readback authentication failed"] + elif status == READBACK_AUTHORIZATION: + reasons = ["post-delete branch readback authorization failed"] + elif status == READBACK_TRANSPORT: + reasons = ["post-delete branch readback transport/upstream failure"] + elif status == READBACK_AMBIGUOUS_404: + reasons = [ + "post-delete readback 404 is not branch-scoped; " + "cannot verify absence" + ] + else: + reasons = ["post-delete branch readback could not verify deletion"] + + return { + "ok": False, + "success": False, + "verified_absent": False, + "readback": { + "status": status, + "verified_absent": False, + "branch_present": payload.get("branch_present"), + "error_class": payload.get("error_class"), + "not_found_scope": payload.get("not_found_scope"), + }, + "reasons": reasons, + "blocker_kind": "post_delete_readback_failed", + } + + +def cleanup_result_envelope( + *, + success: bool, + performed: bool, + delete_acknowledged: bool, + verified_absent: bool, + **extra: Any, +) -> dict[str, Any]: + """R2: consistent top-level cleanup result fields on every return path.""" + out: dict[str, Any] = { + "success": bool(success), + "performed": bool(performed), + "delete_acknowledged": bool(delete_acknowledged), + "verified_absent": bool(verified_absent), + } + out.update(extra) + return out + + +def _repo_matches( + record: dict[str, Any], + *, + remote: str, + org: str, + repo: str, + host: str | None = None, +) -> bool: + """Match ownership record to target remote/org/repo and normalized host.""" + if ( + _norm_str(record.get("remote")).lower() != _norm_str(remote).lower() + or _norm_str(record.get("org")).lower() != _norm_str(org).lower() + or _norm_str(record.get("repo")).lower() != _norm_str(repo).lower() + ): + return False + expected_host = normalize_host(host) + record_host = normalize_host(record.get("host") or record.get("host_name")) + # When both sides declare a host, they must agree after normalization. + # A record host that disagrees with the expected host is out of scope. + # Legacy records without host still match when remote/org/repo agree. + if expected_host and record_host and expected_host != record_host: + return False + return True + + +def _branch_matches(record: dict[str, Any], branch: str) -> bool: + return _norm_str(record.get("branch")) == _norm_str(branch) + + +def assess_ownership_record_activity(record: dict[str, Any]) -> dict[str, Any]: + """Classify one ownership record as blocking or non-blocking. + + Distinguishes active ownership from expired/released/terminal/stale records. + Expired/stale records block unless reclaim_allowed is *explicitly* True + (O2: never treat missing/unknown reclaim as auto-allowed). + """ + status = _norm_str(record.get("status")).lower() + category = _norm_str(record.get("category")) or "unknown" + reclaim_allowed = record.get("reclaim_allowed") + + if category == OWNERSHIP_CATEGORY_INVENTORY_ERROR: + return { + "blocks": True, + "status": status or "unknown", + "category": category, + "reason": "ownership inventory failed closed", + } + + if status in _TERMINAL_OWNERSHIP_STATUSES: + return { + "blocks": False, + "status": status, + "category": category, + "reason": f"{category} ownership is terminal/released ({status})", + } + if status in _ACTIVE_OWNERSHIP_STATUSES: + return { + "blocks": True, + "status": status, + "category": category, + "reason": f"active {category} ownership still uses the target branch", + } + if status in _EXPIRED_STATUSES or status in _STALE_STATUSES: + # O2: only explicit reclaim_allowed=True skips the block. + if reclaim_allowed is True: + return { + "blocks": False, + "status": status, + "category": category, + "reason": ( + f"{category} ownership is {status} and reclaimable; " + "does not block deletion" + ), + } + return { + "blocks": True, + "status": status, + "category": category, + "reason": ( + f"{status} {category} ownership still protects the target " + "branch (reclaim not proven; fail closed)" + ), + } + # Unknown status → fail closed + return { + "blocks": True, + "status": status or "unknown", + "category": category, + "reason": ( + f"unclassified {category} ownership status " + f"'{status or 'unknown'}'; fail closed" + ), + } + + +def assess_active_branch_ownership( + *, + remote: str, + org: str, + repo: str, + branch: str, + host: str | None = None, + records: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Assess whether any active ownership still uses *branch* in *repo*. + + Matching requires remote/org/repo/branch and, when provided, normalized + host identity. Other repositories, hosts, or branches never false-block. + """ + target_branch = _norm_str(branch) + expected_host = normalize_host(host) + considered: list[dict[str, Any]] = [] + blocking: list[dict[str, Any]] = [] + ignored: list[dict[str, Any]] = [] + + for raw in records or []: + if not isinstance(raw, dict): + continue + if not _repo_matches( + raw, remote=remote, org=org, repo=repo, host=expected_host or None + ): + ignored.append( + { + "category": _norm_str(raw.get("category")) or "unknown", + "reason": "different repository or host scope", + } + ) + continue + if not _branch_matches(raw, target_branch): + ignored.append( + { + "category": _norm_str(raw.get("category")) or "unknown", + "reason": "different branch", + } + ) + continue + activity = assess_ownership_record_activity(raw) + entry = { + "category": activity["category"], + "status": activity["status"], + "blocks": activity["blocks"], + "reason": activity["reason"], + } + considered.append(entry) + if activity["blocks"]: + blocking.append(entry) + + block = bool(blocking) + categories = sorted({b["category"] for b in blocking}) + reasons = [ + ( + "active ownership protects branch " + f"'{target_branch}': " + "; ".join(b["reason"] for b in blocking) + ) + ] if block else [] + return { + "block": block, + "safe_to_delete": not block, + "remote": remote, + "org": org, + "repo": repo, + "host": expected_host or None, + "branch": target_branch, + "blocking_categories": categories, + "blocking": blocking, + "considered": considered, + "ignored_out_of_scope": ignored, + "reasons": reasons, + "blocker_kind": "active_branch_ownership" if block else None, + "recommended_action": "keep_remote_branch" if block else "delete_remote_branch", + } diff --git a/docs/architecture/mcp-stable-control-runtime-policy-adr.md b/docs/architecture/mcp-stable-control-runtime-policy-adr.md new file mode 100644 index 0000000..00608eb --- /dev/null +++ b/docs/architecture/mcp-stable-control-runtime-policy-adr.md @@ -0,0 +1,198 @@ +# ADR: Stable control runtime vs dev runtime (Gitea MCP) + +- **Status:** Accepted (policy effective immediately for LLM sessions; tooling may lag) +- **Date:** 2026-07-09 +- **Tracking issue:** [#615](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/615) +- **Related:** + - [#543](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/543) / `docs/mcp-namespace-health.md` — client-namespace health + - `docs/mcp-namespace-eof-recovery.md` — reconnect-only EOF recovery (no PID kill) + - [#558](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/558) / `docs/mcp-daemon-import-guard.md` — sanctioned daemon + - [#557](https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/557) / `docs/bootstrap-review-path.md` — controller bootstrap for self-hosted fixes + - Allocator / control-plane ADR: `docs/architecture/mcp-allocator-control-plane-observability-adr.md` (#613 / PR #614) + +## 1. Context + +The Gitea MCP server is the **control plane** for real issue/PR mutations (create, comment, lock, review, merge, etc.). When author/reviewer/merger/reconciler sessions kill or restart that process, relaunch it from a feature worktree, or edit the checkout that process loads, operators observe: + +- Mid-session identity/preflight resets +- Stale-runtime vs master parity failures +- IDE transport EOF / “tool not found” while code on disk has changed +- Accidental production mutations from experimental code + +This ADR separates **stable control runtime** from **dev/test runtime** and defines promotion proof. + +## 2. Decision + +### 2.1 Stable control runtime + +The Gitea MCP server used for **real workflow mutations** is the **stable control runtime**. + +Characteristics: + +- Loads a known, promoted revision of Gitea-Tools (or the packaged release layout operators designate) +- Registered in the IDE/client as the production namespaces (`gitea-tools`, `gitea-reviewer`, `gitea-merger`, `gitea-reconciler`, etc.) +- Holds production profile credentials via sanctioned keychain/env paths only + +### 2.2 Dev / test runtime + +MCP **server code** development and testing: + +- Happens in isolated **`branches/`** worktrees (or other non-stable checkouts) +- May use a **separate** dev/test MCP runtime/process when process-level testing is required +- **Must not** be used for real Gitea mutations on production issues/PRs + +### 2.3 Forbidden actions (normal sessions) + +Normal **author, reviewer, merger, and reconciler** LLM sessions **must not**: + +| Forbidden | Why | +|-----------|-----| +| Kill the running MCP server process | Drops all concurrent sessions; loses preflight state | +| Restart / relaunch the MCP server process | Same as kill; causes stale/identity churn mid-workflow | +| Relaunch MCP from a development worktree | Runs unpromoted code against production mutations | +| Edit files in the stable runtime checkout | Hot-mutates control plane under concurrent users | +| Use experimental/dev MCP for real Gitea mutations | Bypasses promotion proof and audit expectations | +| Bypass or self-reset a stale master-parity gate | The gate is fail-closed; only an operator reload restores parity | + +**LLM-allowed vs operator-owned (authoritative split):** + +| Actor | May do | Must not do | +|-------|--------|-------------| +| **LLM session** | Call tools on the already-running stable namespaces; **client reconnect** after transport EOF (no process kill); pass `worktree_path` / role worktree args; report blockers and stop mutations when unhealthy/stale | Kill, restart, or relaunch any MCP process; bump config mtimes to force reload; edit the stable checkout; switch to a dev MCP for production mutations | +| **Operator / release-manager** | Supervised restart/reload of the **stable** control runtime; dual-namespace client configuration; §2.4 promotions; incident recovery | — | + +EOF / transport recovery for LLM sessions: **client reconnect only** (see `docs/mcp-namespace-eof-recovery.md`). Do not “fix” health by killing PIDs or bumping MCP config mtimes as a normal session procedure. + +This ADR **supersedes** any older runbook wording that told the LLM to relaunch or restart the client/MCP as a self-service step. Where `docs/llm-workflow-runbooks.md` (or wiki runbooks) discuss dual-namespace setup or workspace rebind, **process restart/relaunch is operator-owned**; the LLM stops, reports, and waits. + +### 2.4 Promotion (operator / release-manager only) + +Promotion of a new revision into the stable control runtime is an **explicit operator/release-manager action**, not an LLM self-service step. + +A promotion **must record** (issue comment, release note, or promotion ledger): + +| Field | Description | +|-------|-------------| +| **previous runtime SHA** | Commit previously loaded by stable runtime | +| **promoted runtime SHA** | Commit after promotion | +| **source branch/PR** | Where the change was reviewed | +| **restart/reload method** | How the process was cycled (e.g. supervised restart, client reload) | +| **health check proof** | Client-namespace probe success (`gitea_whoami` / namespace health) | +| **identity/profile proof** | Expected profile(s) and username(s) after reload | +| **workspace/root proof** | Stable checkout path / root matches intended layout | +| **mutation capability proof** | Required permissions for the target role present; forbidden ops still forbidden | +| **rollback instructions** | How to restore previous SHA and re-verify health | + +Suggested durable marker: + +```text +## MCP STABLE RUNTIME PROMOTION (#615) + +Status: COMPLETED | ROLLED_BACK | ABORTED +Previous-SHA: +Promoted-SHA: +Source-PR: +Source-Branch: +Reload-Method: +Health-Proof: client_namespace whoami OK / assess_mcp_namespace_health OK +Identity-Proof: profile= user= +Workspace-Proof: root= +Mutation-Proof: allowed_ops include <…>; forbidden include <…> +Rollback: checkout ; reload method <…>; re-run health/identity proofs +Operator: +Timestamp: +``` + +### 2.5 Unhealthy stable runtime → stop work + +If the stable MCP runtime is **unhealthy**, including any of: + +- client-namespace probes fail +- wrong identity / wrong profile +- wrong workspace root +- missing mutation capability for the intended role +- persistent EOF after **client reconnect** +- **master parity is stale** (`startup_head` behind on-disk `master` / `restart_required` from the parity gate) + +then: + +1. **Normal PR / review / merge / issue-mutation work must stop immediately.** +2. The session **reports** the unhealthy/stale state (tool error, CTH, or operator handoff) with startup vs current head when known. +3. Do **not** improvise: no LLM process kill/restart, no dev-worktree MCP for production mutations, no env escape hatches, no manual gate bypass. +4. Resume only after: + - an **operator** restores the runtime (see §2.6 for routine post-merge parity reload), **or** + - a **controlled** promotion/rollback completes with the §2.4 promotion record, **or** + - a controller invokes the narrow **bootstrap review path** (#557) when the defect is self-hosted and documented, + - **and** the session re-verifies health (and master parity when applicable) before the next mutation. + +### 2.6 Routine post-merge master-parity staleness (operator reload, not promotion) + +**Symptom:** After merges land on `master`, a long-lived stable MCP process still runs the pre-merge `startup_head`. The master-parity gate marks the server **stale** / `restart_required` and **blocks mutations**. + +**Sanctioned response (authoritative):** + +| Step | Actor | Action | +|------|-------|--------| +| 1 | LLM session | Mutations stop immediately when the gate reports stale. | +| 2 | LLM session | Report the stale state (startup head, current master head, that operator reload is required). Do not retry mutations. | +| 3 | **Operator** | Reload/restart the **stable** control MCP so it loads current `master` (supervised client/daemon reload). | +| 4 | LLM session | Resume only after startup/current-head parity is verified (e.g. `gitea_get_runtime_context` / parity assessment shows in parity). | + +**Not a §2.4 promotion:** Catching the already-designated stable control checkout up to a newly advanced `master` is a **routine operator reload** of the stable runtime. It does **not** require the nine-field promotion ledger. Use §2.4 only when **changing which unpromoted/dev revision** becomes the stable control runtime (new source branch/PR into the stable designation). + +**Code note:** `master_parity_gate.py` may still say “restart the server” in machine-facing reason strings. That string names the **operator recovery action**, not an LLM self-service instruction. This ADR and the runbooks define the actor split. + +## 3. Relationship to other controls + +| Doc / mechanism | Interaction | +|-----------------|-------------| +| Namespace health (#543) | Proves IDE client can call tools; does not authorize restart | +| EOF recovery | Reconnect only; no process kill | +| Daemon import guard (#558) | Mutations require sanctioned daemon; not a bare shell import | +| Bootstrap path (#557) | Only controller-authorized exception when live runtime cannot review its own fix | +| Allocator / control-plane ADR | Coordination DB is separate; still depends on a healthy MCP surface for Gitea writes | + +## 4. Consequences + +### Positive + +- Predictable control plane for concurrent LLMs +- Clear operator-only promotion gate with rollback +- Aligns session behavior with health/EOF docs already landed + +### Costs + +- LLM sessions must wait when runtime is sick (no DIY restart) +- Operators must maintain promotion discipline and dual-runtime config if they use a dev MCP + +### Non-goals + +- Does not ban operator-supervised restarts during incidents +- Does not replace CI or code review for MCP changes +- Does not authorize editing stable checkout “because tests need a quick fix” + +## 5. Implementation follow-ups (optional tooling) + +These may land in later issues; the **policy binds sessions now**: + +1. Session preflight that refuses mutations if workspace root equals a `branches/` feature worktree configured as “dev only.” +2. Explicit `runtime_kind=stable|dev` in MCP config and `gitea_whoami` profile metadata. +3. Promotion checklist script that emits the durable promotion marker fields. + +**Not optional (issue #615 acceptance criterion 2):** operator guide and runbooks **must** cross-link this ADR (see §6). Cross-links are documentation acceptance, not deferred tooling. + +## 6. Acceptance for this ADR + +1. Document merged under `docs/architecture/mcp-stable-control-runtime-policy-adr.md`. +2. **Operator guide / runbooks cross-link this ADR** (`docs/wiki/Operator-Guide.md`, `docs/wiki/Runbooks.md`, `docs/llm-workflow-runbooks.md`). +3. Issue #615 references this path. +4. LLM/operator runbooks treat kill/restart/relaunch-from-worktree as **LLM violations**; process restart is **operator-owned**. +5. Unhealthy runtime (including **stale master parity**) stops normal mutation work until operator restore/reload, promotion/rollback, or #557 bootstrap — then re-verify parity before mutating. +6. Routine post-merge parity reload is documented as operator reload (§2.6), not an LLM self-restart and not a full §2.4 promotion. + +## 7. Document history + +| Date | Change | +|------|--------| +| 2026-07-09 | Initial ADR: stable vs dev runtime, forbidden session actions, promotion proof fields, stop-work rule | +| 2026-07-16 | Review 443 remediation (#615 / PR #616): mandatory cross-links; LLM vs operator restart split; routine post-merge parity staleness (§2.6); stale parity in §2.5 unhealthy triggers | diff --git a/docs/gitea-execution-profiles.md b/docs/gitea-execution-profiles.md index 5cf4325..3b15843 100644 --- a/docs/gitea-execution-profiles.md +++ b/docs/gitea-execution-profiles.md @@ -280,11 +280,43 @@ narrow operation set: - `gitea.issue.comment` - `gitea.issue.close` - `gitea.pr.close` +- `gitea.branch.delete` (merged-branch cleanup only — see below) Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`, `gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and `gitea.repo.commit`. +### Merged-branch cleanup ownership (`gitea.branch.delete`) + +The reconciler is the repository-supported owner of merged-PR source-branch +cleanup: `task_capability_map` maps `cleanup_merged_pr_branch` (and +`reconciliation_cleanup`) to role `reconciler` with permission +`gitea.branch.delete`. Post-merge branch lifecycle is reconciliation work — +it happens after the author, reviewer, and merger roles have completed, and +it must not be reachable from those roles. + +Least-privilege constraints: + +- `gitea.branch.delete` is granted **only** to reconciler profiles. Author, + reviewer, and merger profiles must never hold it; `gitea_delete_branch` + and `gitea_cleanup_merged_pr_branch` fail closed on any profile without + the permission. +- Even with the permission, reconciler deletion is only supported through the + guarded `gitea_cleanup_merged_pr_branch` path (#514 / #687): the PR must be + merged, the head an ancestor of the target, the branch not protected + (`master`/`main`/`dev`), the branch not a preservation/evidence ref (e.g. + `chore/issue-681-preserve-review-session-wip`), no open PR may still use the + head, and an explicit `CLEANUP MERGED PR BRANCH ` confirmation is + required. Raw `gitea_delete_branch` is **denied** to reconciler even when + `gitea.branch.delete` is present. +- Raw `git branch -d` / `git push --delete` cleanup remains blocked by + `branch_cleanup_guard` and the final-report validator regardless of + profile permissions. +- `gitea.branch.delete` has no short alias in `GITEA_OPERATION_ALIASES`; + write it fully qualified in `allowed_operations`. Migration must emit + canonical names such as `gitea.pr.close` (never bare `pr.close` / + `issue.close`, which the production normalizer rejects or drops). + 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 @@ -293,6 +325,159 @@ Launch a static `gitea-reconciler` MCP namespace with fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose heads are not already landed cannot be closed through this path. +### Operational runbook: grant reconciler `gitea.branch.delete` (#687) + +Merging a code PR that updates `migrate_profiles.py` / `reconciler_profile.py` +**does not** change the live operator profile on disk. Apply the profile +change deliberately, then reconnect the client-managed namespace. + +1. **Approved migration / profile-update command** (from the repo root, using + the project venv if present): + + ```bash + # Dry-run first (default): validates v2 output, writes nothing + python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json + + # Apply: creates backup then writes migrated v2 config + python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json -w + # Optional explicit paths: + # python3 migrate_profiles.py -i ~/.config/gitea-tools/profiles.json \ + # -o ~/.config/gitea-tools/profiles.json \ + # --backup ~/.config/gitea-tools/profiles.json.bak -w + ``` + + If the live file is already v2, edit the reconciler identity’s + `allowed_operations` / `forbidden_operations` under + `environments..services.gitea.identities.reconciler` (or the + `prgs-reconciler` alias target) so allowed includes the canonical set + below — then re-validate with a load of the config (see step 3). + +2. **Inspect the generated (or edited) profile** — confirm the reconciler + identity, for example: + + ```bash + python3 - <<'PY' + import json + from pathlib import Path + cfg = json.loads(Path.home().joinpath(".config/gitea-tools/profiles.json").read_text()) + # v2 environments shape: + ident = cfg["environments"]["prgs"]["services"]["gitea"]["identities"]["reconciler"] + print("role:", ident.get("role")) + print("allowed:", ident.get("allowed_operations")) + print("forbidden:", ident.get("forbidden_operations")) + PY + ``` + +3. **Validate canonical operation names and least privilege** + + Expected canonical **allowed** (defaults after migration): + + - `gitea.read` + - `gitea.pr.close` (required) + - `gitea.pr.comment` + - `gitea.issue.comment` + - `gitea.issue.close` + - `gitea.branch.delete` (recommended; cleanup only) + + Expected **forbidden** includes at least: `gitea.pr.approve`, + `gitea.pr.merge`, `gitea.pr.review`, `gitea.pr.create`, + `gitea.branch.push`, `gitea.repo.commit`. + + No shorthand (`pr.close`, `issue.close`, `pr.comment`) may remain. + Validate with the production loader: + + ```bash + python3 - <<'PY' + import gitea_config, reconciler_profile + from pathlib import Path + path = str(Path.home() / ".config/gitea-tools/profiles.json") + gitea_config.load_config(path) # fails closed on invalid config + # Or assess the reconciler lists directly after extracting them: + # print(reconciler_profile.assess_reconciler_profile(allowed, forbidden)) + PY + ``` + +4. **Merging PR #688 (or any code PR) does not update the live profile.** + Code changes only the migration helper, schema, docs, and tests. The + operator must still run `migrate_profiles.py -w` or an equivalent + authorized edit of `~/.config/gitea-tools/profiles.json`. + +5. **Supported apply method:** `python3 migrate_profiles.py … -w` (backup + created automatically) **or** operator-authorized edit of the live + profiles file after backup. Unsupported: silent mtime tricks, manual + process kill to “reload”, or undocumented env overrides. + +6. **Backup and validation:** `-w` copies the input to + `.bak` (or `--backup PATH`) before writing. Re-run + `load_config` / `assess_reconciler_profile` after write. Keep the + `.bak` until live whoami/capability checks pass. + +7. **Client-managed namespace reconnect/reload:** reconnect or reload the + IDE MCP client so `gitea-reconciler` restarts from current `master` and + the updated `GITEA_MCP_PROFILE=prgs-reconciler` config. Do not hand-launch + `mcp_server.py` / `gitea_mcp_server.py` with ad hoc `GITEA_*` env + (see #686 / #630). + +8. **Live reverification** (through the client-managed `gitea-reconciler` + namespace only): + + - `gitea_whoami` → identity + profile `prgs-reconciler` + - `gitea_assess_master_parity` → `stale=false`, `restart_required=false` + - `gitea_resolve_task_capability(task="cleanup_merged_pr_branch")` → + `allowed_in_current_session=true` only when permission and role match + - `gitea_resolve_task_capability(task="delete_branch")` → + **not** allowed for reconciler (role denial must be enforced) + +9. **Guarded cleanup usage** (example for a merged PR whose source branch + remains on the remote): + + ```text + gitea_cleanup_merged_pr_branch( + pr_number=, + branch=, + confirmation="CLEANUP MERGED PR BRANCH ", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path="", + ) + ``` + + The tool refuses unmerged PRs, protected branches, preservation/evidence + branches, open-PR heads, mismatched branch names, and wrong confirmation. + +10. **Prohibitions** + + - No raw `git push --delete`, `git branch -d` / `-D`, or delete refspecs + - No arbitrary `gitea_delete_branch` from reconciler + - No unsupported profile switching mid-run without full re-preflight + - No ad hoc hand-edits of live profiles **unless** operator-authorized, + backed up, and revalidated as above + +Canonical migrated reconciler example: + +```json +{ + "role": "reconciler", + "allowed_operations": [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + "gitea.issue.close", + "gitea.branch.delete" + ], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit" + ] +} +``` + ## Identity and fail-closed rules Before **any** mutating action, a workflow must know both: diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index 5fc591b..213b661 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -195,7 +195,7 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe `gitea_reconcile_already_landed_pr` after ancestry proof — not for normal review or author workflows. -* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks. +* **Fallback (operator-owned):** If the dual-profile MCP launcher pattern is not supported or configured in the client, **do not** have the LLM relaunch or restart the client/MCP. The LLM **stops** role-switching work, reports that the correct static namespace is missing, and waits for an **operator** to configure dual namespaces or reload the client with the correct `GITEA_MCP_PROFILE` for that role. Process restart/relaunch is operator-owned under the [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md) (#615). ## Setup runbook — interactive menu @@ -1200,10 +1200,13 @@ When a mutation blocks on workspace binding: 1. Read the error — it names the **resolved workspace path**, **role namespace**, and **binding source** (tool arg, env var, or process root). -2. Reconnect or relaunch the correct namespace MCP server from the intended - workspace (or set the role-specific env var before launch). -3. Pass `worktree_path` on reviewer/merger mutation tools when the active - branches/ worktree differs from the MCP process root. +2. **LLM-allowed:** pass `worktree_path` on reviewer/merger mutation tools when + the active `branches/` worktree differs from the MCP process root; **client + reconnect** after transport EOF only (no process kill). +3. **Operator-owned:** if the wrong namespace process was launched, or a role- + specific `GITEA_*_WORKTREE` must be set at process start, an **operator** + reloads/relaunches the correct static namespace MCP. LLM sessions must not + kill or restart MCP processes (see [stable control runtime ADR](architecture/mcp-stable-control-runtime-policy-adr.md)). 4. **Do not** clean, reset, or discard foreign role worktrees to unblock your own namespace — that destroys another agent's WIP. @@ -1213,9 +1216,10 @@ When posting a Canonical Thread Handoff after a binding blocker: - State which namespace was active (author / reviewer / merger / reconciler). - Quote the resolved workspace path and binding source from the error. -- Name the safe reconnect action (relaunch MCP from `branches/...`, set - `GITEA_*_WORKTREE`, or pass `worktree_path`). -- Explicitly note that foreign worktrees must not be cleaned to unblock. +- Name the safe next action: pass `worktree_path` if that unblocks the tool, or + request an **operator** reload of the correct namespace MCP / env binding. +- Explicitly note that foreign worktrees must not be cleaned to unblock, and + that the LLM must not self-restart the MCP process. ## Safety notes @@ -1226,6 +1230,7 @@ When posting a Canonical Thread Handoff after a binding blocker: ## Related documents +- [`architecture/mcp-stable-control-runtime-policy-adr.md`](architecture/mcp-stable-control-runtime-policy-adr.md) — stable control runtime vs dev runtime; LLM must not kill/restart MCP; operator-owned reload and promotions; routine post-merge parity staleness (#615). - [`reviewer-handoff-consistency.md`](reviewer-handoff-consistency.md) — reject contradictory reviewer handoffs (#501). - [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500). - [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill. diff --git a/docs/observability/sentry-integration.md b/docs/observability/sentry-integration.md new file mode 100644 index 0000000..e06de0a --- /dev/null +++ b/docs/observability/sentry-integration.md @@ -0,0 +1,138 @@ +# Self-hosted Sentry observability for the Gitea MCP server (#606) + +Optional, **off-by-default** instrumentation that reports MCP runtime errors, +fail-closed workflow blockers, lease / terminal-lock / stale-runtime +collisions, and recurring watchdog check-ins to a **self-hosted** Sentry at +`https://sentry.prgs.cc/`. + +> **Gitea remains the source of truth.** Sentry is observe-only. It never +> approves, merges, closes, or otherwise mutates Gitea workflow state, and it +> never bypasses leases, #332, workflow roles, or the MCP gates. Sentry alerts +> may only feed the *sanctioned* Gitea issue/comment path via the #612 incident +> bridge — never a direct write. + +Implemented by [`sentry_observability.py`](../../sentry_observability.py). + +--- + +## 1. Create the Sentry project + +1. Sign in to the self-hosted Sentry at **`https://sentry.prgs.cc/`** (this is + **not** Sentry Cloud — do not use `*.ingest.sentry.io`). +2. Create a new **Python** project named **`gitea-tools-mcp`**. +3. Open **Settings → Projects → gitea-tools-mcp → Client Keys (DSN)** and copy + the DSN. It looks like `https://@sentry.prgs.cc/`. +4. **Never commit the DSN.** It is a runtime secret supplied via env var only. + +## 2. Configure the environment + +All configuration is env-var driven (see [`.env.example`](../../.env.example)): + +| Variable | Purpose | Default | +|----------|---------|---------| +| `MCP_SENTRY_ENABLED` | Master gate (`1/true/yes/on`). Required. | off | +| `SENTRY_DSN` | Self-hosted DSN. Required. | *(empty)* | +| `SENTRY_ENVIRONMENT` | `local` / `dev` / `prod` tag. | `development` | +| `SENTRY_RELEASE` | Release id (git SHA or version). | *(none)* | +| `MCP_SENTRY_TRACES_SAMPLE_RATE` | Perf-trace sample rate `0.0–1.0` (clamped). | `0.0` | +| `MCP_SENTRY_ENABLE_LOGS` | Forward Python logs as structured logs. | off | + +**The feature stays completely off unless `MCP_SENTRY_ENABLED` is truthy *and* +`SENTRY_DSN` is non-empty.** With either missing, `init_sentry()` is a no-op, +the SDK is never initialised, and no events are sent — existing tool behaviour +and API-call patterns are unchanged. + +### Per-environment examples + +```bash +# local (quiet: capture errors/blockers, no traces) +export MCP_SENTRY_ENABLED=1 +export SENTRY_DSN="https://@sentry.prgs.cc/" +export SENTRY_ENVIRONMENT=local + +# dev (light tracing + logs) +export MCP_SENTRY_ENABLED=1 +export SENTRY_DSN="https://@sentry.prgs.cc/" +export SENTRY_ENVIRONMENT=dev +export MCP_SENTRY_TRACES_SAMPLE_RATE=0.2 +export MCP_SENTRY_ENABLE_LOGS=1 + +# prod (errors/blockers + low-rate tracing, release-tagged) +export MCP_SENTRY_ENABLED=1 +export SENTRY_DSN="https://@sentry.prgs.cc/" +export SENTRY_ENVIRONMENT=prod +export SENTRY_RELEASE="$(git rev-parse --short HEAD)" +export MCP_SENTRY_TRACES_SAMPLE_RATE=0.05 +``` + +The optional SDK is pinned in [`requirements.txt`](../../requirements.txt) +(`sentry-sdk==2.20.0`). It is imported lazily: if the package is absent, the +module still imports and every entry point is a safe no-op. + +## 3. What is instrumented + +| Signal | Where | Notes | +|--------|-------|-------| +| Startup init | `gitea_mcp_server.py` `__main__`, before `mcp.run` | Prints a redaction-safe status line to stderr. | +| Failing mutations (exceptions) | `_audited(...)` context manager | `capture_exception` with scrubbed tags. | +| Fail-closed blockers / failed mutations | `_audit_pr_result(...)` (BLOCKED/FAILED) | Structured `capture_workflow_blocker` event incl. the canonical next action when available (criterion 7). | +| Allocator watchdog check-ins | `gitea_allocate_next_work` tool | `allocator_health`, `stale_lease_scan`, `terminal_lock_scan`. | +| Namespace-health check-in | `gitea_assess_mcp_namespace_health` tool | `namespace_health`. | + +All capture paths are **best-effort / fail open**: a Sentry outage or capture +error never breaks an MCP tool success path. + +## 4. Cron / watchdog monitors + +`sentry_observability.MONITOR_SLUGS` defines stable check-in slugs: + +| Registry key | Sentry monitor slug | Wired at | +|--------------|--------------------|----------| +| `stale_lease_scan` | `gitea-mcp-stale-lease-scan` | allocator run (global lease expiry) | +| `terminal_lock_scan` | `gitea-mcp-terminal-lock-scan` | allocator run (terminal-lock lookup) | +| `allocator_health` | `gitea-mcp-allocator-health` | allocator run | +| `namespace_health` | `gitea-mcp-namespace-health` | namespace-health probe | +| `dashboard_freshness` | `gitea-mcp-dashboard-freshness` | call `monitor_checkin("dashboard_freshness", ...)` from the dashboard refresh job (#605) | +| `reconciler_cleanup` | `gitea-mcp-reconciler-cleanup` | call `monitor_checkin("reconciler_cleanup", ...)` from the reconciler cleanup entrypoint | + +Create matching Cron monitors in Sentry with those slugs. Emit an +`in_progress` check-in at job start and `ok`/`error` at completion via +`sentry_observability.monitor_checkin(slug_key, status)`. + +## 5. Redaction guarantees (fail closed) + +Redaction fails *closed*: if a field cannot be proven safe it is dropped rather +than sent. The `before_send` (and `before_send_log`) hook `scrub_event` +recursively redacts every outgoing event; on any error it drops the event +entirely. Guarantees, proven by `tests/test_sentry_observability.py`: + +- **No** tokens, passwords, keychain IDs, DSNs, cookies, or `user:pass@host`. +- **No** raw session-state or full prompt/comment bodies — `session_id` is only + ever surfaced as a 12-char `session_id_hash`. +- **No** private config contents or raw credential headers. +- **No** full local filesystem paths — a worktree path collapses to a coarse + `worktree_category` (`author` / `reviewer` / `merger` / `reconciler` / + `branches` / `root` / `other`). +- Only the allowlisted tag keys in `ALLOWED_TAG_KEYS` are ever attached. + +## 6. Coexistence with GlitchTip / the #612 incident bridge + +This is the **outbound** path (MCP → Sentry SDK). It complements — it does not +replace — the **inbound** [`incident_bridge.py`](../../incident_bridge.py) +(#612), which turns Sentry/GlitchTip *observations* into durable Gitea issues +and `incident_links` rows. + +- Prefer **one** observability path per environment. Point the MCP server's + `SENTRY_DSN` at the same self-hosted `gitea-tools-mcp` project that the #612 + bridge reconciles from, so an MCP-reported error and its Gitea issue line up. +- GlitchTip is Sentry-protocol compatible; if an existing GlitchTip DSN is in + use, either migrate it to `https://sentry.prgs.cc/` or document the split + (MCP → Sentry, legacy → GlitchTip) explicitly for operators. +- The bridge remains the **only** sanctioned route from an alert back into + Gitea workflow state. + +## 7. Non-goals + +- Sentry must **not** become the workflow source of truth. +- Sentry must **not** approve, merge, close, or mutate Gitea workflow state. +- Sentry must **not** bypass leases, #332, workflow roles, or the MCP gates. diff --git a/docs/wiki/Operator-Guide.md b/docs/wiki/Operator-Guide.md index 9052c47..80c2356 100644 --- a/docs/wiki/Operator-Guide.md +++ b/docs/wiki/Operator-Guide.md @@ -14,6 +14,7 @@ Handbook for LLM operators and human developers using the Gitea-Tools MCP server 4. **No self-review / no self-merge** — The authenticated Gitea user must not approve or merge a PR they authored. 5. **Follow the gates** — Prompts express intent; MCP tools enforce safety. Never bypass gates via prompt instructions. 6. **Global LLM Worktree Rule** — Main checkout stays on `master`/`main`/`dev`; all mutations happen under `branches/`. Prove project root, `cwd`, branch, stable main-checkout branch, and session worktree path before editing. No exceptions. +7. **Stable control runtime** — Real Gitea mutations use only the **stable** MCP control runtime. LLM sessions must not kill, restart, or relaunch MCP processes, edit the stable checkout, or use a dev MCP for production mutations. See the policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md) (#615). ## Supported Gitea instances @@ -29,4 +30,5 @@ Always pass `remote` explicitly on tool calls. The server default is `dadeschool 1. `gitea_whoami` — confirm authenticated user and profile. 2. `gitea_get_runtime_context` — allowed/forbidden operations for this session. 3. `gitea_resolve_task_capability` — prove the session may perform the planned task. -4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations. \ No newline at end of file +4. For reviewer work: dry-run validation (`gitea_dry_run_pr_review`) before live review mutations. +5. Confirm master parity / runtime health when tools report stale or unhealthy control runtime — stop mutations and request an **operator** reload of the stable MCP (see [stable control runtime ADR](../architecture/mcp-stable-control-runtime-policy-adr.md)). \ No newline at end of file diff --git a/docs/wiki/Runbooks.md b/docs/wiki/Runbooks.md index d42fe19..2dcb4b2 100644 --- a/docs/wiki/Runbooks.md +++ b/docs/wiki/Runbooks.md @@ -12,6 +12,17 @@ 4. `gitea_mark_final_review_decision` → approve via `gitea_review_pr`. 5. `gitea_merge_pr` with pinned head SHA and `confirmation="MERGE PR "`. +## Stable MCP control runtime (#615) + +Policy ADR: [mcp-stable-control-runtime-policy-adr.md](../architecture/mcp-stable-control-runtime-policy-adr.md). + +| Situation | Who acts | What to do | +|-----------|----------|------------| +| Transport EOF / missing tools | LLM | **Client reconnect only** — do not kill PIDs | +| Wrong profile / dual-namespace missing | Operator | Configure or reload the correct static namespace(s) | +| Master parity stale after merge | LLM stops + reports; **operator** reloads stable MCP | Resume only after parity re-verified | +| Promote unpromoted MCP code to stable | Operator / release-manager only | Full §2.4 promotion record | + ## Gitea Wiki sync The Gitea Wiki mirrors `docs/wiki/` (source of truth). After merging wiki changes: diff --git a/gitea_auth.py b/gitea_auth.py index 31bdf6d..88bd878 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -283,6 +283,192 @@ def _redact(text): return str(text) +# ── Classified client failures (#699) ───────────────────────────────────────── +# Subclasses of RuntimeError preserve existing ``except RuntimeError`` call +# sites. Exception *messages* are fixed constants only — HTTP response bodies, +# Keychain material, and arbitrary exception text are never stored on the +# exception or re-emitted to tool results / daemon logs. + + +# Fixed messages (must match mcp_tool_error_boundary.FIXED_MESSAGES keys used here). +_MSG_AUTH_INVALID = "Gitea authentication failed: invalid or revoked credentials" +_MSG_AUTH_FAILED = "Gitea authentication failed" +_MSG_AUTHZ_SCOPE = "Gitea authorization failed: insufficient token scope" +_MSG_AUTHZ_DENIED = "Gitea authorization failed: access denied" +_MSG_NETWORK = "Network error contacting Gitea" +_MSG_CONFIG = "Gitea configuration or credential resolution failed" +_MSG_UPSTREAM = "Gitea upstream unavailable" +_MSG_HTTP = "Gitea HTTP request failed" + + +class GiteaClientError(RuntimeError): + """Base for known Gitea client failures with stable reason_code metadata.""" + + reason_code = "client_error" + error_class = "client" + http_status = None + + def __init__(self, message=None, *, reason_code=None, http_status=None): + if reason_code is not None: + self.reason_code = reason_code + if http_status is not None: + self.http_status = http_status + # Message is always a fixed constant; callers cannot inject bodies. + fixed = message if message is not None else _MSG_HTTP + super().__init__(fixed) + + +class GiteaAuthError(GiteaClientError): + """Authentication failure (invalid/revoked credentials → typically HTTP 401).""" + + reason_code = "auth_invalid_token" + error_class = "authentication" + http_status = 401 + + def __init__(self, message=None, *, reason_code=None, http_status=None): + super().__init__( + message if message is not None else _MSG_AUTH_INVALID, + reason_code=reason_code or "auth_invalid_token", + http_status=http_status if http_status is not None else 401, + ) + + +class GiteaAuthzError(GiteaClientError): + """Authorization failure (HTTP 403 — scope deficiency or access denied).""" + + reason_code = "authz_denied" + error_class = "authorization" + http_status = 403 + + def __init__(self, message=None, *, reason_code=None, http_status=None): + code = reason_code or "authz_denied" + if code == "authz_insufficient_scope": + fixed = _MSG_AUTHZ_SCOPE + else: + fixed = _MSG_AUTHZ_DENIED + code = "authz_denied" + super().__init__( + message if message is not None else fixed, + reason_code=code, + http_status=http_status if http_status is not None else 403, + ) + + +class GiteaNetworkError(GiteaClientError): + """Transport / DNS / timeout failure contacting Gitea.""" + + reason_code = "network_error" + error_class = "network" + http_status = None + + def __init__(self, message=None, *, reason_code=None, http_status=None): + super().__init__( + message if message is not None else _MSG_NETWORK, + reason_code=reason_code or "network_error", + http_status=http_status, + ) + + +class GiteaConfigError(GiteaClientError): + """Local configuration / credential resolution failure (not HTTP auth).""" + + reason_code = "config_error" + error_class = "configuration" + http_status = None + + def __init__(self, message=None, *, reason_code=None, http_status=None): + super().__init__( + message if message is not None else _MSG_CONFIG, + reason_code=reason_code or "config_error", + http_status=http_status, + ) + + +class GiteaHttpError(GiteaClientError): + """Non-auth HTTP failure with fixed message (no response body).""" + + reason_code = "http_error" + error_class = "client" + http_status = None + + def __init__(self, message=None, *, reason_code=None, http_status=None): + code = reason_code or "http_error" + if code == "upstream_unavailable": + fixed = _MSG_UPSTREAM + else: + fixed = _MSG_HTTP + code = "http_error" + super().__init__( + message if message is not None else fixed, + reason_code=code, + http_status=http_status, + ) + + +def _looks_like_insufficient_scope(detail: str) -> bool: + """Internal: inspect redacted body *only* to refine 403 reason_code. + + The body is never stored on the exception or returned to callers. + """ + lower = (detail or "").lower() + markers = ( + "insufficient scope", + "required scope", + "does not have at least one of required scope", + "token does not have", + "missing scope", + "scope(s)", + ) + return any(m in lower for m in markers) + + +def classify_http_status(code: int, *, body_hint: str = "") -> tuple[type, str, int]: + """Central HTTP status → (exception_class, reason_code, http_status). + + Every HTTP 403 becomes authorization-class. Body text is used only as a + local hint for scope vs denied reason_code and is never returned. + """ + if code == 401: + return (GiteaAuthError, "auth_invalid_token", 401) + if code == 403: + if _looks_like_insufficient_scope(body_hint or ""): + return (GiteaAuthzError, "authz_insufficient_scope", 403) + return (GiteaAuthzError, "authz_denied", 403) + if code in (502, 503, 504): + return (GiteaHttpError, "upstream_unavailable", code) + return (GiteaHttpError, "http_error", code) + + +def raise_for_http_status(code: int, body: str = "") -> None: + """Raise a typed client error for *code* without embedding *body*. + + *body* may be inspected only to choose scope vs denied for 403; it is + never placed on the exception message. + """ + # Redact before any inspection; discard after classification. + try: + hint = _redact(body or "").strip() + except Exception: + hint = "" + exc_cls, reason, status = classify_http_status(code, body_hint=hint) + # Explicitly construct without passing body/hint into message. + if exc_cls is GiteaAuthError: + raise GiteaAuthError(reason_code=reason, http_status=status) + if exc_cls is GiteaAuthzError: + raise GiteaAuthzError(reason_code=reason, http_status=status) + if reason == "upstream_unavailable": + raise GiteaHttpError( + reason_code="upstream_unavailable", + http_status=status, + ) + raise GiteaHttpError(reason_code="http_error", http_status=status) + + +def _raise_http_error(code: int, detail: str = "") -> None: + """Backward-compatible alias — *detail* is never embedded in the error.""" + raise_for_http_status(code, detail) + + def _add_query(url, **params): """Return *url* with the given query parameters added or overridden. @@ -355,23 +541,24 @@ def api_request(method, url, auth_header, payload=None, *, """Make an authenticated JSON request to the Gitea API. Returns parsed JSON on success (or ``None`` for an empty body), and raises - ``RuntimeError`` on failure. + a classified client error on failure. On HTTP 429 the request is retried up to *max_retries* times: honoring a valid ``Retry-After`` header (seconds or HTTP-date) when present, otherwise using capped jittered exponential backoff. Successful responses are unchanged. - All failures are converted to a ``RuntimeError`` with a clear, secret - -redacted message (no raw stack traces or credential material): + Failures raise typed exceptions with **fixed messages only** (#699). HTTP + response bodies are read solely for local 403 reason refinement and are + never stored on exceptions or returned to callers: - - Non-429 HTTP errors surface the status code and a redacted response body. - 502/503/504 upstream errors get an explicit "Gitea upstream unavailable" - message. - - Timeouts and network/DNS failures (``URLError`` / ``TimeoutError``) surface - a generic "network error contacting Gitea" message. - - A malformed (non-JSON) success body surfaces a "malformed JSON response" - message rather than a raw decode error. + - HTTP 401 → :class:`GiteaAuthError` (``auth_invalid_token``) + - HTTP 403 → :class:`GiteaAuthzError` (scope or denied) + - 502/503/504 → :class:`GiteaHttpError` (``upstream_unavailable``) + - Other non-429 HTTP → :class:`GiteaHttpError` (``http_error``) + - Timeouts / DNS / ``URLError`` → :class:`GiteaNetworkError` + - Malformed success JSON → plain ``RuntimeError`` (programming/protocol; + not reclassified as authentication) The ``*_func`` parameters and ``timeout`` are injection points for deterministic testing. @@ -409,22 +596,23 @@ def api_request(method, url, auth_header, payload=None, *, error_body = e.read().decode("utf-8", errors="replace") except Exception: error_body = "" - detail = _redact(error_body).strip() - if e.code in (502, 503, 504): - msg = f"HTTP {e.code}: Gitea upstream unavailable" - raise RuntimeError(f"{msg}: {detail}" if detail else msg) from e - raise RuntimeError(f"HTTP {e.code}: {detail}") from e + # Classify from status (+ local body hint). Body is not embedded. + try: + raise_for_http_status(e.code, error_body) + except GiteaClientError: + raise + # Defensive: raise_for_http_status always raises. + raise GiteaHttpError(http_status=e.code) from e # pragma: no cover except (urllib.error.URLError, TimeoutError) as e: - reason = getattr(e, "reason", e) - raise RuntimeError( - f"network error contacting Gitea: {_redact(reason)}" - ) from e + # Fixed message only — do not embed URLError reason (may leak paths). + raise GiteaNetworkError(reason_code="network_error") from e if not body: return None try: return json.loads(body) except ValueError as e: + # Programming/protocol failure — not authentication. raise RuntimeError("malformed JSON response from Gitea") from e diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index b39b571..9ceb044 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -195,6 +195,12 @@ RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE" import namespace_workspace_binding as nwb # noqa: E402 import mcp_namespace_health # noqa: E402 +import stale_binding_recovery # noqa: E402 + +# Worktree env bindings inherited from the parent environment at daemon boot +# (#702). A value that still equals its boot snapshot was never established by +# a sanctioned tool in this daemon's lifetime. +_BOOT_ENV_WORKTREE_BINDINGS = stale_binding_recovery.snapshot_boot_bindings() def _preflight_in_test_mode() -> bool: @@ -260,8 +266,124 @@ def _actual_profile_role() -> str: ) +def _assess_stale_active_binding(auto_recover: bool = False) -> dict: + """Classify the GITEA_ACTIVE_WORKTREE binding; recover when provably stale (#702). + + Runs during ``gitea_resolve_task_capability`` / runtime context. Clearing + is the sanctioned in-daemon mechanism and happens only for the two + provably-stale classes (missing path, superseded by a sanctioned session + lease); an uncorroborated boot-inherited binding is surfaced with the + managed-reconnect next action, never cleared silently. + """ + active_value = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip() or None + try: + role = _actual_profile_role() + except Exception: + role = "author" + role_env_key = nwb.ROLE_WORKTREE_ENVS.get(role, AUTHOR_WORKTREE_ENV) + boot_value = ( + (_BOOT_ENV_WORKTREE_BINDINGS.get("active_worktree") or "").strip() or None + ) + classification = stale_binding_recovery.classify_active_worktree_binding( + active_value=active_value, + role_env_value=(os.environ.get(role_env_key) or "").strip() or None, + session_lease_worktree=_reviewer_session_worktree(), + boot_inherited=bool(active_value and boot_value == active_value), + path_exists=os.path.isdir(active_value) if active_value else None, + role_kind=role, + ) + plan = stale_binding_recovery.plan_recovery(classification) + applied: dict = {"performed": False} + recovery_enabled = ( + not _preflight_in_test_mode() + or os.environ.get("GITEA_FORCE_STALE_BINDING_RECOVERY") == "1" + ) + audit_persisted: bool | None = None + if auto_recover and plan.get("clear_allowed") and recovery_enabled: + # #702 F6: the durable audit record must exist BEFORE the environment + # is cleared. If audit persistence fails the recovery does not run — + # an env clear without durable proof is an unauditable mutation. + pending_value = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip() or None + audit_error: str | None = None + try: + import mcp_session_state as mss + + mss.save_state( + kind=mss.KIND_STALE_BINDING_RECOVERY, + payload={ + "cleared_env": ACTIVE_WORKTREE_ENV, + "cleared_value": pending_value, + "classification": plan.get("classification"), + "reasons": list(plan.get("reasons") or []), + "recovery_status": "pending", + }, + ) + audit_persisted = True + except Exception as exc: + audit_persisted = False + audit_error = f"{type(exc).__name__}: {exc}" + if audit_persisted: + applied = stale_binding_recovery.apply_recovery(plan) + if applied.get("performed"): + try: + import mcp_session_state as mss + + mss.save_state( + kind=mss.KIND_STALE_BINDING_RECOVERY, + payload={ + "cleared_env": applied.get("cleared_env"), + "cleared_value": applied.get("cleared_value"), + "classification": applied.get("classification"), + "reasons": applied.get("reasons"), + "recovery_status": "performed", + }, + ) + except Exception: + # The pre-clear "pending" record is already durable; the + # status refresh is best-effort and never rolls back. + pass + else: + applied = { + "performed": False, + "action": stale_binding_recovery.RECOVERY_ACTION_NONE, + "audit_write_failed": True, + "reasons": [ + "audit persistence failed; stale binding NOT cleared " + "(fail closed, #702 F6)" + ] + + ([audit_error] if audit_error else []), + } + report = { + "classification": classification.get("classification"), + "active_worktree": classification.get("active_worktree"), + "boot_inherited": classification.get("boot_inherited", False), + "clear_eligible": bool(classification.get("clear_eligible")), + "recovery_action": plan.get("action"), + "recovery_performed": bool(applied.get("performed")), + "reasons": classification.get("reasons") or [], + } + if audit_persisted is not None: + report["audit_persisted"] = audit_persisted + if applied.get("audit_write_failed"): + report["audit_write_failed"] = True + report["reasons"] = list(report["reasons"]) + list( + applied.get("reasons") or [] + ) + if applied.get("performed"): + report["cleared_value"] = applied.get("cleared_value") + if plan.get("exact_next_action"): + report["exact_next_action"] = plan.get("exact_next_action") + return report + + def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str: - """Resolve the namespace-scoped workspace root inspected by pre-flight guards.""" + """Resolve the namespace-scoped workspace root inspected by pre-flight guards. + + #702 F2: preflight resolves with the same existence verification as the + canonical mutation context (verify_paths). A dead env binding must demote + here exactly as it does in :func:`_resolve_namespace_mutation_context`, + or preflight would inspect a path the mutation guard never uses. + """ role = _effective_workspace_role() workspace, _source = nwb.resolve_namespace_workspace( role_kind=role, @@ -271,6 +393,7 @@ def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str: _reviewer_session_worktree() if role in {"reviewer", "merger"} else None ), profile_name=get_profile().get("profile_name"), + verify_paths=True, ) return workspace @@ -317,6 +440,15 @@ def _get_git_root(path: str) -> str | None: return (res.stdout or "").strip() or None +# #702 F2: synthetic tracked-dirty porcelain returned when the preflight +# workspace cannot be inspected (missing path, git failure). Shaped as a +# modified tracked entry so every consumer treats "workspace unavailable" +# as dirty — an uninspectable workspace must never read as clean. +PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN = ( + " M __gitea_preflight_workspace_unavailable__\n" +) + + def _get_workspace_porcelain(worktree_path: str | None = None) -> str: """Return tracked-workspace porcelain for pre-flight attribution.""" if os.environ.get("GITEA_TEST_FORCE_DIRTY"): @@ -325,6 +457,8 @@ def _get_workspace_porcelain(worktree_path: str | None = None) -> str: if override is not None: return override workspace = _resolve_preflight_workspace_path(worktree_path) + if not os.path.isdir(workspace): + return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN try: res = subprocess.run( ["git", "status", "--porcelain"], @@ -332,9 +466,11 @@ def _get_workspace_porcelain(worktree_path: str | None = None) -> str: text=True, cwd=workspace, ) - return res.stdout or "" except Exception: - return "" + return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN + if res.returncode != 0: + return PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN + return res.stdout or "" def _parse_porcelain_entries(porcelain: str) -> dict[str, str]: @@ -619,6 +755,188 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> ) +def _anti_stomp_in_test_mode() -> bool: + """Whether the #604 anti-stomp gate is skipped under pytest. + + Mirrors remote-repo / stable-contamination test bypasses so the existing + suite (bare remotes, mocked APIs) stays green. Set + ``GITEA_TEST_FORCE_ANTI_STOMP=1`` to exercise the live gate under tests. + """ + if not _preflight_in_test_mode(): + return False + return not bool(os.environ.get("GITEA_TEST_FORCE_ANTI_STOMP")) + + +def _run_anti_stomp_preflight( + task: str | None, + *, + remote: str | None = None, + worktree_path: str | None = None, + host: str | None = None, + org: str | None = None, + repo: str | None = None, + expected_head_sha: str | None = None, + live_head_sha: str | None = None, + require_head_sha: bool = False, + lease_required: bool = False, + foreign_lease: bool | None = None, + lease_reasons: list[str] | None = None, + terminal_lock_blocks: bool | None = None, + terminal_lock_reasons: list[str] | None = None, + workflow_hash_valid: bool | None = None, + workflow_hash_reasons: list[str] | None = None, + raise_on_block: bool = True, +) -> dict | None: + """Shared #604 anti-stomp preflight for MCP mutation entrypoints. + + Gathers live workspace/parity/role/repo facts and invokes the pure + :func:`anti_stomp_preflight.assess_anti_stomp_preflight` assessor. On + block, raises ``RuntimeError`` (default) or returns a structured + :func:`anti_stomp_preflight.block_response` when *raise_on_block* is + False. Returns ``None`` when the mutation may proceed. + """ + if _anti_stomp_in_test_mode(): + return None + + # Only enforce when the caller names a known mutation task. Read-only + # paths and legacy verify_preflight_purity calls without a task stay + # on the pre-#604 gate chain (whoami/capability/root/worktree). + if not task or not anti_stomp_preflight.is_mutation_task(task): + return None + + profile = get_profile() + profile_name = profile.get("profile_name") + profile_role = _actual_profile_role() + allowed_operations = list(profile.get("allowed_operations") or []) + req_role = None + req_perm = None + if task: + try: + req_role = task_capability_map.required_role(task) + except KeyError: + req_role = _preflight_resolved_role + try: + req_perm = task_capability_map.required_permission(task) + except KeyError: + req_perm = None + + ctx = _resolve_namespace_mutation_context(worktree_path) + workspace = ctx["workspace_path"] + canonical_root = ctx["canonical_repo_root"] + git_state = issue_lock_worktree.read_worktree_git_state(canonical_root) + remote_master_sha = root_checkout_guard.resolve_remote_master_sha(canonical_root) + + # Repo/org facts (best-effort; explicit org/repo when provided). + resolved_org = org + resolved_repo = repo + local_remote_url = None + org_explicit = org is not None + repo_explicit = repo is not None + if remote: + try: + local_remote_url = _local_git_remote_url(remote) + except Exception: + local_remote_url = None + if resolved_org is None or resolved_repo is None: + try: + rem = _effective_remote(remote) + if rem in REMOTES: + if resolved_org is None: + resolved_org = REMOTES[rem]["org"] + if resolved_repo is None: + resolved_repo = REMOTES[rem]["repo"] + except Exception: + pass + + parity = _current_master_parity() + startup_head = parity.get("startup_head") + current_code_head = parity.get("current_head") + + # Source contamination from durable #671 marker (role-aware). + source_contaminated = None + contamination_reasons = None + try: + marker = _load_stable_contamination_marker(remote) + contaminated, cont_reasons = anti_stomp_preflight.contamination_from_stable_marker( + marker, + task=task, + actual_role=profile_role, + ) + if marker is not None: + source_contaminated = contaminated + contamination_reasons = cont_reasons + except Exception: + # Fail soft on marker I/O: existing #671 enforcer still runs separately. + source_contaminated = None + + # Workflow-hash facts when the task is review/merge oriented. + if workflow_hash_valid is None and task in { + "review_pr", + "submit_pr_review", + "approve_pr", + "request_changes_pr", + "merge_pr", + }: + try: + wf_reasons = _review_workflow_load_gate_reasons() + workflow_hash_valid = not bool(wf_reasons) + workflow_hash_reasons = list(wf_reasons) if wf_reasons else None + except Exception: + pass + + # Mirror remote_repo_guard's pytest bypass: under the unit suite bare + # remote=prgs still defaults to Timesheet, and re-checking here would + # break happy-path reconciler/author tests that already rely on the + # #530 gate being opt-in via GITEA_FORCE_REMOTE_REPO_CHECK. + check_repo = True + if "pytest" in sys.modules and not os.environ.get( + "GITEA_FORCE_REMOTE_REPO_CHECK" + ): + check_repo = False + + assessment = anti_stomp_preflight.assess_anti_stomp_preflight( + task=task, + remote=remote, + resolved_org=resolved_org, + resolved_repo=resolved_repo, + local_remote_url=local_remote_url, + org_explicit=org_explicit, + repo_explicit=repo_explicit, + check_repo=check_repo, + profile_name=profile_name, + profile_role=profile_role, + required_role=req_role or _preflight_resolved_role, + required_permission=req_perm, + allowed_operations=allowed_operations, + workspace_path=workspace, + project_root=canonical_root, + current_branch=git_state.get("current_branch"), + root_head_sha=git_state.get("head_sha"), + root_porcelain=git_state.get("porcelain_status") or "", + remote_master_sha=remote_master_sha, + startup_head=startup_head, + current_code_head=current_code_head, + lease_required=lease_required, + foreign_lease=foreign_lease, + lease_reasons=lease_reasons, + terminal_lock_blocks=terminal_lock_blocks, + terminal_lock_reasons=terminal_lock_reasons, + require_head_sha=require_head_sha, + expected_head_sha=expected_head_sha, + live_head_sha=live_head_sha, + workflow_hash_valid=workflow_hash_valid, + workflow_hash_reasons=workflow_hash_reasons, + source_contaminated=source_contaminated, + contamination_reasons=contamination_reasons, + manual_bypass_attempted=False, + ) + if not assessment.get("block"): + return None + if raise_on_block: + raise RuntimeError(anti_stomp_preflight.format_anti_stomp_error(assessment)) + return anti_stomp_preflight.block_response(assessment) + + def verify_preflight_purity( remote: str | None = None, worktree_path: str | None = None, @@ -626,13 +944,29 @@ def verify_preflight_purity( *, target_issue_number: int | None = None, require_author_lock: bool = False, + expected_head_sha: str | None = None, + live_head_sha: str | None = None, + require_head_sha: bool = False, + lease_required: bool = False, + foreign_lease: bool | None = None, + lease_reasons: list[str] | None = None, + terminal_lock_blocks: bool | None = None, + terminal_lock_reasons: list[str] | None = None, + workflow_hash_valid: bool | None = None, + workflow_hash_reasons: list[str] | None = None, + org: str | None = None, + repo: str | None = None, ): - """Verify identity/capability order, then production workspace guards. + """Verify identity/capability order, production workspace guards, then anti-stomp. #683: pytest/unittest must not skip production root/branches/scope enforcement when force-on signals request production behavior. The early return below only skips *preflight-order* purity checks under pure unit-test isolation — never when production guards are active. + + #604: also runs the shared anti-stomp preflight for mutation tasks (typed + blocker + exact next action). Extended lease/head/terminal facts may be + supplied by review/merge callers. """ global _preflight_reviewer_violation_files @@ -643,7 +977,12 @@ def verify_preflight_purity( # Pure unit-test isolation: skip purity-order unless legacy dirty/porcelain # force flags request the dirtiness path. #683 FORCE_PRODUCTION_GUARDS alone # runs production root/branches/scope without requiring whoami/capability. - skip_purity_order = in_test and not workflow_scope_guard.purity_order_forced() + force_anti_stomp = bool(os.environ.get("GITEA_TEST_FORCE_ANTI_STOMP")) + skip_purity_order = ( + in_test + and not workflow_scope_guard.purity_order_forced() + and not force_anti_stomp + ) if not skip_purity_order: if not _preflight_whoami_called: @@ -738,6 +1077,25 @@ def verify_preflight_purity( target_issue_number=target_issue_number, require_author_lock=require_author_lock, ) + # #604: common anti-stomp preflight after legacy + #683 enforcers. + _run_anti_stomp_preflight( + task, + remote=remote, + worktree_path=worktree_path, + org=org, + repo=repo, + expected_head_sha=expected_head_sha, + live_head_sha=live_head_sha, + require_head_sha=require_head_sha, + lease_required=lease_required, + foreign_lease=foreign_lease, + lease_reasons=lease_reasons, + terminal_lock_blocks=terminal_lock_blocks, + terminal_lock_reasons=terminal_lock_reasons, + workflow_hash_valid=workflow_hash_valid, + workflow_hash_reasons=workflow_hash_reasons, + raise_on_block=True, + ) _clear_preflight_capability_state() return @@ -752,6 +1110,25 @@ def verify_preflight_purity( target_issue_number=target_issue_number, require_author_lock=require_author_lock, ) + if force_anti_stomp: + _run_anti_stomp_preflight( + task, + remote=remote, + worktree_path=worktree_path, + org=org, + repo=repo, + expected_head_sha=expected_head_sha, + live_head_sha=live_head_sha, + require_head_sha=require_head_sha, + lease_required=lease_required, + foreign_lease=foreign_lease, + lease_reasons=lease_reasons, + terminal_lock_blocks=terminal_lock_blocks, + terminal_lock_reasons=terminal_lock_reasons, + workflow_hash_valid=workflow_hash_valid, + workflow_hash_reasons=workflow_hash_reasons, + raise_on_block=True, + ) def _session_issue_lock_snapshot( @@ -905,12 +1282,18 @@ def _verify_role_mutation_workspace( worktree_path: str | None = None, worktree: str | None = None, task: str | None = None, + org: str | None = None, + repo: str | None = None, ) -> str: """Bind reviewer/merger mutations to the active namespace workspace (#510). #683: must NOT early-return solely because pytest/unittest is loaded. Production workspace binding always runs; test isolation uses explicit env fixtures / force-on flags, never a production short-circuit here. + + org/repo are forwarded into anti-stomp (#604) so callers that pass explicit + repository targets (e.g. prgs + Gitea-Tools when REMOTES defaults to + Timesheet) do not fail closed with wrong_repo after a successful resolve. """ # Check running runtimes to prevent stale mutations @@ -973,7 +1356,13 @@ def _verify_role_mutation_workspace( ) ) resolved = assessment["mutation_workspace"] - verify_preflight_purity(remote, worktree_path=resolved, task=task) + verify_preflight_purity( + remote, + worktree_path=resolved, + task=task, + org=org, + repo=repo, + ) return resolved @@ -1084,7 +1473,9 @@ from gitea_auth import ( # noqa: E402 repo_api_url, get_profile, gitea_url, + GiteaConfigError, ) +import mcp_tool_error_boundary # noqa: E402 import gitea_audit # noqa: E402 import gitea_config # noqa: E402 import capability_stop_terminal # noqa: E402 @@ -1102,6 +1493,7 @@ import allocator_service # noqa: E402 import control_plane_db # noqa: E402 import lease_lifecycle # noqa: E402 import incident_bridge # noqa: E402 +import sentry_observability # noqa: E402 (#606 optional Sentry observability) import agent_temp_artifacts import issue_lock_worktree # noqa: E402 import issue_lock_provenance # noqa: E402 @@ -1117,6 +1509,7 @@ import root_checkout_guard # noqa: E402 import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards import stable_branch_push_guard # noqa: E402 import remote_repo_guard # noqa: E402 +import anti_stomp_preflight # noqa: E402 import issue_claim_heartbeat # noqa: E402 import session_context_binding as session_ctx # noqa: E402 # #714 immutable session context @@ -1229,6 +1622,7 @@ import review_merge_state_machine # noqa: E402 import pr_work_lease # noqa: E402 import workflow_skill # noqa: E402 import conflict_fix_classification # noqa: E402 +import pr_sync_status # noqa: E402 # PR sync / update-by-merge lifecycle import native_mcp_preference # noqa: E402 import branch_cleanup_guard # noqa: E402 import thread_state_ledger_validator # noqa: E402 @@ -1564,6 +1958,12 @@ def _with_optional_url(result: dict, url: str | None) -> dict: result["url"] = url return result +# #699: known auth/authz/network/config failures → structured CallToolResult +# isError; stdio transport must survive (no unhandled raise / process exit). +from mcp.server.fastmcp.tools.base import Tool as _FastMCPTool # noqa: E402 + +mcp_tool_error_boundary.install_tool_run_boundary(_FastMCPTool) + mcp = FastMCP("gitea-tools", instructions=( "Gitea issue tracker and PR management for dadeschools and prgs instances. " "Use the gitea_ prefixed tools to create issues, PRs, list issues, etc." @@ -1926,13 +2326,16 @@ def _enforce_remote_repo_guard( def _auth(host: str) -> str: - """Get auth header, raise if unavailable.""" + """Get auth header, raise if unavailable. + + Missing credentials are a configuration failure, not a silent internal + crash. Typed as :class:`gitea_auth.GiteaConfigError` so the tool-error + boundary (#699) maps them to a structured isError result without EOF. + The exception message is a fixed constant (no host/token material). + """ header = get_auth_header(host) if header is None: - raise RuntimeError( - f"No credentials for {host}. " - "Ensure you've logged in via HTTPS at least once." - ) + raise GiteaConfigError(reason_code="config_error") return header @@ -1947,6 +2350,8 @@ _UNSET = object() # Best-effort identity cache keyed by host, so an enabled audit trail resolves # the authenticated username at most once per host per process. _IDENTITY_CACHE: dict = {} +# Stable actor identity (id + login) for recovery evidence binding (#709 F7). +_ACTOR_IDENTITY_CACHE: dict = {} def _authenticated_username(host: str): @@ -1969,6 +2374,33 @@ def _authenticated_username(host: str): return user +def _authenticated_actor(host: str) -> dict: + """Resolve the authenticated actor's stable identity (#709 F7 review 438). + + Display names are mutable, so recovery evidence is bound to the immutable + numeric user id with the login carried alongside for consistency checks. + Read-only and fail-soft; never surfaces credential material. + """ + cached = _ACTOR_IDENTITY_CACHE.get(host) + if cached is not None: + return dict(cached) + actor: dict = {"user_id": None, "login": None} + try: + header = get_auth_header(host) + if header: + who = api_request("GET", gitea_url(host, "/api/v1/user"), header) + if isinstance(who, dict): + raw_id = who.get("id") + actor = { + "user_id": int(raw_id) if isinstance(raw_id, int) else None, + "login": (who.get("login") or None), + } + except Exception: + actor = {"user_id": None, "login": None} + _ACTOR_IDENTITY_CACHE[host] = dict(actor) + return dict(actor) + + def _ensure_matching_profile( required_permission: str, required_role: str, @@ -2007,6 +2439,7 @@ def _ensure_matching_profile( ) if allowed: return active_profile + # #714: no automatic profile substitution — fail closed. return None @@ -2076,6 +2509,18 @@ def _audited(action: str, *, host, remote, org=None, repo=None, result=gitea_audit.FAILED, reason=_redact(str(exc)), request_metadata=request_metadata, issue_number=issue_number, pr_number=pr_number, target_branch=target_branch) + # #606: best-effort Sentry capture of the failing mutation (fail open). + sentry_observability.capture_exception( + exc, + tags={ + "mutation_tool": action, + "remote": remote, + "repo": repo, + "org": org, + "issue_number": issue_number, + "pr_number": pr_number, + }, + ) raise _audit(action, host=host, remote=remote, org=org, repo=repo, result=gitea_audit.SUCCEEDED, request_metadata=request_metadata, @@ -2122,6 +2567,20 @@ def _audit_pr_result(action: str): "merge_method": result.get("merge_method"), }, ) + # #606: surface fail-closed blockers / failed mutations to + # Sentry as structured events (best-effort, fail open). + if status in (gitea_audit.BLOCKED, gitea_audit.FAILED): + sentry_observability.capture_workflow_blocker( + action, + message="; ".join(reasons) or action, + next_action=result.get("safe_next_action"), + level="error" if status == gitea_audit.FAILED else "warning", + tags={ + "mutation_tool": action, + "pr_number": result.get("pr_number"), + "current_head_sha": result.get("head_sha"), + }, + ) except Exception: pass # best-effort; never break the tool return result @@ -3435,6 +3894,11 @@ def _save_review_decision_lock(data): ) payload["session_profile_lock"] = binding["session_profile_lock"] payload["profile_identity"] = binding["profile_identity"] + # #720: durable decision locks are recovery-critical terminal provenance, + # not generic TTL session cache. Stamp kind + recovery_critical for + # pre-existing readers and identity_match_reasons flag-based exempt. + payload["kind"] = mcp_session_state.KIND_DECISION_LOCK + payload["recovery_critical"] = True if binding.get("remote") and not payload.get("remote"): payload["remote"] = binding["remote"] # #695 AC6: stamp native transport provenance on durable decision locks. @@ -3489,17 +3953,28 @@ def _review_decision_session_reasons(lock: dict | None) -> list[str]: def init_review_decision_lock(remote: str | None, task: str | None, force: bool = True): - """Seed read-only-until-ready state for reviewer PR review tasks.""" + """Seed read-only-until-ready state for reviewer PR review tasks. + + #709 AC2: never overwrite unresolved terminal decision-lock evidence with + an empty initialized lock — even when *force* is True. Terminal ledgers + are cleared only via moot cleanup or sanctioned recovery/archive paths. + """ if task != "review_pr": return - if not force: - lock = _load_review_decision_lock() - if lock is not None: + existing = _load_review_decision_lock() + if existing is not None: + overwrite = stale_review_decision_lock.assess_init_overwrite( + existing, force=force + ) + if not overwrite.get("overwrite_allowed"): + # Preserve terminal evidence; keep existing durable lock. + return + if not force: env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip() - stored_lock = (lock.get("session_profile_lock") or "").strip() - same_remote = lock.get("remote") == remote + stored_lock = (lock_get_session_profile_lock(existing)).strip() + same_remote = existing.get("remote") == remote same_profile = (not env_lock or not stored_lock or env_lock == stored_lock) - if same_remote and same_profile and not _review_decision_session_reasons(lock): + if same_remote and same_profile and not _review_decision_session_reasons(existing): return review_workflow_load.clear_review_workflow_load() profile = get_profile() @@ -3529,9 +4004,22 @@ def init_review_decision_lock(remote: str | None, task: str | None, force: bool "live_mutations": [], "correction_authorized": False, "correction_reason": None, + "correction_pr_number": None, + "correction_head_sha": None, }) +def lock_get_session_profile_lock(lock: dict | None) -> str: + if not isinstance(lock, dict): + return "" + return ( + lock.get("session_profile_lock") + or lock.get("profile_identity") + or lock.get("session_profile") + or "" + ) + + def _review_workflow_load_gate_reasons() -> list[str]: """Fail closed when canonical review workflow was not loaded (#389).""" return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT) @@ -3613,21 +4101,30 @@ def check_review_decision_gate( if stale_review_decision_lock.prior_live_mutations_block_boundary( lock, pr_number=pr_number, expected_head_sha=ready_head ): - if prior and not lock.get("correction_authorized"): + scoped = stale_review_decision_lock.correction_applies( + lock, pr_number=pr_number, expected_head_sha=ready_head + ) + if prior and not scoped: reasons.append( "live review mutation already recorded for this PR head in this " "run; only one live review mutation is allowed per head unless " - "gitea_authorize_review_correction was invoked (fail closed, #620)" + "gitea_authorize_review_correction was invoked for this same " + "PR/head (fail closed, #620/#693)" ) elif ( action in _TERMINAL_REVIEW_ACTIONS - and any(m.get("action") in _TERMINAL_REVIEW_ACTIONS for m in prior) - and not lock.get("correction_authorized") + and any( + isinstance(m, dict) + and m.get("action") in _TERMINAL_REVIEW_ACTIONS + and m.get("pr_number") == pr_number + for m in prior + ) + and not scoped ): reasons.append( "terminal review decision already submitted for this PR head in " "this run; blocked unless an operator-approved correction was " - "authorized (fail closed, #620)" + "authorized for this same PR/head (fail closed, #620/#693)" ) return reasons @@ -3656,6 +4153,8 @@ def record_live_review_mutation(pr_number: int, action: str, review_id: int | No if lock.get("correction_authorized"): lock["correction_authorized"] = False lock["correction_reason"] = None + lock["correction_pr_number"] = None + lock["correction_head_sha"] = None _save_review_decision_lock(lock) @@ -3699,11 +4198,13 @@ def terminal_review_hard_stop_reasons( and last.get("pr_number") == pr_number ): return [] - if operation in ("mark_ready", "review", "resume") and lock.get( - "correction_authorized" + if operation in ("mark_ready", "review", "resume") and ( + stale_review_decision_lock.correction_applies( + lock, pr_number=pr_number, expected_head_sha=expected_head_sha + ) ): return [] - # #620: same PR, different reviewed head → fresh decision boundary. + # #620 same-PR new head + #693 cross-PR isolation. if stale_review_decision_lock.terminal_boundary_allows_fresh_decision( lock, pr_number=pr_number, @@ -3725,16 +4226,20 @@ def terminal_review_hard_stop_reasons( else "" ) recovery = ( - "if that PR is already merged/closed, use " - "gitea_cleanup_stale_review_decision_lock (apply=true) after live-state " - "proof (#594); if the PR is still open but the head moved, mark/submit " - "with the new expected_head_sha (#620); never delete session-state " - "files by hand" + "exact_next_action: if that PR is already merged/closed, use " + "gitea_cleanup_stale_review_decision_lock(apply=true) after live-state " + "proof (#594); if the same PR head moved, mark/submit with the new " + "expected_head_sha (#620); if the target is a different PR, " + "gitea_diagnose_review_decision_lock then mark_final on the target " + "(cross-PR isolation, #693); if same-head verdict was mistaken, " + "gitea_authorize_review_correction with matching target_pr_number " + "(not a generic unlock); never delete session-state files by hand; " + "if still blocked create/update a durable Gitea issue and stop" ) return [ "terminal review mutation already consumed in this run " f"({last.get('action')} on PR #{last.get('pr_number')}{head_note}); " - f"{guidance} (fail closed, #332); {recovery}" + f"{guidance} (fail closed, #332/#693); {recovery}" ] @@ -4161,7 +4666,11 @@ def _evaluate_pr_review_submission( ) -> dict: """Shared gate chain for live submit and dry-run review tools.""" _verify_role_mutation_workspace( - remote, worktree_path=worktree_path, task="review_pr" + remote, + worktree_path=worktree_path, + task="review_pr", + org=org, + repo=repo, ) action = (action or "").strip().lower() workflow_blockers = _review_workflow_load_gate_reasons() if live else [] @@ -4294,6 +4803,30 @@ def _evaluate_pr_review_submission( result["pr_work_lease"] = lease_block return result + if live: + # #604: common anti-stomp with live head + lease proof (typed blocker). + anti_task = { + "approve": "approve_pr", + "request_changes": "request_changes_pr", + "comment": "submit_pr_review", + }.get(action, "submit_pr_review") + try: + _run_anti_stomp_preflight( + anti_task, + remote=remote, + worktree_path=worktree_path, + org=org, + repo=repo, + expected_head_sha=pinned_sha, + live_head_sha=actual_sha, + require_head_sha=True, + foreign_lease=False, + terminal_lock_blocks=False, + ) + except RuntimeError as exc: + reasons.append(str(exc)) + return result + if (body or "").strip(): gate = _canonical_comment_gate(body, context="pr_review") if gate["blocked"]: @@ -4399,10 +4932,33 @@ def gitea_mark_final_review_decision( "reasons": [ "review decision lock missing; resolve review_pr capability first" ], + "exact_next_action": ( + "gitea_resolve_task_capability(task='review_pr') then retry " + "mark_final" + ), + "durable_issue_handoff": { + "required": True, + "instruction": ( + "If the lock cannot be seeded, create/update a durable " + "Gitea issue and stop (#693 AC8)" + ), + }, } session_reasons = _review_decision_session_reasons(lock) if session_reasons: - return {"marked_ready": False, "reasons": session_reasons} + classification = stale_review_decision_lock.classify_review_decision_lock( + lock, + target_pr_number=pr_number, + target_head_sha=expected_head_sha, + session_blockers=session_reasons, + ) + fail = stale_review_decision_lock.build_precise_mark_final_failure( + classification + ) + return { + "marked_ready": False, + **fail, + } if remote != lock.get("remote"): return { "marked_ready": False, @@ -4410,6 +4966,7 @@ def gitea_mark_final_review_decision( f"requested remote '{remote}' does not match locked remote " f"'{lock.get('remote')}' (fail closed)" ], + "exact_next_action": "use the remote bound on the decision lock", } try: _, resolved_org, resolved_repo = _resolve(remote, None, org, repo) @@ -4444,7 +5001,26 @@ def gitea_mark_final_review_decision( pr_number, "mark_ready", expected_head_sha=expected_head_sha ) if hard_stop: - return {"marked_ready": False, "reasons": hard_stop} + classification = stale_review_decision_lock.classify_review_decision_lock( + lock, + target_pr_number=pr_number, + target_head_sha=expected_head_sha, + active_profile_identity=_decision_lock_binding().get( + "profile_identity" + ), + ) + fail = stale_review_decision_lock.build_precise_mark_final_failure( + classification + ) + # Prefer classification next_action when more precise; keep hard_stop + # text as primary reasons for operators. + return { + "marked_ready": False, + "reasons": hard_stop + list(fail.get("reasons") or []), + "classification": fail.get("classification"), + "exact_next_action": fail.get("exact_next_action"), + "durable_issue_handoff": fail.get("durable_issue_handoff"), + } if action not in _REVIEW_ACTIONS: return { "marked_ready": False, @@ -4460,17 +5036,63 @@ def gitea_mark_final_review_decision( "expected_head_sha required before marking final review " "decision (fail closed, #399)" ], + "exact_next_action": "retry mark_final with expected_head_sha pin", + } + # Safe idempotency: already ready for same PR/action/head (#693 AC4). + ready_head = stale_review_decision_lock.normalize_head_sha( + lock.get("ready_expected_head_sha") + ) + target_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha) + if ( + lock.get("final_review_decision_ready") + and lock.get("ready_pr_number") == pr_number + and lock.get("ready_action") == action + and ready_head + and target_head + and stale_review_decision_lock.heads_equal(ready_head, target_head) + and lock.get("ready_remote") == remote + and lock.get("ready_org") == org + and lock.get("ready_repo") == repo + ): + return { + "marked_ready": True, + "idempotent": True, + "pr_number": pr_number, + "action": action, + "expected_head_sha": expected_head_sha, + "remote": remote, + "org": org, + "repo": repo, + "final_review_decision_ready": True, + "head_scoped": True, + "reasons": [ + "final review decision already marked ready for this PR/action/" + "head (idempotent, #693)" + ], } if stale_review_decision_lock.prior_live_mutations_block_boundary( lock, pr_number=pr_number, expected_head_sha=expected_head_sha ): + classification = stale_review_decision_lock.classify_review_decision_lock( + lock, + target_pr_number=pr_number, + target_head_sha=expected_head_sha, + ) + fail = stale_review_decision_lock.build_precise_mark_final_failure( + classification + ) return { "marked_ready": False, "reasons": [ "cannot mark final decision after a live review mutation was " "already recorded for this PR head unless an operator-approved " - "correction was authorized (fail closed, #620)" - ], + "correction was authorized for this same PR/head " + "(fail closed, #620/#693)" + ] + + list(fail.get("reasons") or []), + "classification": fail.get("classification"), + "exact_next_action": fail.get("exact_next_action"), + "durable_issue_handoff": fail.get("durable_issue_handoff"), } elig = gitea_check_pr_eligibility( pr_number=pr_number, @@ -4554,8 +5176,19 @@ def gitea_authorize_review_correction( prior_review_state: str, reason: str, operator_authorized: bool = False, + target_pr_number: int | None = None, + expected_head_sha: str | None = None, + remote: str = "dadeschools", + org: str | None = None, + repo: str | None = None, + post_audit_comment: bool = True, ) -> dict: - """Authorize one operator-approved correction after a mistaken live review.""" + """Authorize one operator-approved correction after a mistaken live review. + + #693: correction is scoped to the named prior review's **PR and head**. + It cannot unlock a different PR (no generic decision-lock bypass). + Successful authorization posts a thread-visible audit on the target PR. + """ reason = (reason or "").strip() prior_review_state = (prior_review_state or "").strip().lower() lock = _load_review_decision_lock() @@ -4581,7 +5214,20 @@ def gitea_authorize_review_correction( last_mutation = prior[-1] last_review_id = last_mutation.get("review_id") last_review_state = last_mutation.get("action") + last_pr = last_mutation.get("pr_number") + last_head = stale_review_decision_lock.mutation_head_sha(last_mutation, lock) reasons = [] + if target_pr_number is None: + reasons.append( + "target_pr_number is required so correction cannot become a " + "generic unlock (fail closed, #693)" + ) + elif last_pr is not None and int(target_pr_number) != int(last_pr): + reasons.append( + f"target_pr_number #{target_pr_number} does not match last " + f"recorded mutation PR #{last_pr}; correction cannot unlock a " + f"different PR (fail closed, #693)" + ) if last_review_id is not None and last_review_id != prior_review_id: reasons.append( f"prior review ID '{prior_review_id}' does not match last " @@ -4592,14 +5238,652 @@ def gitea_authorize_review_correction( f"prior review state '{prior_review_state}' does not match last " f"recorded review state '{last_review_state}' (fail closed)" ) + want_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha) + if want_head and last_head and not stale_review_decision_lock.heads_equal( + want_head, last_head + ): + reasons.append( + f"expected_head_sha does not match last mutation head " + f"{last_head[:12]}… (fail closed, #693)" + ) if not reason: reasons.append("correction reason is required") + try: + actor = None + h, o, r = _resolve(remote, None, org, repo) + try: + actor = _authenticated_username(h) + except Exception: + actor = None + except Exception as exc: # noqa: BLE001 + return { + "authorized": False, + "reasons": [f"could not resolve remote for audit: {_redact(str(exc))}"], + } + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or None + audit = stale_review_decision_lock.build_correction_audit_record( + prior_review_id=prior_review_id, + prior_review_state=prior_review_state, + target_pr_number=target_pr_number if target_pr_number is not None else last_pr, + target_head_sha=want_head or last_head, + reason=reason, + actor_username=actor, + profile_name=profile_name, + authorized=False, + ) if reasons: - return {"authorized": False, "reasons": reasons} + return { + "authorized": False, + "reasons": reasons, + "audit": audit, + "exact_next_action": ( + "use same-PR correction only, or for a different PR use " + "gitea_diagnose_review_decision_lock / cross-PR isolation " + "(#693) or moot cleanup (#594)" + ), + } + scope_pr = int(target_pr_number) if target_pr_number is not None else int(last_pr) + scope_head = want_head or last_head lock["correction_authorized"] = True lock["correction_reason"] = reason + lock["correction_pr_number"] = scope_pr + lock["correction_head_sha"] = scope_head _save_review_decision_lock(lock) - return {"authorized": True, "correction_reason": reason, "reasons": []} + audit = stale_review_decision_lock.build_correction_audit_record( + prior_review_id=prior_review_id, + prior_review_state=prior_review_state, + target_pr_number=scope_pr, + target_head_sha=scope_head, + reason=reason, + actor_username=actor, + profile_name=profile_name, + authorized=True, + ) + report = { + "authorized": True, + "correction_reason": reason, + "correction_pr_number": scope_pr, + "correction_head_sha": scope_head, + "audit": audit, + "audit_comment_id": None, + "reasons": [], + "scope": "same_pr_head_only", + } + if post_audit_comment and scope_pr is not None: + comment_block = _profile_operation_gate("gitea.pr.comment") + if comment_block: + report["audit_comment_skipped"] = comment_block + else: + try: + auth = _auth(h) + body = stale_review_decision_lock.format_correction_audit_comment( + audit + ) + with _audited( + "comment_pr", + host=h, + remote=remote, + org=o, + repo=r, + pr_number=scope_pr, + request_metadata={"source": "authorize_review_correction"}, + ): + posted = api_request( + "POST", + f"{repo_api_url(h, o, r)}/issues/{scope_pr}/comments", + auth, + {"body": body}, + ) + report["audit_comment_id"] = (posted or {}).get("id") + except Exception as exc: # noqa: BLE001 + report["audit_comment_error"] = _redact(str(exc)) + return report + + +@mcp.tool() +def gitea_diagnose_review_decision_lock( + target_pr_number: int, + expected_head_sha: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Read-only: classify durable review-decision lock state for a target PR (#693). + + Returns a deterministic classification, owner/evidence, exact next_action, + and whether mark_final / cleanup / scoped correction are appropriate. + Never mutates the lock and never authorizes correction. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + binding = _decision_lock_binding() + lock = _load_review_decision_lock() + session_blockers = _review_decision_session_reasons(lock) if lock else [] + last = stale_review_decision_lock.last_terminal_mutation(lock) + pr_live = None + pr_lookup_error = None + last_pr = last.get("pr_number") if last else None + if last_pr is not None: + try: + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{last_pr}", auth + ) or {} + if not pr_live: + pr_lookup_error = "empty PR payload" + except Exception as exc: # noqa: BLE001 + pr_lookup_error = _redact(str(exc)) + classification = stale_review_decision_lock.classify_review_decision_lock( + lock, + target_pr_number=target_pr_number, + target_head_sha=expected_head_sha, + pr_live=pr_live, + pr_lookup_error=pr_lookup_error, + active_profile_identity=binding.get("profile_identity"), + session_blockers=session_blockers, + ) + recovery = stale_review_decision_lock.assess_open_pr_decision_lock_recovery( + lock, + target_pr_number=target_pr_number, + target_head_sha=expected_head_sha, + last_terminal_pr_live=pr_live, + pr_lookup_error=pr_lookup_error, + active_profile_identity=binding.get("profile_identity"), + session_blockers=session_blockers, + ) + try: + actor = _authenticated_username(h) + except Exception: + actor = None + return { + "success": True, + "remote": remote, + "org": o, + "repo": r, + "authenticated_username": actor, + "active_profile_identity": binding.get("profile_identity"), + "classification": classification.get("classification"), + "mark_final_allowed": classification.get("mark_final_allowed"), + "cleanup_allowed": classification.get("cleanup_allowed"), + "correction_eligible": classification.get("correction_eligible"), + "recovery_allowed": recovery.get("recovery_allowed"), + "recovery_mode": recovery.get("recovery_mode"), + "exact_next_action": classification.get("next_action"), + "owner_profile_identity": classification.get("owner_profile_identity"), + "last_terminal_pr": classification.get("last_terminal_pr"), + "last_terminal_action": classification.get("last_terminal_action"), + "locked_head_sha": classification.get("locked_head_sha"), + "target_pr_number": target_pr_number, + "target_head_sha": stale_review_decision_lock.normalize_head_sha( + expected_head_sha + ), + "lock_summary": classification.get("lock_summary"), + "evidence": classification.get("evidence"), + "reasons": list(classification.get("reasons") or []), + "manual_file_delete_forbidden": True, + "correction_as_generic_unlock_forbidden": True, + "durable_issue_handoff": stale_review_decision_lock.build_precise_mark_final_failure( + classification + ).get("durable_issue_handoff"), + } + + + +def _record_post_merge_decision_recovery( + *, + pr_number: int, + head_sha: str | None, + merge_commit_sha: str | None, + target_profile_identity: str | None, + failed_step: str, + error: str | None, + remote: str | None, + org: str | None, + repo: str | None, +) -> dict: + """Persist durable post-merge recovery-required state (#709 AC3).""" + try: + actor = None + try: + if remote in REMOTES: + h, _, _ = _resolve(remote, None, org, repo) + actor = _authenticated_username(h) + except Exception: + actor = None + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or None + payload = stale_review_decision_lock.build_post_merge_recovery_record( + pr_number=pr_number, + head_sha=head_sha, + merge_commit_sha=merge_commit_sha, + target_profile_identity=target_profile_identity, + failed_step=failed_step, + error=error, + remote=remote, + org=org, + repo=repo, + actor_username=actor, + profile_name=profile_name, + ) + # Key by merger/active profile so the merging session owns the recovery row. + binding = _decision_lock_binding() + saved = mcp_session_state.save_state( + kind=mcp_session_state.KIND_POST_MERGE_DECISION_RECOVERY, + payload=payload, + remote=remote, + org=org, + repo=repo, + profile_identity=binding.get("profile_identity"), + ) + return dict(saved or payload) + except Exception as exc: # noqa: BLE001 + return {"status": "recovery_record_failed", "error": _redact(str(exc))} + + +def _clear_decision_lock_for_profile( + *, + profile_identity: str, + pr_number: int, + expected_head_sha: str | None, + remote: str | None, + org: str | None, + repo: str | None, +) -> dict: + """Clear one profile's durable decision lock when it targets *pr_number* approve. + + #709 F3: require remote/org/repo scope match on load and exact head identity + for any terminal match. PR-number-only fallback is forbidden — malformed, + legacy incomplete, cross-repository, or wrong-head locks are never cleared. + """ + try: + import irrecoverable_provenance as _irp + + path_gate = _irp.assess_profile_path_identity(profile_identity) + if not path_gate.get("valid"): + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": "; ".join(path_gate.get("reasons") or ["invalid profile"]), + "recovery_required": True, + } + except Exception: + raw = str(profile_identity or "") + if ".." in raw or "/" in raw or "\\" in raw: + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": "profile identity path traversal rejected (fail closed, #709 F3)", + "recovery_required": True, + } + + # expected_head_sha is mandatory for destructive clear (#709 F3). + want_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha) + if not want_head: + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "expected_head_sha required for decision-lock clear " + "(no PR-number-only fallback; fail closed, #709 F3)" + ), + "recovery_required": False, + } + if not (remote and org and repo): + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "remote/org/repo required for decision-lock clear " + "(no incomplete-identity clear; fail closed, #709 F3)" + ), + "recovery_required": False, + } + + lock = mcp_session_state.load_state_for_profile( + kind=mcp_session_state.KIND_DECISION_LOCK, + profile_identity=profile_identity, + remote=remote, + org=org, + repo=repo, + skip_identity_match=True, + enforce_repo_scope=True, + ) + if lock is None: + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "no durable lock for profile at exact remote/org/repo scope " + "(or identity/expiry mismatch; fail closed)" + ), + } + + # Primary: approve of this PR at exact head. + targets_approve = stale_review_decision_lock.lock_targets_merged_pr_approval( + lock, pr_number=pr_number, expected_head_sha=want_head + ) + if not targets_approve: + # Secondary: any terminal for this PR **only** when head also matches. + # Never fall back to PR-number alone (#709 F3 / review 434). + last = stale_review_decision_lock.last_terminal_mutation(lock) + if not last or last.get("pr_number") != pr_number: + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": "lock terminal does not target this PR approval", + } + locked_head = stale_review_decision_lock.mutation_head_sha(last, lock) + if not locked_head: + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "legacy/incomplete terminal head identity; refuse destructive " + "clear (inspect/report recovery-required only, #709 F3)" + ), + "recovery_required": True, + "prior_summary": stale_review_decision_lock.lock_summary(lock), + } + if not stale_review_decision_lock.heads_equal(locked_head, want_head): + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "lock terminal head does not match expected_head_sha " + "(fail closed, #709 F3)" + ), + } + + # Archive then clear — archival is a hard prerequisite (#709 F8 review 438). + # A failed, empty, or unconfirmed archive must never be followed by a clear: + # that is exactly the terminal-evidence destruction #709 exists to prevent. + archive_identity = f"{profile_identity}-archive-pr{pr_number}" + archive_payload = { + **dict(lock), + "archived_reason": "post_merge_cross_profile_cleanup", + "archived_for_pr": pr_number, + "archived_for_head": want_head, + "archived_remote": remote, + "archived_org": org, + "archived_repo": repo, + "recovery_critical": True, + "kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE, + # The copied lock carries the *source* profile's identity. Leaving it in + # place makes save_state key the archive under that profile instead of + # the archive identity, so the archive would silently overwrite/land + # elsewhere and never read back (#709 F8 review 438). + "profile_identity": archive_identity, + "session_profile_lock": archive_identity, + "archived_from_profile_identity": profile_identity, + } + + def _archive_failure(step: str, detail: str) -> dict: + """Retain the terminal lock and report an actionable, retryable failure.""" + try: + _record_post_merge_decision_recovery( + pr_number=int(pr_number), + head_sha=want_head, + merge_commit_sha=None, + target_profile_identity=profile_identity, + failed_step=step, + error=detail, + remote=remote, + org=org, + repo=repo, + ) + except Exception as exc: # noqa: BLE001 + detail = f"{detail}; recovery record write also failed: {_redact(str(exc))}" + return { + "profile_identity": profile_identity, + "cleared": False, + "archive_ok": False, + "archive_failed_step": step, + "archive_identity": archive_identity, + "terminal_lock_retained": True, + "recovery_required": True, + "retry_safe": True, + "reason": ( + f"decision-lock archival failed at {step}: {detail}. Terminal " + "evidence retained and NOT cleared; resolve the archive failure " + "and retry this cleanup (fail closed, #709 F8 review 438)" + ), + "prior_summary": stale_review_decision_lock.lock_summary(lock), + } + + try: + archived = mcp_session_state.save_state( + kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE, + payload=archive_payload, + remote=remote, + org=org, + repo=repo, + profile_identity=archive_identity, + ) + except Exception as exc: # noqa: BLE001 + return _archive_failure("archive_save_state", _redact(str(exc))) + if not archived: + return _archive_failure( + "archive_save_state", + "save_state returned no durable archive record (false/empty result)", + ) + + # Durable read-back: a write that cannot be re-read is not an archive. + try: + confirmed = mcp_session_state.load_state_for_profile( + kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE, + profile_identity=archive_identity, + remote=remote, + org=org, + repo=repo, + skip_identity_match=True, + enforce_repo_scope=True, + ) + except Exception as exc: # noqa: BLE001 + return _archive_failure("archive_readback", _redact(str(exc))) + if not confirmed: + return _archive_failure( + "archive_readback", + "archive record could not be read back from durable session state", + ) + if int(confirmed.get("archived_for_pr") or -1) != int( + pr_number + ) or not stale_review_decision_lock.heads_equal( + confirmed.get("archived_for_head"), want_head + ): + return _archive_failure( + "archive_readback", + "archive read-back does not match the PR/head being cleaned " + "(partial or stale archive)", + ) + + mcp_session_state.clear_state( + kind=mcp_session_state.KIND_DECISION_LOCK, + profile_identity=profile_identity, + remote=remote, + org=org, + repo=repo, + ) + # If this is the in-memory active profile lock, clear memory too. + active = _decision_lock_binding().get("profile_identity") + if active and active == profile_identity: + global _REVIEW_DECISION_LOCK + _REVIEW_DECISION_LOCK = None + return { + "profile_identity": profile_identity, + "cleared": True, + "archive_ok": True, + "archive_identity": archive_identity, + "reason": ( + f"cleared terminal lock for merged PR #{pr_number} " + f"at head {want_head[:12]}… (exact-scope; durable archive confirmed)" + ), + "prior_summary": stale_review_decision_lock.lock_summary(lock), + } + + +def _reconcile_decision_locks_after_merge( + *, + pr_number: int, + head_sha: str | None, + merge_commit_sha: str | None, + remote: str, + host: str, + org: str, + repo: str, + auth, +) -> dict: + """Cross-profile decision-lock reconcile after a successful merge (#709).""" + report: dict = { + "cleared_any": False, + "profiles_scanned": [], + "cleared_profiles": [], + "audit_comment_ids": [], + "recovery_required": False, + "reason_lines": [], + "applied": False, # overall "historical applied cleanup" never claimed blindly + } + try: + actor = _authenticated_username(host) + except Exception: + actor = None + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or None + + identities = list(mcp_session_state.list_decision_lock_profile_identities()) + active = _decision_lock_binding().get("profile_identity") + if active and active not in identities: + identities.append(active) + # Always consider common role profiles so empty local merger lock cannot + # hide a reviewer terminal ledger (#709 AC1). + for candidate in ("prgs-reviewer", "prgs-merger", "prgs-author", "prgs-reconciler"): + if candidate not in identities: + identities.append(candidate) + report["profiles_scanned"] = list(identities) + + for identity in identities: + try: + outcome = _clear_decision_lock_for_profile( + profile_identity=identity, + pr_number=pr_number, + expected_head_sha=head_sha, + remote=remote, + org=org, + repo=repo, + ) + except Exception as exc: # noqa: BLE001 + report["recovery_required"] = True + report["reason_lines"].append( + f"failed clearing decision lock for {identity}: {_redact(str(exc))}" + ) + _record_post_merge_decision_recovery( + pr_number=pr_number, + head_sha=head_sha, + merge_commit_sha=merge_commit_sha, + target_profile_identity=identity, + failed_step="clear_profile_lock", + error=_redact(str(exc)), + remote=remote, + org=org, + repo=repo, + ) + continue + if outcome.get("cleared"): + report["cleared_any"] = True + report["cleared_profiles"].append(identity) + report["reason_lines"].append( + f"cleared decision lock profile={identity} after merge of " + f"PR #{pr_number} (#709)" + ) + # Audit publication (AC4): post and require comment id for full reconcile. + audit = stale_review_decision_lock.build_cleanup_audit_record( + assessment={ + "last_terminal_pr": pr_number, + "last_terminal_action": "approve", + "pr_state": "closed", + "pr_merged": True, + "pr_merged_or_closed": True, + "merge_commit_sha": merge_commit_sha, + "cleanup_allowed": True, + "is_moot": True, + "lock_summary": outcome.get("prior_summary"), + "reasons": [outcome.get("reason") or "cleared"], + }, + actor_username=actor, + profile_name=profile_name, + applied=True, + ) + audit["cleanup_target_profile"] = identity + audit["issue_ref"] = "#709" + comment_block = _profile_operation_gate("gitea.pr.comment") + if comment_block: + report["recovery_required"] = True + report["reason_lines"].append( + f"audit comment blocked for {identity}: {comment_block}" + ) + _record_post_merge_decision_recovery( + pr_number=pr_number, + head_sha=head_sha, + merge_commit_sha=merge_commit_sha, + target_profile_identity=identity, + failed_step="audit_comment_permission", + error=str(comment_block), + remote=remote, + org=org, + repo=repo, + ) + continue + try: + body = stale_review_decision_lock.format_cleanup_audit_comment(audit) + comment_url = f"{repo_api_url(host, org, repo)}/issues/{pr_number}/comments" + with _audited( + "comment_pr", + host=host, + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + request_metadata={"source": "post_merge_decision_lock_reconcile"}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + cid = (posted or {}).get("id") + if not cid: + raise RuntimeError("audit comment missing id on readback") + report["audit_comment_ids"].append(cid) + report["reason_lines"].append( + f"audit comment published id={cid} for profile={identity}" + ) + except Exception as exc: # noqa: BLE001 + report["recovery_required"] = True + report["reason_lines"].append( + f"audit comment failed for {identity}: {_redact(str(exc))}" + ) + _record_post_merge_decision_recovery( + pr_number=pr_number, + head_sha=head_sha, + merge_commit_sha=merge_commit_sha, + target_profile_identity=identity, + failed_step="audit_comment_publish", + error=_redact(str(exc)), + remote=remote, + org=org, + repo=repo, + ) + + if report["cleared_any"] and not report["recovery_required"]: + report["applied"] = True # this-session successful cleanup only + elif not report["cleared_any"]: + report["reason_lines"].append( + f"no cross-profile decision lock targeted PR #{pr_number} for cleanup" + ) + return report @mcp.tool() @@ -4646,6 +5930,12 @@ def gitea_cleanup_stale_review_decision_lock( binding = _decision_lock_binding() active_identity = binding.get("profile_identity") lock = _load_review_decision_lock() + # #720: when normal load yields no lock, inspect disk so assessment does not + # silently report "absent" while an expired/non-critical envelope remains. + disk_inspect = mcp_session_state.inspect_state_envelope( + kind=mcp_session_state.KIND_DECISION_LOCK, + profile_identity=active_identity, + ) last = stale_review_decision_lock.last_terminal_mutation(lock) pr_live = None pr_lookup_error = None @@ -4667,6 +5957,26 @@ def gitea_cleanup_stale_review_decision_lock( pr_lookup_error=pr_lookup_error, active_profile_identity=active_identity, ) + if lock is None and disk_inspect.get("on_disk"): + assessment = dict(assessment) + assessment["reasons"] = list(assessment.get("reasons") or []) + [ + "decision-lock file is present on disk but not loadable via normal " + f"TTL/identity gates: {disk_inspect.get('summary')} " + "(do not rm session-state files; #720)" + ] + assessment["disk_inspect"] = { + k: disk_inspect.get(k) + for k in ( + "on_disk", + "has_payload", + "age_hours", + "age_exceeds_default_ttl", + "recovery_critical", + "ttl_exempt", + "would_ttl_reject", + "summary", + ) + } # Optional pin: refuse apply against a different terminal PR than expected. if ( @@ -4721,6 +6031,19 @@ def gitea_cleanup_stale_review_decision_lock( "pr_merged_or_closed": assessment.get("pr_merged_or_closed"), "merge_commit_sha": assessment.get("merge_commit_sha"), "lock_summary": assessment.get("lock_summary"), + "disk_inspect": assessment.get("disk_inspect") or { + k: disk_inspect.get(k) + for k in ( + "on_disk", + "has_payload", + "age_hours", + "age_exceeds_default_ttl", + "recovery_critical", + "ttl_exempt", + "would_ttl_reject", + "summary", + ) + }, "audit": audit, "audit_comment_id": None, "reasons": list(assessment.get("reasons") or []), @@ -4801,12 +6124,838 @@ def gitea_cleanup_stale_review_decision_lock( "POST", comment_url, auth, {"body": body} ) report["audit_comment_id"] = (posted or {}).get("id") + if not report["audit_comment_id"]: + report["reconciled"] = False + report["recovery_required"] = True + report["reasons"] = list(report.get("reasons") or []) + [ + "cleanup applied but audit comment id missing on readback " + "(#709 AC4 fail-closed for full reconcile)" + ] + _record_post_merge_decision_recovery( + pr_number=int(assessment["last_terminal_pr"]), + head_sha=assessment.get("locked_head_sha"), + merge_commit_sha=assessment.get("merge_commit_sha"), + target_profile_identity=active_identity, + failed_step="audit_comment_missing_id", + error="comment response missing id", + remote=remote, + org=o, + repo=r, + ) + else: + report["reconciled"] = True except Exception as exc: # noqa: BLE001 report["audit_comment_error"] = _redact(str(exc)) + report["reconciled"] = False + report["recovery_required"] = True + report["reasons"] = list(report.get("reasons") or []) + [ + "cleanup applied but audit comment failed; recovery-required " + "recorded (#709 AC4)" + ] + try: + _record_post_merge_decision_recovery( + pr_number=int(assessment["last_terminal_pr"]), + head_sha=assessment.get("locked_head_sha"), + merge_commit_sha=assessment.get("merge_commit_sha"), + target_profile_identity=active_identity, + failed_step="audit_comment_publish", + error=_redact(str(exc)), + remote=remote, + org=o, + repo=r, + ) + except Exception: + pass + if post_audit_comment is False: + report["reconciled"] = False + report["reasons"] = list(report.get("reasons") or []) + [ + "cleanup applied without audit comment (post_audit_comment=false); " + "not fully reconciled (#709 AC4)" + ] return report +def _irrecoverable_capability_gate() -> list[str] | None: + """Dedicated recovery capability (gitea.read alone is insufficient).""" + import irrecoverable_provenance as irp + + profile = get_profile() + assessment = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=profile.get("allowed_operations") or [], + forbidden_operations=profile.get("forbidden_operations") or [], + role_kind=profile.get("role") or profile.get("role_kind"), + profile_name=profile.get("profile_name"), + ) + if assessment.get("allowed"): + return None + return list(assessment.get("reasons") or ["capability denied"]) + + +@mcp.tool() +def gitea_issue_irrecoverable_provenance_authorization( + pr_number: int, + expected_head_sha: str, + incident_issue: int, + incident_comment_id: int, + decision_lock_id: str = "", + confirmation: str = "", + destroyed_subject: str | None = None, + recorded_head_sha: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Mint a server-side authorization artifact for irrecoverable recovery (#709 F1/F5). + + Non-forgeable: requires production native MCP transport (or pytest), the + dedicated ``gitea.decision_lock.irrecoverable_recovery`` capability (no + reconciler equivalence), live head equality, durable HMAC key, and + authoritative incident evidence (author + canonical content_digest). + Confirmation is human intent only — never authorization. Caller Booleans + are not accepted. + """ + import irrecoverable_provenance as irp + + h, o, r = _resolve(remote, host, org, repo) + report: dict = { + "success": False, + "performed": False, + "authorization": None, + "authorization_id": None, + "pr_number": pr_number, + "expected_head_sha": expected_head_sha, + "incident_issue": incident_issue, + "incident_comment_id": incident_comment_id, + "reasons": [], + } + + cap_block = _irrecoverable_capability_gate() + if cap_block: + report["reasons"].extend(cap_block) + report["permission_report"] = { + "required_operation": irp.CAPABILITY_IRRECOVERABLE_RECOVERY, + "reasons": cap_block, + } + return report + + transport = irp.assess_transport_for_auth_mint() + if not transport.get("allowed"): + report["reasons"].extend(transport.get("reasons") or []) + return report + + expected_confirm = irp.expected_confirmation(pr_number) + if (confirmation or "").strip() != expected_confirm: + report["reasons"].append( + f"confirmation must equal exactly {expected_confirm!r} " + "(human intent only; not an authorization credential; fail closed)" + ) + return report + + if not (decision_lock_id or "").strip(): + report["reasons"].append( + "decision_lock_id is required so evidence is bound to the exact " + "decision lock being recovered (fail closed, #709 F7 review 438)" + ) + return report + + try: + actor = _authenticated_username(h) + except Exception: + actor = None + if not actor: + report["reasons"].append( + "authenticated identity could not be verified (fail closed)" + ) + return report + actor_identity = _authenticated_actor(h) + if actor_identity.get("user_id") is None: + report["reasons"].append( + "authenticated actor id could not be verified; display-name-only " + "identity is not accepted (fail closed, #709 F7 review 438)" + ) + return report + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or None + + # Live PR head (authoritative). + live_head = None + pr_state = None + pr_err = None + try: + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h) + ) + live_head = (pr_live or {}).get("head", {}) + if isinstance(live_head, dict): + live_head = live_head.get("sha") + else: + live_head = (pr_live or {}).get("head_sha") or (pr_live or {}).get( + "head_commit_sha" + ) + pr_state = (pr_live or {}).get("state") + except Exception as exc: # noqa: BLE001 + pr_err = _redact(str(exc)) + head_gate = irp.assess_live_head_binding( + expected_head_sha=expected_head_sha, + live_head_sha=live_head, + pr_lookup_error=pr_err, + pr_state=pr_state, + ) + if not head_gate.get("valid"): + report["reasons"].extend(head_gate.get("reasons") or []) + return report + + # Incident evidence live validation (author + canonical digest, #709 F5). + comment_payload = None + comment_err = None + try: + comment_payload = api_request( + "GET", + f"{repo_api_url(h, o, r)}/issues/comments/{int(incident_comment_id)}", + _auth(h), + ) + except Exception as exc: # noqa: BLE001 + comment_err = _redact(str(exc)) + try: + active_key_version = irp.auth_key_version() + except irp.AuthSecretError as exc: + report["reasons"].append(str(exc)) + return report + incident_gate = irp.assess_incident_evidence( + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + comment_payload=comment_payload if isinstance(comment_payload, dict) else None, + comment_lookup_error=comment_err, + expected_remote=remote, + expected_org=o, + expected_repo=r, + expected_pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + expected_recorded_head_sha=recorded_head_sha, + expected_decision_lock_id=decision_lock_id.strip(), + expected_recovery_action=irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE, + expected_key_version=active_key_version, + mint_actor_id=actor_identity.get("user_id"), + mint_actor_username=actor, + reject_self_authored=True, + ) + if not incident_gate.get("valid"): + report["reasons"].extend(incident_gate.get("reasons") or []) + return report + + auth_profile = irp.auth_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + # Idempotent: return unconsumed matching auth. + existing = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + if isinstance(existing, dict): + v = irp.verify_authorization_artifact( + existing, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + require_unconsumed=True, + ) + if v.get("valid"): + report["success"] = True + report["performed"] = False + report["authorization"] = existing + report["authorization_id"] = existing.get("authorization_id") + report["reasons"].append( + "idempotent: unconsumed matching authorization already present" + ) + return report + + try: + artifact = irp.build_authorization_artifact( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + incident_issue=int(incident_issue), + incident_comment_id=int(incident_comment_id), + destroyed_subject=destroyed_subject, + issuer_username=actor, + issuer_profile=profile_name or "unknown", + native_provenance=mcp_daemon_guard.mutation_provenance_fields(), + ) + except irp.AuthSecretError as exc: + report["reasons"].append(str(exc)) + return report + artifact["kind"] = mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH + saved = mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + payload=artifact, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + report["authorization"] = dict(saved or artifact) + report["authorization_id"] = report["authorization"].get("authorization_id") + report["performed"] = True + report["success"] = True + report["reasons"].append( + "issued server-side irrecoverable provenance authorization " + "(non-forgeable; bound to remote/org/repo/PR/head/incident; " + f"key_version={artifact.get('key_version')})" + ) + return report + + +@mcp.tool() +def gitea_record_irrecoverable_decision_lock_provenance( + pr_number: int, + reason: str, + confirmation: str = "", + expected_head_sha: str | None = None, + incident_issue: int | None = None, + incident_comment_id: int | None = None, + authorization_id: str | None = None, + decision_lock_id: str = "", + destroyed_subject: str | None = None, + incident_ref: str | None = None, + # Deprecated: retained so callers that still pass it get an explicit deny. + operator_authorized: bool = False, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + post_audit_comment: bool = True, +) -> dict: + """Record truthful absence of decision-lock cleanup proof (#709 AC5). + + Never emits applied=true or claims historical cleanup was proven. + + Authorization is a **server-side artifact** (see + ``gitea_issue_irrecoverable_provenance_authorization``). Caller-supplied + ``operator_authorized`` is **never** authorization evidence (review 434 F1). + Confirmation is human intent only. ``expected_head_sha``, + ``incident_issue``, and ``incident_comment_id`` are mandatory. + """ + import irrecoverable_provenance as irp + + h, o, r = _resolve(remote, host, org, repo) + expected_confirm = irp.expected_confirmation(pr_number) + report = { + "success": False, + "performed": False, + "applied": False, + "historical_cleanup_proven": False, + "status": "provenance_irrecoverable", + "pr_number": pr_number, + "expected_head_sha": expected_head_sha, + "reasons": [], + "record": None, + "audit_comment_id": None, + "authorization_id": authorization_id, + "merger_may_accept": False, + } + + # Explicitly reject self-assertable Boolean as sole/any authorization. + if operator_authorized: + report["reasons"].append( + "operator_authorized is not accepted as authorization evidence " + "(#709 F1 / review 434); mint a server-side authorization via " + "gitea_issue_irrecoverable_provenance_authorization" + ) + # Do not return yet — still report other failures — but never authorize. + # Actually fail immediately so success cannot be claimed. + return report + + cap_block = _irrecoverable_capability_gate() + if cap_block: + report["reasons"].extend(cap_block) + report["permission_report"] = { + "required_operation": irp.CAPABILITY_IRRECOVERABLE_RECOVERY, + "reasons": cap_block, + } + return report + + transport = irp.assess_transport_for_auth_mint() + if not transport.get("allowed"): + report["reasons"].extend(transport.get("reasons") or []) + return report + + if (confirmation or "").strip() != expected_confirm: + report["reasons"].append( + f"confirmation must equal exactly {expected_confirm!r} " + "(human intent only; not authorization; fail closed)" + ) + return report + if not (reason or "").strip(): + report["reasons"].append("reason is required (fail closed)") + return report + if not expected_head_sha or not str(expected_head_sha).strip(): + report["reasons"].append( + "expected_head_sha is mandatory (fail closed, #709 F1)" + ) + return report + if incident_issue is None or int(incident_issue) <= 0: + report["reasons"].append( + "incident_issue is mandatory (canonical incident evidence, #709 F1)" + ) + return report + if incident_comment_id is None or int(incident_comment_id) <= 0: + report["reasons"].append( + "incident_comment_id is mandatory (canonical incident evidence, #709 F1)" + ) + return report + # incident_ref alone is never sufficient (legacy arg ignored as authority). + _ = incident_ref + + try: + actor = _authenticated_username(h) + except Exception: + actor = None + if not actor: + report["reasons"].append( + "authenticated identity could not be verified (fail closed)" + ) + return report + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or None + + # Live head must match. + live_head = None + pr_err = None + try: + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h) + ) + live_head = (pr_live or {}).get("head", {}) + if isinstance(live_head, dict): + live_head = live_head.get("sha") + else: + live_head = (pr_live or {}).get("head_sha") or (pr_live or {}).get( + "head_commit_sha" + ) + except Exception as exc: # noqa: BLE001 + pr_err = _redact(str(exc)) + head_gate = irp.assess_live_head_binding( + expected_head_sha=expected_head_sha, + live_head_sha=live_head, + pr_lookup_error=pr_err, + ) + if not head_gate.get("valid"): + report["reasons"].extend(head_gate.get("reasons") or []) + return report + + # Re-validate incident evidence at record time. + comment_payload = None + comment_err = None + try: + comment_payload = api_request( + "GET", + f"{repo_api_url(h, o, r)}/issues/comments/{int(incident_comment_id)}", + _auth(h), + ) + except Exception as exc: # noqa: BLE001 + comment_err = _redact(str(exc)) + try: + active_key_version = irp.auth_key_version() + except irp.AuthSecretError as exc: + report["reasons"].append(str(exc)) + return report + incident_gate = irp.assess_incident_evidence( + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + comment_payload=comment_payload if isinstance(comment_payload, dict) else None, + comment_lookup_error=comment_err, + expected_remote=remote, + expected_org=o, + expected_repo=r, + expected_pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + expected_decision_lock_id=(decision_lock_id or "").strip() or None, + expected_recovery_action=irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE, + expected_key_version=active_key_version, + mint_actor_id=_authenticated_actor(h).get("user_id"), + mint_actor_username=actor, + reject_self_authored=True, + ) + if not incident_gate.get("valid"): + report["reasons"].extend(incident_gate.get("reasons") or []) + return report + + # Load server-side authorization artifact (by scope; optional id check). + auth_profile = irp.auth_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + authorization = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + if not isinstance(authorization, dict): + report["reasons"].append( + "no server-side authorization artifact for this exact scope; call " + "gitea_issue_irrecoverable_provenance_authorization first (fail closed)" + ) + return report + if authorization_id and authorization.get("authorization_id") != authorization_id: + report["reasons"].append( + "authorization_id does not match durable artifact for this scope " + "(fail closed)" + ) + return report + auth_check = irp.verify_authorization_artifact( + authorization, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + incident_issue=int(incident_issue), + incident_comment_id=int(incident_comment_id), + require_unconsumed=True, + ) + if not auth_check.get("valid"): + report["reasons"].extend(auth_check.get("reasons") or []) + return report + + recovery_profile = irp.recovery_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + existing = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + remote=remote, + org=o, + repo=r, + profile_identity=recovery_profile, + ) + if ( + isinstance(existing, dict) + and existing.get("pr_number") == pr_number + and stale_review_decision_lock.heads_equal( + existing.get("head_sha"), expected_head_sha + ) + and existing.get("status") == "provenance_irrecoverable" + and existing.get("authorization_id") == authorization.get("authorization_id") + ): + report["success"] = True + report["performed"] = False + report["record"] = existing + report["merger_may_accept"] = bool(existing.get("merger_may_accept")) + report["authorization_id"] = existing.get("authorization_id") + report["reasons"].append( + "idempotent: matching irrecoverable record already present" + ) + return report + + record = irp.build_irrecoverable_provenance_record( + pr_number=pr_number, + head_sha=str(expected_head_sha), + remote=remote, + org=o, + repo=r, + actor_username=actor, + profile_name=profile_name, + reason=reason.strip(), + incident_issue=int(incident_issue), + incident_comment_id=int(incident_comment_id), + authorization=authorization, + destroyed_subject=destroyed_subject, + ) + if not record.get("merger_may_accept"): + report["reasons"].append( + "built record is not merger-acceptable (authorization verify failed)" + ) + report["record"] = record + return report + + record["kind"] = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE + saved = mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + payload=record, + remote=remote, + org=o, + repo=r, + profile_identity=recovery_profile, + ) + report["record"] = dict(saved or record) + report["performed"] = True + report["success"] = True + report["merger_may_accept"] = True + report["authorization_id"] = record.get("authorization_id") + report["reasons"].append( + "recorded provenance_irrecoverable (applied=false; historical cleanup " + "not proven; server authorization bound)" + ) + + if post_audit_comment: + issue_block = _profile_operation_gate("gitea.issue.comment") + if issue_block: + report["reasons"].append(f"audit comment skipped: {issue_block}") + else: + try: + body = irp.format_irrecoverable_audit_comment(report["record"]) + comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + with _audited( + "comment_issue", + host=h, + remote=remote, + org=o, + repo=r, + issue_number=pr_number, + request_metadata={ + "source": "record_irrecoverable_decision_lock_provenance" + }, + ): + posted = api_request( + "POST", + comment_url, + _auth(h), + {"body": body}, + ) + report["audit_comment_id"] = (posted or {}).get("id") + if report["audit_comment_id"]: + report["record"]["audit_comment_id"] = report["audit_comment_id"] + mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + payload=report["record"], + remote=remote, + org=o, + repo=r, + profile_identity=recovery_profile, + ) + except Exception as exc: # noqa: BLE001 + report["reasons"].append( + f"audit comment failed: {_redact(str(exc))} (record still durable)" + ) + return report + + +@mcp.tool() +def gitea_consume_irrecoverable_decision_lock_provenance( + pr_number: int, + expected_head_sha: str, + confirmation: str = "", + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Merger-side fail-closed consumption of an irrecoverable recovery record (#709 F2). + + Resolves **only** the historical prior-provenance blocker for the exact + remote/org/repo/PR/head. Never bypasses approval, change-requests, lease, + mergeability, anti-stomp, runtime, or workspace gates. Consumption is + durable, auditable, and idempotent. + """ + import irrecoverable_provenance as irp + + h, o, r = _resolve(remote, host, org, repo) + expected_confirm = f"CONSUME IRRECOVERABLE PROVENANCE PR {int(pr_number)}" + report: dict = { + "success": False, + "performed": False, + "pr_number": pr_number, + "expected_head_sha": expected_head_sha, + "historical_cleanup_proven": False, + "historical_cleanup_not_proven": True, + "irrecoverable_recovery_authorized": False, + "recovery_record_consumed": False, + "resolves_prior_provenance_blocker": False, + "normal_approval_and_merge_gates": "not_substituted", + "reasons": [], + "assessment": None, + } + + # Merger or reconciler may consume; gitea.read alone insufficient. + merge_block = _profile_operation_gate("gitea.pr.merge") + cap_block = _irrecoverable_capability_gate() + if merge_block and cap_block: + report["reasons"].append( + "consume requires gitea.pr.merge (merger) or irrecoverable-recovery " + "capability (reconciler); gitea.read alone is insufficient (#709 F2)" + ) + if merge_block: + report["reasons"].extend( + merge_block if isinstance(merge_block, list) else [str(merge_block)] + ) + report["reasons"].extend(cap_block) + return report + + if (confirmation or "").strip() != expected_confirm: + report["reasons"].append( + f"confirmation must equal exactly {expected_confirm!r} " + "(human intent; fail closed)" + ) + return report + + try: + actor = _authenticated_username(h) + except Exception: + actor = None + if not actor: + report["reasons"].append("authenticated identity unverified (fail closed)") + return report + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or None + + # Live head. + live_head = None + pr_err = None + try: + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h) + ) + live_head = (pr_live or {}).get("head", {}) + if isinstance(live_head, dict): + live_head = live_head.get("sha") + else: + live_head = (pr_live or {}).get("head_sha") + except Exception as exc: # noqa: BLE001 + pr_err = _redact(str(exc)) + head_gate = irp.assess_live_head_binding( + expected_head_sha=expected_head_sha, + live_head_sha=live_head, + pr_lookup_error=pr_err, + ) + if not head_gate.get("valid"): + report["reasons"].extend(head_gate.get("reasons") or []) + return report + + recovery_profile = irp.recovery_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + recovery = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + remote=remote, + org=o, + repo=r, + profile_identity=recovery_profile, + ) + auth_profile = irp.auth_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + authorization = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + + # Pull formal review state so consumption cannot be claimed when normal + # gates would fail (assessment only — does not merge). + feedback = gitea_get_pr_review_feedback( + pr_number=pr_number, remote=remote, host=host, org=org, repo=repo + ) + assessment = irp.assess_merger_consumption( + recovery if isinstance(recovery, dict) else None, + authorization if isinstance(authorization, dict) else None, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + live_head_sha=live_head, + approval_at_current_head=feedback.get("approval_at_current_head") + if feedback.get("success") + else None, + has_blocking_change_requests=feedback.get("has_blocking_change_requests") + if feedback.get("success") + else None, + ) + report["assessment"] = assessment + report["reasons"].extend(assessment.get("reasons") or []) + report["irrecoverable_recovery_authorized"] = bool( + assessment.get("irrecoverable_recovery_authorized") + ) + report["resolves_prior_provenance_blocker"] = bool( + assessment.get("resolves_prior_provenance_blocker") + ) + report["historical_cleanup_proven"] = False + report["historical_cleanup_not_proven"] = True + + if not assessment.get("allowed") and not assessment.get( + "recovery_record_consumed" + ): + return report + + # Atomic-ish durable consumption (auth then recovery under exclusive locks + # via save_state). + if isinstance(authorization, dict) and not authorization.get("consumed_at"): + consumed_auth = irp.mark_consumed( + authorization, + consumer_username=actor, + consumer_profile=profile_name, + ) + mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + payload=consumed_auth, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + if isinstance(recovery, dict) and not recovery.get("consumed_at"): + consumed_rec = irp.mark_consumed( + recovery, + consumer_username=actor, + consumer_profile=profile_name, + ) + consumed_rec["prior_provenance_blocker_resolved"] = True + saved = mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + payload=consumed_rec, + remote=remote, + org=o, + repo=r, + profile_identity=recovery_profile, + ) + report["record"] = dict(saved or consumed_rec) + report["performed"] = True + else: + report["record"] = recovery + report["performed"] = False + + report["recovery_record_consumed"] = True + report["success"] = True + report["resolves_prior_provenance_blocker"] = True + report["reasons"].append( + "prior-provenance blocker resolved for exact scope; historical cleanup " + "remains unproven; normal merge gates still required" + ) + return report + + @mcp.tool() def gitea_dry_run_pr_review( pr_number: int, @@ -5258,7 +7407,12 @@ def gitea_edit_pr( raise ValueError("At least one field to edit (title, body, state, base) must be provided.") closing = payload.get("state") == "closed" - verify_preflight_purity(remote, task="close_pr" if closing else None) + # Closing uses close_pr; non-closing title/body/base edits use edit_pr so + # the shared #604 anti-stomp inventory stays consistent with wiring. + if closing: + verify_preflight_purity(remote, task="close_pr") + else: + verify_preflight_purity(remote, task="edit_pr") # PR closure is a first-class capability, distinct from retitling or # rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close @@ -5616,7 +7770,11 @@ def gitea_merge_pr( available. Never secrets. """ _verify_role_mutation_workspace( - remote, worktree_path=worktree_path, task="merge_pr" + remote, + worktree_path=worktree_path, + task="merge_pr", + org=org, + repo=repo, ) allowed = get_profile()["allowed_operations"] forbidden = get_profile()["forbidden_operations"] @@ -5752,6 +7910,25 @@ def gitea_merge_pr( reasons.extend(lease_block.get("reasons") or []) result["pr_work_lease"] = lease_block return result + + # #604: common anti-stomp with live head + lease proof (typed blocker). + try: + _run_anti_stomp_preflight( + "merge_pr", + remote=remote, + worktree_path=worktree_path, + org=org, + repo=repo, + expected_head_sha=expected_head_sha, + live_head_sha=actual_sha, + require_head_sha=True, + foreign_lease=False, + terminal_lock_blocks=False, + ) + except RuntimeError as exc: + reasons.append(str(exc)) + return result + if expected_head_sha and actual_sha and expected_head_sha != actual_sha: reasons.append( "expected head SHA does not match current PR head (fail closed)" @@ -5859,6 +8036,137 @@ def gitea_merge_pr( reasons.append(str(e)) return result + # Gate 8b — optional irrecoverable prior-provenance recovery report (#709 F2). + # Never substitutes for approval/lease/mergeability gates above. Surfaces + # truthful distinctions for controllers/mergers. + try: + import irrecoverable_provenance as _irp_merge + + _rec_prof = _irp_merge.recovery_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha or actual_sha or ""), + ) + _auth_prof = _irp_merge.auth_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha or actual_sha or ""), + ) + _recovery = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + remote=remote, + org=o, + repo=r, + profile_identity=_rec_prof, + ) + _auth_art = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + remote=remote, + org=o, + repo=r, + profile_identity=_auth_prof, + ) + if isinstance(_recovery, dict) or isinstance(_auth_art, dict): + _assess = _irp_merge.assess_merger_consumption( + _recovery if isinstance(_recovery, dict) else None, + _auth_art if isinstance(_auth_art, dict) else None, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + live_head_sha=actual_sha, + approval_at_current_head=bool( + feedback.get("approval_at_current_head") + ), + has_blocking_change_requests=bool( + feedback.get("has_blocking_change_requests") + ), + mergeable=result.get("mergeable"), + lease_ok=True, + runtime_ok=True, + workspace_ok=True, + anti_stomp_ok=True, + ) + result["irrecoverable_provenance"] = { + "historical_cleanup_proven": False, + "historical_cleanup_not_proven": True, + "irrecoverable_recovery_authorized": bool( + _assess.get("irrecoverable_recovery_authorized") + ), + "recovery_record_consumed": bool( + _assess.get("recovery_record_consumed") + or ( + isinstance(_recovery, dict) and _recovery.get("consumed_at") + ) + ), + "resolves_prior_provenance_blocker": bool( + _assess.get("resolves_prior_provenance_blocker") + ), + "assessment_reasons": list(_assess.get("reasons") or []), + } + # Consume on successful merge path when allowed and not yet consumed. + if _assess.get("allowed") and isinstance(_recovery, dict) and not _recovery.get( + "consumed_at" + ): + try: + if isinstance(_auth_art, dict) and not _auth_art.get("consumed_at"): + mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + payload=_irp_merge.mark_consumed( + _auth_art, + consumer_username=auth_user, + consumer_profile=result.get("profile_name"), + ), + remote=remote, + org=o, + repo=r, + profile_identity=_auth_prof, + ) + mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + payload={ + **_irp_merge.mark_consumed( + _recovery, + consumer_username=auth_user, + consumer_profile=result.get("profile_name"), + ), + "prior_provenance_blocker_resolved": True, + }, + remote=remote, + org=o, + repo=r, + profile_identity=_rec_prof, + ) + result["irrecoverable_provenance"][ + "recovery_record_consumed" + ] = True + result["irrecoverable_provenance"][ + "consumed_at_merge_preflight" + ] = True + except Exception as _cons_exc: # noqa: BLE001 + result["irrecoverable_provenance"]["consume_error"] = _redact( + str(_cons_exc) + ) + else: + result["irrecoverable_provenance"] = { + "historical_cleanup_proven": False, + "historical_cleanup_not_proven": True, + "irrecoverable_recovery_authorized": False, + "recovery_record_consumed": False, + "resolves_prior_provenance_blocker": False, + "note": "no irrecoverable recovery record for this exact scope", + } + except Exception as _irp_exc: # noqa: BLE001 — never block merge path + result["irrecoverable_provenance"] = { + "error": _redact(str(_irp_exc)), + "historical_cleanup_proven": False, + "historical_cleanup_not_proven": True, + } + # All gates passed — perform the single merge mutation. try: auth = _auth(h) @@ -5906,29 +8214,49 @@ def gitea_merge_pr( reviewer_pr_lease.get_session_lease() ) ) - # Same-profile auto-expire (#594): if *this* profile's decision lock ends - # with an approve of the PR just merged, clear it so durable state cannot - # block later unrelated reviews. Cross-profile locks (e.g. reviewer vs - # merger) still require gitea_cleanup_stale_review_decision_lock. + # #709 AC1/AC3/AC4: reconcile decision locks after irreversible merge. + # Clears the same-profile lock when it holds the approve, and also scans + # other durable profile locks (e.g. prgs-reviewer) so merger-local empty + # init cannot leave the reviewer terminal ledger behind. Failures write a + # recovery-required record (applied=false) — merge is never undone. try: - lock_after = _load_review_decision_lock() - last_term = stale_review_decision_lock.last_terminal_mutation(lock_after) - if ( - last_term - and last_term.get("action") == "approve" - and last_term.get("pr_number") == pr_number - ): - _save_review_decision_lock(None) - result["review_decision_lock_cleared_after_merge"] = True - reasons.append( - f"cleared same-profile review decision lock after merge of " - f"approved PR #{pr_number} (#594)" - ) - else: - result["review_decision_lock_cleared_after_merge"] = False + reconcile = _reconcile_decision_locks_after_merge( + pr_number=pr_number, + head_sha=expected_head_sha, + merge_commit_sha=( + (merged or {}).get("merge_commit_sha") + if isinstance(merged, dict) + else None + ), + remote=remote, + host=h, + org=o, + repo=r, + auth=auth, + ) + result["review_decision_lock_reconcile"] = reconcile + result["review_decision_lock_cleared_after_merge"] = bool( + reconcile.get("cleared_any") + ) + for line in reconcile.get("reason_lines") or []: + reasons.append(line) except Exception as clear_exc: # noqa: BLE001 — never fail the merge result["review_decision_lock_cleared_after_merge"] = False result["review_decision_lock_clear_error"] = _redact(str(clear_exc)) + try: + _record_post_merge_decision_recovery( + pr_number=pr_number, + head_sha=expected_head_sha, + merge_commit_sha=None, + target_profile_identity=None, + failed_step="reconcile_exception", + error=_redact(str(clear_exc)), + remote=remote, + org=o, + repo=r, + ) + except Exception: + pass reasons.append(f"all gates passed; merged PR #{pr_number} via '{do}'") return result @@ -6230,6 +8558,65 @@ def gitea_delete_branch( "permission_report": _permission_block_report("gitea.branch.delete"), } + # Possessing gitea.branch.delete alone is not enough for arbitrary deletion. + # task_capability_map maps delete_branch → author; reconciler must use the + # guarded cleanup_merged_pr_branch path only (#687 / #514). + profile = get_profile() + active_role = _profile_role_kind(profile) + required_role = task_capability_map.required_role("delete_branch") + if active_role == "reconciler": + return { + "success": False, + "performed": False, + "required_permission": "gitea.branch.delete", + "required_role_kind": required_role, + "active_role_kind": active_role, + "reasons": [ + "reconciler profile cannot use raw gitea_delete_branch; " + "use gitea_cleanup_merged_pr_branch for a fully merged PR " + "source branch only (fail closed)" + ], + "exact_next_action": ( + "Call gitea_cleanup_merged_pr_branch with pr_number, the " + "exact PR head branch, and confirmation " + "'CLEANUP MERGED PR BRANCH ' after capability " + "resolve for cleanup_merged_pr_branch." + ), + "permission_report": _permission_block_report("gitea.branch.delete"), + } + if active_role != required_role: + return { + "success": False, + "performed": False, + "required_permission": "gitea.branch.delete", + "required_role_kind": required_role, + "active_role_kind": active_role, + "reasons": [ + f"Active profile role '{active_role}' cannot perform " + f"{required_role} task 'delete_branch' even when " + "gitea.branch.delete is present (fail closed)" + ], + "permission_report": _permission_block_report("gitea.branch.delete"), + } + + if branch_cleanup_guard.is_preservation_or_evidence_branch(branch): + return { + "success": False, + "performed": False, + "required_permission": "gitea.branch.delete", + "reasons": [ + f"branch '{branch}' is a preservation/evidence branch and " + "cannot be deleted (fail closed)" + ], + } + if branch in branch_cleanup_guard.PROTECTED_BRANCHES: + return { + "success": False, + "performed": False, + "required_permission": "gitea.branch.delete", + "reasons": [f"branch '{branch}' is protected (fail closed)"], + } + audit_allowed, audit_reasons = ( audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch") ) @@ -6272,41 +8659,49 @@ 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 = _role_kind( - profile.get("allowed_operations", []), - profile.get("forbidden_operations", []), - ) - if active_role == "reviewer": - return { - "success": False, - "performed": False, - "required_permission": "gitea.branch.delete", - "reasons": [ - "reviewer profile is not authorized for merged branch cleanup " - "(fail closed)" + active_role = _profile_role_kind(profile) + # cleanup_merged_pr_branch is reconciler-owned (task_capability_map). + # Author/reviewer/merger must not reach this path even if they somehow + # hold gitea.branch.delete. + if active_role != "reconciler": + 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, @@ -6324,16 +8719,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 = { @@ -6363,19 +8760,86 @@ 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_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 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", + ) import urllib.parse encoded_branch = urllib.parse.quote(head_branch, safe="") url = f"{base}/branches/{encoded_branch}" + request_metadata = { + "branch": head_branch, + "required_permission": "gitea.branch.delete", + "cleanup_path": "gitea_cleanup_merged_pr_branch", + "ownership_checked": True, + "ownership_blocking_categories": [], + } with _audited( "cleanup_merged_pr_branch", host=h, @@ -6384,36 +8848,416 @@ def gitea_cleanup_merged_pr_branch( repo=r, pr_number=pr_number, target_branch=head_branch, - request_metadata={ - "branch": head_branch, - "required_permission": "gitea.branch.delete", - "cleanup_path": "gitea_cleanup_merged_pr_branch", - }, + request_metadata=request_metadata, ): api_request("DELETE", url, auth) - return { - "success": True, - "performed": True, - "pr_number": pr_number, - "branch": head_branch, - "message": f"Merged PR #{pr_number} source branch '{head_branch}' deleted.", - "assessment": assessment, + + # #687: authoritative post-delete readback. DELETE success alone is not + # 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": verified_absent, + "error_class": (readback_assessment.get("readback") or {}).get( + "error_class" + ), + "not_found_scope": (readback_assessment.get("readback") or {}).get( + "not_found_scope" + ), } + if gitea_audit.audit_enabled(): + _audit( + "cleanup_merged_pr_branch_readback", + host=h, + remote=remote, + org=o, + repo=r, + result=( + gitea_audit.SUCCEEDED + if readback_assessment.get("ok") + else gitea_audit.FAILED + ), + reason="; ".join(readback_assessment.get("reasons") or []) or None, + request_metadata=request_metadata, + pr_number=pr_number, + target_branch=head_branch, + ) + + if not readback_assessment.get("ok"): + 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") + or "post_delete_readback_failed", + message=( + f"DELETE accepted for '{head_branch}' but post-delete readback " + "did not verify absence" + ), + ) + + 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 branch-scoped post-delete readback." + ), + assessment=assessment, + ownership={ + "block": False, + "blocking_categories": [], + "checked": True, + }, + readback=readback_assessment.get("readback"), + ) -def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> bool: +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. + + 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="") url = f"{repo_api_url(h, o, r)}/branches/{encoded}" try: api_request("GET", url, auth) - return True + return branch_cleanup_guard.classify_branch_readback_http_status(200) except Exception as exc: - message = str(exc).lower() - if "404" in message or "not found" in message: - return False - raise + 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 branch 404. + + 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 + and probe.get("verified_absent") + ): + return False + if status == branch_cleanup_guard.READBACK_EXISTS: + return True + error_class = probe.get("error_class") or "unexpected" + raise RuntimeError( + f"remote branch probe failed ({error_class}/{status})" + ) + + +def _collect_branch_ownership_records( + *, + remote: str, + host: str, + org: str, + repo: str, + branch: str, + pr_number: int | None, + project_root: str, + 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": 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: + for path in issue_lock_store.iter_lock_files(): + lock = issue_lock_store.read_lock_file(path) + if not isinstance(lock, dict): + continue + if ( + str(lock.get("remote") or "") != str(remote) + or str(lock.get("org") or "") != str(org) + 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) + reclaim = issue_lock_store.assess_expired_lock_reclaim(lock) + status = str(freshness.get("status") or "unknown") + if freshness.get("live"): + status = "active" + records.append( + _base_rec( + category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, + status=status, + reclaim_allowed=bool(reclaim.get("reclaim_allowed")), + role="author", + ) + ) + if freshness.get("live"): + records.append( + _base_rec( + category=( + branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_SESSION + ), + status="active", + reclaim_allowed=False, + role="author", + ) + ) + except Exception: + inventory_error = True + records.append( + _base_rec( + category=branch_cleanup_guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, + status="unknown", + reclaim_allowed=False, + role="author", + ) + ) + + # --- Control-plane leases (role-tagged) --- + try: + 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"): + try: + db = control_plane_db.ControlPlaneDB() + except Exception: + db = 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, + org=org, + repo=repo, + include_non_active=True, + limit=200, + ) + for lease in listed.get("leases") or []: + work_kind = str(lease.get("work_kind") or "") + work_number = lease.get("work_number") + lease_branch = str( + lease.get("branch") + or lease.get("branch_name") + or "" + ).strip() + matches_branch = lease_branch == target_branch + matches_pr = ( + work_kind == "pr" + and pr_number is not None + and int(work_number or 0) == int(pr_number) + ) + if not (matches_branch or matches_pr): + continue + if matches_pr and not lease_branch: + 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 + ) + freshness_status = str( + (fr.get("freshness") if isinstance(fr, dict) else None) + or lease.get("status") + or "unknown" + ) + role = str(lease.get("role") or "unknown") + category = branch_cleanup_guard.ownership_category_for_role(role) + # O2: expired never auto-receives reclaim_allowed=True. + if freshness_status == "active": + status = "active" + reclaim_allowed = False + elif freshness_status in {"released", "abandoned"}: + status = freshness_status + reclaim_allowed = True + elif freshness_status == "expired" or ( + isinstance(fr, dict) and fr.get("expired_by_time") + ): + status = "expired" + reclaim_allowed = False # O2 fail closed + else: + status = freshness_status + reclaim_allowed = False + records.append( + _base_rec( + category=category, + status=status, + reclaim_allowed=reclaim_allowed, + role=role, + host=lease_host or host_n or host, + ) + ) + except Exception: + # 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: + for entry in worktree_cleanup_audit.list_worktrees(project_root): + wt_branch = str(entry.get("branch") or "").strip() + if wt_branch != target_branch: + continue + records.append( + _base_rec( + category=( + branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING + ), + status="active", + reclaim_allowed=False, + role="worktree", + ) + ) + except Exception: + inventory_error = True + records.append( + _base_rec( + category=branch_cleanup_guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING, + status="unknown", + reclaim_allowed=False, + role="worktree", + ) + ) + + return {"records": records, "inventory_error": inventory_error} + @mcp.tool() @@ -6600,6 +9444,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( @@ -6609,14 +9513,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 [], } ) @@ -8096,7 +11014,17 @@ def gitea_acquire_reviewer_pr_lease( "permission_report": _permission_block_report("gitea.pr.comment"), } - _verify_role_mutation_workspace(remote, worktree=worktree, task="review_pr") + # task=acquire_reviewer_pr_lease so verify_preflight_purity runs shared #604 + # anti-stomp for the declared lease-acquire mutation inventory entry. + # Forward explicit org/repo so anti-stomp does not default bare remote=prgs + # to Timesheet when the local git remote is Gitea-Tools (#604 wrong_repo). + _verify_role_mutation_workspace( + remote, + worktree=worktree, + task="acquire_reviewer_pr_lease", + org=org, + repo=repo, + ) h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) profile = get_profile() @@ -8178,6 +11106,200 @@ def gitea_acquire_reviewer_pr_lease( } +@mcp.tool() +def gitea_acquire_merger_pr_lease( + pr_number: int, + worktree: str, + candidate_head: str | None = None, + target_branch: str = "master", + target_branch_sha: str | None = None, + issue_number: int | None = None, + session_id: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Acquire a per-PR lease for a merger session when no reviewer lease exists (#718). + + Merger sessions should normally adopt an active reviewer lease using + gitea_adopt_merger_pr_lease. However, if no active reviewer lease exists + or it has expired, a merger can acquire one directly using this tool. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "acquired": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + comment_block = _profile_operation_gate("gitea.pr.comment") + if comment_block: + return { + "success": False, + "acquired": False, + "reasons": comment_block, + "permission_report": _permission_block_report("gitea.pr.comment"), + } + merge_block = _profile_operation_gate("gitea.pr.merge") + if merge_block: + return { + "success": False, + "acquired": False, + "reasons": merge_block, + "permission_report": _permission_block_report("gitea.pr.merge"), + } + + head_pin = (candidate_head or "").strip() + if not head_pin: + return { + "success": False, + "acquired": False, + "reasons": [ + "candidate_head is required for merger lease acquisition " + "(exact-head scoping; fail closed)" + ], + } + + try: + _verify_role_mutation_workspace( + remote, + worktree=worktree, + task="acquire_merger_pr_lease", + org=org, + repo=repo, + ) + except RuntimeError as e: + return { + "success": False, + "acquired": False, + "reasons": [str(e)], + } + + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + profile = get_profile() + identity = _authenticated_username(h) or profile.get("username") or "" + sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id() + repo_label = f"{o}/{r}" + + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo) + + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {} + live_head = ( + (pr_live.get("head") or {}).get("sha") + or pr_live.get("head_sha") + or "" + ) + live_head = str(live_head).strip() + # Fail closed when live PR head cannot be resolved — never proceed with + # an unpinned/unknown head even if candidate_head was supplied (#718). + if not live_head: + return { + "success": False, + "acquired": False, + "reasons": [ + "live PR head could not be resolved from GET /pulls response " + "(exact-head scoping; fail closed)" + ], + "live_head": None, + "candidate_head": head_pin, + } + if live_head != head_pin: + return { + "success": False, + "acquired": False, + "reasons": [ + f"candidate_head {head_pin[:12]}… does not match live PR head " + f"{live_head[:12]}… (exact-head scoping; fail closed)" + ], + "live_head": live_head, + "candidate_head": head_pin, + } + + pr_merged_or_closed = bool( + pr_live.get("merged") or pr_live.get("merged_at") + ) or (str(pr_live.get("state") or "").strip().lower() == "closed") + + assessment = reviewer_pr_lease.assess_acquire_lease( + comments, + pr_number=pr_number, + reviewer_identity=identity, + profile=profile.get("profile_name") or "unknown", + session_id=sid, + repo=repo_label, + issue_number=issue_number, + worktree=worktree, + candidate_head=head_pin, + target_branch=target_branch, + target_branch_sha=target_branch_sha, + pr_merged_or_closed=pr_merged_or_closed, + ) + if not assessment.get("acquire_allowed"): + return { + "success": False, + "acquired": False, + "reasons": assessment.get("reasons") or [], + "existing_lease": assessment.get("existing_lease"), + "post_merge_moot": assessment.get("post_merge_moot", False), + } + + body = assessment["lease_body"] + comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + with _audited( + "comment_pr", + host=h, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + request_metadata={"source": "acquire_merger_pr_lease"}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + + if not isinstance(posted, dict) or not posted.get("id"): + return { + "success": False, + "acquired": False, + "reasons": [ + "lease comment POST failed or returned no comment id " + "(no partial session lease recorded)" + ], + } + + session_lease = reviewer_pr_lease.record_session_lease({ + "pr_number": pr_number, + "issue_number": issue_number, + "session_id": sid, + "reviewer_identity": identity, + "profile": profile.get("profile_name"), + "worktree": worktree, + "phase": "claimed", + "candidate_head": head_pin, + "target_branch": target_branch, + "target_branch_sha": target_branch_sha, + "repo": repo_label, + "comment_id": posted.get("id"), + }, lease_provenance=merger_lease_adoption.build_lease_provenance( + source=merger_lease_adoption.SOURCE_ACQUIRE_MERGER, + comment_id=posted.get("id"), + )) + return { + "success": True, + "acquired": True, + "pr_number": pr_number, + "session_id": sid, + "comment_id": posted.get("id"), + "session_lease": session_lease, + "candidate_head": head_pin, + "repo": repo_label, + "reasons": [], + } + + @mcp.tool() def gitea_adopt_merger_pr_lease( pr_number: int, @@ -8237,8 +11359,13 @@ def gitea_adopt_merger_pr_lease( "permission_report": _permission_block_report("gitea.pr.merge"), } + # task=adopt_merger_pr_lease so verify_preflight_purity runs shared #604 anti-stomp. _verify_role_mutation_workspace( - remote, worktree=worktree, task="adopt_merger_pr_lease" + remote, + worktree=worktree, + task="adopt_merger_pr_lease", + org=org, + repo=repo, ) h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) @@ -8567,6 +11694,16 @@ def gitea_diagnose_reviewer_pr_lease_handoff( except Exception: worktree_clean = None + # #702: derive owner-process evidence from the durable session-lease + # shadow left behind by a crashed daemon. Only a provably dead recorded + # owner pid yields False; PID liveness alone never proves ownership. + orphan_evidence = reviewer_pr_lease.assess_crashed_session_lease_orphan( + reviewer_pr_lease.load_session_lease_shadow( + session_id=(active or {}).get("session_id") + ), + lease=active, + ) + diagnosis = reviewer_pr_lease.diagnose_reviewer_pr_lease_handoff( comments, pr_number=pr_number, @@ -8580,8 +11717,9 @@ def gitea_diagnose_reviewer_pr_lease_handoff( formal_reviews=formal_reviews, worktree_exists=worktree_exists, worktree_clean=worktree_clean, - owner_process_alive=None, # PID never proves ownership; leave unset + owner_process_alive=orphan_evidence.get("owner_process_alive"), ) + diagnosis["crashed_session_lease_evidence"] = orphan_evidence diagnosis["success"] = True diagnosis["remote"] = remote diagnosis["authenticated_user"] = username @@ -8811,6 +11949,15 @@ def gitea_cleanup_obsolete_reviewer_comment_lease( worktree_clean = None session = reviewer_pr_lease.get_session_lease() or {} + # #702: when the caller supplies no owner evidence, consult the durable + # session-lease shadow; an explicitly passed value always wins. + if owner_process_alive is None: + owner_process_alive = reviewer_pr_lease.assess_crashed_session_lease_orphan( + reviewer_pr_lease.load_session_lease_shadow( + session_id=(lease or {}).get("session_id") + ), + lease=lease, + ).get("owner_process_alive") assessment = reviewer_pr_lease.assess_obsolete_reviewer_comment_lease_cleanup( comments, pr_number=pr_number, @@ -8940,7 +12087,11 @@ def gitea_release_reviewer_pr_lease( dict reporting release status. """ _verify_role_mutation_workspace( - remote, worktree=worktree, task="review_pr" + remote, + worktree=worktree, + task="review_pr", + org=org, + repo=repo, ) h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) @@ -10709,6 +13860,17 @@ def gitea_get_runtime_context( PROJECT_ROOT), } + # #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE + # binding; recovery itself runs during capability resolution. + try: + stale_binding = _assess_stale_active_binding(auto_recover=False) + if stale_binding.get("classification") != ( + stale_binding_recovery.CLASSIFICATION_UNBOUND + ): + result["stale_binding_recovery"] = stale_binding + except Exception: + pass + parity = _current_master_parity() result["master_parity"] = { "in_parity": parity["in_parity"], @@ -10998,6 +14160,11 @@ def gitea_assess_mcp_namespace_health( probe_source=probe_source, ) _record_live_namespace_health(result) + # #606: namespace-health watchdog check-in (best-effort, fail open). + sentry_observability.monitor_checkin( + "namespace_health", + "ok" if result.get("healthy", result.get("callable", True)) else "error", + ) return result @@ -11051,6 +14218,7 @@ def gitea_activate_profile( # 3. Clear identity cache to force a fresh verification if h: _IDENTITY_CACHE.pop(h, None) + _ACTOR_IDENTITY_CACHE.pop(h, None) # 4. Resolve fresh identity after_profile_data = get_profile() @@ -11494,6 +14662,777 @@ def gitea_assess_conflict_fix_classification( return result +def _count_commits_behind( + base_url: str, + auth: dict, + *, + pr_head_sha: str, + base_head_sha: str, +) -> int | None: + """Return how many base commits are missing from the PR head, if knowable.""" + # compare old...new returns commits reachable from new not from old. + # pr_head...base_head ≈ commits on base not in PR (behind count). + try: + cmp_url = ( + f"{base_url}/compare/{pr_head_sha}...{base_head_sha}" + ) + data = api_request("GET", cmp_url, auth) or {} + total = data.get("total_commits") + if isinstance(total, int) and total >= 0: + return total + commits = data.get("commits") + if isinstance(commits, list): + return len(commits) + except Exception: + pass + return None + + +def _branch_protection_requires_current_base( + base_url: str, + auth: dict, + *, + base_branch: str, +) -> bool | None: + """Read Gitea branch protection ``block_on_outdated_branch`` when present.""" + branch = (base_branch or "").strip() + if not branch: + return None + try: + # Prefer the named protection rule matching the base branch. + protections = api_request( + "GET", f"{base_url}/branch_protections", auth + ) or [] + if isinstance(protections, list): + for rule in protections: + if not isinstance(rule, dict): + continue + name = (rule.get("branch_name") or rule.get("rule_name") or "").strip() + # Exact match or glob-ish contains for common patterns. + if name == branch or name in ("*", f"{branch}"): + if "block_on_outdated_branch" in rule: + return bool(rule.get("block_on_outdated_branch")) + # Fallback: any protection that mentions the base branch. + for rule in protections: + if not isinstance(rule, dict): + continue + name = (rule.get("branch_name") or rule.get("rule_name") or "").strip() + if branch in name or name.endswith(branch): + if "block_on_outdated_branch" in rule: + return bool(rule.get("block_on_outdated_branch")) + # Branch payload may embed effective protection. + br = api_request("GET", f"{base_url}/branches/{branch}", auth) or {} + prot = br.get("protection") or br.get("effective_branch_protection") or {} + if isinstance(prot, dict) and "block_on_outdated_branch" in prot: + return bool(prot.get("block_on_outdated_branch")) + except Exception: + return None + return None + + +def _prove_author_ownership_for_pr( + *, + pr_number: int, + pr_title: str | None, + pr_body: str | None, + source_branch: str | None, + remote: str, + host: str | None, + org: str, + repo: str, + worktree_path: str | None = None, +) -> dict: + """Prove author ownership for PR mutations without requiring issue==pr. + + Issue #727 / PR #728: tracking issues and PR numbers often differ. Ownership + is proven via linked issues (title/body/branch), a live session lock for one + of those issues (or the PR number itself), a durable issue lock for a linked + issue, and optional branch/worktree binding on the lock. Unrelated issue + locks fail closed. + """ + reasons: list[str] = [] + text = f"{pr_title or ''}\n{pr_body or ''}" + linked = list( + extract_linked_issue_numbers(text, branch_name=source_branch) + ) + # Always allow legacy same-number ownership when the lock is for the PR index. + candidates = sorted(set(linked + [int(pr_number)])) + remote_key = remote if remote in REMOTES else (host or "unknown") + matched_issue: int | None = None + matched_via: str | None = None + lock_record: dict | None = None + + # 1) Session lock: active issue work lease for this session. + try: + session = issue_lock_store.read_session_issue_lock() + except Exception: + session = None + if session and issue_lock_store.is_lease_live(session): + try: + sess_issue = int(session.get("issue_number") or 0) + except (TypeError, ValueError): + sess_issue = 0 + if sess_issue in candidates: + # Optional branch binding: when both sides declare a branch, they must match. + lock_branch = str(session.get("branch_name") or "").strip() + src = (source_branch or "").strip() + if lock_branch and src and lock_branch != src: + reasons.append( + f"session lock for issue #{sess_issue} is bound to branch " + f"'{lock_branch}', not PR source branch '{src}' (fail closed)" + ) + else: + wt = (worktree_path or "").strip() + lock_wt = str(session.get("worktree_path") or "").strip() + if wt and lock_wt: + try: + same_wt = os.path.realpath(wt) == os.path.realpath(lock_wt) + except Exception: + same_wt = wt == lock_wt + if not same_wt: + reasons.append( + f"session lock for issue #{sess_issue} is bound to a " + "different worktree (fail closed)" + ) + else: + matched_issue = sess_issue + matched_via = "session_lock" + lock_record = dict(session) + else: + matched_issue = sess_issue + matched_via = "session_lock" + lock_record = dict(session) + elif sess_issue: + reasons.append( + f"session lock issue #{sess_issue} is not linked to PR #{pr_number} " + f"(linked={linked or 'none'}; fail closed)" + ) + + # 2) Durable per-issue locks for each ownership candidate. + if matched_issue is None: + for issue_n in candidates: + try: + durable = issue_lock_store.load_issue_lock( + remote=remote_key, + org=org, + repo=repo, + issue_number=issue_n, + ) + except Exception: + durable = None + if not durable or not issue_lock_store.is_lease_live(durable): + continue + lock_branch = str(durable.get("branch_name") or "").strip() + src = (source_branch or "").strip() + if lock_branch and src and lock_branch != src: + reasons.append( + f"durable lock for issue #{issue_n} is bound to branch " + f"'{lock_branch}', not PR source '{src}'" + ) + continue + matched_issue = issue_n + matched_via = "durable_lock" + lock_record = dict(durable) + break + + # 3) Branch-owned live lock (any issue) matching the PR source branch. + if matched_issue is None and (source_branch or "").strip(): + try: + by_branch = issue_lock_store.find_live_lock_for_branch( + str(source_branch).strip() + ) + except Exception: + by_branch = None + if by_branch: + try: + branch_issue = int(by_branch.get("issue_number") or 0) + except (TypeError, ValueError): + branch_issue = 0 + if branch_issue in candidates: + matched_issue = branch_issue + matched_via = "branch_lock" + lock_record = dict(by_branch) + elif branch_issue: + reasons.append( + f"live branch lock for '{source_branch}' belongs to unrelated " + f"issue #{branch_issue} (not linked to PR #{pr_number}; fail closed)" + ) + + proven = matched_issue is not None and lock_record is not None + if not proven and not reasons: + reasons.append( + "no live author issue lock proven for PR " + f"#{pr_number} via linked issues {linked or '[]'}, session lock, " + "durable lock, or branch lock (fail closed; issue_number need not " + "equal pr_number when the tracking issue is linked)" + ) + return { + "proven": proven, + "has_author_lock": proven, + "linked_issues": linked, + "ownership_candidates": candidates, + "matched_issue": matched_issue, + "matched_via": matched_via, + "lock_record": lock_record, + "reasons": reasons, + } + + +@mcp.tool() +def gitea_assess_pr_sync_status( + pr_number: int, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + prepared_verdict_head_sha: str | None = None, + branch_protection_requires_current_base: bool | None = None, + checks_status: str | None = None, +) -> dict: + """Read-only: assess whether an approved/open PR is merge-ready or needs sync. + + Distinguishes ``merge_now``, ``update_branch_by_merge``, + ``author_conflict_remediation``, ``fresh_review_required``, and ``blocked``. + + Pins exact PR head and live base head. Never mutates. Never returns tokens. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "performed": False, + "recommended_next_action": pr_sync_status.ACTION_BLOCKED, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + + try: + h, o, r = _resolve(remote, host, org, repo) + except Exception as exc: + return { + "success": False, + "performed": False, + "recommended_next_action": pr_sync_status.ACTION_BLOCKED, + "reasons": [f"repository identity resolve failed: {_redact(str(exc))}"], + } + + auth = _auth(h) + if not auth: + return { + "success": False, + "performed": False, + "recommended_next_action": pr_sync_status.ACTION_BLOCKED, + "reasons": ["credentials unavailable (fail closed)"], + "host": h, + "org": o, + "repo": r, + } + + base = repo_api_url(h, o, r) + try: + pr = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {} + except Exception as exc: + return { + "success": False, + "performed": False, + "recommended_next_action": pr_sync_status.ACTION_BLOCKED, + "reasons": [f"PR details could not be retrieved: {_redact(str(exc))}"], + "host": h, + "org": o, + "repo": r, + "pr_number": pr_number, + } + + pr_state = (pr.get("state") or "").strip().lower() or None + head_obj = pr.get("head") or {} + base_obj = pr.get("base") or {} + pr_head_sha = (head_obj.get("sha") or "").strip() or None + source_branch = (head_obj.get("ref") or "").strip() or None + base_branch = (base_obj.get("ref") or "").strip() or "master" + base_head_sha = (base_obj.get("sha") or "").strip() or None + mergeable = pr.get("mergeable") + # Gitea sometimes exposes conflict via mergeable=false only. + has_conflicts = False if mergeable is True else (True if mergeable is False else None) + + # Prefer live base branch tip over the PR's stored base SHA (may lag). + try: + br = api_request("GET", f"{base}/branches/{base_branch}", auth) or {} + live_base = ((br.get("commit") or {}).get("id") or "").strip() + if live_base: + base_head_sha = live_base + except Exception: + pass + + commits_behind = None + if pr_head_sha and base_head_sha: + commits_behind = _count_commits_behind( + base, auth, pr_head_sha=pr_head_sha, base_head_sha=base_head_sha + ) + if commits_behind is None: + # Fail closed on unknown behind-count only when SHAs differ. + commits_behind = 0 if pr_head_sha == base_head_sha else None + + if branch_protection_requires_current_base is None: + branch_protection_requires_current_base = ( + _branch_protection_requires_current_base( + base, auth, base_branch=base_branch + ) + ) + # When protection cannot be read, fail closed by requiring current base + # whenever the PR is behind (safer for protected repos). Callers may pass + # an explicit False when policy is known not to require it. + if branch_protection_requires_current_base is None: + if commits_behind is not None and commits_behind > 0: + branch_protection_requires_current_base = True + else: + branch_protection_requires_current_base = False + + approval_at_current_head = None + try: + feedback = gitea_get_pr_review_feedback( + pr_number=pr_number, remote=remote, host=host, org=org, repo=repo, + ) + if feedback.get("success"): + approval_at_current_head = bool(feedback.get("approval_at_current_head")) + else: + approval_at_current_head = False + except Exception: + approval_at_current_head = None + + # Locks / leases (best-effort; absence is reported as None/False). + # Ownership uses linked tracking issue / branch / session lock — not only + # issue_number == pr_number (#727 / PR #728). + active_author_lock = None + try: + ownership = _prove_author_ownership_for_pr( + pr_number=pr_number, + pr_title=pr.get("title"), + pr_body=pr.get("body"), + source_branch=source_branch, + remote=remote, + host=host, + org=o, + repo=r, + worktree_path=None, + ) + active_author_lock = bool(ownership.get("has_author_lock")) + except Exception: + active_author_lock = None + + active_reviewer_lease = None + active_merger_lease = None + try: + comments = api_request( + "GET", f"{base}/issues/{pr_number}/comments", auth + ) or [] + if isinstance(comments, list): + rev = pr_work_lease.find_active_reviewer_lease( + comments, pr_number=pr_number + ) + active_reviewer_lease = bool(rev) + # Merger lease comments share reviewer_pr_lease session store when present. + try: + sess = reviewer_pr_lease.get_session_lease() or {} + active_merger_lease = bool( + sess.get("active") + and int(sess.get("pr_number") or 0) == int(pr_number) + and (sess.get("phase") or "").startswith("merger") + ) + except Exception: + active_merger_lease = False + except Exception: + pass + + if checks_status is None: + checks_status = "unknown" + # Combined status on PR head when Actions/status API is available. + if pr_head_sha: + try: + st = api_request( + "GET", + f"{base}/commits/{pr_head_sha}/status", + auth, + ) or {} + state = (st.get("state") or "").strip().lower() + if state: + checks_status = state + elif not st: + checks_status = "none" + except Exception: + checks_status = "unknown" + + assessment = pr_sync_status.assess_pr_sync_status( + host=h, + org=o, + repo=r, + pr_number=pr_number, + pr_state=pr_state, + source_branch=source_branch, + pr_head_sha=pr_head_sha, + base_head_sha=base_head_sha, + commits_behind=commits_behind, + mergeable=mergeable, + has_conflicts=has_conflicts, + branch_protection_requires_current_base=branch_protection_requires_current_base, + approval_at_current_head=approval_at_current_head, + checks_status=checks_status, + active_author_lock=active_author_lock, + active_reviewer_lease=active_reviewer_lease, + active_merger_lease=active_merger_lease, + prepared_verdict_head_sha=prepared_verdict_head_sha, + ) + assessment["remote"] = remote if remote in REMOTES else None + assessment["base_branch"] = base_branch + assessment["success"] = True + assessment["performed"] = False + return assessment + + +@mcp.tool() +def gitea_update_pr_branch_by_merge( + pr_number: int, + expected_pr_head_sha: str, + expected_base_head_sha: str, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + worktree_path: str | None = None, +) -> dict: + """Author-only: merge live base into the PR branch (Gitea Update-by-merge). + + Pins both expected PR head and expected base head; fails closed on either + race. Never rebases or force-pushes. On conflicts, returns a structured + author-remediation handoff without partial remote mutation. + + Requires author profile, existing issue/PR lock, PR worktree under + branches/, clean control checkout, and master parity. + """ + profile = get_profile() + allowed = profile.get("allowed_operations") or [] + forbidden = profile.get("forbidden_operations") or [] + role = _role_kind(allowed, forbidden) + + # Permission: author branch push / PR mutation surface. + push_block = _profile_operation_gate("gitea.branch.push") + if push_block: + return { + "success": False, + "performed": False, + "mutation_allowed": False, + "reasons": push_block, + "permission_report": _permission_block_report("gitea.branch.push"), + "role_kind": role, + } + + if role != "author": + pre = pr_sync_status.assess_update_pr_branch_preflight( + role_kind=role, + expected_pr_head_sha=expected_pr_head_sha, + live_pr_head_sha=expected_pr_head_sha, + expected_base_head_sha=expected_base_head_sha, + live_base_head_sha=expected_base_head_sha, + style="merge", + ) + pre["success"] = False + return pre + + try: + h, o, r = _resolve(remote, host, org, repo) + except Exception as exc: + return { + "success": False, + "performed": False, + "mutation_allowed": False, + "reasons": [f"repository identity resolve failed: {_redact(str(exc))}"], + "role_kind": role, + } + + # Workspace / control / parity gates. + try: + _verify_role_mutation_workspace( + remote, worktree_path=worktree_path, task="push_branch" + ) + except Exception as exc: + return { + "success": False, + "performed": False, + "mutation_allowed": False, + "reasons": [f"workspace gate failed: {_redact(str(exc))}"], + "role_kind": role, + "host": h, + "org": o, + "repo": r, + "pr_number": pr_number, + } + + control_clean = True + try: + porcelain = _get_workspace_porcelain(PROJECT_ROOT) + # Untracked-only dirt is ignored; tracked edits contaminate control. + tracked_dirty = [ + line for line in (porcelain or "").splitlines() + if line.strip() and not line.startswith("??") + ] + control_clean = not bool(tracked_dirty) + except Exception: + control_clean = False + + master_ok = True + try: + stale = _master_parity_block("gitea.branch.push") + master_ok = not bool(stale) + except Exception: + master_ok = False + + wt = (worktree_path or "").strip() or ( + os.environ.get("GITEA_AUTHOR_WORKTREE") + or os.environ.get("GITEA_ACTIVE_WORKTREE") + or "" + ).strip() + + auth = _auth(h) + if not auth: + return { + "success": False, + "performed": False, + "mutation_allowed": False, + "reasons": ["credentials unavailable (fail closed)"], + "role_kind": role, + "host": h, + "org": o, + "repo": r, + "pr_number": pr_number, + } + + base = repo_api_url(h, o, r) + try: + pr = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {} + except Exception as exc: + return { + "success": False, + "performed": False, + "mutation_allowed": False, + "reasons": [f"PR details could not be retrieved: {_redact(str(exc))}"], + "role_kind": role, + "host": h, + "org": o, + "repo": r, + "pr_number": pr_number, + } + + if (pr.get("state") or "").strip().lower() != "open": + return { + "success": False, + "performed": False, + "mutation_allowed": False, + "reasons": [ + f"PR is not open (state={pr.get('state')}); refuse update" + ], + "role_kind": role, + "host": h, + "org": o, + "repo": r, + "pr_number": pr_number, + } + + head_obj = pr.get("head") or {} + base_obj = pr.get("base") or {} + live_pr_head = (head_obj.get("sha") or "").strip() or None + source_branch = (head_obj.get("ref") or "").strip() or None + base_branch = (base_obj.get("ref") or "").strip() or "master" + live_base_head = (base_obj.get("sha") or "").strip() or None + try: + br = api_request("GET", f"{base}/branches/{base_branch}", auth) or {} + tip = ((br.get("commit") or {}).get("id") or "").strip() + if tip: + live_base_head = tip + except Exception: + pass + + mergeable = pr.get("mergeable") + has_conflicts = False if mergeable is True else (True if mergeable is False else None) + + owns_branch = True + if wt and source_branch: + try: + # Best-effort: worktree branch should match PR source branch. + import subprocess as _sp + out = _sp.check_output( + ["git", "-C", wt, "rev-parse", "--abbrev-ref", "HEAD"], + text=True, + stderr=_sp.DEVNULL, + ).strip() + if out and out != source_branch: + owns_branch = False + except Exception: + owns_branch = None # unknown — do not hard-fail solely on probe + + # Prove ownership via linked issue lock / session / branch — not issue==pr. + ownership = _prove_author_ownership_for_pr( + pr_number=pr_number, + pr_title=pr.get("title"), + pr_body=pr.get("body"), + source_branch=source_branch, + remote=remote, + host=host, + org=o, + repo=r, + worktree_path=wt or None, + ) + has_lock = bool(ownership.get("has_author_lock")) + + preflight = pr_sync_status.assess_update_pr_branch_preflight( + role_kind=role, + expected_pr_head_sha=expected_pr_head_sha, + live_pr_head_sha=live_pr_head, + expected_base_head_sha=expected_base_head_sha, + live_base_head_sha=live_base_head, + has_conflicts=has_conflicts, + mergeable=mergeable, + style="merge", + has_author_lock=has_lock, + worktree_path=wt or None, + worktree_owns_source_branch=owns_branch, + control_checkout_clean=control_clean, + master_parity_ok=master_ok, + force_push=False, + rebase=False, + ) + if not has_lock and ownership.get("reasons"): + # Surface ownership diagnostics beyond the generic preflight line. + preflight.setdefault("reasons", []) + for reason in ownership["reasons"]: + if reason not in preflight["reasons"]: + preflight["reasons"].append(reason) + preflight["ownership"] = { + "linked_issues": ownership.get("linked_issues"), + "matched_issue": ownership.get("matched_issue"), + "matched_via": ownership.get("matched_via"), + } + preflight["host"] = h + preflight["org"] = o + preflight["repo"] = r + preflight["pr_number"] = pr_number + preflight["source_branch"] = source_branch + preflight["base_branch"] = base_branch + preflight["worktree_path"] = wt or None + preflight["profile_name"] = profile.get("profile_name") + + if not preflight.get("mutation_allowed"): + preflight["success"] = False + preflight["performed"] = False + if preflight.get("author_remediation_handoff"): + preflight["recommended_next_action"] = ( + pr_sync_status.ACTION_AUTHOR_CONFLICT_REMEDIATION + ) + return preflight + + # Native Gitea update: POST .../pulls/{index}/update?style=merge + update_url = f"{base}/pulls/{pr_number}/update?style=merge" + try: + api_request("POST", update_url, auth) + except Exception as exc: + msg = _redact(str(exc)).lower() + conflictish = any( + token in msg + for token in ("conflict", "409", "merge conflict", "not mergeable") + ) + if conflictish: + return { + "success": False, + "performed": False, + "mutation_allowed": False, + "author_remediation_handoff": True, + "recommended_next_action": ( + pr_sync_status.ACTION_AUTHOR_CONFLICT_REMEDIATION + ), + "reasons": [ + "Gitea refused update-by-merge due to conflicts; " + "no partial remote update applied", + _redact(str(exc)), + ], + "host": h, + "org": o, + "repo": r, + "pr_number": pr_number, + "expected_pr_head_sha": expected_pr_head_sha, + "expected_base_head_sha": expected_base_head_sha, + "live_pr_head_sha_before": live_pr_head, + "style": "merge", + "role_kind": role, + } + return { + "success": False, + "performed": False, + "mutation_allowed": False, + "reasons": [ + f"update-by-merge API failed (fail closed): {_redact(str(exc))}" + ], + "host": h, + "org": o, + "repo": r, + "pr_number": pr_number, + "style": "merge", + "role_kind": role, + } + + # Re-read new head after successful update. + new_head = None + try: + pr_after = api_request("GET", f"{base}/pulls/{pr_number}", auth) or {} + new_head = ((pr_after.get("head") or {}).get("sha") or "").strip() or None + mergeable_after = pr_after.get("mergeable") + except Exception: + pr_after = {} + mergeable_after = None + + transition = pr_sync_status.assess_post_update_head_transition( + former_pr_head_sha=live_pr_head, + new_pr_head_sha=new_head, + former_approval_head_sha=live_pr_head, + prepared_verdict_head_sha=live_pr_head, + ) + + return { + "success": True, + "performed": True, + "mutation_allowed": True, + "style": "merge", + "force_push": False, + "rebase": False, + "host": h, + "org": o, + "repo": r, + "pr_number": pr_number, + "source_branch": source_branch, + "base_branch": base_branch, + "former_pr_head_sha": live_pr_head, + "new_pr_head_sha": new_head, + "base_head_sha": live_base_head, + "mergeable_after": mergeable_after, + "approval_invalidated_for_former_head": transition.get( + "approval_invalidated", True + ), + "reviewer_lease_superseded": transition.get("reviewer_lease_superseded"), + "merger_lease_superseded": transition.get("merger_lease_superseded"), + "prepared_verdict_invalidated": transition.get( + "prepared_verdict_invalidated" + ), + "recommended_next_action": transition.get( + "recommended_next_action", + pr_sync_status.ACTION_FRESH_REVIEW_REQUIRED, + ), + "transition": transition, + "role_kind": role, + "profile_name": profile.get("profile_name"), + "worktree_path": wt or None, + "reasons": list(transition.get("reasons") or []) + [ + "update-by-merge completed via native Gitea API (style=merge only)" + ], + } + + @mcp.tool() def gitea_assess_conflict_fix_push( pr_number: int, @@ -12012,36 +15951,18 @@ def gitea_route_task_session( ) -_restart_triggered = False - - -def _trigger_mcp_auto_restart(): - global _restart_triggered - if _restart_triggered or _preflight_in_test_mode(): - return - _restart_triggered = True - - config_path = os.environ.get( - "MCP_CONFIG_PATH", - os.path.expanduser("~/.gemini/config/mcp_config.json") - ) - - try: - if os.path.exists(config_path): - os.utime(config_path, None) - except Exception: - pass - - import threading - import time - def delayed_exit(): - time.sleep(1.0) - os._exit(0) - threading.Thread(target=delayed_exit, daemon=True).start() +# #685: resolver stale-runtime detection is report-only. Config touch / os._exit +# self-recovery was removed from the read-only path (was _trigger_mcp_auto_restart). +# Recovery is owned exclusively by the IDE/client reconnect path. def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]: - """Check running runtimes and return errors if they are missing or stale.""" + """Read-only: report missing or stale MCP runtimes (no config or process mutation). + + #685: Never touches MCP client config, never spawns recovery threads, never + calls ``os._exit``. Stale detection remains fail-closed via returned reasons + only; the IDE/client owns reconnect/reload. + """ import subprocess import re from datetime import datetime @@ -12121,11 +16042,13 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> } if self_stale: - _trigger_mcp_auto_restart() + # #685: report-only — no config utime, no thread, no os._exit. reasons.append( - "stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). " - "Auto-restart has been triggered: touched mcp_config.json to reload the daemon. " - "The current process will cleanly exit shortly." + "stale-runtime: The active Gitea MCP server process is stale " + "(running code from before changes were merged). " + "Reconnect the IDE/client-managed MCP namespace for this profile " + "so it reloads current master. The resolver does not touch " + "mcp_config.json, spawn recovery threads, or terminate this process." ) if matching_profiles: @@ -12157,10 +16080,15 @@ def gitea_resolve_task_capability( remote: str = "dadeschools", host: str | None = None, ) -> dict: - """Read-only: Resolve which capability, profile, and namespace is required for a Gitea task. + """Read-only / side-effect free: resolve capability, profile, and namespace for a task. - Helps the client or LLM determine the correct namespace or profile before acting, - and returns exact next action instructions if the current session is not authorized. + Does **not** mutate MCP client configuration, spawn recovery threads, kill + processes, or trigger daemon reloads (#685). Stale-runtime detection remains + fail-closed: when the serving process is stale the result includes + ``blocker_kind=runtime_reconnect_required``, ``restart_required=true``, + ``stop_required=true``, and ``mutation_performed=false`` with a precise + ``exact_safe_next_action`` pointing at IDE/client reconnect. Recovery is + owned by the client reconnect path — never by this resolver. Args: task: The task/action to check (e.g. review_pr, create_issue). @@ -12170,11 +16098,56 @@ def gitea_resolve_task_capability( TASK_MAP = task_capability_map.TASK_CAPABILITY_MAP if task not in TASK_MAP: - raise ValueError(f"Unknown task/action: '{task}' (fail closed)") + # #723: structured fail-closed unknown_task (never raise into internal_error). + profile = get_profile() + h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None) + username = _authenticated_username(h) if h else None + active_role_kind = _profile_role_kind(profile) + switching = gitea_config.is_runtime_switching_enabled() + result = { + "requested_task": task, + "required_operation_permission": "unknown", + "required_role_kind": "unknown", + "active_profile": profile.get("profile_name", "unknown"), + "active_identity": username, + "active_role_kind": active_role_kind, + "active_profile_allowed_operations": profile.get("allowed_operations") or [], + "active_profile_permission_allowed": False, + "allowed_in_current_session": False, + "available_in_session": False, + "configured": False, + "restart_required": False, + "stop_required": True, + "task_role_guidance": [ + f"STOP: Unknown task/action: '{task}' (fail closed)" + ], + "matching_configured_profile": [], + "runtime_switching_supported": switching, + "different_mcp_namespace_required": False, + "exact_safe_next_action": ( + f"None; task '{task}' is unrecognized by the capability map." + ), + "reason": f"Unknown task/action: '{task}' (fail closed)", + "reason_code": "unknown_task", + "mutation_performed": False, + } + role_session_router.sync_route_from_capability(result) + was_terminal = capability_stop_terminal.is_active() + terminal = capability_stop_terminal.sync_from_capability_result(result) + if terminal: + result["terminal_mode"] = True + result["terminal_report"] = ( + capability_stop_terminal.build_terminal_report(result) + ) + elif was_terminal: + result["cleared_stale_denial"] = True + return result required_permission = task_capability_map.required_permission(task) required_role = task_capability_map.required_role(task) role_exclusive_tasks = { + "acquire_reviewer_pr_lease", + "gitea_acquire_reviewer_pr_lease", "review_pr", "approve_pr", "request_changes_pr", @@ -12182,13 +16155,21 @@ def gitea_resolve_task_capability( "pr_queue_cleanup", "pr-queue-cleanup", "merge_pr", + "acquire_merger_pr_lease", + "gitea_acquire_merger_pr_lease", + "adopt_merger_pr_lease", + "gitea_adopt_merger_pr_lease", "create_branch", "push_branch", "create_pr", "commit_files", "gitea_commit_files", "address_pr_change_requests", + "update_pr_branch_by_merge", + "gitea_update_pr_branch_by_merge", "delete_branch", + "cleanup_merged_pr_branch", + "reconciliation_cleanup", "work_issue", "work-issue", } @@ -12228,6 +16209,18 @@ def gitea_resolve_task_capability( "exact_safe_next_action": next_safe_action, } + # #702 F1 + #685: assess inherited GITEA_ACTIVE_WORKTREE binding BEFORE the + # terminal launcher probe so missing-cwd wedges can be diagnosed. On the + # read-only capability-resolve path recovery is report-only + # (auto_recover=False): never clear env, write session-state, or otherwise + # mutate during gitea_resolve_task_capability. Sanctioned recovery remains + # available to mutation entrypoints that call _assess_stale_active_binding + # with auto_recover=True. + try: + stale_binding = _assess_stale_active_binding(auto_recover=False) + except Exception: + stale_binding = None + # ── Terminal Launcher Preflight Check (#556) ── if task in native_mcp_preference.GITEA_MUTATION_TASKS: in_test = _preflight_in_test_mode() @@ -12251,7 +16244,7 @@ def gitea_resolve_task_capability( "Do not attempt git/pytest finalization or unsafe fallbacks. " "Run gitea_diagnose_terminal, emit blocked-diagnose-report, and stop." ) - return { + blocked_result = { "requested_task": task, "required_operation_permission": required_permission, "required_role_kind": required_role, @@ -12271,6 +16264,11 @@ def gitea_resolve_task_capability( "different_mcp_namespace_required": False, "exact_safe_next_action": next_safe_action, } + if stale_binding is not None and stale_binding.get( + "classification" + ) != stale_binding_recovery.CLASSIFICATION_UNBOUND: + blocked_result["stale_binding_recovery"] = stale_binding + return blocked_result record_preflight_check("capability", required_role, resolved_task=task) @@ -12359,16 +16357,14 @@ def gitea_resolve_task_capability( configured = len(matching_profiles) > 0 available_in_session = allowed_in_current_session + runtime_stale_blocker = False if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ: runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles) if runtime_reasons: restart_required = True + runtime_stale_blocker = True reason_msg = "; ".join(runtime_reasons) - next_safe_action = ( - "stale-runtime: Gitea MCP runtime conflict or missing process detected. " - "Please fully restart the Gitea MCP server and retry." - ) if not allowed_in_current_session: deny_parts: list[str] = [] @@ -12391,18 +16387,21 @@ def gitea_resolve_task_capability( # Same-remote profile exists but is not the active one — explicit switch only. restart_required = False available_in_session = False - reason_msg = ( - f"Active profile cannot perform '{required_permission}'. " - f"Same-remote matching profiles (advisory only, not activated): " - f"{matching_profiles}." - ) + if not reason_msg: + reason_msg = ( + f"Active profile cannot perform '{required_permission}'. " + f"Same-remote matching profiles (advisory only, not activated): " + f"{matching_profiles}." + ) elif not configured: - reason_msg = ( - f"No same-remote profile configured with permission " - f"'{required_permission}' for remote '{remote}'." - ) + if not reason_msg: + reason_msg = ( + f"No same-remote profile configured with permission " + f"'{required_permission}' for remote '{remote}'." + ) elif role_mismatch_reason: - reason_msg = role_mismatch_reason + if not reason_msg: + reason_msg = role_mismatch_reason different_namespace_required = False next_safe_action = "None; ready for operations." @@ -12441,6 +16440,16 @@ def gitea_resolve_task_capability( "or use the corresponding MCP namespace." ) + # #685: stale-runtime typed remediation wins for exact_next_action when the + # serving process/profile inventory is stale — even if permission is OK. + if runtime_stale_blocker: + next_safe_action = ( + "blocker_kind=runtime_reconnect_required: reconnect/restart the " + "IDE-managed Gitea MCP server for this profile so it reloads current " + "master. Do not edit mcp_config.json by hand; the resolver does not " + "touch config, spawn recovery threads, or terminate the process." + ) + # Task/role alignment guards (#167): the requested task, not the # available credential, decides what the session may do. A review/merge # task under a non-reviewer profile must stop — not silently degrade @@ -12502,6 +16511,8 @@ def gitea_resolve_task_capability( "configured": configured, "restart_required": restart_required, "stop_required": stop_required or restart_required, + # #685: resolver is always side-effect free; never claims mutations. + "mutation_performed": False, "task_role_guidance": task_role_guidance, "matching_configured_profile": matching_profiles, "runtime_switching_supported": switching, @@ -12515,6 +16526,14 @@ def gitea_resolve_task_capability( "identity_match": not identity_block, "auto_profile_substitution": False, } + # #685: report typed reconnect blocker without mutating config or exiting. + if runtime_stale_blocker: + result["blocker_kind"] = "runtime_reconnect_required" + # #702: optional stale-binding recovery guidance (read-only annotation). + if stale_binding is not None and stale_binding.get("classification") != ( + stale_binding_recovery.CLASSIFICATION_UNBOUND + ): + result["stale_binding_recovery"] = stale_binding if reason_msg: result["reason"] = reason_msg if task in ("review_pr", "merge_pr"): @@ -13072,6 +17091,18 @@ def gitea_allocate_next_work( result["inventory_source"] = ( "candidates_json" if candidates_json else "gitea_live" ) + # #606: watchdog check-ins for the recurring jobs this allocator run + # performs — global stale-lease expiry, terminal-lock lookup, and the + # allocator itself. Best-effort; a failed selection reports "error". + _alloc_ok = bool(result.get("success")) + sentry_observability.monitor_checkin( + "allocator_health", "ok" if _alloc_ok else "error" + ) + if _alloc_ok: + # These two scans complete inside allocate_next_work before selection; + # a successful result proves both ran. + sentry_observability.monitor_checkin("stale_lease_scan", "ok") + sentry_observability.monitor_checkin("terminal_lock_scan", "ok") return result @@ -13633,4 +17664,17 @@ if __name__ == "__main__": # processes (e.g. review_pr.py) can detect and refuse profile # side-channel overrides (#199). _export_session_profile_lock() + # #702: an auto-reconnected daemon inherits GITEA_ACTIVE_WORKTREE from its + # parent environment. Validate it at boot and clear it only when provably + # stale (missing path); anything less certain is surfaced, not cleared. + try: + _assess_stale_active_binding(auto_recover=True) + except Exception: + pass + # #606: optional self-hosted Sentry observability. No-op unless + # MCP_SENTRY_ENABLED is truthy and SENTRY_DSN is set; never blocks startup. + _sentry_status = sentry_observability.init_sentry() + sys.stderr.write( + f"--- Sentry observability: {_sentry_status.get('reason')} ---\n" + ) mcp.run(transport="stdio") diff --git a/irrecoverable_provenance.py b/irrecoverable_provenance.py new file mode 100644 index 0000000..7d5ca76 --- /dev/null +++ b/irrecoverable_provenance.py @@ -0,0 +1,1722 @@ +"""Server-side irrecoverable decision-lock provenance authorization (#709 AC5). + +Authorization is **not** a caller-supplied Boolean. A durable, non-forgeable +authorization artifact must be minted under production native MCP transport +(or pytest) with a dedicated mutation capability, live head binding, and +validated incident evidence. Merger consumption is fail-closed and resolves +only the historical-provenance blocker — never normal approval, lease, +mergeability, anti-stomp, or workspace gates. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import os +import re +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +import mcp_daemon_guard +import mcp_session_state +from stale_review_decision_lock import heads_equal, normalize_head_sha + +# Dedicated mutation capability (#709 review 434 F1). +CAPABILITY_IRRECOVERABLE_RECOVERY = "gitea.decision_lock.irrecoverable_recovery" + +KIND_AUTH = "irrecoverable_provenance_authorization" +KIND_RECOVERY = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE + +CONFIRMATION_PREFIX = "IRRECOVERABLE DECISION PROVENANCE PR" +# Canonical incident-comment marker (#709 review 435 F5). +INCIDENT_MARKER = "IRRECOVERABLE DECISION PROVENANCE INCIDENT" + +# #709 F7 (review 438): strictly canonical incident evidence. The digest binds +# every security-relevant field, so evidence cannot be replayed or substituted +# across repositories, PRs, decision locks, heads, actors, or recovery actions. +INCIDENT_SCHEMA_VERSION = "2" +RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE = "irrecoverable_decision_lock_provenance" +SUPPORTED_RECOVERY_ACTIONS = frozenset({RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE}) +INCIDENT_FIELD_ORDER = ( + "schema_version", + "remote", + "org", + "repo", + "pr_number", + "decision_lock_id", + "destroyed_subject", + "recovery_action", + "recorded_head_sha", + "expected_head_sha", + "incident_issue", + "evidence_author_id", + "evidence_author_login", + "mint_actor_id", + "mint_actor_login", + "key_version", + "nonce", + "issued_at", +) +INCIDENT_DIGEST_FIELD = "content_digest" +AUTH_TTL_HOURS = 24.0 +RECORD_TYPE = "irrecoverable_decision_provenance" +AUTH_TYPE = "irrecoverable_provenance_authorization" + +# Durable HMAC key origin (#709 review 435 F4). Never generate an ephemeral +# production key — mint/verify across reconciler/merger processes and restarts +# requires a shared secret from env (or pytest constant / explicit env override). +ENV_AUTH_HMAC_KEY = "GITEA_IRRECOVERABLE_AUTH_HMAC_KEY" +ENV_AUTH_HMAC_KEY_VERSION = "GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION" +DEFAULT_KEY_VERSION = "v1" +_PYTEST_AUTH_SECRET = b"pytest-irrecoverable-auth-v1" +_PYTEST_KEY_VERSION = "pytest-v1" + +# #709 F6 (review 438): key version is part of the authenticated data and must +# be present exactly once, well-formed, and equal to the configured active +# version. There is deliberately no legacy fallback for versionless artifacts. +KEY_VERSION_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") +KEY_VERSION_FIELD = "key_version" +# Any of these aliases anywhere in the artifact counts as a key-version field; +# more than one occurrence is a duplicate and fails closed even when the values +# are identical. +_KEY_VERSION_ALIASES = ( + "key_version", + "keyVersion", + "key-version", + "auth_key_version", +) + +_PROCESS_AUTH_SECRET: bytes | None = None +_PROCESS_AUTH_KEY_VERSION: str | None = None +_PROCESS_AUTH_SECRET_SOURCE: str | None = None + + +class AuthSecretError(RuntimeError): + """Raised when the durable HMAC signing key cannot be resolved (fail closed).""" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _now_iso() -> str: + return _now().isoformat() + + +def _decode_key_material(raw: str) -> bytes: + """Decode operator-supplied key material (hex, base64, or utf-8 literal).""" + text = (raw or "").strip() + if not text: + raise AuthSecretError("empty HMAC key material (fail closed, #709 F4)") + # Prefer hex (64 chars = 32 bytes). + if len(text) >= 32 and all(c in "0123456789abcdefABCDEF" for c in text): + try: + if len(text) % 2 == 0: + decoded = bytes.fromhex(text) + if len(decoded) >= 16: + return decoded + except ValueError: + pass + # base64 + try: + import base64 + + decoded = base64.b64decode(text, validate=True) + if len(decoded) >= 16: + return decoded + except Exception: + pass + # utf-8 literal (min 16 chars after strip) + encoded = text.encode("utf-8") + if len(encoded) < 16: + raise AuthSecretError( + "HMAC key material too short (need >=16 bytes; fail closed, #709 F4)" + ) + return encoded + + +def reset_process_auth_secret_for_tests() -> None: + """Clear cached HMAC key (tests only).""" + global _PROCESS_AUTH_SECRET, _PROCESS_AUTH_KEY_VERSION, _PROCESS_AUTH_SECRET_SOURCE + _PROCESS_AUTH_SECRET = None + _PROCESS_AUTH_KEY_VERSION = None + _PROCESS_AUTH_SECRET_SOURCE = None + + +def _validate_key_version_text(value: Any, *, source: str) -> tuple[str, list[str]]: + """Validate a key-version string. Never echoes key material — versions only.""" + if isinstance(value, bool) or not isinstance(value, str): + return "", [ + f"{source} key version is malformed (must be a string; fail closed, #709 F6)" + ] + text = value.strip() + if not text: + return "", [f"{source} key version is empty (fail closed, #709 F6)"] + if value != text: + return "", [ + f"{source} key version has surrounding whitespace (fail closed, #709 F6)" + ] + if not KEY_VERSION_RE.match(text): + return "", [ + f"{source} key version is malformed (allowed charset A-Za-z0-9._-, " + "1-64 chars; fail closed, #709 F6)" + ] + return text, [] + + +def auth_key_version() -> str: + """Return the active configured signing-key version (never secret material).""" + _process_secret() # ensure version is resolved with the secret + version = _PROCESS_AUTH_KEY_VERSION or "" + if not version: + raise AuthSecretError( + "active HMAC key version is unresolved (fail closed, #709 F6 review 438)" + ) + return version + + +def supported_key_versions() -> tuple[str, ...]: + """Versions accepted for verification. + + Exactly one version — the configured active one — is honored. Rotation is + performed by changing ``GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION`` (and the + key); artifacts minted under a superseded version stop verifying, which is + the intended fail-closed rotation behavior (#709 F6 review 438). + """ + return (auth_key_version(),) + + +def extract_artifact_key_version(auth: dict[str, Any] | None) -> dict[str, Any]: + """Require exactly one nonempty, well-formed key-version field (#709 F6). + + Missing, empty, malformed, or duplicated (even identical-valued) key-version + fields fail closed. Versionless artifacts are never accepted — there is no + legacy fallback. + """ + if not isinstance(auth, dict): + return { + "valid": False, + "key_version": None, + "reasons": ["authorization artifact missing (fail closed)"], + } + + containers: list[tuple[str, dict[str, Any]]] = [("artifact", auth)] + for nested in ("native_provenance", "scope"): + value = auth.get(nested) + if isinstance(value, dict): + containers.append((nested, value)) + + seen: list[tuple[str, Any]] = [] + for container_name, container in containers: + for alias in _KEY_VERSION_ALIASES: + if alias in container: + seen.append((f"{container_name}.{alias}", container[alias])) + + if not seen: + return { + "valid": False, + "key_version": None, + "reasons": [ + "authorization is missing key_version; versionless artifacts are " + "never accepted (no legacy fallback; fail closed, #709 F6 review 438)" + ], + } + if len(seen) > 1: + names = ", ".join(sorted(name for name, _ in seen)) + return { + "valid": False, + "key_version": None, + "reasons": [ + f"authorization carries duplicate key-version fields ({names}); " + "exactly one is required, even when the values are identical " + "(fail closed, #709 F6 review 438)" + ], + } + + name, raw = seen[0] + text, reasons = _validate_key_version_text(raw, source=f"authorization {name}") + if reasons: + return {"valid": False, "key_version": None, "reasons": reasons} + return {"valid": True, "key_version": text, "reasons": []} + + +def assess_artifact_key_version( + auth: dict[str, Any] | None, + *, + expected_version: str | None = None, +) -> dict[str, Any]: + """Artifact key version must exactly equal the configured active version.""" + extracted = extract_artifact_key_version(auth) + if not extracted.get("valid"): + return extracted + have = str(extracted.get("key_version") or "") + try: + want = (expected_version or "").strip() or auth_key_version() + except AuthSecretError as exc: + return { + "valid": False, + "key_version": None, + "reasons": [ + f"configured HMAC key version unavailable: {exc} " + "(fail closed, #709 F6)" + ], + } + if have != want: + return { + "valid": False, + "key_version": None, + "reasons": [ + f"authorization key_version {have!r} is unknown or does not match " + f"the configured expected version {want!r} (fail closed, #709 F6 " + "review 438)" + ], + } + return {"valid": True, "key_version": have, "reasons": []} + + +def _process_secret() -> bytes: + """Resolve durable HMAC key for irrecoverable auth artifacts (#709 F4). + + Production (non-pytest): **requires** ``GITEA_IRRECOVERABLE_AUTH_HMAC_KEY``. + Silently generating an ephemeral per-process key is forbidden — that made + mint+verify fail across merger/reconciler processes and restarts. + + Pytest: uses a fixed constant unless the env key is set (so cross-process + regression tests can inject a shared durable key). + """ + global _PROCESS_AUTH_SECRET, _PROCESS_AUTH_KEY_VERSION, _PROCESS_AUTH_SECRET_SOURCE + if _PROCESS_AUTH_SECRET is not None: + return _PROCESS_AUTH_SECRET + + env_raw = (os.environ.get(ENV_AUTH_HMAC_KEY) or "").strip() + env_ver = (os.environ.get(ENV_AUTH_HMAC_KEY_VERSION) or "").strip() + pytest = mcp_daemon_guard.is_pytest_runtime() + + if env_raw: + secret = _decode_key_material(env_raw) + if not env_ver and not pytest: + # #709 F6 (review 438): production must configure the key version + # explicitly; silently defaulting it makes rotation ambiguous. + raise AuthSecretError( + f"{ENV_AUTH_HMAC_KEY_VERSION} is required for production " + "irrecoverable auth HMAC; an implicit default key version is " + "forbidden (fail closed, #709 F6 review 438)" + ) + version, version_reasons = _validate_key_version_text( + env_ver or (_PYTEST_KEY_VERSION if pytest else ""), + source="configured", + ) + if version_reasons: + raise AuthSecretError("; ".join(version_reasons)) + _PROCESS_AUTH_SECRET = secret + _PROCESS_AUTH_KEY_VERSION = version + _PROCESS_AUTH_SECRET_SOURCE = "env" + return _PROCESS_AUTH_SECRET + + if pytest: + version, version_reasons = _validate_key_version_text( + env_ver or _PYTEST_KEY_VERSION, source="configured" + ) + if version_reasons: + raise AuthSecretError("; ".join(version_reasons)) + _PROCESS_AUTH_SECRET = _PYTEST_AUTH_SECRET + _PROCESS_AUTH_KEY_VERSION = version + _PROCESS_AUTH_SECRET_SOURCE = "pytest_constant" + return _PROCESS_AUTH_SECRET + + # Production fail-closed: do NOT call secrets.token_bytes. + raise AuthSecretError( + f"{ENV_AUTH_HMAC_KEY} is required for production irrecoverable auth HMAC; " + "ephemeral per-process key generation is forbidden (fail closed, #709 F4 " + "review 435). Configure a durable shared secret for mint+verify across " + "processes and restarts." + ) + + +def expected_confirmation(pr_number: int) -> str: + """Human intent confirmation text (not an authorization credential).""" + return f"{CONFIRMATION_PREFIX} {int(pr_number)}" + + +def auth_state_profile_identity( + *, + remote: str, + org: str, + repo: str, + pr_number: int, + expected_head_sha: str, +) -> str: + """Stable durable key segment for one exact-scope authorization.""" + head = normalize_head_sha(expected_head_sha) or "nohead" + segs = [ + KIND_AUTH, + mcp_session_state._sanitize_segment(remote), + mcp_session_state._sanitize_segment(org), + mcp_session_state._sanitize_segment(repo), + f"pr{int(pr_number)}", + head[:16], + ] + return "-".join(segs) + + +def recovery_state_profile_identity( + *, + remote: str, + org: str, + repo: str, + pr_number: int, + expected_head_sha: str, +) -> str: + head = normalize_head_sha(expected_head_sha) or "nohead" + segs = [ + KIND_RECOVERY, + mcp_session_state._sanitize_segment(remote), + mcp_session_state._sanitize_segment(org), + mcp_session_state._sanitize_segment(repo), + f"pr{int(pr_number)}", + head[:16], + ] + return "-".join(segs) + + +def _scope_payload( + *, + remote: str, + org: str, + repo: str, + pr_number: int, + expected_head_sha: str, + incident_issue: int, + incident_comment_id: int, + destroyed_subject: str | None, + issuer_username: str, + issuer_profile: str, + created_at: str, + expires_at: str, + authorization_id: str, +) -> dict[str, Any]: + return { + "authorization_id": authorization_id, + "remote": remote, + "org": org, + "repo": repo, + "blocked_pr_number": int(pr_number), + "expected_head_sha": normalize_head_sha(expected_head_sha), + "incident_issue": int(incident_issue), + "incident_comment_id": int(incident_comment_id), + "destroyed_subject": (destroyed_subject or "").strip() or None, + "issuer_username": issuer_username, + "issuer_profile": issuer_profile, + "created_at": created_at, + "expires_at": expires_at, + } + + +def _sign_scope( + scope: dict[str, Any], + native_provenance: dict[str, Any], + *, + key_version: str | None = None, +) -> str: + """HMAC over key_version + canonical scope + native transport fingerprint. + + The signing key itself is never serialized. Key *version* is bound into the + MAC so operators can rotate durable keys without ambiguous verification. + """ + material = { + "key_version": (key_version or auth_key_version()), + "scope": scope, + "native": { + "native_mcp_transport": bool( + native_provenance.get("native_mcp_transport") + ), + "production_native_mcp_transport": bool( + native_provenance.get("production_native_mcp_transport") + ), + "token_fingerprint": native_provenance.get("token_fingerprint"), + "entrypoint": native_provenance.get("entrypoint"), + "pid": native_provenance.get("pid"), + }, + } + blob = json.dumps(material, sort_keys=True, separators=(",", ":")).encode( + "utf-8" + ) + return hmac.new(_process_secret(), blob, hashlib.sha256).hexdigest() + + +def assess_capability_for_irrecoverable_recovery( + *, + allowed_operations: list[str] | None, + forbidden_operations: list[str] | None = None, + role_kind: str | None = None, + profile_name: str | None = None, +) -> dict[str, Any]: + """Whether the active profile may mint/use irrecoverable recovery (#709 F5). + + **Dedicated capability only.** Reconciler role / ``gitea.issue.comment`` + equivalence is intentionally rejected so a reconciler cannot self-mint + recovery authority by authoring its own incident comment (#709 review 435 + F5). Bare ``gitea.read`` is never sufficient. + """ + import gitea_config + + allowed = list(allowed_operations or []) + forbidden = list(forbidden_operations or []) + reasons: list[str] = [] + + dedicated_ok, _ = gitea_config.check_operation( + CAPABILITY_IRRECOVERABLE_RECOVERY, allowed, forbidden + ) + if dedicated_ok: + return { + "allowed": True, + "capability": CAPABILITY_IRRECOVERABLE_RECOVERY, + "via": "dedicated_capability", + "reasons": [], + } + + role = (role_kind or "").strip().lower() + name = (profile_name or "").strip().lower() + role_hint = role or name or "unknown" + reasons.append( + f"missing dedicated capability {CAPABILITY_IRRECOVERABLE_RECOVERY} " + f"(profile={role_hint!r}; reconciler/issue.comment equivalence is " + "not accepted — dedicated grant required, #709 F5 review 435; " + "gitea.read alone is insufficient)" + ) + return { + "allowed": False, + "capability": CAPABILITY_IRRECOVERABLE_RECOVERY, + "via": None, + "reasons": reasons, + } + + +def assess_transport_for_auth_mint() -> dict[str, Any]: + """Native transport required for minting non-forgeable auth artifacts.""" + reasons: list[str] = [] + native = mcp_daemon_guard.is_native_mcp_transport() + pytest = mcp_daemon_guard.is_pytest_runtime() + production = mcp_daemon_guard.is_production_native_mcp_transport() + if not native and not pytest: + reasons.append( + "irrecoverable provenance authorization requires production native " + "MCP transport; ordinary Python processes cannot mint acceptable " + "recovery authorization (#709 F1)" + ) + return { + "allowed": not reasons, + "native_mcp_transport": native, + "production_native_mcp_transport": production, + "pytest": pytest, + "reasons": reasons, + } + + +def incident_actor_identity(payload: dict[str, Any] | None) -> dict[str, Any]: + """Resolve one stable actor identity from a comment payload (#709 F7). + + Uses the immutable numeric user id as the primary identity and requires the + login to be internally consistent. Multiple or conflicting ids/logins (e.g. + ``user.login`` disagreeing with ``user.username``) fail closed rather than + silently preferring one string (review 438 F7). + """ + reasons: list[str] = [] + if not isinstance(payload, dict): + return { + "valid": False, + "user_id": None, + "login": None, + "reasons": ["actor payload missing (fail closed, #709 F7)"], + } + + ids: set[int] = set() + logins: set[str] = set() + + def _add_id(value: Any) -> None: + if isinstance(value, bool) or value is None: + return + if isinstance(value, int): + ids.add(int(value)) + elif isinstance(value, str) and value.strip().lstrip("-").isdigit(): + ids.add(int(value.strip())) + + def _add_login(value: Any) -> None: + if isinstance(value, str) and value.strip(): + logins.add(value.strip()) + + user = payload.get("user") + if isinstance(user, dict): + _add_id(user.get("id")) + _add_id(user.get("user_id")) + _add_login(user.get("login")) + _add_login(user.get("username")) + elif isinstance(user, str): + _add_login(user) + for key in ("login", "author", "username"): + _add_login(payload.get(key)) + _add_id(payload.get("user_id")) + + if len(logins) > 1: + reasons.append( + "incident comment author has conflicting logins " + f"({sorted(logins)!r}); a single stable identity is required " + "(fail closed, #709 F7 review 438)" + ) + if len(ids) > 1: + reasons.append( + "incident comment author has conflicting user ids " + f"({sorted(ids)!r}); a single stable identity is required " + "(fail closed, #709 F7 review 438)" + ) + if not ids: + reasons.append( + "incident comment author is missing a stable user id; " + "display-name-only identity is not accepted " + "(fail closed, #709 F7 review 438)" + ) + if not logins: + reasons.append( + "incident comment author login is missing (fail closed, #709 F7)" + ) + + return { + "valid": not reasons, + "user_id": next(iter(ids)) if len(ids) == 1 else None, + "login": next(iter(logins)) if len(logins) == 1 else None, + "reasons": reasons, + } + + +def canonical_incident_fields( + *, + remote: str, + org: str, + repo: str, + pr_number: int, + decision_lock_id: str, + destroyed_subject: str, + recovery_action: str, + recorded_head_sha: str, + expected_head_sha: str, + incident_issue: int, + evidence_author_id: int, + evidence_author_login: str, + mint_actor_id: int, + mint_actor_login: str, + key_version: str, + nonce: str, + issued_at: str, +) -> dict[str, str]: + """Ordered fields that form the authoritative incident content digest. + + Every security-relevant element of the recovery scope is bound here so the + digest cannot be replayed across repositories, PRs, decision locks, heads, + actors, or recovery actions (#709 F7 review 438). + """ + action = str(recovery_action or "").strip() + if action not in SUPPORTED_RECOVERY_ACTIONS: + raise ValueError( + f"unsupported recovery_action {action!r}; supported=" + f"{sorted(SUPPORTED_RECOVERY_ACTIONS)!r} (fail closed, #709 F7)" + ) + fields = { + "schema_version": INCIDENT_SCHEMA_VERSION, + "remote": str(remote or ""), + "org": str(org or ""), + "repo": str(repo or ""), + "pr_number": str(int(pr_number)), + "decision_lock_id": str(decision_lock_id or ""), + "destroyed_subject": str(destroyed_subject or ""), + "recovery_action": action, + "recorded_head_sha": normalize_head_sha(recorded_head_sha) or "", + "expected_head_sha": normalize_head_sha(expected_head_sha) or "", + "incident_issue": str(int(incident_issue)), + "evidence_author_id": str(int(evidence_author_id)), + "evidence_author_login": str(evidence_author_login or ""), + "mint_actor_id": str(int(mint_actor_id)), + "mint_actor_login": str(mint_actor_login or ""), + "key_version": str(key_version or ""), + "nonce": str(nonce or ""), + "issued_at": str(issued_at or ""), + } + for name in INCIDENT_FIELD_ORDER: + value = fields[name] + if not value.strip(): + raise ValueError( + f"canonical incident field {name!r} is empty (fail closed, #709 F7)" + ) + if value != value.strip(): + raise ValueError( + f"canonical incident field {name!r} has surrounding whitespace " + "(fail closed, #709 F7)" + ) + if "\n" in value or "\r" in value: + raise ValueError( + f"canonical incident field {name!r} contains a newline; ambiguous " + "evidence cannot be built (fail closed, #709 F7)" + ) + return fields + + +def incident_content_digest(fields: dict[str, str]) -> str: + """SHA-256 hex digest over the canonical field lines (fixed order, no sort).""" + lines = [INCIDENT_MARKER] + for name in INCIDENT_FIELD_ORDER: + if name not in fields: + raise ValueError( + f"canonical incident field {name!r} missing for digest " + "(fail closed, #709 F7)" + ) + lines.append(f"{name}={fields[name]}") + blob = "\n".join(lines).encode("utf-8") + return hashlib.sha256(blob).hexdigest() + + +def render_canonical_incident_block(fields: dict[str, str]) -> str: + """Render the one accepted canonical representation of *fields*.""" + lines = [INCIDENT_MARKER] + lines.extend(f"{name}: {fields[name]}" for name in INCIDENT_FIELD_ORDER) + lines.append(f"{INCIDENT_DIGEST_FIELD}: {incident_content_digest(fields)}") + return "\n".join(lines) + + +def _narrative_is_ambiguous(narrative: str) -> list[str]: + """Reject narrative text that could be mistaken for canonical evidence.""" + reasons: list[str] = [] + known = set(INCIDENT_FIELD_ORDER) | {INCIDENT_DIGEST_FIELD} + for line in narrative.split("\n"): + text = line.strip() + if text == INCIDENT_MARKER: + reasons.append( + "narrative repeats the canonical marker (ambiguous evidence)" + ) + continue + if ":" in text and text.split(":", 1)[0].strip() in known: + reasons.append( + f"narrative contains canonical field line {text.split(':', 1)[0]!r} " + "(ambiguous evidence)" + ) + return reasons + + +def build_canonical_incident_body( + *, + remote: str, + org: str, + repo: str, + pr_number: int, + decision_lock_id: str, + destroyed_subject: str, + recovery_action: str, + recorded_head_sha: str, + expected_head_sha: str, + incident_issue: int, + evidence_author_id: int, + evidence_author_login: str, + mint_actor_id: int, + mint_actor_login: str, + key_version: str, + nonce: str, + issued_at: str, + narrative: str | None = None, +) -> str: + """Build the one accepted canonical incident comment body (#709 F7). + + The builder is the single source of the accepted format and refuses to emit + anything ambiguous: empty/multiline fields, unsupported recovery actions, and + narrative text that mimics canonical evidence all raise ``ValueError``. Its + own output is re-parsed before return, so a body this function produces + always validates as canonical. + """ + fields = canonical_incident_fields( + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + decision_lock_id=decision_lock_id, + destroyed_subject=destroyed_subject, + recovery_action=recovery_action, + recorded_head_sha=recorded_head_sha, + expected_head_sha=expected_head_sha, + incident_issue=incident_issue, + evidence_author_id=evidence_author_id, + evidence_author_login=evidence_author_login, + mint_actor_id=mint_actor_id, + mint_actor_login=mint_actor_login, + key_version=key_version, + nonce=nonce, + issued_at=issued_at, + ) + body = render_canonical_incident_block(fields) + if narrative and narrative.strip(): + text = narrative.strip() + ambiguous = _narrative_is_ambiguous(text) + if ambiguous: + raise ValueError( + "; ".join(ambiguous) + " (fail closed, #709 F7 review 438)" + ) + body = f"{body}\n\n{text}" + check = parse_canonical_incident_body(body) + if not check.get("valid"): + raise ValueError( + "builder produced a non-canonical body: " + + "; ".join(check.get("reasons") or ["unknown"]) + ) + return body + + +def parse_canonical_incident_body(body: str | None) -> dict[str, Any]: + """Strictly parse the canonical incident block (#709 F7 review 438). + + Enforces the exact schema: marker on the first line, every field present + exactly once, in fixed canonical order, with no unknown, duplicate, + conflicting, or empty fields anywhere in the body. + """ + reasons: list[str] = [] + text = (body or "").replace("\r\n", "\n").replace("\r", "\n").strip("\n") + if not text.strip(): + return { + "valid": False, + "reasons": ["incident comment body is empty (fail closed)"], + "fields": {}, + } + + lines = text.split("\n") + if lines[0] != INCIDENT_MARKER: + return { + "valid": False, + "reasons": [ + f"incident body line 1 must be exactly {INCIDENT_MARKER!r} " + "(canonical marker position; fail closed, #709 F7 review 438)" + ], + "fields": {}, + } + + order = list(INCIDENT_FIELD_ORDER) + [INCIDENT_DIGEST_FIELD] + block_len = 1 + len(order) + if len(lines) < block_len: + return { + "valid": False, + "reasons": [ + "incident body canonical block is incomplete; every field is " + "required exactly once in canonical order (fail closed, #709 F7)" + ], + "fields": {}, + } + + fields: dict[str, str] = {} + for index, name in enumerate(order, start=1): + line = lines[index] + prefix = f"{name}: " + if not line.startswith(prefix): + return { + "valid": False, + "reasons": [ + f"incident body line {index + 1} must be field {name!r} in " + "canonical order (reordered, duplicated, missing, or unknown " + "field; fail closed, #709 F7 review 438)" + ], + "fields": {}, + } + value = line[len(prefix) :] + if not value.strip(): + reasons.append( + f"incident field {name!r} is empty (fail closed, #709 F7)" + ) + elif value != value.strip(): + reasons.append( + f"incident field {name!r} has surrounding whitespace " + "(fail closed, #709 F7)" + ) + fields[name] = value + + # Nothing outside the canonical block may restate the marker or any field. + known = set(order) + tail = lines[block_len:] + if tail and tail[0].strip(): + reasons.append( + "narrative must be separated from the canonical block by a blank " + "line (fail closed, #709 F7)" + ) + for line in tail: + stripped = line.strip() + if stripped == INCIDENT_MARKER: + reasons.append( + "incident body repeats the canonical marker outside the signed " + "block (ambiguous evidence; fail closed, #709 F7 review 438)" + ) + continue + if ":" in stripped and stripped.split(":", 1)[0].strip() in known: + duplicated = stripped.split(":", 1)[0].strip() + reasons.append( + f"incident body restates canonical field {duplicated!r} outside " + "the signed block (duplicate/conflicting; fail closed, #709 F7)" + ) + + return { + "valid": not reasons, + "reasons": reasons, + "fields": fields, + "canonical_block": "\n".join(lines[:block_len]), + } + + +def assess_incident_evidence( + *, + incident_issue: int | None, + incident_comment_id: int | None, + comment_payload: dict[str, Any] | None, + comment_lookup_error: str | None = None, + expected_remote: str | None = None, + expected_org: str | None = None, + expected_repo: str | None = None, + expected_pr_number: int | None = None, + expected_head_sha: str | None = None, + expected_recorded_head_sha: str | None = None, + expected_decision_lock_id: str | None = None, + expected_recovery_action: str | None = None, + expected_key_version: str | None = None, + mint_actor_id: int | None = None, + mint_actor_username: str | None = None, + reject_self_authored: bool = True, +) -> dict[str, Any]: + """Validate strictly canonical, fully scoped incident evidence (#709 F7). + + The body must be exactly the one canonical representation produced by + :func:`build_canonical_incident_body`: marker first, every field present + once in fixed order, no duplicates/unknowns/conflicts, digest binding the + complete recovery scope, and a single stable actor identity independent of + the minting actor. Reordered, duplicated, conflicting, replayed, or + substituted evidence fails closed (review 438 F7). + """ + reasons: list[str] = [] + if incident_issue is None or int(incident_issue) <= 0: + reasons.append("incident_issue is required and must be a positive integer") + if incident_comment_id is None or int(incident_comment_id) <= 0: + reasons.append( + "incident_comment_id is required and must be a positive integer" + ) + if comment_lookup_error: + reasons.append( + f"incident evidence lookup failed: {comment_lookup_error} (fail closed)" + ) + if not isinstance(comment_payload, dict): + if not comment_lookup_error: + reasons.append( + "incident evidence not found or not a comment object (fail closed)" + ) + return {"valid": False, "reasons": reasons, "comment": None} + + cid = comment_payload.get("id") + try: + if int(cid) != int(incident_comment_id): # type: ignore[arg-type] + reasons.append( + "incident comment id mismatch against live payload (fail closed)" + ) + except (TypeError, ValueError): + reasons.append("incident comment payload missing valid id (fail closed)") + + # Edited evidence is not authoritative (#709 F5 review 435). + created_at = str(comment_payload.get("created_at") or "").strip() + updated_at = str(comment_payload.get("updated_at") or "").strip() + if updated_at and created_at and updated_at != created_at: + reasons.append( + "incident comment has been edited after creation; edited evidence is " + "not authoritative (fail closed, #709 F5 review 435)" + ) + + actor = incident_actor_identity(comment_payload) + if not actor.get("valid"): + reasons.extend(actor.get("reasons") or []) + + parsed = parse_canonical_incident_body(comment_payload.get("body")) + fields = parsed.get("fields") or {} + if not parsed.get("valid"): + reasons.extend(parsed.get("reasons") or []) + else: + # Every field is bound to the exact live recovery scope. + def _match(name: str, expected: Any, *, label: str | None = None) -> None: + if expected is None or str(expected).strip() == "": + return + have = fields.get(name, "") + if have != str(expected).strip(): + reasons.append( + f"incident body {label or name} does not match the live " + f"recovery scope (fail closed, #709 F7 review 438)" + ) + + if fields.get("schema_version") != INCIDENT_SCHEMA_VERSION: + reasons.append( + f"incident schema_version must be {INCIDENT_SCHEMA_VERSION!r} " + "(fail closed, #709 F7)" + ) + _match("remote", expected_remote) + _match("org", expected_org) + _match("repo", expected_repo) + _match("decision_lock_id", expected_decision_lock_id) + _match("recovery_action", expected_recovery_action) + _match("key_version", expected_key_version) + + if fields.get("recovery_action") not in SUPPORTED_RECOVERY_ACTIONS: + reasons.append( + f"incident recovery_action {fields.get('recovery_action')!r} is " + "not a supported recovery action (fail closed, #709 F7)" + ) + + if expected_pr_number is not None: + try: + if int(fields.get("pr_number", "")) != int(expected_pr_number): + reasons.append( + "incident body pr_number does not match mint PR " + "(fail closed, #709 F7)" + ) + except (TypeError, ValueError): + reasons.append("incident body pr_number invalid (fail closed)") + + try: + if int(fields.get("incident_issue", "")) != int(incident_issue): # type: ignore[arg-type] + reasons.append( + "incident body incident_issue does not match provided " + "incident_issue (fail closed, #709 F7)" + ) + except (TypeError, ValueError): + reasons.append("incident body incident_issue invalid (fail closed)") + + if expected_head_sha and not heads_equal( + fields.get("expected_head_sha"), expected_head_sha + ): + reasons.append( + "incident body expected_head_sha does not match the live mint " + "head (fail closed, #709 F7)" + ) + if expected_recorded_head_sha and not heads_equal( + fields.get("recorded_head_sha"), expected_recorded_head_sha + ): + reasons.append( + "incident body recorded_head_sha does not match the recorded " + "decision-lock head (fail closed, #709 F7 review 438)" + ) + + # Evidence author must be the actual live comment author. + if actor.get("valid"): + try: + if int(fields.get("evidence_author_id", "")) != int( + actor.get("user_id") + ): + reasons.append( + "incident body evidence_author_id does not match the live " + "comment author (fail closed, #709 F7 review 438)" + ) + except (TypeError, ValueError): + reasons.append( + "incident body evidence_author_id invalid (fail closed)" + ) + if fields.get("evidence_author_login") != str(actor.get("login") or ""): + reasons.append( + "incident body evidence_author_login does not match the live " + "comment author (fail closed, #709 F7 review 438)" + ) + + # Minting actor must be the live caller, and must not be the author. + if mint_actor_id is not None: + try: + if int(fields.get("mint_actor_id", "")) != int(mint_actor_id): + reasons.append( + "incident body mint_actor_id does not match the minting " + "actor (fail closed, #709 F7 review 438)" + ) + except (TypeError, ValueError): + reasons.append("incident body mint_actor_id invalid (fail closed)") + if mint_actor_username and fields.get("mint_actor_login") != str( + mint_actor_username + ).strip(): + reasons.append( + "incident body mint_actor_login does not match the minting actor " + "(fail closed, #709 F7 review 438)" + ) + + # Digest binds the complete canonical content. + try: + expected_digest = incident_content_digest(fields) + if not hmac.compare_digest( + expected_digest.lower(), + str(fields.get(INCIDENT_DIGEST_FIELD, "")).strip().lower(), + ): + reasons.append( + "incident content_digest mismatch (forged, substituted, or " + "incomplete canonical body; fail closed, #709 F7)" + ) + except (TypeError, ValueError) as exc: + reasons.append( + f"incident content_digest recompute failed: {exc} (fail closed)" + ) + + # Reconstruct the exact canonical representation and require equality. + try: + rebuilt = render_canonical_incident_block(fields) + if rebuilt != parsed.get("canonical_block"): + reasons.append( + "incident body is not the exact canonical representation " + "(fail closed, #709 F7 review 438)" + ) + except (TypeError, ValueError) as exc: + reasons.append( + f"incident canonical reconstruction failed: {exc} (fail closed)" + ) + + # Independent-author requirement (#709 F5 review 435), by stable identity. + if reject_self_authored and actor.get("valid"): + if mint_actor_id is not None and int(actor.get("user_id") or -1) == int( + mint_actor_id + ): + reasons.append( + "incident comment author is the minting actor; self-authored " + "incident evidence is not accepted (fail closed, #709 F5 review 435)" + ) + elif ( + mint_actor_username + and str(actor.get("login") or "").strip().lower() + == str(mint_actor_username).strip().lower() + ): + reasons.append( + "incident comment author is the minting actor; self-authored " + "incident evidence is not accepted (fail closed, #709 F5 review 435)" + ) + + # Hard scope check when URLs are present. + issue_url = str( + comment_payload.get("issue_url") + or comment_payload.get("html_url") + or "" + ) + if expected_org and issue_url and f"/{expected_org}/" not in issue_url: + if expected_repo and f"/{expected_repo}/" not in issue_url: + reasons.append( + "incident evidence URL does not match expected repository " + "(fail closed)" + ) + + return { + "valid": not reasons, + "reasons": reasons, + "fields": fields, + "comment": { + "id": comment_payload.get("id"), + "author": actor.get("login"), + "author_id": actor.get("user_id"), + "created_at": comment_payload.get("created_at"), + "canonical": bool(parsed.get("valid")), + }, + } + + +def assess_live_head_binding( + *, + expected_head_sha: str | None, + live_head_sha: str | None, + pr_lookup_error: str | None = None, + pr_state: str | None = None, +) -> dict[str, Any]: + """expected_head_sha mandatory and must equal live PR head.""" + reasons: list[str] = [] + want = normalize_head_sha(expected_head_sha) + have = normalize_head_sha(live_head_sha) + if not want: + reasons.append( + "expected_head_sha is mandatory and must be a non-empty SHA " + "(fail closed, #709 F1)" + ) + if pr_lookup_error: + reasons.append(f"live PR head lookup failed: {pr_lookup_error} (fail closed)") + if want and not have: + reasons.append("live PR head SHA unavailable (fail closed)") + if want and have and not heads_equal(want, have): + reasons.append( + "expected_head_sha does not equal live PR head " + f"(expected={want[:12]}… live={have[:12]}…; fail closed, #709 F1)" + ) + return { + "valid": not reasons, + "expected_head_sha": want, + "live_head_sha": have, + "pr_state": pr_state, + "reasons": reasons, + } + + +def build_authorization_artifact( + *, + remote: str, + org: str, + repo: str, + pr_number: int, + expected_head_sha: str, + incident_issue: int, + incident_comment_id: int, + destroyed_subject: str | None, + issuer_username: str, + issuer_profile: str, + native_provenance: dict[str, Any] | None = None, + ttl_hours: float = AUTH_TTL_HOURS, +) -> dict[str, Any]: + """Build a server-side authorization artifact (caller cannot forge signature).""" + provenance = dict( + native_provenance or mcp_daemon_guard.mutation_provenance_fields() + ) + created = _now() + expires = created + timedelta(hours=float(ttl_hours)) + authorization_id = str(uuid.uuid4()) + scope = _scope_payload( + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + expected_head_sha=expected_head_sha, + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + destroyed_subject=destroyed_subject, + issuer_username=issuer_username, + issuer_profile=issuer_profile, + created_at=created.isoformat(), + expires_at=expires.isoformat(), + authorization_id=authorization_id, + ) + try: + kv = auth_key_version() + signature = _sign_scope(scope, provenance, key_version=kv) + except AuthSecretError as exc: + # Surface fail-closed mint: caller must not get a forgeable blank sig. + raise AuthSecretError(str(exc)) from exc + return { + "kind": KIND_AUTH, + "auth_type": AUTH_TYPE, + "record_type": AUTH_TYPE, + "status": "issued", + "consumption_state": "issued", + "consumed_at": None, + "recovery_critical": True, + "issue_ref": "#709", + "server_signature": signature, + "key_version": kv, + # Never serialize the secret; only the version id. + "native_provenance": provenance, + **scope, + "timestamp": created.isoformat(), + "recorded_at": created.isoformat(), + "updated_at": created.isoformat(), + } + + +def verify_authorization_artifact( + auth: dict[str, Any] | None, + *, + remote: str, + org: str, + repo: str, + pr_number: int, + expected_head_sha: str, + incident_issue: int | None = None, + incident_comment_id: int | None = None, + require_unconsumed: bool = True, + now: datetime | None = None, +) -> dict[str, Any]: + """Fail-closed verification of a server-side authorization artifact.""" + reasons: list[str] = [] + if not isinstance(auth, dict): + return { + "valid": False, + "reasons": ["authorization artifact missing (fail closed)"], + } + if (auth.get("kind") or auth.get("auth_type") or "") not in ( + KIND_AUTH, + AUTH_TYPE, + ) and auth.get("record_type") != AUTH_TYPE: + if (auth.get("kind") or "") != KIND_AUTH: + reasons.append( + f"authorization kind mismatch (expected {KIND_AUTH!r}; fail closed)" + ) + + for field, want in ( + ("remote", remote), + ("org", org), + ("repo", repo), + ): + have = (str(auth.get(field) or "")).strip() + if not have or have != (want or "").strip(): + reasons.append( + f"authorization {field} mismatch " + f"(stored={have!r}, expected={want!r}; fail closed)" + ) + + try: + if int(auth.get("blocked_pr_number")) != int(pr_number): + reasons.append("authorization PR number mismatch (fail closed)") + except (TypeError, ValueError): + reasons.append("authorization missing blocked_pr_number (fail closed)") + + if not heads_equal(auth.get("expected_head_sha"), expected_head_sha): + reasons.append("authorization head SHA mismatch (fail closed)") + + if incident_issue is not None: + try: + if int(auth.get("incident_issue")) != int(incident_issue): + reasons.append("authorization incident_issue mismatch (fail closed)") + except (TypeError, ValueError): + reasons.append("authorization missing incident_issue (fail closed)") + if incident_comment_id is not None: + try: + if int(auth.get("incident_comment_id")) != int(incident_comment_id): + reasons.append( + "authorization incident_comment_id mismatch (fail closed)" + ) + except (TypeError, ValueError): + reasons.append("authorization missing incident_comment_id (fail closed)") + + if not (auth.get("issuer_username") or "").strip(): + reasons.append("authorization missing issuer_username (fail closed)") + if not (auth.get("issuer_profile") or "").strip(): + reasons.append("authorization missing issuer_profile (fail closed)") + if not (auth.get("server_signature") or "").strip(): + reasons.append("authorization missing server_signature (fail closed)") + + # #709 F6 (review 438): validate key version *before* any MAC work so an + # attacker-chosen version can never select the signing key. Missing, empty, + # malformed, duplicated, unknown, and mismatched versions all fail closed. + key_version_gate = assess_artifact_key_version(auth) + verified_key_version = ( + key_version_gate.get("key_version") if key_version_gate.get("valid") else None + ) + if not key_version_gate.get("valid"): + reasons.extend( + key_version_gate.get("reasons") + or ["authorization key_version invalid (fail closed, #709 F6)"] + ) + + # Recompute signature over stored scope fields + verified key_version. + if verified_key_version is None: + reasons.append( + "authorization server_signature not verified: key version failed " + "validation (fail closed, #709 F6 review 438)" + ) + else: + try: + scope = _scope_payload( + remote=str(auth.get("remote") or ""), + org=str(auth.get("org") or ""), + repo=str(auth.get("repo") or ""), + pr_number=int(auth.get("blocked_pr_number")), + expected_head_sha=str(auth.get("expected_head_sha") or ""), + incident_issue=int(auth.get("incident_issue")), + incident_comment_id=int(auth.get("incident_comment_id")), + destroyed_subject=auth.get("destroyed_subject"), + issuer_username=str(auth.get("issuer_username") or ""), + issuer_profile=str(auth.get("issuer_profile") or ""), + created_at=str(auth.get("created_at") or ""), + expires_at=str(auth.get("expires_at") or ""), + authorization_id=str(auth.get("authorization_id") or ""), + ) + native = auth.get("native_provenance") or {} + if not isinstance(native, dict): + native = {} + expected_sig = _sign_scope( + scope, native, key_version=verified_key_version + ) + if not hmac.compare_digest( + expected_sig, str(auth.get("server_signature") or "") + ): + reasons.append( + "authorization server_signature invalid (forged, corrupt, or " + "HMAC key mismatch across process/restart; fail closed, " + "#709 F4/F1)" + ) + except AuthSecretError as exc: + reasons.append(f"authorization HMAC key unavailable: {exc} (fail closed)") + except (TypeError, ValueError) as exc: + reasons.append(f"authorization scope incomplete: {exc} (fail closed)") + + # Native provenance required on the artifact itself. + native = auth.get("native_provenance") or {} + if not isinstance(native, dict) or not ( + native.get("native_mcp_transport") or native.get("pytest") + ): + # Pytest artifacts stamp pytest=True via mutation_provenance_fields. + if not mcp_daemon_guard.is_pytest_runtime(): + if not (isinstance(native, dict) and native.get("native_mcp_transport")): + reasons.append( + "authorization lacks native transport provenance (fail closed)" + ) + + # Expiry / consumption. + now_dt = now or _now() + expires_raw = auth.get("expires_at") + expires_dt = None + if expires_raw: + text = str(expires_raw).strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + expires_dt = datetime.fromisoformat(text) + if expires_dt.tzinfo is None: + expires_dt = expires_dt.replace(tzinfo=timezone.utc) + except ValueError: + reasons.append("authorization expires_at unparseable (fail closed)") + else: + reasons.append("authorization missing expires_at (fail closed)") + if expires_dt is not None and now_dt > expires_dt: + reasons.append("authorization expired (fail closed)") + + state = (auth.get("consumption_state") or auth.get("status") or "").strip() + if require_unconsumed and state in ("consumed", "expired"): + reasons.append( + f"authorization already {state}; cannot be replayed (fail closed)" + ) + if require_unconsumed and auth.get("consumed_at"): + reasons.append("authorization already consumed (fail closed)") + + return { + "valid": not reasons, + "reasons": reasons, + "authorization_id": auth.get("authorization_id"), + "consumption_state": state or None, + "key_version": verified_key_version, + } + + +def build_irrecoverable_provenance_record( + *, + pr_number: int, + head_sha: str, + remote: str, + org: str, + repo: str, + actor_username: str | None, + profile_name: str | None, + reason: str, + incident_issue: int, + incident_comment_id: int, + authorization: dict[str, Any], + destroyed_subject: str | None = None, + historical_provenance_subject: str | None = None, +) -> dict[str, Any]: + """Truthful absence-of-proof record. Never sets applied=True. + + ``merger_may_accept`` is True only when *authorization* verifies for the + exact scope. Caller-supplied Booleans are never consulted. + """ + head = normalize_head_sha(head_sha) + auth_check = verify_authorization_artifact( + authorization, + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + expected_head_sha=head or "", + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + require_unconsumed=True, + ) + may_accept = bool(auth_check.get("valid")) + return { + "event": "irrecoverable_decision_lock_provenance", + "status": "provenance_irrecoverable", + "record_type": RECORD_TYPE, + "kind": KIND_RECOVERY, + "operator_recovery_required": True, + "issue_ref": "#709", + "recovery_critical": True, + "applied": False, + "historical_cleanup_proven": False, + "timestamp": _now_iso(), + "pr_number": int(pr_number), + "blocked_pr_number": int(pr_number), + "head_sha": head, + "remote": remote, + "org": org, + "repo": repo, + "actor_username": actor_username, + "profile_name": profile_name, + "reason": reason, + "incident_issue": int(incident_issue), + "incident_comment_id": int(incident_comment_id), + # Legacy field for audit readability; not a caller Boolean gate. + "incident_ref": f"issue:{int(incident_issue)}/comment:{int(incident_comment_id)}", + "authorization_id": authorization.get("authorization_id"), + "authorization_issuer": authorization.get("issuer_username"), + "authorization_issuer_profile": authorization.get("issuer_profile"), + "authorization_verified": may_accept, + "authorization_verify_reasons": list(auth_check.get("reasons") or []), + "destroyed_subject": (destroyed_subject or "").strip() or None, + "historical_provenance_subject": ( + (historical_provenance_subject or destroyed_subject or "").strip() + or None + ), + "consumption_state": "issued", + "consumed_at": None, + "merger_may_accept": may_accept, + "acceptance_rule": ( + "Merger may accept this record only when a server-side authorization " + "artifact verifies for remote/org/repo/PR/exact-head/incident, the " + "record is durable and read back, the auth is unexpired and unconsumed, " + "and normal merge gates still pass. Resolves only the historical " + "prior-provenance blocker; never proves historical cleanup " + "(applied=false, historical_cleanup_proven=false)." + ), + "native_provenance": mcp_daemon_guard.mutation_provenance_fields(), + } + + +def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str: + """Markdown body for irrecoverable provenance audit (no applied=true claim).""" + lines = [ + "## Irrecoverable decision-lock provenance (#709)", + "", + "Status: **PROVENANCE_IRRECOVERABLE** (not applied cleanup)", + "", + f"- actor: `{record.get('actor_username')}`", + f"- profile: `{record.get('profile_name')}`", + f"- timestamp: `{record.get('timestamp')}`", + f"- PR: `#{record.get('pr_number')}`", + f"- head_sha: `{record.get('head_sha')}`", + f"- incident_issue: `{record.get('incident_issue')}`", + f"- incident_comment_id: `{record.get('incident_comment_id')}`", + f"- authorization_id: `{record.get('authorization_id')}`", + f"- authorization_issuer: `{record.get('authorization_issuer')}`", + f"- authorization_verified: `{record.get('authorization_verified')}`", + f"- historical_cleanup_proven: `{record.get('historical_cleanup_proven')}`", + f"- applied: `{record.get('applied')}` (must remain false)", + f"- merger_may_accept: `{record.get('merger_may_accept')}`", + f"- destroyed_subject: `{record.get('destroyed_subject')}`", + "", + f"Reason: {record.get('reason')}", + "", + "This record documents **absence of proof**, not successful cleanup.", + "It must not be reused for a different PR or head (#709 AC6).", + "Authorization is a server-side artifact — not a caller Boolean.", + ] + return "\n".join(lines) + + +def assess_merger_consumption( + recovery: dict[str, Any] | None, + authorization: dict[str, Any] | None, + *, + remote: str, + org: str, + repo: str, + pr_number: int, + live_head_sha: str | None, + # Normal merge gate outcomes (must still pass independently). + approval_at_current_head: bool | None = None, + has_blocking_change_requests: bool | None = None, + mergeable: bool | None = None, + lease_ok: bool | None = None, + runtime_ok: bool | None = None, + workspace_ok: bool | None = None, + anti_stomp_ok: bool | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + """Fail-closed merger assessment for consuming an irrecoverable recovery. + + Resolves **only** the historical prior-provenance blocker when all + recovery checks pass. Never grants a pass when normal merge gates fail. + """ + reasons: list[str] = [] + result: dict[str, Any] = { + "allowed": False, + "resolves_prior_provenance_blocker": False, + "historical_cleanup_proven": False, + "irrecoverable_recovery_authorized": False, + "recovery_record_consumed": False, + "reasons": reasons, + "normal_gates": { + "approval_at_current_head": approval_at_current_head, + "has_blocking_change_requests": has_blocking_change_requests, + "mergeable": mergeable, + "lease_ok": lease_ok, + "runtime_ok": runtime_ok, + "workspace_ok": workspace_ok, + "anti_stomp_ok": anti_stomp_ok, + }, + } + + if not isinstance(recovery, dict): + reasons.append("recovery record missing (fail closed)") + return result + if (recovery.get("record_type") or recovery.get("kind")) not in ( + RECORD_TYPE, + KIND_RECOVERY, + "irrecoverable_decision_provenance", + ): + if recovery.get("status") != "provenance_irrecoverable": + reasons.append("recovery record type/status invalid (fail closed)") + + if recovery.get("applied") is True: + reasons.append( + "recovery record claims applied=true; refuse (fabrication, fail closed)" + ) + if recovery.get("historical_cleanup_proven") is True: + reasons.append( + "recovery record claims historical_cleanup_proven=true; refuse " + "(fail closed)" + ) + + for field, want in (("remote", remote), ("org", org), ("repo", repo)): + have = (str(recovery.get(field) or "")).strip() + if have != (want or "").strip(): + reasons.append( + f"recovery {field} mismatch (stored={have!r}, expected={want!r})" + ) + + try: + if int(recovery.get("pr_number") or recovery.get("blocked_pr_number")) != int( + pr_number + ): + reasons.append("recovery PR number mismatch (fail closed)") + except (TypeError, ValueError): + reasons.append("recovery missing pr_number (fail closed)") + + if not heads_equal(recovery.get("head_sha"), live_head_sha): + reasons.append( + "recovery head SHA does not match live PR head (fail closed)" + ) + + if recovery.get("consumption_state") == "consumed" or recovery.get("consumed_at"): + # Idempotent: already consumed for this exact scope is OK if head matches. + result["recovery_record_consumed"] = True + reasons.append("recovery record already consumed (idempotent check)") + + auth_check = verify_authorization_artifact( + authorization, + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + expected_head_sha=str(live_head_sha or ""), + incident_issue=recovery.get("incident_issue"), + incident_comment_id=recovery.get("incident_comment_id"), + # When recovery already consumed, allow already-consumed auth for + # idempotent re-report; otherwise require unconsumed. + require_unconsumed=not result["recovery_record_consumed"], + now=now, + ) + if not auth_check.get("valid"): + reasons.extend(auth_check.get("reasons") or ["authorization invalid"]) + else: + result["irrecoverable_recovery_authorized"] = True + + if not recovery.get("merger_may_accept") and not result["recovery_record_consumed"]: + reasons.append( + "recovery record merger_may_accept is false (fail closed)" + ) + + # Auth id binding. + if ( + authorization + and recovery.get("authorization_id") + and authorization.get("authorization_id") + and recovery.get("authorization_id") != authorization.get("authorization_id") + ): + reasons.append("recovery authorization_id does not match artifact (fail closed)") + + # Normal gates: if explicitly False, refuse consumption as merge-authorizing. + normal_blockers: list[str] = [] + if approval_at_current_head is False: + normal_blockers.append("missing/stale approval at current head") + if has_blocking_change_requests is True: + normal_blockers.append("blocking change requests present") + if mergeable is False: + normal_blockers.append("PR not mergeable") + if lease_ok is False: + normal_blockers.append("lease gate failed") + if runtime_ok is False: + normal_blockers.append("runtime gate failed") + if workspace_ok is False: + normal_blockers.append("workspace gate failed") + if anti_stomp_ok is False: + normal_blockers.append("anti-stomp gate failed") + if normal_blockers: + reasons.append( + "recovery cannot bypass normal merge gates: " + + "; ".join(normal_blockers) + + " (#709 F2)" + ) + result["resolves_prior_provenance_blocker"] = False + result["allowed"] = False + result["reasons"] = reasons + return result + + # Filter pure informational "already consumed" when everything else matches + # for idempotent success. + hard = [ + r + for r in reasons + if "already consumed" not in r + ] + if not hard and result["irrecoverable_recovery_authorized"]: + result["allowed"] = True + result["resolves_prior_provenance_blocker"] = True + result["historical_cleanup_proven"] = False + if result["recovery_record_consumed"]: + reasons.append( + "idempotent: prior-provenance blocker already resolved for this scope" + ) + else: + reasons.append( + "prior-provenance blocker may be resolved by consuming this record " + "(historical cleanup remains unproven)" + ) + result["reasons"] = reasons + return result + + +def mark_consumed( + record: dict[str, Any], + *, + consumer_username: str | None, + consumer_profile: str | None, +) -> dict[str, Any]: + """Return a copy of *record* marked consumed (crash-safe write is caller's job).""" + out = dict(record) + out["consumption_state"] = "consumed" + out["status"] = out.get("status") or "issued" + if out.get("kind") == KIND_AUTH or out.get("auth_type") == AUTH_TYPE: + out["status"] = "consumed" + out["consumed_at"] = _now_iso() + out["consumed_by"] = consumer_username + out["consumed_by_profile"] = consumer_profile + out["updated_at"] = out["consumed_at"] + return out + + +def assess_profile_path_identity(profile_identity: str | None) -> dict[str, Any]: + """Reject traversal / malformed profile identity segments (#709 F3).""" + reasons: list[str] = [] + raw = profile_identity if profile_identity is not None else "" + text = str(raw) + if not text.strip(): + reasons.append("profile identity empty (fail closed)") + return {"valid": False, "reasons": reasons, "sanitized": None} + if text != text.strip(): + reasons.append("profile identity has surrounding whitespace (fail closed)") + if ".." in text or "/" in text or "\\" in text or "\x00" in text: + reasons.append( + "profile identity contains path traversal or separator characters " + "(fail closed, #709 F3)" + ) + if text.startswith("-") or text.startswith("."): + reasons.append("profile identity has unsafe leading character (fail closed)") + # After sanitize, must not collapse to something that collides emptily. + sanitized = mcp_session_state._sanitize_segment(text) + if sanitized in ("_", ""): + reasons.append("profile identity sanitizes to empty (fail closed)") + if sanitized != text and any(c in text for c in ("..", "/", "\\")): + # Already covered; keep fail closed. + pass + return { + "valid": not reasons, + "reasons": reasons, + "sanitized": sanitized if not reasons else None, + } diff --git a/mcp_session_state.py b/mcp_session_state.py index b94b90a..1f1a28d 100644 --- a/mcp_session_state.py +++ b/mcp_session_state.py @@ -36,7 +36,43 @@ KIND_REVIEW_DRAFT = "review_draft" # other session proofs; a contaminated session fails closed on gated mutations # until a reconciler audits and clears it. KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination" +# Durable shadow of the in-memory reviewer session lease (#702). Written on +# every sanctioned record/heartbeat and removed on sanctioned clear, so a +# daemon that dies without teardown leaves provable orphan evidence (owner +# pid + session id) for the guarded lease-cleanup path. Never a substitute +# for the in-session lease: mutation gates ignore it. +KIND_REVIEWER_SESSION_LEASE = "reviewer_session_lease" +# Durable audit record written whenever the daemon's sanctioned stale-binding +# recovery clears an inherited GITEA_ACTIVE_WORKTREE binding (#702). +KIND_STALE_BINDING_RECOVERY = "stale_binding_recovery" +# #709: archive prior terminal decision ledgers instead of silent overwrite. +KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive" +# #709: post-merge cleanup/audit reconciliation-required durable record. +KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery" +# #709: truthful record when historical terminal evidence is irrecoverably gone. +KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance" +# #709 F1: server-side non-forgeable authorization artifact for recovery. +KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization" +# Kinds that must survive the default session-state TTL (forensic / recovery). +# +# KIND_DECISION_LOCK is recovery-critical (#720): terminal review provenance is +# not disposable cache. A generic four-hour TTL must not drop old-head evidence +# or make ``fresh_review_on_current_head_allowed`` unreachable. Same-head / same- +# run #332 protections still apply once the ledger is loadable. Other session +# kinds (workflow load, drafts, etc.) remain TTL-bound. +RECOVERY_CRITICAL_KINDS = frozenset( + { + KIND_DECISION_LOCK, + KIND_DECISION_LOCK_ARCHIVE, + KIND_POST_MERGE_DECISION_RECOVERY, + KIND_IRRECOVERABLE_DECISION_PROVENANCE, + KIND_IRRECOVERABLE_PROVENANCE_AUTH, + # #702 crash-orphan evidence (must outlive TTL; F4) + KIND_REVIEWER_SESSION_LEASE, + KIND_STALE_BINDING_RECOVERY, + } +) _SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+") @@ -114,6 +150,7 @@ def state_key( org: str | None = None, repo: str | None = None, profile_identity: str | None = None, + instance_id: str | None = None, ) -> str: """Build durable filename key. @@ -121,17 +158,20 @@ def state_key( so the primary key is kind + profile identity. Remote/org/repo are stored inside the payload and validated on load (#559), which lets a later daemon process recover state without already knowing the remote argument. + + *instance_id* extends the key for kinds where one-per-profile collides — + concurrent same-profile sessions each own their reviewer-lease shadow + (#702 F5), keyed by their lease session id so a later heartbeat can never + overwrite or misattribute another session's crash evidence. """ # Keep remote/org/repo parameters for API stability / future kinds; they are # intentionally not part of the filename for session-scoped proofs. _ = (remote, org, repo) - return "-".join( - _sanitize_segment(part) - for part in ( - kind, - profile_identity or "unknown-profile", - ) - ) + parts = [kind, profile_identity or "unknown-profile"] + instance = (instance_id or "").strip() + if instance: + parts.append(instance) + return "-".join(_sanitize_segment(part) for part in parts) def state_file_path( @@ -142,6 +182,7 @@ def state_file_path( repo: str | None = None, profile_identity: str | None = None, state_dir: str | None = None, + instance_id: str | None = None, ) -> str: root = (state_dir or default_state_dir()).strip() name = state_key( @@ -150,6 +191,7 @@ def state_file_path( org=org, repo=repo, profile_identity=profile_identity, + instance_id=instance_id, ) return os.path.join(root, f"{name}.json") @@ -228,6 +270,16 @@ def _write_json(path: str, data: dict[str, Any]) -> None: pass +def is_recovery_critical_record(record: dict[str, Any] | None, kind: str | None = None) -> bool: + """True when a durable record must outlive the generic session-state TTL.""" + if not record and not kind: + return False + record_kind = ((record or {}).get("kind") or kind or "").strip() + if record_kind in RECOVERY_CRITICAL_KINDS: + return True + return bool((record or {}).get("recovery_critical")) + + def identity_match_reasons( record: dict[str, Any] | None, *, @@ -270,7 +322,9 @@ def identity_match_reasons( reasons.append("session state missing recorded_at timestamp (fail closed)") else: age = _now_utc() - recorded_at - if age > timedelta(hours=ttl_hours()): + kind = (record.get("kind") or "").strip() + ttl_exempt = is_recovery_critical_record(record, kind=kind) + if age > timedelta(hours=ttl_hours()) and not ttl_exempt: reasons.append( f"session state expired after {ttl_hours():g}h (fail closed)" ) @@ -279,6 +333,113 @@ def identity_match_reasons( return reasons +def inspect_state_envelope( + *, + kind: str, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + profile_identity: str | None = None, + state_dir: str | None = None, +) -> dict[str, Any]: + """Read-only disk inspection for assessment when TTL would otherwise hide state (#720). + + Does **not** apply identity/TTL rejection to the returned presence flags. + Callers use this to distinguish: + * no file on disk + * file present but TTL would reject a non-critical kind + * recovery-critical ledger (e.g. KIND_DECISION_LOCK) still loadable + Never mutates files. Never returns secrets. + """ + profile = current_profile_identity(profile_identity=profile_identity) + path = state_file_path( + kind=kind, + remote=remote, + org=org, + repo=repo, + profile_identity=profile, + state_dir=state_dir, + ) + result: dict[str, Any] = { + "kind": kind, + "profile_identity": profile, + "path_basename": os.path.basename(path), + "on_disk": False, + "has_payload": False, + "recorded_at": None, + "updated_at": None, + "age_hours": None, + "ttl_hours": ttl_hours(), + "age_exceeds_default_ttl": False, + "recovery_critical": kind in RECOVERY_CRITICAL_KINDS, + "ttl_exempt": kind in RECOVERY_CRITICAL_KINDS, + "would_ttl_reject": False, + "identity_reasons": [], + "summary": "no session-state file on disk", + } + if not path or not os.path.exists(path): + return result + result["on_disk"] = True + envelope = _read_json(path) + if not envelope: + result["summary"] = "session-state file present but unreadable or empty" + return result + payload = envelope.get("payload") + merged: dict[str, Any] = dict(payload) if isinstance(payload, dict) else {} + result["has_payload"] = isinstance(payload, dict) + for key in ( + "kind", + "remote", + "org", + "repo", + "profile_identity", + "session_profile_lock", + "recorded_at", + "updated_at", + "writer_pid", + "recovery_critical", + ): + if key in envelope and key not in merged: + merged[key] = envelope[key] + if not merged.get("kind"): + merged["kind"] = kind + recorded_at = _parse_iso(merged.get("recorded_at") or merged.get("updated_at")) + result["recorded_at"] = merged.get("recorded_at") or merged.get("updated_at") + result["updated_at"] = merged.get("updated_at") or merged.get("recorded_at") + if recorded_at is not None: + age = _now_utc() - recorded_at + age_hours = age.total_seconds() / 3600.0 + result["age_hours"] = age_hours + result["age_exceeds_default_ttl"] = age > timedelta(hours=ttl_hours()) + ttl_exempt = is_recovery_critical_record(merged, kind=kind) + result["recovery_critical"] = ttl_exempt + result["ttl_exempt"] = ttl_exempt + identity_reasons = identity_match_reasons( + merged, + remote=remote, + org=org, + repo=repo, + profile_identity=profile, + ) + result["identity_reasons"] = list(identity_reasons) + result["would_ttl_reject"] = any("expired" in r for r in identity_reasons) + if result["has_payload"] and ttl_exempt: + result["summary"] = ( + "recovery-critical session-state present on disk and TTL-exempt; " + "load via load_state for full payload" + ) + elif result["has_payload"] and result["would_ttl_reject"]: + result["summary"] = ( + "session-state file present on disk but generic TTL would reject load " + f"(age_hours={result.get('age_hours')!r}, ttl={ttl_hours():g}h)" + ) + elif result["has_payload"]: + result["summary"] = "session-state file present and within TTL / identity gates" + else: + result["summary"] = "session-state file present without a dict payload" + return result + + def load_state( *, kind: str, @@ -287,6 +448,7 @@ def load_state( repo: str | None = None, profile_identity: str | None = None, state_dir: str | None = None, + instance_id: str | None = None, ) -> dict[str, Any] | None: """Load durable state payload when identity checks pass.""" profile = current_profile_identity(profile_identity=profile_identity) @@ -297,6 +459,7 @@ def load_state( repo=repo, profile_identity=profile, state_dir=state_dir, + instance_id=instance_id, ) lock_path = f"{path}.lock" with _exclusive_file_lock(lock_path): @@ -342,6 +505,7 @@ def save_state( repo: str | None = None, profile_identity: str | None = None, state_dir: str | None = None, + instance_id: str | None = None, ) -> dict[str, Any] | None: """Persist or clear durable state for the given session identity key.""" profile = current_profile_identity( @@ -354,6 +518,11 @@ def save_state( key_remote = remote if remote is not None else (payload or {}).get("remote") key_org = org if org is not None else (payload or {}).get("org") key_repo = repo if repo is not None else (payload or {}).get("repo") + key_instance = ( + (instance_id or "").strip() + or str((payload or {}).get("instance_id") or "").strip() + or None + ) root = _ensure_state_dir(state_dir) path = state_file_path( @@ -363,6 +532,7 @@ def save_state( repo=key_repo, profile_identity=profile, state_dir=root, + instance_id=key_instance, ) lock_path = f"{path}.lock" with _exclusive_file_lock(lock_path): @@ -392,6 +562,8 @@ def save_state( body["repo"] = key_repo # Stamp session-state authority used for this write (#695 AC2 / AC6). body.setdefault("session_state_dir", root) + if key_instance: + body["instance_id"] = key_instance try: import mcp_daemon_guard @@ -425,6 +597,8 @@ def save_state( "transport": body.get("transport"), "payload": body, } + if key_instance: + envelope["instance_id"] = key_instance _write_json(path, envelope) return dict(body) @@ -437,6 +611,7 @@ def clear_state( repo: str | None = None, profile_identity: str | None = None, state_dir: str | None = None, + instance_id: str | None = None, ) -> None: save_state( kind=kind, @@ -446,4 +621,229 @@ def clear_state( repo=repo, profile_identity=profile_identity, state_dir=state_dir, + instance_id=instance_id, ) + +def list_states( + *, + kind: str, + profile_identity: str | None = None, + state_dir: str | None = None, +) -> list[dict[str, Any]]: + """Enumerate durable records of *kind* across all instance keys. + + Crash recovery cannot know which session id left a shadow behind, so it + must be able to enumerate every record of a kind (#702 F5). Identity + checks (profile / TTL policy) apply per record exactly as in + :func:`load_state`; records that fail closed are omitted. + """ + root = (state_dir or default_state_dir()).strip() + if not root or not os.path.isdir(root): + return [] + profile = current_profile_identity(profile_identity=profile_identity) + results: list[dict[str, Any]] = [] + try: + names = sorted(os.listdir(root)) + except OSError: + return [] + for name in names: + if not name.endswith(".json") or name.startswith("."): + continue + envelope = _read_json(os.path.join(root, name)) + if not envelope or envelope.get("kind") != kind: + continue + payload = envelope.get("payload") + if not isinstance(payload, dict): + continue + merged = dict(payload) + for key in ( + "kind", + "remote", + "org", + "repo", + "profile_identity", + "session_profile_lock", + "recorded_at", + "updated_at", + "writer_pid", + ): + if key in envelope and key not in merged: + merged[key] = envelope[key] + if identity_match_reasons(merged, profile_identity=profile): + continue + results.append(merged) + return results + +def list_decision_lock_profile_identities( + state_dir: str | None = None, +) -> list[str]: + """Return profile identities that have a durable review_decision_lock file (#709). + + Filename form: ``review_decision_lock-.json`` (see ``state_key``). + Does not validate TTL or identity — callers must load via ``load_state``. + """ + root = (state_dir or default_state_dir()).strip() + if not root or not os.path.isdir(root): + return [] + prefix = f"{_sanitize_segment(KIND_DECISION_LOCK)}-" + suffix = ".json" + found: list[str] = [] + try: + names = os.listdir(root) + except OSError: + return [] + for name in names: + if not name.startswith(prefix) or not name.endswith(suffix): + continue + if name.endswith(".lock"): + continue + mid = name[len(prefix) : -len(suffix)] + if mid: + found.append(mid) + return sorted(set(found)) + + +def load_state_for_profile( + *, + kind: str, + profile_identity: str, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + state_dir: str | None = None, + skip_identity_match: bool = False, + enforce_repo_scope: bool = True, +) -> dict[str, Any] | None: + """Load durable state for an explicit profile identity (#709 cross-profile). + + When *skip_identity_match* is True, still requires the file's recorded + profile_identity to equal the requested profile (anti-stomp), but does not + require the *active* session identity to match — needed so a merger can + inspect a reviewer lock after merge. + + #709 F3: remote/org/repo filter reasons are **enforced** (not merely + computed). When *enforce_repo_scope* is True (default) and the caller + supplies remote/org/repo, mismatches fail closed with ``None``. + """ + # #709 F3: refuse traversal / malformed profile identities. + try: + from irrecoverable_provenance import assess_profile_path_identity + + path_gate = assess_profile_path_identity(profile_identity) + if not path_gate.get("valid"): + return None + except Exception: + # Module may be mid-import in edge bootstraps; fall through to + # conservative checks below. + raw = str(profile_identity or "") + if ".." in raw or "/" in raw or "\\" in raw or "\x00" in raw: + return None + + profile = current_profile_identity(profile_identity=profile_identity) + root = _ensure_state_dir(state_dir) + # Refuse symlink escape of the state root (#709 F3). + try: + real_root = os.path.realpath(root) + path = state_file_path( + kind=kind, + remote=remote, + org=org, + repo=repo, + profile_identity=profile, + state_dir=root, + ) + real_path = os.path.realpath(path) if os.path.exists(path) else path + if os.path.exists(path) and not str(real_path).startswith( + str(real_root) + os.sep + ) and str(real_path) != str(real_root): + return None + except OSError: + return None + + path = state_file_path( + kind=kind, + remote=remote, + org=org, + repo=repo, + profile_identity=profile, + state_dir=root, + ) + lock_path = f"{path}.lock" + with _exclusive_file_lock(lock_path): + envelope = _read_json(path) + if not envelope: + return None + payload = envelope.get("payload") + if not isinstance(payload, dict): + return None + merged = dict(payload) + for key in ( + "kind", + "remote", + "org", + "repo", + "profile_identity", + "session_profile_lock", + "recorded_at", + "updated_at", + "writer_pid", + ): + if key in envelope and key not in merged: + merged[key] = envelope[key] + stored = (merged.get("profile_identity") or "").strip() + if stored and stored != profile: + return None + if skip_identity_match: + # Enforce remote/org/repo when caller provides them (#709 F3). + # Drop only *active session* profile-identity mismatches; keep + # expiry, spoof, and repository-scope reasons. + scope_remote = remote if enforce_repo_scope else None + scope_org = org if enforce_repo_scope else None + scope_repo = repo if enforce_repo_scope else None + reasons = identity_match_reasons( + merged, + remote=scope_remote, + org=scope_org, + repo=scope_repo, + profile_identity=stored or profile, + ) + filtered = [ + r + for r in reasons + if "profile identity mismatch" not in r + or (stored and stored != profile) + ] + # When caller requested a specific remote/org/repo, also fail if the + # stored record lacks those identity fields entirely (legacy incomplete). + if enforce_repo_scope: + for field, want in ( + ("remote", remote), + ("org", org), + ("repo", repo), + ): + want_s = (want or "").strip() + if not want_s: + continue + have = (str(merged.get(field) or "")).strip() + if not have: + filtered.append( + f"session state {field} missing on durable record " + f"(expected={want_s!r}; fail closed, #709 F3)" + ) + elif have != want_s: + # identity_match_reasons already adds mismatch; ensure kept + pass + if filtered: + return None + return merged + reasons = identity_match_reasons( + merged, + remote=remote, + org=org, + repo=repo, + profile_identity=profile, + ) + if reasons: + return None + return merged + diff --git a/mcp_tool_error_boundary.py b/mcp_tool_error_boundary.py new file mode 100644 index 0000000..81f6828 --- /dev/null +++ b/mcp_tool_error_boundary.py @@ -0,0 +1,451 @@ +"""MCP tool-boundary error mapping for known Gitea client failures (#699). + +Known authentication / authorization / network / configuration failures leave +the tool boundary as a sanitized structured ``CallToolResult`` with +``isError=True``. Stdio transport remains connected. + +Design constraints (reviewer-ratified, #699 / PR #701): + +1. **No secret material** in tool results or daemon logs. Messages are fixed + constants keyed by ``reason_code``; HTTP bodies / exception text never + surface. Sanitization failure fails closed to ``internal_error``. +2. **Narrow boundary** wraps the original FastMCP ``Tool.run`` success path; + re-raises framework control-flow exceptions (``UrlElicitationRequiredError``); + maps only typed client failures. Installation is idempotent. +3. **No RuntimeError substring heuristics.** Only explicit typed + authentication / authorization / network / configuration exceptions + receive those labels. +4. **HTTP classification** lives in ``gitea_auth.classify_http_status``; + every 403 is authorization-class. +""" + +from __future__ import annotations + +import json +import logging +import sys +from typing import Any + +logger = logging.getLogger("gitea_mcp.tool_error_boundary") + +# Stable reason codes (#699). +REASON_AUTH_FAILED = "auth_failed" +REASON_AUTH_INVALID_TOKEN = "auth_invalid_token" +REASON_AUTHZ_INSUFFICIENT_SCOPE = "authz_insufficient_scope" +REASON_AUTHZ_DENIED = "authz_denied" +REASON_NETWORK_ERROR = "network_error" +REASON_CONFIG_ERROR = "config_error" +REASON_INTERNAL_ERROR = "internal_error" +REASON_UPSTREAM_UNAVAILABLE = "upstream_unavailable" +REASON_HTTP_ERROR = "http_error" + +ERROR_CLASS_AUTHENTICATION = "authentication" +ERROR_CLASS_AUTHORIZATION = "authorization" +ERROR_CLASS_NETWORK = "network" +ERROR_CLASS_CONFIGURATION = "configuration" +ERROR_CLASS_INTERNAL = "internal" +ERROR_CLASS_UPSTREAM = "upstream" + +# Fixed, secret-free operator messages. Never interpolate HTTP bodies, +# Keychain contents, tokens, or arbitrary exception text. +FIXED_MESSAGES: dict[str, str] = { + REASON_AUTH_FAILED: "Gitea authentication failed", + REASON_AUTH_INVALID_TOKEN: ( + "Gitea authentication failed: invalid or revoked credentials" + ), + REASON_AUTHZ_INSUFFICIENT_SCOPE: ( + "Gitea authorization failed: insufficient token scope" + ), + REASON_AUTHZ_DENIED: "Gitea authorization failed: access denied", + REASON_NETWORK_ERROR: "Network error contacting Gitea", + REASON_CONFIG_ERROR: "Gitea configuration or credential resolution failed", + REASON_INTERNAL_ERROR: "Internal tool error", + REASON_UPSTREAM_UNAVAILABLE: "Gitea upstream unavailable", + REASON_HTTP_ERROR: "Gitea HTTP request failed", +} + +_INSTALL_FLAG = "_gitea_auth_boundary_installed" +_ORIGINAL_ATTR = "_gitea_auth_boundary_original" + + +def fixed_message(reason_code: str) -> str: + """Return the fixed sanitized message for *reason_code* (fail closed).""" + return FIXED_MESSAGES.get(reason_code, FIXED_MESSAGES[REASON_INTERNAL_ERROR]) + + +def _safe_profile_name() -> str | None: + try: + from gitea_auth import get_profile + + name = (get_profile() or {}).get("profile_name") + if name is None: + return None + text = str(name).strip() + # Profile names are non-secret identifiers; still bound length. + return text[:80] if text else None + except Exception: + return None + + +def _is_framework_control_flow(exc: BaseException) -> bool: + """True for exceptions the MCP framework must re-raise unchanged.""" + try: + from mcp.shared.exceptions import UrlElicitationRequiredError + + if isinstance(exc, UrlElicitationRequiredError): + return True + except Exception: + pass + # BaseException subclasses that must never become tool isError payloads. + if isinstance(exc, (KeyboardInterrupt, SystemExit, GeneratorExit)): + return True + return False + + +def is_known_client_failure(exc: BaseException) -> bool: + """True only for explicit typed Gitea client / config failures. + + Never true for arbitrary ``RuntimeError`` (reviewer finding #3). + """ + try: + import gitea_auth + + if isinstance( + exc, + ( + gitea_auth.GiteaAuthError, + gitea_auth.GiteaAuthzError, + gitea_auth.GiteaNetworkError, + gitea_auth.GiteaConfigError, + gitea_auth.GiteaHttpError, + ), + ): + return True + except Exception: + return False + try: + import gitea_config + + if isinstance(exc, gitea_config.ConfigError): + return True + except Exception: + pass + return False + + +def classify_exception(exc: BaseException) -> dict[str, Any]: + """Return structured classification with **fixed** messages only. + + Only typed authentication / authorization / network / configuration + failures receive those labels. Unexpected programming failures are + ``internal_error``. HTTP bodies and exception text are never copied + into ``message``. + """ + try: + return _classify_exception_impl(exc) + except Exception: + # Fail closed: sanitization / classification failure must not leak. + return { + "reason_code": REASON_INTERNAL_ERROR, + "error_class": ERROR_CLASS_INTERNAL, + "http_status": None, + "message": fixed_message(REASON_INTERNAL_ERROR), + "transport_survives": True, + } + + +def _classify_exception_impl(exc: BaseException) -> dict[str, Any]: + import gitea_auth + + if isinstance(exc, gitea_auth.GiteaAuthError): + code = getattr(exc, "reason_code", None) or REASON_AUTH_INVALID_TOKEN + if code not in ( + REASON_AUTH_FAILED, + REASON_AUTH_INVALID_TOKEN, + ): + code = REASON_AUTH_INVALID_TOKEN + return { + "reason_code": code, + "error_class": ERROR_CLASS_AUTHENTICATION, + "http_status": getattr(exc, "http_status", None) or 401, + "message": fixed_message(code), + "transport_survives": True, + } + if isinstance(exc, gitea_auth.GiteaAuthzError): + code = getattr(exc, "reason_code", None) or REASON_AUTHZ_DENIED + if code not in (REASON_AUTHZ_DENIED, REASON_AUTHZ_INSUFFICIENT_SCOPE): + code = REASON_AUTHZ_DENIED + return { + "reason_code": code, + "error_class": ERROR_CLASS_AUTHORIZATION, + "http_status": getattr(exc, "http_status", None) or 403, + "message": fixed_message(code), + "transport_survives": True, + } + if isinstance(exc, gitea_auth.GiteaNetworkError): + return { + "reason_code": REASON_NETWORK_ERROR, + "error_class": ERROR_CLASS_NETWORK, + "http_status": getattr(exc, "http_status", None), + "message": fixed_message(REASON_NETWORK_ERROR), + "transport_survives": True, + } + if isinstance(exc, gitea_auth.GiteaConfigError): + return { + "reason_code": REASON_CONFIG_ERROR, + "error_class": ERROR_CLASS_CONFIGURATION, + "http_status": getattr(exc, "http_status", None), + "message": fixed_message(REASON_CONFIG_ERROR), + "transport_survives": True, + } + if isinstance(exc, gitea_auth.GiteaHttpError): + code = getattr(exc, "reason_code", None) or REASON_HTTP_ERROR + if code == REASON_UPSTREAM_UNAVAILABLE: + error_class = ERROR_CLASS_UPSTREAM + else: + error_class = ERROR_CLASS_INTERNAL + code = REASON_HTTP_ERROR + return { + "reason_code": code, + "error_class": error_class, + "http_status": getattr(exc, "http_status", None), + "message": fixed_message(code), + "transport_survives": True, + } + + try: + import gitea_config + + if isinstance(exc, gitea_config.ConfigError): + return { + "reason_code": REASON_CONFIG_ERROR, + "error_class": ERROR_CLASS_CONFIGURATION, + "http_status": None, + "message": fixed_message(REASON_CONFIG_ERROR), + "transport_survives": True, + } + except Exception: + pass + + # Unwrap FastMCP ToolError cause when the original was a typed failure. + cause = getattr(exc, "__cause__", None) + if cause is not None and cause is not exc and is_known_client_failure(cause): + return _classify_exception_impl(cause) + + # No message-substring authentication heuristics (reviewer finding #3). + return { + "reason_code": REASON_INTERNAL_ERROR, + "error_class": ERROR_CLASS_INTERNAL, + "http_status": None, + "message": fixed_message(REASON_INTERNAL_ERROR), + "transport_survives": True, + } + + +def build_structured_error_payload( + classification: dict[str, Any], + *, + tool_name: str | None = None, + profile_name: str | None = None, +) -> dict[str, Any]: + """LLM-safe structured payload — fixed message + typed metadata only.""" + try: + reason = str(classification.get("reason_code") or REASON_INTERNAL_ERROR) + message = fixed_message(reason) + # Refuse to emit any classification message that is not the fixed constant. + if classification.get("message") != message: + message = fixed_message(reason) + payload: dict[str, Any] = { + "success": False, + "isError": True, + "reason_code": reason if reason in FIXED_MESSAGES else REASON_INTERNAL_ERROR, + "error_class": classification.get("error_class") or ERROR_CLASS_INTERNAL, + "message": message, + "transport_survives": True, + "retryable": classification.get("error_class") + in { + ERROR_CLASS_AUTHENTICATION, + ERROR_CLASS_NETWORK, + ERROR_CLASS_CONFIGURATION, + }, + } + status = classification.get("http_status") + if isinstance(status, int): + payload["http_status"] = status + if tool_name and isinstance(tool_name, str): + # Tool names are identifiers, not secrets; bound length. + payload["tool"] = tool_name[:120] + if profile_name and isinstance(profile_name, str): + payload["profile"] = profile_name[:80] + return payload + except Exception: + return { + "success": False, + "isError": True, + "reason_code": REASON_INTERNAL_ERROR, + "error_class": ERROR_CLASS_INTERNAL, + "message": fixed_message(REASON_INTERNAL_ERROR), + "transport_survives": True, + "retryable": False, + } + + +def log_sanitized_daemon_reason( + classification: dict[str, Any], + *, + tool_name: str | None = None, + stream=None, +) -> None: + """Daemon log: reason codes only — never exception text or response bodies.""" + stream = stream if stream is not None else sys.stderr + try: + reason = classification.get("reason_code") or REASON_INTERNAL_ERROR + error_class = classification.get("error_class") or ERROR_CLASS_INTERNAL + # Only emit known tokens; never classification['message'] from callers + # that might have been poisoned. + if reason not in FIXED_MESSAGES: + reason = REASON_INTERNAL_ERROR + error_class = ERROR_CLASS_INTERNAL + parts = [ + "mcp_tool_error", + f"reason_code={reason}", + f"error_class={error_class}", + ] + if tool_name and isinstance(tool_name, str): + parts.append(f"tool={tool_name[:120]}") + status = classification.get("http_status") + if isinstance(status, int): + parts.append(f"http_status={status}") + # Intentionally no detail= / message= field — secrets lived there. + line = " ".join(parts) + stream.write(line + "\n") + if hasattr(stream, "flush"): + stream.flush() + logger.warning(line) + except Exception: + # Fail closed: never fall back to logging the exception. + try: + stream.write( + "mcp_tool_error reason_code=internal_error " + "error_class=internal\n" + ) + if hasattr(stream, "flush"): + stream.flush() + except Exception: + pass + + +def to_call_tool_result( + exc: BaseException, + *, + tool_name: str | None = None, + profile_name: str | None = None, + log: bool = True, +) -> Any: + """Build a FastMCP ``CallToolResult`` with ``isError=True`` for *exc*.""" + from mcp.types import CallToolResult, TextContent + + try: + classification = classify_exception(exc) + if log: + log_sanitized_daemon_reason(classification, tool_name=tool_name) + payload = build_structured_error_payload( + classification, tool_name=tool_name, profile_name=profile_name + ) + text = json.dumps(payload, indent=2, sort_keys=True) + return CallToolResult( + content=[TextContent(type="text", text=text)], + structuredContent=payload, + isError=True, + ) + except Exception: + # Absolute fail-closed path — no exception text. + fallback = { + "success": False, + "isError": True, + "reason_code": REASON_INTERNAL_ERROR, + "error_class": ERROR_CLASS_INTERNAL, + "message": fixed_message(REASON_INTERNAL_ERROR), + "transport_survives": True, + "retryable": False, + } + return CallToolResult( + content=[ + TextContent( + type="text", + text=json.dumps(fallback, indent=2, sort_keys=True), + ) + ], + structuredContent=fallback, + isError=True, + ) + + +def install_tool_run_boundary(Tool) -> bool: + """Install a **narrow** error boundary around FastMCP ``Tool.run``. + + Strategy (preserves framework semantics): + + * Call the **original** ``Tool.run`` for the success path (async, return + types, convert_result, protocol behavior unchanged). + * Re-raise ``UrlElicitationRequiredError`` and other control-flow + exceptions without mapping to ``internal_error``. + * Map typed Gitea client failures (and ToolError whose ``__cause__`` is + typed) to structured ``CallToolResult(isError=True)``. + * Map remaining unexpected tool failures to fixed ``internal_error`` + isError results (transport survival) without secret-bearing text. + * Idempotent: second install is a no-op and returns ``False``. + """ + if getattr(Tool.run, _INSTALL_FLAG, False): + return False + + from mcp.server.fastmcp.exceptions import ToolError + from mcp.shared.exceptions import UrlElicitationRequiredError + + original_run = Tool.run + + async def run_boundary( + self, + arguments: dict[str, Any], + context=None, + convert_result: bool = False, + ) -> Any: + try: + return await original_run( + self, + arguments, + context=context, + convert_result=convert_result, + ) + except UrlElicitationRequiredError: + # Framework control-flow — must not become internal_error. + raise + except BaseException as exc: + if _is_framework_control_flow(exc): + raise + if not isinstance(exc, Exception): + raise + + # Prefer typed cause under FastMCP ToolError wrappers. + target: BaseException = exc + if isinstance(exc, ToolError) and exc.__cause__ is not None: + target = exc.__cause__ + + # Known client failures → structured isError with reason codes. + # Unexpected failures → fixed internal_error isError (no secrets). + profile_name = _safe_profile_name() + return to_call_tool_result( + target, + tool_name=getattr(self, "name", None), + profile_name=profile_name, + ) + + setattr(run_boundary, _INSTALL_FLAG, True) + setattr(run_boundary, _ORIGINAL_ATTR, original_run) + Tool.run = run_boundary # type: ignore[method-assign] + return True + + +def boundary_is_installed(Tool) -> bool: + """True when the #699 boundary is active on *Tool.run*.""" + return bool(getattr(Tool.run, _INSTALL_FLAG, False)) diff --git a/merger_lease_adoption.py b/merger_lease_adoption.py index 821779a..46ae926 100644 --- a/merger_lease_adoption.py +++ b/merger_lease_adoption.py @@ -18,11 +18,13 @@ DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head" SOURCE_ADOPT = "gitea_adopt_merger_pr_lease" SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease" +SOURCE_ACQUIRE_MERGER = "gitea_acquire_merger_pr_lease" SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease" SANCTIONED_PROVENANCE_SOURCES = frozenset({ SOURCE_ADOPT, SOURCE_ACQUIRE, + SOURCE_ACQUIRE_MERGER, SOURCE_HEARTBEAT, }) @@ -209,8 +211,10 @@ _SANCTIONED_LEASE_EVIDENCE_RE = re.compile( r"(?is)\b(" r"gitea_adopt_merger_pr_lease|" r"gitea_acquire_reviewer_pr_lease|" + r"gitea_acquire_merger_pr_lease|" r"lease_proof_source\s*[:=]\s*gitea_adopt_merger_pr_lease|" r"lease_proof_source\s*[:=]\s*gitea_acquire_reviewer_pr_lease|" + r"lease_proof_source\s*[:=]\s*gitea_acquire_merger_pr_lease|" r"lease_proof_kind\s*[:=]\s*sanctioned_adoption|" r"lease_proof_kind\s*[:=]\s*sanctioned_acquire|" r"adoption_comment_id\s*[:=]|" diff --git a/migrate_profiles.py b/migrate_profiles.py index 6bf150c..4cefd85 100755 --- a/migrate_profiles.py +++ b/migrate_profiles.py @@ -20,12 +20,114 @@ if PROJECT_ROOT not in sys.path: import gitea_config -AUTHOR_DEFAULT_ALLOWED = ["read", "branch", "commit", "push", "open_pr", "comment"] -AUTHOR_DEFAULT_FORBIDDEN = ["approve", "request_changes", "merge"] -REVIEWER_DEFAULT_ALLOWED = [ - "read", "review", "comment", "approve", "request_changes", "merge" +# Defaults emit *canonical* operation names only. Shorthand that is not in +# gitea_config.GITEA_OPERATION_ALIASES (e.g. ``pr.close``, ``issue.close``) +# is silently dropped by the production loader and must never appear here. +AUTHOR_DEFAULT_ALLOWED = [ + "gitea.read", + "gitea.branch.create", + "gitea.repo.commit", + "gitea.branch.push", + "gitea.pr.create", + "gitea.pr.comment", ] -REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"] +AUTHOR_DEFAULT_FORBIDDEN = [ + "gitea.pr.approve", + "gitea.pr.request_changes", + "gitea.pr.merge", +] +REVIEWER_DEFAULT_ALLOWED = [ + "gitea.read", + "gitea.pr.review", + "gitea.pr.comment", + "gitea.pr.approve", + "gitea.pr.request_changes", + "gitea.pr.merge", +] +REVIEWER_DEFAULT_FORBIDDEN = [ + "gitea.branch.create", + "gitea.repo.commit", + "gitea.branch.push", + "gitea.pr.create", +] +# Required reconciler ops (read + pr.close) plus recommended comment/close and +# branch.delete for guarded merged-PR cleanup. All names must normalize via +# gitea_config.normalize_operation without being dropped. +RECONCILER_DEFAULT_ALLOWED = [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + "gitea.issue.close", + "gitea.branch.delete", +] +RECONCILER_DEFAULT_FORBIDDEN = [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", +] + +# Migration-only expansions for common shorthands that are *not* in +# GITEA_OPERATION_ALIASES. Emitted output is always the canonical form so a +# second canonicalize pass is a no-op (idempotent). +_MIGRATION_ONLY_ALIASES = { + "pr.close": "gitea.pr.close", + "pr.comment": "gitea.pr.comment", + "issue.close": "gitea.issue.close", + "branch.delete": "gitea.branch.delete", +} + +# Reconciler required ops that must survive migration (from reconciler_profile). +RECONCILER_REQUIRED_CANONICAL = ("gitea.read", "gitea.pr.close") + + +def canonicalize_operation(op: str) -> str: + """Return a canonical operation name accepted by the production loader. + + Fail closed on unknown/ambiguous spellings so required permissions cannot + be silently dropped by ``check_operation`` later. + """ + if not isinstance(op, str) or not op.strip(): + raise ValueError("operation must be a non-empty string (fail closed)") + op = op.strip() + try: + return gitea_config.normalize_operation(op) + except gitea_config.ConfigError: + pass + if op in _MIGRATION_ONLY_ALIASES: + return _MIGRATION_ONLY_ALIASES[op] + raise ValueError( + f"operation {op!r} cannot be canonicalized for migration " + "(unknown/ambiguous; fail closed — production loader would drop it)" + ) + + +def canonicalize_operations(ops, *, context: str = "operations") -> list[str]: + """Canonicalize a list of operations; preserve order, drop duplicates.""" + if not isinstance(ops, list): + raise ValueError(f"{context} must be a list (fail closed)") + out: list[str] = [] + seen: set[str] = set() + for entry in ops: + canon = canonicalize_operation(entry) + if canon not in seen: + seen.add(canon) + out.append(canon) + return out + + +def _assert_reconciler_required_survive(allowed: list[str], profile_name: str) -> None: + """Fail visibly when migration would leave a reconciler without required ops.""" + missing = [op for op in RECONCILER_REQUIRED_CANONICAL if op not in set(allowed)] + if missing: + raise ValueError( + f"Profile '{profile_name}' (reconciler) is missing required " + f"operation(s) after migration: {missing}. Refusing to emit a " + "profile that would silently fail pr.close / read (fail closed)." + ) def infer_role(name, execution_profile): @@ -90,9 +192,11 @@ def migrate_v1_to_v2(v1_data): ident_name = "reviewer" elif role == "author": ident_name = "author" + elif role == "reconciler": + ident_name = "reconciler" else: role = prof.get("role") - if role not in (None, "author", "reviewer"): + if role not in (None, "author", "reviewer", "reconciler"): raise ValueError( f"Profile '{name}' has unsupported role {role!r}" ) @@ -124,20 +228,35 @@ def migrate_v1_to_v2(v1_data): raise ValueError( f"Profile '{name}' operation fields must be lists" ) - identity_data["allowed_operations"] = list(allowed) - identity_data["forbidden_operations"] = list(forbidden) + try: + identity_data["allowed_operations"] = canonicalize_operations( + allowed, context=f"profile '{name}' allowed_operations" + ) + identity_data["forbidden_operations"] = canonicalize_operations( + forbidden, context=f"profile '{name}' forbidden_operations" + ) + except ValueError as exc: + raise ValueError(f"Profile '{name}': {exc}") from exc elif role == "author": identity_data["allowed_operations"] = list(AUTHOR_DEFAULT_ALLOWED) identity_data["forbidden_operations"] = list(AUTHOR_DEFAULT_FORBIDDEN) elif role == "reviewer": identity_data["allowed_operations"] = list(REVIEWER_DEFAULT_ALLOWED) identity_data["forbidden_operations"] = list(REVIEWER_DEFAULT_FORBIDDEN) + elif role == "reconciler": + identity_data["allowed_operations"] = list(RECONCILER_DEFAULT_ALLOWED) + identity_data["forbidden_operations"] = list(RECONCILER_DEFAULT_FORBIDDEN) else: raise ValueError( f"Profile '{name}' has no explicit operation lists and no " "unambiguous author/reviewer role marker (fail closed)" ) + if role == "reconciler": + _assert_reconciler_required_survive( + identity_data["allowed_operations"], name + ) + # Nest inside environments/services structure env = environments.setdefault(env_name, {}) services = env.setdefault("services", {}) diff --git a/namespace_workspace_binding.py b/namespace_workspace_binding.py index 4831907..3f44f3d 100644 --- a/namespace_workspace_binding.py +++ b/namespace_workspace_binding.py @@ -56,23 +56,46 @@ def resolve_namespace_workspace( env: dict[str, str] | os._Environ | None = None, session_lease_worktree: str | None = None, profile_name: str | None = None, + demotions: list[str] | None = None, + verify_paths: bool = False, ) -> tuple[str, str]: - """Return ``(resolved_path, binding_source)`` for *role_kind*.""" + """Return ``(resolved_path, binding_source)`` for *role_kind*. + + With *verify_paths*, env-sourced candidates whose path no longer exists + are demoted (#702): a binding to a deleted worktree can never name a + valid task workspace, so resolution falls through to the next candidate. + Explicit arguments are never demoted — a caller-declared path must fail + loudly downstream rather than silently rebind. Demotion notes are + appended to *demotions* when provided. Runtime-context and mutation + guards resolve through :func:`resolve_namespace_mutation_context`, which + always verifies. + """ env_map = env if env is not None else os.environ role = normalize_role_kind(role_kind, profile_name=profile_name) role_env_key = ROLE_WORKTREE_ENVS[role] - for candidate, source in ( - (worktree_path, "worktree_path argument"), - (worktree, "worktree argument"), - (_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"), - (_env_value(env_map, role_env_key), f"{role_env_key} environment variable"), + for candidate, source, env_sourced in ( + (worktree_path, "worktree_path argument", False), + (worktree, "worktree argument", False), + (_env_value(env_map, ACTIVE_WORKTREE_ENV), + f"{ACTIVE_WORKTREE_ENV} environment variable", True), + (_env_value(env_map, role_env_key), + f"{role_env_key} environment variable", True), (session_lease_worktree if role in {"reviewer", "merger"} else None, - "reviewer PR lease worktree"), + "reviewer PR lease worktree", False), ): text = (candidate or "").strip() - if text: - return os.path.realpath(os.path.abspath(text)), source + if not text: + continue + real = os.path.realpath(os.path.abspath(text)) + if verify_paths and env_sourced and not os.path.isdir(real): + if demotions is not None: + demotions.append( + f"{source} '{real}' demoted: path no longer exists " + "(stale binding, #702)" + ) + continue + return real, source return os.path.realpath(process_project_root), "MCP server process root (default)" @@ -88,6 +111,7 @@ def resolve_namespace_mutation_context( profile_name: str | None = None, ) -> dict: """Shared workspace resolution for runtime_context and mutation guards.""" + demotions: list[str] = [] workspace, binding_source = resolve_namespace_workspace( role_kind=role_kind, worktree_path=worktree_path, @@ -96,6 +120,8 @@ def resolve_namespace_mutation_context( env=env, session_lease_worktree=session_lease_worktree, profile_name=profile_name, + demotions=demotions, + verify_paths=True, ) process_root = os.path.realpath(process_project_root) role = normalize_role_kind(role_kind, profile_name=profile_name) @@ -111,7 +137,7 @@ def resolve_namespace_mutation_context( "workspace_path": workspace, "workspace_binding_source": binding_source, "workspace_role_kind": role, - "ignored_bindings": pollution.get("ignored_bindings") or [], + "ignored_bindings": demotions + (pollution.get("ignored_bindings") or []), "process_project_root": process_root, "canonical_repo_root": canonical_root, "roots_aligned": canonical_root == process_root, diff --git a/pr_sync_status.py b/pr_sync_status.py new file mode 100644 index 0000000..546c42c --- /dev/null +++ b/pr_sync_status.py @@ -0,0 +1,648 @@ +"""PR synchronization and conflict-remediation lifecycle (#PR-SYNC). + +Pure assessment helpers for: + +* ``gitea_assess_pr_sync_status`` — recommend the next sanctioned action for an + open PR when the base branch advances, approvals go stale, or conflicts appear. +* ``gitea_update_pr_branch_by_merge`` preflight — author-only, fail-closed pin + of both PR head and live base head; never rebase/force-push. + +All recommendation logic is hermetic (no network) so unit tests cover every +acceptance path without Gitea credentials. +""" + +from __future__ import annotations + +import re +from typing import Any + +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + +# Recommended next actions (controller / skill vocabulary). +ACTION_MERGE_NOW = "merge_now" +ACTION_UPDATE_BRANCH_BY_MERGE = "update_branch_by_merge" +ACTION_AUTHOR_CONFLICT_REMEDIATION = "author_conflict_remediation" +ACTION_FRESH_REVIEW_REQUIRED = "fresh_review_required" +ACTION_BLOCKED = "blocked" + +_VALID_ACTIONS = frozenset({ + ACTION_MERGE_NOW, + ACTION_UPDATE_BRANCH_BY_MERGE, + ACTION_AUTHOR_CONFLICT_REMEDIATION, + ACTION_FRESH_REVIEW_REQUIRED, + ACTION_BLOCKED, +}) + +# Author-only mutation; reviewer/merger must never update author branches. +_AUTHOR_UPDATE_ROLES = frozenset({"author"}) +_DENIED_UPDATE_ROLES = frozenset({"reviewer", "merger", "reconciler", "mixed", "limited"}) + +# Allowed Gitea update style — merge only (never rebase). +UPDATE_STYLE_MERGE = "merge" +_FORBIDDEN_UPDATE_STYLES = frozenset({"rebase", "rebase-merge", "squash", "force"}) + + +def _normalize_sha(value: str | None) -> str | None: + text = (value or "").strip().lower() + if not text: + return None + return text if _FULL_SHA.match(text) else None + + +def _normalize_role(role: str | None) -> str: + return (role or "").strip().lower() or "unknown" + + +def assess_pr_sync_status( + *, + host: str | None = None, + org: str | None = None, + repo: str | None = None, + pr_number: int, + pr_state: str | None = None, + source_branch: str | None = None, + pr_head_sha: str | None = None, + base_head_sha: str | None = None, + commits_behind: int | None = None, + mergeable: bool | None = None, + has_conflicts: bool | None = None, + branch_protection_requires_current_base: bool | None = None, + approval_at_current_head: bool | None = None, + checks_status: str | None = None, + active_author_lock: bool | None = None, + active_reviewer_lease: bool | None = None, + active_merger_lease: bool | None = None, + prepared_verdict_head_sha: str | None = None, + checks_required: bool = True, +) -> dict[str, Any]: + """Recommend the next sanctioned PR lifecycle action. + + Decision order (fail closed): + + 1. Incomplete identity / open state / head SHAs → ``blocked`` + 2. Conflicts (or mergeable false with conflict signal) → + ``author_conflict_remediation`` + 3. Head changed after approval / prepared verdict for former head → + ``fresh_review_required`` (unless conflicts already routed author work) + 4. Behind live base + branch protection requires current base + + auto-mergeable → ``update_branch_by_merge`` + 5. Approval at current head + mergeable + checks ok (+ update not + required) → ``merge_now`` + 6. Otherwise ``blocked`` with structured reasons + """ + reasons: list[str] = [] + pr_head = _normalize_sha(pr_head_sha) + base_head = _normalize_sha(base_head_sha) + prepared_head = _normalize_sha(prepared_verdict_head_sha) + state = (pr_state or "").strip().lower() + checks = (checks_status or "unknown").strip().lower() + behind = commits_behind if isinstance(commits_behind, int) and commits_behind >= 0 else None + requires_current = bool(branch_protection_requires_current_base) + approval_ok = bool(approval_at_current_head) + + # Derive conflict when caller only supplies mergeable=False. + if has_conflicts is None and mergeable is False: + has_conflicts = True + conflicts = bool(has_conflicts) if has_conflicts is not None else None + + result: dict[str, Any] = { + "host": (host or "").strip() or None, + "org": (org or "").strip() or None, + "repo": (repo or "").strip() or None, + "pr_number": pr_number, + "pr_state": state or None, + "source_branch": (source_branch or "").strip() or None, + "pr_head_sha": pr_head, + "base_head_sha": base_head, + "commits_behind": behind, + "mergeable": mergeable, + "has_conflicts": conflicts, + "branch_protection_requires_current_base": requires_current, + "approval_at_current_head": approval_ok if approval_at_current_head is not None else None, + "checks_status": checks, + "active_locks_and_leases": { + "author_lock": bool(active_author_lock) if active_author_lock is not None else None, + "reviewer_lease": bool(active_reviewer_lease) if active_reviewer_lease is not None else None, + "merger_lease": bool(active_merger_lease) if active_merger_lease is not None else None, + }, + "prepared_verdict_head_sha": prepared_head, + "recommended_next_action": ACTION_BLOCKED, + "approval_valid_for_merge": False, + "stale_approval": False, + "stale_prepared_verdict": False, + "reasons": reasons, + "success": True, + "performed": False, + } + + if not isinstance(pr_number, int) or pr_number <= 0: + reasons.append("pr_number must be a positive integer (fail closed)") + return result + + if state and state not in ("open",): + reasons.append(f"PR is not open (state={state}); cannot synchronize or merge") + result["recommended_next_action"] = ACTION_BLOCKED + return result + if not state: + reasons.append("PR state missing (fail closed)") + return result + + if pr_head is None: + reasons.append( + "exact PR head SHA missing or not a full 40-char hex SHA (fail closed)" + ) + if base_head is None: + reasons.append( + "exact live base head SHA missing or not a full 40-char hex SHA (fail closed)" + ) + if behind is None: + reasons.append("commits_behind missing or invalid (fail closed)") + if mergeable is None: + reasons.append("mergeable signal missing (fail closed)") + if approval_at_current_head is None: + reasons.append("approval_at_current_head missing (fail closed)") + if branch_protection_requires_current_base is None: + reasons.append( + "branch_protection_requires_current_base missing (fail closed)" + ) + + if reasons: + result["recommended_next_action"] = ACTION_BLOCKED + return result + + # Stale prepared verdict / approval across heads. + stale_prepared = bool(prepared_head and prepared_head != pr_head) + result["stale_prepared_verdict"] = stale_prepared + stale_approval = not approval_ok + result["stale_approval"] = stale_approval + result["approval_valid_for_merge"] = bool(approval_ok and not stale_prepared) + + # ── Conflicts: author remediation in dedicated worktree ────────────── + if conflicts is True or mergeable is False: + if conflicts is True or mergeable is False: + # Distinguish pure non-mergeable without explicit conflict flag + # still routes author remediation (Gitea cannot auto-update). + reasons.append( + f"PR #{pr_number} has conflicts or is not mergeable at head " + f"{pr_head}; route author conflict remediation in the existing " + "issue/PR worktree under branches/ (never force-push or rebase)" + ) + if stale_approval: + reasons.append( + "any approval at a former head is invalid; after remediation " + "require a fresh independent review at the new exact head" + ) + result["recommended_next_action"] = ACTION_AUTHOR_CONFLICT_REMEDIATION + result["approval_valid_for_merge"] = False + return result + + # ── Stale approval / prepared verdict at former head ───────────────── + if not approval_ok or stale_prepared: + if behind and behind > 0 and requires_current and mergeable is True: + # Must update first; update will also invalidate approval. + reasons.append( + f"PR is {behind} commit(s) behind live base and branch protection " + "requires current base; automatic merge-from-base is available" + ) + reasons.append( + "approval is not valid at the current head (or prepared verdict " + "is head-scoped to a former SHA); after update require fresh review" + ) + result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE + result["approval_valid_for_merge"] = False + return result + reasons.append( + "approval is not valid at the exact current PR head " + f"({pr_head}); never reuse a former-head approval for merge" + ) + if stale_prepared: + reasons.append( + f"prepared verdict pinned to former head {prepared_head} cannot " + f"authorize review/merge at current head {pr_head}" + ) + if active_reviewer_lease: + reasons.append( + "active reviewer lease must be released or superseded for the " + "new head before a fresh independent review" + ) + if active_merger_lease: + reasons.append( + "active merger lease cannot cross heads; release or re-acquire " + "at the new exact head" + ) + result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED + result["approval_valid_for_merge"] = False + return result + + # ── Outdated + protection requires current base, auto-updatable ────── + if behind and behind > 0 and requires_current: + if mergeable is True and conflicts is not True: + reasons.append( + f"PR is {behind} commit(s) behind live base {base_head}; " + "branch protection requires the PR branch to contain current base; " + "Gitea can merge base into the PR branch without conflicts" + ) + reasons.append( + "route author-only update_branch_by_merge; never rebase or force-push; " + "new head invalidates prior approval and requires fresh independent review" + ) + result["recommended_next_action"] = ACTION_UPDATE_BRANCH_BY_MERGE + # Approval today is at old head that will change — not mergeable yet + # for final merge until re-reviewed, but assess marks update path. + result["approval_valid_for_merge"] = False + result["stale_approval"] = True # will become stale after update + return result + reasons.append( + "update required by branch protection but automatic merge is not available" + ) + result["recommended_next_action"] = ACTION_BLOCKED + return result + + # ── Checks gate for merge_now ──────────────────────────────────────── + if checks_required and checks not in ("success", "passed", "ok", "none", "skipped", "not_required"): + if checks in ("pending", "running", "queued"): + reasons.append(f"required checks are not finished (status={checks})") + result["recommended_next_action"] = ACTION_BLOCKED + return result + if checks in ("failure", "failed", "error", "cancelled"): + reasons.append(f"required checks failed (status={checks})") + result["recommended_next_action"] = ACTION_BLOCKED + return result + # unknown — fail closed when checks_required + if checks == "unknown": + reasons.append("checks status unknown (fail closed)") + result["recommended_next_action"] = ACTION_BLOCKED + return result + + # ── Ready to merge without update ──────────────────────────────────── + # Includes: current with approval; outdated when update is NOT required. + if approval_ok and mergeable is True and conflicts is not True: + if behind and behind > 0 and not requires_current: + reasons.append( + f"PR is {behind} commit(s) behind live base but branch protection " + "does not require the latest base; preserve the approved head" + ) + else: + reasons.append( + "PR has a valid approval at the exact current head, is conflict-free " + "and mergeable; do not update the branch unnecessarily" + ) + reasons.append("route directly to the sanctioned merger workflow (merge_now)") + result["recommended_next_action"] = ACTION_MERGE_NOW + result["approval_valid_for_merge"] = True + return result + + reasons.append("no sanctioned next action matched the observed PR state (fail closed)") + result["recommended_next_action"] = ACTION_BLOCKED + return result + + +def assess_update_pr_branch_preflight( + *, + role_kind: str | None, + expected_pr_head_sha: str | None, + live_pr_head_sha: str | None, + expected_base_head_sha: str | None, + live_base_head_sha: str | None, + has_conflicts: bool | None = None, + mergeable: bool | None = None, + style: str | None = UPDATE_STYLE_MERGE, + has_author_lock: bool | None = None, + worktree_path: str | None = None, + worktree_owns_source_branch: bool | None = None, + control_checkout_clean: bool | None = None, + master_parity_ok: bool | None = None, + force_push: bool = False, + rebase: bool = False, +) -> dict[str, Any]: + """Author-only preflight for update-by-merge; fail closed on any race. + + When conflicts exist, returns a structured author-remediation handoff and + sets ``mutation_allowed=False`` so no partial remote update is performed. + """ + reasons: list[str] = [] + role = _normalize_role(role_kind) + exp_pr = _normalize_sha(expected_pr_head_sha) + live_pr = _normalize_sha(live_pr_head_sha) + exp_base = _normalize_sha(expected_base_head_sha) + live_base = _normalize_sha(live_base_head_sha) + style_norm = (style or "").strip().lower() or UPDATE_STYLE_MERGE + + conflicts = has_conflicts + if conflicts is None and mergeable is False: + conflicts = True + + result: dict[str, Any] = { + "mutation_allowed": False, + "performed": False, + "style": style_norm, + "role_kind": role, + "expected_pr_head_sha": exp_pr, + "live_pr_head_sha": live_pr, + "expected_base_head_sha": exp_base, + "live_base_head_sha": live_base, + "head_race": False, + "base_race": False, + "has_conflicts": conflicts, + "author_remediation_handoff": False, + "approval_invalidated_for_former_head": False, + "force_push": bool(force_push), + "rebase": bool(rebase), + "reasons": reasons, + "success": True, + } + + # Role gate — author only. + if role not in _AUTHOR_UPDATE_ROLES: + reasons.append( + f"role '{role}' cannot update an author PR branch " + "(author-only; reviewer/merger denial)" + ) + return result + + if force_push: + reasons.append("force-push is forbidden for PR branch update (fail closed)") + return result + if rebase or style_norm in _FORBIDDEN_UPDATE_STYLES: + reasons.append( + f"update style '{style_norm}' forbidden; only style=merge is allowed " + "(never rebase)" + ) + return result + if style_norm != UPDATE_STYLE_MERGE: + reasons.append( + f"unknown update style '{style_norm}'; expected '{UPDATE_STYLE_MERGE}'" + ) + return result + + if not exp_pr or not live_pr: + reasons.append( + "expected and live PR head SHAs must be full 40-char hex (fail closed)" + ) + if not exp_base or not live_base: + reasons.append( + "expected and live base head SHAs must be full 40-char hex (fail closed)" + ) + if reasons: + return result + + if exp_pr != live_pr: + result["head_race"] = True + reasons.append( + f"PR head race: expected {exp_pr} but live is {live_pr} " + "(fail closed; no partial mutation)" + ) + return result + if exp_base != live_base: + result["base_race"] = True + reasons.append( + f"base head race: expected {exp_base} but live is {live_base} " + "(fail closed; no partial mutation)" + ) + return result + + if has_author_lock is not True: + reasons.append( + "existing issue/PR lock required before update_branch_by_merge (fail closed)" + ) + wt = (worktree_path or "").strip() + if not wt: + reasons.append("existing PR worktree path required under branches/ (fail closed)") + else: + norm = wt.replace("\\", "/") + if "/branches/" not in norm and not norm.startswith("branches/"): + reasons.append( + f"worktree_path '{wt}' must be under branches/ (fail closed)" + ) + if worktree_owns_source_branch is False: + reasons.append( + "worktree does not own the PR source branch (fail closed)" + ) + if control_checkout_clean is False: + reasons.append("control checkout is not clean (fail closed)") + if master_parity_ok is False: + reasons.append("runtime/master parity failed (fail closed)") + + if conflicts is True: + result["author_remediation_handoff"] = True + reasons.append( + "conflicts present: return structured author-remediation handoff " + "without creating a partial remote update" + ) + reasons.append( + "resolve conflicts explicitly in the existing dedicated worktree, " + "run focused and regression tests, commit/push via sanctioned author " + "workflow, then route the new exact head to independent review" + ) + return result + + if mergeable is False and conflicts is not False: + result["author_remediation_handoff"] = True + reasons.append( + "PR is not mergeable; refuse automatic update and hand off to " + "author conflict remediation (fail closed)" + ) + return result + + if reasons: + return result + + result["mutation_allowed"] = True + reasons.append( + "preflight passed: author may merge live base into the PR branch via " + "native MCP update (style=merge only)" + ) + return result + + +def assess_post_update_head_transition( + *, + former_pr_head_sha: str | None, + new_pr_head_sha: str | None, + former_approval_head_sha: str | None = None, + former_reviewer_lease_head_sha: str | None = None, + former_merger_lease_head_sha: str | None = None, + prepared_verdict_head_sha: str | None = None, +) -> dict[str, Any]: + """After a successful update-by-merge, invalidate cross-head artifacts. + + The new head never inherits approval, reviewer/merger leases, or prepared + verdicts from the former head. + """ + former = _normalize_sha(former_pr_head_sha) + new = _normalize_sha(new_pr_head_sha) + reasons: list[str] = [] + result: dict[str, Any] = { + "former_pr_head_sha": former, + "new_pr_head_sha": new, + "head_changed": bool(former and new and former != new), + "approval_invalidated": False, + "reviewer_lease_superseded": False, + "merger_lease_superseded": False, + "prepared_verdict_invalidated": False, + "recommended_next_action": ACTION_BLOCKED, + "reasons": reasons, + "success": True, + } + + if not former or not new: + reasons.append("former and new PR head SHAs required (fail closed)") + return result + if former == new: + reasons.append( + "PR head unchanged after update; unexpected for merge-from-base" + ) + result["recommended_next_action"] = ACTION_BLOCKED + return result + + result["head_changed"] = True + # Approval at former head is always void at new head. + former_approval = _normalize_sha(former_approval_head_sha) or former + if former_approval != new: + result["approval_invalidated"] = True + reasons.append( + f"approval for former head {former_approval} is invalid at new head " + f"{new}; never preserve approval across a head change" + ) + + rev_lease = _normalize_sha(former_reviewer_lease_head_sha) + if rev_lease and rev_lease != new: + result["reviewer_lease_superseded"] = True + reasons.append( + f"reviewer lease for head {rev_lease} cannot cross to {new}; " + "release or supersede before fresh review" + ) + elif rev_lease is None: + # Unknown lease head still must not authorize at new head. + result["reviewer_lease_superseded"] = True + reasons.append( + "any reviewer lease scoped to the former head is superseded at the new head" + ) + + mer_lease = _normalize_sha(former_merger_lease_head_sha) + if mer_lease and mer_lease != new: + result["merger_lease_superseded"] = True + reasons.append( + f"merger lease for head {mer_lease} cannot cross to {new}" + ) + else: + result["merger_lease_superseded"] = True + reasons.append( + "any merger lease scoped to the former head is superseded at the new head" + ) + + prepared = _normalize_sha(prepared_verdict_head_sha) + if prepared and prepared != new: + result["prepared_verdict_invalidated"] = True + reasons.append( + f"prepared verdict for head {prepared} cannot authorize the new head {new}" + ) + elif prepared is None: + result["prepared_verdict_invalidated"] = True + reasons.append( + "prepared verdicts associated with the former head are invalidated" + ) + + result["recommended_next_action"] = ACTION_FRESH_REVIEW_REQUIRED + reasons.append( + "route the new exact head to independent review " + f"({ACTION_FRESH_REVIEW_REQUIRED})" + ) + return result + + +def assess_sequential_queue_step( + *, + just_merged: bool, + master_refreshed: bool, + next_pr_number: int | None = None, +) -> dict[str, Any]: + """Controller sequential processing: reassess only after merge + master refresh.""" + reasons: list[str] = [] + if just_merged and not master_refreshed: + reasons.append( + "master must be refreshed after each merge before reassessing the next PR " + "(do not update every PR simultaneously)" + ) + return { + "reassess_allowed": False, + "process_next": False, + "next_pr_number": next_pr_number, + "reasons": reasons, + "success": True, + } + if not just_merged: + reasons.append("no merge completed; sequential step does not advance") + return { + "reassess_allowed": False, + "process_next": False, + "next_pr_number": next_pr_number, + "reasons": reasons, + "success": True, + } + if next_pr_number: + reasons.append( + "merge completed and live master refreshed; reassess the next PR " + f"#{next_pr_number}" + ) + else: + reasons.append( + "merge completed and live master refreshed; reassess the next PR" + ) + return { + "reassess_allowed": True, + "process_next": True, + "next_pr_number": next_pr_number, + "post_merge_cleanup_handoff": True, + "reasons": reasons, + "success": True, + } + + +def merge_approval_usable_at_head( + *, + current_head_sha: str | None, + approved_head_sha: str | None, +) -> dict[str, Any]: + """Stale approval cannot authorize merge (head-scoped only).""" + current = _normalize_sha(current_head_sha) + approved = _normalize_sha(approved_head_sha) + ok = bool(current and approved and current == approved) + reasons: list[str] = [] + if not ok: + reasons.append( + f"stale approval: approved head '{approved or '(none)'}' does not " + f"match current head '{current or '(none)'}'; cannot authorize merge" + ) + return { + "approval_usable": ok, + "current_head_sha": current, + "approved_head_sha": approved, + "reasons": reasons, + } + + +def lease_usable_at_head( + *, + lease_kind: str, + lease_head_sha: str | None, + current_head_sha: str | None, +) -> dict[str, Any]: + """Reviewer/merger leases cannot cross heads.""" + current = _normalize_sha(current_head_sha) + lease_head = _normalize_sha(lease_head_sha) + kind = (lease_kind or "").strip().lower() or "unknown" + ok = bool(current and lease_head and current == lease_head) + reasons: list[str] = [] + if not ok: + reasons.append( + f"stale {kind} lease: lease head '{lease_head or '(none)'}' cannot " + f"authorize work at current head '{current or '(none)'}'" + ) + return { + "lease_usable": ok, + "lease_kind": kind, + "lease_head_sha": lease_head, + "current_head_sha": current, + "reasons": reasons, + } diff --git a/reconciler_profile.py b/reconciler_profile.py index 4d9d589..b300ec2 100644 --- a/reconciler_profile.py +++ b/reconciler_profile.py @@ -18,6 +18,11 @@ RECONCILER_RECOMMENDED_OPERATIONS = ( "gitea.pr.comment", "gitea.issue.comment", "gitea.issue.close", + # Merged-branch cleanup is reconciler-owned (task_capability_map maps + # cleanup_merged_pr_branch -> reconciler). The permission is only + # exercisable through the guarded gitea_cleanup_merged_pr_branch path + # (#514): merged proof, protected-branch refusal, explicit confirmation. + "gitea.branch.delete", ) RECONCILER_FORBIDDEN_OPERATIONS = ( diff --git a/requirements.txt b/requirements.txt index bbe0703..f022c56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,6 +31,7 @@ python-multipart==0.0.32 referencing==0.37.0 rich==15.0.0 rpds-py==2026.5.1 +sentry-sdk==2.20.0 shellingham==1.5.4 sse-starlette==3.4.5 starlette==1.3.1 diff --git a/reviewer_pr_lease.py b/reviewer_pr_lease.py index bdbc1c4..796a4cb 100644 --- a/reviewer_pr_lease.py +++ b/reviewer_pr_lease.py @@ -407,18 +407,194 @@ def record_session_lease( if lease_provenance: stored["lease_provenance"] = dict(lease_provenance) _SESSION_LEASE = stored + _persist_session_lease_shadow(stored) return dict(_SESSION_LEASE) def clear_session_lease() -> None: global _SESSION_LEASE + prior = _SESSION_LEASE _SESSION_LEASE = None + _persist_session_lease_shadow(None, prior=prior) def get_session_lease() -> dict[str, Any] | None: return dict(_SESSION_LEASE) if _SESSION_LEASE else None +def _persist_session_lease_shadow( + lease: dict[str, Any] | None, + *, + prior: dict[str, Any] | None = None, +) -> None: + """Best-effort durable shadow of the in-session lease (#702). + + A daemon that dies without teardown leaves this record behind, giving a + later process provable orphan evidence (owner pid + session id) for the + guarded cleanup path. Observability only — mutation gates never read it, + and failures here must never block lease operations. + + Shadows are keyed by lease session id (#702 F5): concurrent same-profile + sessions each own a distinct record, so one session's heartbeat can never + overwrite or misattribute another's crash evidence. A sanctioned clear is + the terminal reconciliation of that record's lifecycle. + """ + try: + import mcp_session_state as mss + + if lease is None: + sid = ((prior or {}).get("session_id") or "").strip() or None + if sid: + mss.clear_state( + kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid + ) + # Legacy single-slot record from pre-F5 code: clearing it is safe + # because collision-safe writes never target that key again. + mss.clear_state(kind=mss.KIND_REVIEWER_SESSION_LEASE) + return + mss.save_state( + kind=mss.KIND_REVIEWER_SESSION_LEASE, + instance_id=((lease.get("session_id") or "").strip() or None), + payload={ + "pr_number": lease.get("pr_number"), + "session_id": lease.get("session_id"), + "comment_id": lease.get("comment_id"), + "candidate_head": lease.get("candidate_head"), + "worktree": lease.get("worktree"), + "phase": lease.get("phase"), + "reviewer_identity": lease.get("reviewer_identity"), + "profile": lease.get("profile"), + "lease_repo": lease.get("repo"), + "owner_pid": os.getpid(), + }, + ) + except Exception: + pass + + +def load_session_lease_shadow( + session_id: str | None = None, +) -> dict[str, Any] | None: + """Load the durable session-lease shadow left by this profile identity. + + With *session_id*, load that session's collision-safe record (#702 F5), + falling back to the legacy single-slot record only when its payload names + the same session. Without *session_id*, return the legacy record or the + sole surviving instance record — ambiguity (multiple candidates) returns + ``None`` because a shadow that cannot be attributed proves nothing. + """ + try: + import mcp_session_state as mss + + sid = (session_id or "").strip() + if sid: + record = mss.load_state( + kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=sid + ) + if record is not None: + return record + legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE) + if legacy and (legacy.get("session_id") or "").strip() == sid: + return legacy + return None + legacy = mss.load_state(kind=mss.KIND_REVIEWER_SESSION_LEASE) + if legacy is not None: + return legacy + records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE) + if len(records) == 1: + return records[0] + return None + except Exception: + return None + + +def _pid_alive(pid: int) -> bool: + """Probe process existence; fail closed toward 'alive' when unsure.""" + try: + os.kill(int(pid), 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except Exception: + return True + + +def assess_crashed_session_lease_orphan( + shadow: dict[str, Any] | None, + *, + lease: dict[str, Any] | None, + current_pid: int | None = None, + pid_alive: bool | None = None, +) -> dict[str, Any]: + """Derive owner-process evidence for a durable lease from the shadow (#702). + + Returns ``owner_process_alive`` tri-state. Only a provably dead recorded + owner pid yields ``False`` (a dead pid cannot still be running the owning + daemon); an alive pid is *not* ownership proof and stays ``None`` unless + it is this very process. Session ids must match exactly — the shadow of a + different lease proves nothing. + """ + reasons: list[str] = [] + if not shadow or not lease: + return { + "orphan_evidence": False, + "owner_process_alive": None, + "reasons": ["no durable session-lease shadow evidence available"], + } + shadow_sid = (shadow.get("session_id") or "").strip() + lease_sid = (lease.get("session_id") or "").strip() + if not shadow_sid or not lease_sid or shadow_sid != lease_sid: + return { + "orphan_evidence": False, + "owner_process_alive": None, + "reasons": [ + "session-lease shadow session_id does not match the durable " + f"lease (shadow={shadow_sid or None}, lease={lease_sid or None})" + ], + } + owner_pid = shadow.get("owner_pid") or shadow.get("writer_pid") + try: + owner_pid = int(owner_pid) + except (TypeError, ValueError): + return { + "orphan_evidence": False, + "owner_process_alive": None, + "reasons": ["session-lease shadow has no usable owner pid (fail closed)"], + } + this_pid = current_pid if current_pid is not None else os.getpid() + if owner_pid == this_pid: + return { + "orphan_evidence": False, + "owner_process_alive": True, + "owner_pid": owner_pid, + "reasons": ["shadow owner pid is the current process"], + } + alive = pid_alive if pid_alive is not None else _pid_alive(owner_pid) + if alive: + reasons.append( + f"shadow owner pid {owner_pid} observed alive; PID liveness is not " + "ownership proof — owner evidence stays unknown (fail closed)" + ) + return { + "orphan_evidence": False, + "owner_process_alive": None, + "owner_pid": owner_pid, + "reasons": reasons, + } + reasons.append( + f"shadow owner pid {owner_pid} is dead: the daemon that recorded the " + "matching session lease exited without teardown (crash orphan, #702)" + ) + return { + "orphan_evidence": True, + "owner_process_alive": False, + "owner_pid": owner_pid, + "reasons": reasons, + } + + def assess_mutation_lease_gate( *, pr_number: int, @@ -557,6 +733,7 @@ _HANDOFF_CLASSIFICATIONS = frozenset({ "foreign_completed_superseded_head", "foreign_expired_superseded_head", "orphaned_owner_missing", + "orphaned_expired_superseded_head", "ambiguous_conflicting_evidence", "instructed_lease_missing_with_replacement", "worktree_binding_mismatch", @@ -850,6 +1027,38 @@ def assess_obsolete_reviewer_comment_lease_cleanup( classification = "foreign_expired_superseded_head" elif head_superseded and has_terminal and not past_expiry: classification = "foreign_completed_superseded_head" + elif head_superseded and not has_terminal and past_expiry: + # Crash-orphan shape (#702): the lease outlived its head with no + # formal terminal review and has now passed canonical expiry, so + # it no longer gates fresh acquisition. Guarded cleanup of the + # ledger marker still demands provable owner-process-exit + # evidence and a safe worktree — never inferred. + classification = "orphaned_expired_superseded_head" + if owner_process_alive is True: + fail_closed_reasons.append( + "lease past expiry on superseded head but owner process " + "still alive; cleanup denied" + ) + elif owner_process_alive is None: + fail_closed_reasons.append( + "expired superseded-head lease with no terminal review: " + "cleanup requires provable owner-process-exit evidence " + "(owner_process_alive=false); the expired lease no longer " + "gates fresh acquisition" + ) + elif worktree_clean is False: + fail_closed_reasons.append( + "owner process absent but worktree dirty; cleanup denied" + ) + elif not (worktree_clean is True or worktree_exists is False): + # #702 F3: unknown worktree evidence must fail closed, exactly + # like the sibling orphaned_owner_missing gate and the + # diagnosis path — cleanup needs affirmative safe evidence. + fail_closed_reasons.append( + "owner process absent but worktree evidence is unknown; " + "cleanup requires affirmative safe evidence " + "(worktree_clean=true or worktree_exists=false)" + ) elif head_superseded and not has_terminal: classification = "ambiguous_conflicting_evidence" fail_closed_reasons.append( @@ -891,6 +1100,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup( "foreign_expired_superseded_head", "foreign_expired_current_head", "orphaned_owner_missing", + "orphaned_expired_superseded_head", }: fail_closed_reasons.append( "controller/recovery capability required " @@ -909,6 +1119,7 @@ def assess_obsolete_reviewer_comment_lease_cleanup( "foreign_completed_superseded_head", "foreign_expired_superseded_head", "orphaned_owner_missing", + "orphaned_expired_superseded_head", "foreign_expired_current_head", } # foreign_expired_current_head needs orphan/clean worktree + authority. @@ -1181,6 +1392,30 @@ def diagnose_reviewer_pr_lease_handoff( "foreign lease pinned to superseded head with completed formal " "review; use guarded obsolete-lease cleanup (do not wait indefinitely)" ) + elif not is_own and head_superseded and not terminal and past_expiry: + # Crash-orphan shape after canonical expiry (#702): the expired + # marker no longer gates acquisition, so a fresh reviewer may + # acquire at the current head. Guarded ledger cleanup becomes + # available only with provable owner-exit evidence. + classification = "orphaned_expired_superseded_head" + if owner_process_alive is False and ( + worktree_clean is True or worktree_exists is False + ): + next_action = NEXT_ACTION_CLEANUP_OBSOLETE_LEASE + reasons.append( + "expired superseded-head lease with no terminal review and " + "provable owner-process exit (crash orphan, #702); guarded " + "obsolete-lease cleanup eligible" + ) + else: + next_action = NEXT_ACTION_ACQUIRE + reasons.append( + "lease head superseded and lease past canonical expiry with " + "no formal terminal review (crash orphan, #702); expired " + "marker no longer gates acquisition — acquire a fresh lease " + "at the current head; guarded cleanup needs " + "owner_process_alive=false and a clean/absent worktree" + ) elif not is_own and head_superseded and not terminal: classification = "ambiguous_conflicting_evidence" next_action = NEXT_ACTION_WAIT diff --git a/sentry_observability.py b/sentry_observability.py new file mode 100644 index 0000000..292af7a --- /dev/null +++ b/sentry_observability.py @@ -0,0 +1,535 @@ +"""Optional self-hosted Sentry observability for the Gitea MCP server (#606). + +Adds env-var-gated Sentry SDK instrumentation so runtime errors, fail-closed +workflow blockers, lease/terminal-lock/stale-runtime collisions, and recurring +watchdog check-ins are visible in a *self-hosted* Sentry at +``https://sentry.prgs.cc/`` — never Sentry Cloud, and never as the workflow +source of truth (Gitea stays canonical). + +Design constraints (mirror ``gitea_audit`` and the #612 incident bridge): + +- **Off by default.** With ``MCP_SENTRY_ENABLED`` false/unset *or* ``SENTRY_DSN`` + empty, ``init_sentry`` is a no-op and no events are ever sent — existing tool + behaviour and API-call patterns are unchanged (acceptance criterion 1). +- **Fail *open* for observability.** A Sentry outage, a missing ``sentry_sdk`` + package, or any capture error must never break an MCP tool success path. Every + public entry point swallows its own exceptions. +- **Fail *closed* for redaction.** If a field cannot be proven safe it is dropped + rather than sent. Tokens, passwords, keychain IDs, DSNs, private config, raw + session-state, full prompt bodies, and full filesystem paths never leave here. +- **No hard dependency.** ``sentry_sdk`` is imported lazily; the module is fully + importable and testable without it installed. + +Sentry is observe-only: it must not approve, merge, close, or otherwise mutate +Gitea workflow state, nor bypass leases, #332, or MCP gates. Alerts may only feed +the sanctioned Gitea issue/comment path via the #612 incident bridge. +""" + +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass +from typing import Any + +# Reuse the most comprehensive existing scrubber so redaction stays consistent +# with the #612 incident bridge (tokens, DSNs, cookies, bearer/basic, keychain +# ids, session ids, user:pass@host). +from incident_bridge import redact_text as _redact_text + +# Second, complementary scrubber: catches bare ``token `` / +# ``Bearer `` / ``Basic `` prefixes and raw URLs that the +# incident-bridge delimiter patterns miss. +from gitea_audit import _redact_str as _redact_prefixes + +# ── Optional SDK (lazy, never a hard dependency) ──────────────────────────── +try: # pragma: no cover - trivial import guard + import sentry_sdk # type: ignore +except Exception: # pragma: no cover - absence is a supported state + sentry_sdk = None # type: ignore + + +# ── Env var names (single source of truth) ────────────────────────────────── +ENV_ENABLED = "MCP_SENTRY_ENABLED" +ENV_DSN = "SENTRY_DSN" +ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT" +ENV_RELEASE = "SENTRY_RELEASE" +ENV_TRACES_SAMPLE_RATE = "MCP_SENTRY_TRACES_SAMPLE_RATE" +ENV_ENABLE_LOGS = "MCP_SENTRY_ENABLE_LOGS" + +_TRUTHY = frozenset({"1", "true", "yes", "on"}) + +REDACTED = "[REDACTED]" +REDACTED_PATH = "[REDACTED_PATH]" + + +# ── Cron / watchdog monitor slugs (acceptance criterion 6) ────────────────── +# Stable slugs for the recurring/watchdog jobs #606 wants check-ins for. The +# slug is the durable monitor identity in Sentry; the wiring call sites pass one +# of these keys (or an explicit slug) to ``monitor_checkin``. +MONITOR_SLUGS: dict[str, str] = { + "stale_lease_scan": "gitea-mcp-stale-lease-scan", + "terminal_lock_scan": "gitea-mcp-terminal-lock-scan", + "allocator_health": "gitea-mcp-allocator-health", + "namespace_health": "gitea-mcp-namespace-health", + "dashboard_freshness": "gitea-mcp-dashboard-freshness", + "reconciler_cleanup": "gitea-mcp-reconciler-cleanup", +} + +_CHECKIN_STATUSES = frozenset({"in_progress", "ok", "error"}) + + +# ── Tag allowlist (issue "Suggested Sentry tags/context") ─────────────────── +# Only these keys are ever attached as Sentry tags. Anything else is dropped so +# a caller cannot accidentally leak a sensitive value through a tag. +ALLOWED_TAG_KEYS = frozenset({ + "role", + "profile", + "namespace", + "repo", + "org", + "issue_number", + "pr_number", + "blocker_type", + "workflow_hash", + "session_id_hash", # hash only — never the raw session id + "pid", + "worktree_category", # category, never the full sensitive path + "lease_comment_id", + "expected_head_sha", + "current_head_sha", + "terminal_lock_state", + "capability", + "mutation_tool", +}) + +# Absolute-path shapes that must never be sent verbatim (macOS/Linux + temp). +_PATH_RE = re.compile(r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*") + +# ``extra`` keys whose *full* contents are forbidden by the redaction rules +# (raw session-state, full prompt/comment bodies, private config blobs, raw +# headers). +_FORBIDDEN_EXTRA_KEYS = frozenset({ + "prompt", + "prompt_body", + "next_prompt", + "body", + "raw_body", + "session_state", + "session_state_contents", + "config", + "config_contents", + "private_config", + "headers", + "authorization", +}) + + +# ── Configuration ─────────────────────────────────────────────────────────── +@dataclass(frozen=True) +class SentryConfig: + """Immutable snapshot of the Sentry env configuration.""" + + enabled: bool = False + dsn: str | None = None + environment: str = "development" + release: str | None = None + traces_sample_rate: float = 0.0 + enable_logs: bool = False + + @property + def active(self) -> bool: + """True only when the operator both opted in *and* supplied a DSN. + + This is the single gate that keeps the feature off by default: enabling + the flag without a DSN (or vice versa) sends nothing. + """ + return bool(self.enabled and self.dsn) + + def safe_summary(self) -> dict[str, Any]: + """Operator-facing status with **no** DSN value (only presence).""" + return { + "enabled": self.enabled, + "dsn_present": bool(self.dsn), + "environment": self.environment, + "release": self.release, + "traces_sample_rate": self.traces_sample_rate, + "enable_logs": self.enable_logs, + "active": self.active, + } + + +def _env_bool(name: str, env: dict[str, str]) -> bool: + return (env.get(name) or "").strip().lower() in _TRUTHY + + +def _env_float(name: str, default: float, env: dict[str, str]) -> float: + raw = (env.get(name) or "").strip() + if not raw: + return default + try: + val = float(raw) + except (TypeError, ValueError): + return default + # Clamp to Sentry's valid [0.0, 1.0] sample-rate range. + if val < 0.0: + return 0.0 + if val > 1.0: + return 1.0 + return val + + +def load_config(env: dict[str, str] | None = None) -> SentryConfig: + """Build a :class:`SentryConfig` from the environment (read at call time).""" + env = dict(os.environ if env is None else env) + dsn = (env.get(ENV_DSN) or "").strip() or None + return SentryConfig( + enabled=_env_bool(ENV_ENABLED, env), + dsn=dsn, + environment=(env.get(ENV_ENVIRONMENT) or "").strip() or "development", + release=(env.get(ENV_RELEASE) or "").strip() or None, + traces_sample_rate=_env_float(ENV_TRACES_SAMPLE_RATE, 0.0, env), + enable_logs=_env_bool(ENV_ENABLE_LOGS, env), + ) + + +def sdk_available() -> bool: + """True when the optional ``sentry_sdk`` package is importable.""" + return sentry_sdk is not None + + +# ── Redaction (fail closed) ───────────────────────────────────────────────── +def sanitize_path(value: Any) -> str: + """Reduce a filesystem path to a non-sensitive *category* token. + + Full local paths must never be sent. We keep only a coarse worktree + category derived from the path shape (author/reviewer/merger/reconciler/ + branches/root/other). + """ + text = "" if value is None else str(value) + low = text.lower() + if not text: + return "unknown" + # Order matters: more specific role markers before the generic "branches". + if "reconcile" in low: + return "reconciler" + if "review" in low: + return "reviewer" + if "merge" in low or "merger" in low: + return "merger" + if "author" in low or re.search(r"/branches/(?:feat|fix|docs|chore|issue)", low): + return "author" + if "/branches/" in low: + return "branches" + if low.rstrip("/").endswith("gitea-tools"): + return "root" + return "other" + + +def redact_value(value: Any) -> Any: + """Recursively redact a JSON-able value: secret text, absolute paths, and + known-sensitive dict keys are removed. Fail closed — any error drops the + value entirely rather than risk leaking it.""" + try: + if isinstance(value, dict): + out: dict[str, Any] = {} + for k, v in value.items(): + key = str(k) + low = key.lower() + if low in _FORBIDDEN_EXTRA_KEYS or any( + s in low + for s in ("token", "secret", "password", "cookie", "auth", "dsn", "keychain") + ): + out[key] = REDACTED + continue + out[key] = redact_value(v) + return out + if isinstance(value, (list, tuple)): + return [redact_value(v) for v in value] + if isinstance(value, str): + scrubbed = _redact_text(value) + scrubbed = _redact_prefixes(scrubbed) + scrubbed = _PATH_RE.sub(REDACTED_PATH, scrubbed) + return scrubbed + return value + except Exception: + return REDACTED + + +def hash_session_id(session_id: Any) -> str: + """Short, stable, non-reversible fingerprint of a session id.""" + digest = hashlib.sha256(str(session_id).encode("utf-8", "replace")).hexdigest() + return digest[:12] + + +def build_tags(**kwargs: Any) -> dict[str, str]: + """Return a scrubbed, allowlisted tag dict. + + ``session_id`` is accepted but only ever surfaced as ``session_id_hash``. + ``worktree_path`` collapses to ``worktree_category``. Any non-allowlisted + key, or a value that still contains redacted material after scrubbing, is + dropped. + """ + raw: dict[str, Any] = dict(kwargs) + + # Hash the session id — never emit it raw. + session_id = raw.pop("session_id", None) + if session_id and "session_id_hash" not in raw: + raw["session_id_hash"] = hash_session_id(session_id) + + # A full worktree path collapses to a category tag. + wt = raw.pop("worktree_path", None) + if wt and "worktree_category" not in raw: + raw["worktree_category"] = sanitize_path(wt) + + out: dict[str, str] = {} + for key, val in raw.items(): + if key not in ALLOWED_TAG_KEYS: + continue + if val is None: + continue + scrubbed = redact_value(val) + text = str(scrubbed) + if not text or REDACTED in text or REDACTED_PATH in text: + continue + if len(text) > 200: + text = text[:200] + "…" + out[key] = text + return out + + +def scrub_event(event: Any, hint: Any = None) -> dict[str, Any] | None: + """Sentry ``before_send`` / ``before_send_log`` hook. + + Recursively redacts the outgoing event. On *any* failure it returns ``None`` + so the event is dropped rather than sent unscrubbed (fail closed for + redaction). + """ + try: + if not isinstance(event, dict): + return None + scrubbed = redact_value(event) + # Drop server_name if it leaked a hostname/path; PID is kept via tags. + scrubbed.pop("server_name", None) + return scrubbed + except Exception: + return None + + +# ── Event builders (pure, independently testable) ─────────────────────────── +def build_blocker_event( + blocker_type: str, + *, + message: str | None = None, + next_action: str | None = None, + level: str = "warning", + tags: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a redacted, structured Sentry event for a workflow blocker. + + ``next_action`` maps the issue's "canonical next action when available" + requirement (acceptance criterion 7). + """ + merged_tags = dict(tags or {}) + merged_tags.setdefault("blocker_type", blocker_type) + safe_tags = build_tags(**merged_tags) + + safe_extra = redact_value(dict(extra or {})) + if next_action: + # A short canonical next action is allowed (it is not a full prompt). + safe_extra["canonical_next_action"] = redact_value(str(next_action)[:500]) + + event: dict[str, Any] = { + "message": redact_value(message or blocker_type), + "level": level if level in ("debug", "info", "warning", "error", "fatal") else "warning", + "logger": "gitea-mcp.workflow", + "tags": safe_tags, + "extra": safe_extra, + "fingerprint": ["workflow-blocker", blocker_type], + } + return event + + +def build_checkin_payload( + monitor: str, + status: str, + *, + check_in_id: str | None = None, + duration: float | None = None, +) -> dict[str, Any]: + """Build a Sentry cron check-in payload for one of :data:`MONITOR_SLUGS`. + + ``monitor`` may be a registry key (e.g. ``"stale_lease_scan"``) or an + explicit slug. Raises ``ValueError`` on an unknown status so callers cannot + silently send a malformed check-in. + """ + if status not in _CHECKIN_STATUSES: + raise ValueError( + f"invalid check-in status {status!r}; expected one of {sorted(_CHECKIN_STATUSES)}" + ) + slug = MONITOR_SLUGS.get(monitor, monitor) + payload: dict[str, Any] = {"monitor_slug": slug, "status": status} + if check_in_id: + payload["check_in_id"] = str(check_in_id) + if duration is not None: + try: + payload["duration"] = float(duration) + except (TypeError, ValueError): + pass + return payload + + +# ── Runtime init + capture (fail open) ────────────────────────────────────── +_STATE: dict[str, Any] = {"initialized": False, "config": None} + + +def is_initialized() -> bool: + return bool(_STATE.get("initialized")) + + +def active_config() -> SentryConfig | None: + return _STATE.get("config") + + +def reset_for_tests() -> None: + """Clear module init state. Test-only helper (never called in production).""" + _STATE["initialized"] = False + _STATE["config"] = None + + +def init_sentry(config: SentryConfig | None = None) -> dict[str, Any]: + """Initialise the Sentry SDK if (and only if) enabled + DSN + SDK present. + + Idempotent and never raises. Returns an operator-safe status dict (no DSN + value). Behaviour is unchanged when the feature is off. + """ + cfg = config or load_config() + status: dict[str, Any] = {"initialized": False, **cfg.safe_summary()} + try: + if not cfg.active: + status["reason"] = "disabled (MCP_SENTRY_ENABLED false or SENTRY_DSN empty)" + _STATE["config"] = cfg + return status + if not sdk_available(): + status["reason"] = "sentry_sdk not installed" + _STATE["config"] = cfg + return status + + init_kwargs: dict[str, Any] = { + "dsn": cfg.dsn, + "environment": cfg.environment, + "release": cfg.release, + "traces_sample_rate": cfg.traces_sample_rate, + "before_send": scrub_event, + "send_default_pii": False, + } + if cfg.enable_logs: + # sentry-sdk 2.x captures Python logs as structured logs when the + # experimental logs feature is enabled; scrub those too. + init_kwargs["_experiments"] = { + "enable_logs": True, + "before_send_log": scrub_event, + } + sentry_sdk.init(**init_kwargs) # type: ignore[union-attr] + _STATE["initialized"] = True + _STATE["config"] = cfg + status["initialized"] = True + status["reason"] = "sentry initialised" + except Exception as exc: # fail open: observability must not block startup + status["reason"] = f"init failed (ignored): {type(exc).__name__}" + _STATE["initialized"] = False + return status + + +def _set_scope_tags(scope: Any, tags: dict[str, str]) -> None: + for key, val in tags.items(): + try: + scope.set_tag(key, val) + except Exception: + pass + + +def capture_workflow_blocker( + blocker_type: str, + *, + message: str | None = None, + next_action: str | None = None, + level: str = "warning", + tags: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Capture a fail-closed workflow blocker as a structured Sentry event. + + Always returns the redacted event dict (so callers/tests can inspect it), + and sends it to Sentry only when initialised. Fail open. + """ + event = build_blocker_event( + blocker_type, + message=message, + next_action=next_action, + level=level, + tags=tags, + extra=extra, + ) + try: + if is_initialized() and sdk_available(): + sentry_sdk.capture_event(event) # type: ignore[union-attr] + except Exception: + pass + return event + + +def capture_exception( + exc: BaseException, + *, + tags: dict[str, Any] | None = None, + extra: dict[str, Any] | None = None, +) -> bool: + """Capture a runtime exception with scrubbed tags. Fail open. + + Returns True only when the event was handed to an initialised SDK. + """ + try: + if not (is_initialized() and sdk_available()): + return False + safe_tags = build_tags(**(tags or {})) + safe_extra = redact_value(dict(extra or {})) + with sentry_sdk.push_scope() as scope: # type: ignore[union-attr] + _set_scope_tags(scope, safe_tags) + for key, val in safe_extra.items(): + try: + scope.set_extra(key, val) + except Exception: + pass + sentry_sdk.capture_exception(exc) # type: ignore[union-attr] + return True + except Exception: + return False + + +def monitor_checkin( + monitor: str, + status: str, + *, + check_in_id: str | None = None, + duration: float | None = None, +) -> dict[str, Any] | None: + """Send a Sentry cron check-in for a watchdog job. Fail open. + + Returns the payload (for inspection/tests), or ``None`` if the status was + invalid. Only transmits when initialised. + """ + try: + payload = build_checkin_payload( + monitor, status, check_in_id=check_in_id, duration=duration + ) + except ValueError: + return None + try: + if is_initialized() and sdk_available() and hasattr(sentry_sdk, "capture_checkin"): + sentry_sdk.capture_checkin(**payload) # type: ignore[union-attr] + except Exception: + pass + return payload diff --git a/skills/llm-project-workflow/templates/merge-pr.md b/skills/llm-project-workflow/templates/merge-pr.md index 04b4730..5507096 100644 --- a/skills/llm-project-workflow/templates/merge-pr.md +++ b/skills/llm-project-workflow/templates/merge-pr.md @@ -33,22 +33,34 @@ Steps: *If the current identity does not match the required role (or is the PR author), STOP. Relaunch/switch to the correct profile first.* 2. Verify authenticated identity + active profile. 3. Confirm PR #: author (not you), state open, mergeable, review approved. Check if PR body uses `Closes #N` or `Fixes #N`; if it uses `Implements #N` or `Refs #N`, manual closing will be needed in step 29. -4. Capability evidence (#179): cite the exact gitea_resolve_task_capability +4. **PR sync assess (required):** call `gitea_assess_pr_sync_status` with + explicit `remote`/`org`/`repo`. Route on `recommended_next_action`: + - `merge_now` → continue merger path (do **not** update the branch) + - `update_branch_by_merge` → STOP; hand off to **author** for + `gitea_update_pr_branch_by_merge` (pins expected PR head + base head) + - `author_conflict_remediation` → STOP; author worktree conflict fix + - `fresh_review_required` → STOP; independent re-review at current head + - `blocked` → STOP and diagnose + Never merge on a former-head approval after update/remediation. +5. Capability evidence (#179): cite the exact gitea_resolve_task_capability output (or runtime context) proving merge_pr is allowed — a bare "capability checks passed" claim is downgraded. -5. Final live-state recheck (#179), immediately before the merge mutation — +6. Final live-state recheck (#179), immediately before the merge mutation — re-read the live PR and prove: - PR still open - live head SHA still equals the pinned/reviewed head SHA - base branch unchanged - no undismissed REQUEST_CHANGES / blocking review state remains If any recheck fails → STOP, re-pin, re-validate. -6. If any gate fails → STOP and report. -7. Merge with explicit confirmation (e.g. confirmation="MERGE PR "), +7. If any gate fails → STOP and report. +8. Merge with explicit confirmation (e.g. confirmation="MERGE PR "), pinning the reviewed head SHA (expected_head_sha) and, where supported, the changed-file set. -8. Confirm remote master now contains the merge commit (or the expected changes if squash merged). +9. Confirm remote master now contains the merge commit (or the expected changes if squash merged). *Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.* +10. **Sequential queue:** after merge, refresh live `master`, run post-merge + cleanup handoff, then reassess the **next** PR (do not batch-update all + open PRs). Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup. - Record merge mutations separately from cleanup mutations in the controller handoff. diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 95d0bc4..4ac6cce 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -950,9 +950,53 @@ Final reports must state: * final live head SHA before merge * whether any push occurred during validation +## 26D. PR synchronization and conflict-remediation lifecycle + +**Do not treat “approved” as the final readiness state.** For every approved +open PR, call `gitea_assess_pr_sync_status` (native MCP only) and route by +`recommended_next_action`: + +| Action | Meaning | Next role / tools | +|--------|---------|-------------------| +| `merge_now` | Valid approval at exact current head; conflict-free; update not required; checks ok | Merger: sanctioned merge workflow only — **do not** update the branch | +| `update_branch_by_merge` | Behind live base; protection requires current base; Gitea can merge base without conflicts | **Author only:** `gitea_update_pr_branch_by_merge` with pinned `expected_pr_head_sha` + `expected_base_head_sha` | +| `author_conflict_remediation` | Conflicts / not auto-updatable | **Author only:** existing `branches/` worktree, issue lock, merge master, resolve, test, push; never force-push/rebase | +| `fresh_review_required` | Head changed after approval, or update/remediation produced a new head | Independent reviewer at the **new exact head**; old approvals/leases/verdicts are void | +| `blocked` | Other gate (checks, incomplete facts, closed PR, …) | Diagnose; do not merge or update | + +### Hard rules + +* Pin both expected PR head and expected base head for every update. Either + race → fail closed with no partial mutation. +* Never rebase or force-push author branches. +* Never update an author branch from a reviewer or merger profile. +* Never preserve approval, reviewer lease, merger lease, or prepared verdict + across a head change. +* Process PRs **sequentially**: synchronize/remediate one → review new head → + merge → refresh live `master` → reassess the next PR. Do not update every + open PR at once (each merge restales the rest). +* Conflict remediation only in the existing dedicated issue/PR worktree under + `branches/`. Do not delete that worktree until the updated PR is merged and + cleanup eligibility is proven. +* Post-merge: canonical cleanup handoff to reconciler (section 28). + +### After `gitea_update_pr_branch_by_merge` succeeds + +1. Treat former-head approval as invalidated. +2. Release/supersede obsolete reviewer and merger leases. +3. Route `recommended_next_action=fresh_review_required` at the new head. +4. Independent re-review, then merger lease/adopt + merge at the new head only. + +All Gitea reads/mutations use native MCP. Never substitute direct API, curl, +tea/gh, Web UI mutation by an LLM, database changes, raw Git push, or token +access. + ## 27. Merge rules -Before merge, rerun fresh live checks: +Before merge, call `gitea_assess_pr_sync_status` when the PR is approved/open +and may be behind base. Only proceed with merge when +`recommended_next_action` is `merge_now` and approval remains at the current +head. Then rerun fresh live checks: * whoami * active profile/runtime diff --git a/stale_binding_recovery.py b/stale_binding_recovery.py new file mode 100644 index 0000000..37b73d4 --- /dev/null +++ b/stale_binding_recovery.py @@ -0,0 +1,257 @@ +"""Sanctioned recovery for stale env-bound task workspace bindings (#702). + +When the MCP daemon dies from an unhandled exception it never runs teardown, +and the auto-reconnected daemon inherits ``GITEA_ACTIVE_WORKTREE`` from its +parent environment. That inherited value can permanently bind the fresh +session to a prior task's worktree (the PR #701 / ``review-pr-654`` incident). + +This module classifies the active env binding against live session evidence +and produces a fail-closed recovery plan. Only two situations permit the +daemon to clear the binding on its own: + +- the bound path no longer exists on disk (``provably_stale_missing_path``) +- the binding was inherited at daemon boot and a *sanctioned* in-session + reviewer/merger lease later bound a different worktree + (``superseded_by_session_lease``) + +Everything else is surfaced (``unverified_inherited``) and left for the +managed reconnect path — never cleared silently, never rebound to a guessed +worktree. +""" + +from __future__ import annotations + +import os +from typing import Any + +ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE" + +CLASSIFICATION_UNBOUND = "unbound" +CLASSIFICATION_CORROBORATED = "corroborated" +CLASSIFICATION_UNVERIFIED_INHERITED = "unverified_inherited" +CLASSIFICATION_PROVABLY_STALE_MISSING_PATH = "provably_stale_missing_path" +CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE = "superseded_by_session_lease" + +CLASSIFICATIONS = frozenset({ + CLASSIFICATION_UNBOUND, + CLASSIFICATION_CORROBORATED, + CLASSIFICATION_UNVERIFIED_INHERITED, + CLASSIFICATION_PROVABLY_STALE_MISSING_PATH, + CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE, +}) + +# Roles whose task workspace is owned by a lease lifecycle, so an inherited +# generic binding without a corroborating lease is suspect. +LEASE_BOUND_ROLES = frozenset({"reviewer", "merger"}) + +RECOVERY_ACTION_NONE = "none" +RECOVERY_ACTION_CLEAR_ENV = "clear_env" + + +def _norm(path: str | None) -> str: + text = (path or "").strip() + if not text: + return "" + return os.path.realpath(os.path.abspath(text)) + + +def snapshot_boot_bindings( + env: dict[str, str] | os._Environ | None = None, +) -> dict[str, Any]: + """Capture worktree env bindings present when the daemon booted. + + A value present in this snapshot was inherited from the parent + environment, not established by any sanctioned tool in this daemon's + lifetime. + """ + env_map = env if env is not None else os.environ + return { + "active_worktree": (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None, + } + + +def classify_active_worktree_binding( + *, + active_value: str | None, + role_env_value: str | None = None, + session_lease_worktree: str | None = None, + boot_inherited: bool, + path_exists: bool | None, + role_kind: str | None = None, +) -> dict[str, Any]: + """Classify the GITEA_ACTIVE_WORKTREE binding against live evidence. + + Fail closed: unknown evidence never yields a clear-eligible class. + """ + reasons: list[str] = [] + active = _norm(active_value) + if not active: + return { + "classification": CLASSIFICATION_UNBOUND, + "clear_eligible": False, + "active_worktree": None, + "reasons": ["no GITEA_ACTIVE_WORKTREE binding present"], + } + + role = (role_kind or "").strip().lower() + role_env = _norm(role_env_value) + lease_wt = _norm(session_lease_worktree) + + result: dict[str, Any] = { + "active_worktree": active, + "boot_inherited": bool(boot_inherited), + "role_kind": role or None, + "session_lease_worktree": lease_wt or None, + "role_env_worktree": role_env or None, + } + + if path_exists is False: + reasons.append( + f"bound worktree '{active}' no longer exists on disk; the binding " + "cannot name a valid task workspace" + ) + result.update({ + "classification": CLASSIFICATION_PROVABLY_STALE_MISSING_PATH, + "clear_eligible": True, + "reasons": reasons, + }) + return result + + if lease_wt and lease_wt == active: + reasons.append("binding matches the sanctioned in-session lease worktree") + result.update({ + "classification": CLASSIFICATION_CORROBORATED, + "clear_eligible": False, + "reasons": reasons, + }) + return result + + if lease_wt and lease_wt != active and boot_inherited: + reasons.append( + "boot-inherited binding disagrees with the sanctioned in-session " + f"lease worktree '{lease_wt}'; the lease is live authority" + ) + result.update({ + "classification": CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE, + "clear_eligible": True, + "reasons": reasons, + }) + return result + + if lease_wt and lease_wt != active and not boot_inherited: + # Set after boot by tooling; disagreement is surfaced, never auto-cleared. + reasons.append( + "post-boot binding disagrees with the in-session lease worktree; " + "surfacing only (fail closed, no automatic clear)" + ) + result.update({ + "classification": CLASSIFICATION_UNVERIFIED_INHERITED, + "clear_eligible": False, + "reasons": reasons, + }) + return result + + if role_env and role_env == active: + reasons.append("binding matches the role-specific worktree env binding") + result.update({ + "classification": CLASSIFICATION_CORROBORATED, + "clear_eligible": False, + "reasons": reasons, + }) + return result + + if boot_inherited and role in LEASE_BOUND_ROLES and not lease_wt: + reasons.append( + "binding was inherited at daemon boot, no sanctioned in-session " + f"lease corroborates it, and the {role} role binds workspaces " + "through the lease lifecycle; treat as unverified until a fresh " + "lease or managed reconnect re-establishes the workspace" + ) + result.update({ + "classification": CLASSIFICATION_UNVERIFIED_INHERITED, + "clear_eligible": False, + "reasons": reasons, + }) + return result + + reasons.append("no contradicting evidence; binding treated as corroborated") + result.update({ + "classification": CLASSIFICATION_CORROBORATED, + "clear_eligible": False, + "reasons": reasons, + }) + return result + + +def plan_recovery(classification_result: dict[str, Any]) -> dict[str, Any]: + """Turn a classification into an explicit, fail-closed recovery plan.""" + classification = classification_result.get("classification") + clear_eligible = bool(classification_result.get("clear_eligible")) + reasons = list(classification_result.get("reasons") or []) + + if classification not in CLASSIFICATIONS: + return { + "action": RECOVERY_ACTION_NONE, + "clear_allowed": False, + "classification": classification, + "reasons": reasons + ["unknown classification (fail closed)"], + } + + if clear_eligible and classification in { + CLASSIFICATION_PROVABLY_STALE_MISSING_PATH, + CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE, + }: + return { + "action": RECOVERY_ACTION_CLEAR_ENV, + "clear_allowed": True, + "classification": classification, + "active_worktree": classification_result.get("active_worktree"), + "reasons": reasons, + } + + exact_next_action = None + if classification == CLASSIFICATION_UNVERIFIED_INHERITED: + exact_next_action = ( + "managed recovery required: reconnect the MCP namespace through " + "the managed client configuration (or start a fresh session) so " + "the daemon boots without the inherited GITEA_ACTIVE_WORKTREE, or " + "corroborate the binding by acquiring a lease for that worktree; " + "do not clear or rewrite the environment manually" + ) + return { + "action": RECOVERY_ACTION_NONE, + "clear_allowed": False, + "classification": classification, + "active_worktree": classification_result.get("active_worktree"), + "reasons": reasons, + **({"exact_next_action": exact_next_action} if exact_next_action else {}), + } + + +def apply_recovery( + plan: dict[str, Any], + env: dict[str, str] | os._Environ | None = None, +) -> dict[str, Any]: + """Apply a clear-eligible plan by removing the stale env binding. + + This is the sanctioned in-daemon mechanism (#702 AC2); callers must + persist the returned record for audit. Refuses anything but an explicit + ``clear_env`` plan. + """ + env_map = env if env is not None else os.environ + if not plan.get("clear_allowed") or plan.get("action") != RECOVERY_ACTION_CLEAR_ENV: + return { + "performed": False, + "action": RECOVERY_ACTION_NONE, + "reasons": ["recovery plan does not authorize clearing (fail closed)"], + } + cleared_value = (env_map.get(ACTIVE_WORKTREE_ENV) or "").strip() or None + env_map.pop(ACTIVE_WORKTREE_ENV, None) + return { + "performed": True, + "action": RECOVERY_ACTION_CLEAR_ENV, + "cleared_env": ACTIVE_WORKTREE_ENV, + "cleared_value": cleared_value, + "classification": plan.get("classification"), + "reasons": list(plan.get("reasons") or []), + } diff --git a/stale_review_decision_lock.py b/stale_review_decision_lock.py index 5f47486..82239b7 100644 --- a/stale_review_decision_lock.py +++ b/stale_review_decision_lock.py @@ -1,4 +1,4 @@ -"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620). +"""Stale / moot #332 review-decision lock detection and cleanup policy (#594/#620/#693). #332 correctly hard-stops a reviewer session after a terminal live review mutation. After #559 those locks are durable on disk, so they can outlive the @@ -12,6 +12,11 @@ on head B of the **same open PR**. Same-head duplicates remain fail-closed. Open-PR lock *cleanup* remains forbidden unless the PR is truly merged/closed (#594); new-head re-review is allowed without deleting the durable lock. +#693 scopes the terminal boundary by **PR number**: a terminal on open PR A +must not block a fresh formal decision on open PR B on the same durable +profile lock (cross-PR isolation). Correction authorization is scoped to the +named prior review's PR/head and cannot unlock a different PR. + This module is pure policy (no Gitea I/O). The MCP tool ``gitea_cleanup_stale_review_decision_lock`` fetches live PR state, calls these helpers, and only then clears durable state when cleanup is allowed. @@ -80,17 +85,45 @@ def last_terminal_mutation(lock: dict | None) -> dict | None: return terminals[-1] if terminals else None +def correction_applies( + lock: dict | None, + *, + pr_number: int | None = None, + expected_head_sha: str | None = None, +) -> bool: + """True when operator correction is active and scoped to the target (#693). + + Global ``correction_authorized`` alone is not enough: correction must name + the same PR (and head when recorded) as the decision being re-opened. + Missing scope fields on legacy locks fail closed (do not apply). + """ + if not lock or not lock.get("correction_authorized"): + return False + corr_pr = lock.get("correction_pr_number") + if corr_pr is None: + # Legacy unscoped correction — refuse as generic unlock (#693). + return False + if pr_number is not None and int(corr_pr) != int(pr_number): + return False + corr_head = normalize_head_sha(lock.get("correction_head_sha")) + target = normalize_head_sha(expected_head_sha) + if corr_head and target and not heads_equal(corr_head, target): + return False + return True + + def prior_live_mutations_block_boundary( lock: dict | None, *, pr_number: int, expected_head_sha: str | None, ) -> bool: - """True when prior live mutations block a new decision for PR+head (#620). + """True when prior live mutations block a new decision for PR+head (#620/#693). Historical terminals on a **different head of the same PR** do not block. - Any prior mutation on another PR, the same head, or without a comparable - head SHA fails closed (blocks). + Terminals on a **different PR** do not block (#693 cross-PR isolation). + Same PR + same head (or same PR without a comparable head SHA) blocks + unless a scoped correction applies. Head resolution uses ``mutation_head_sha(m, lock)`` so pre-#620 ledgers that only stored ``ready_expected_head_sha`` still compare correctly @@ -98,7 +131,9 @@ def prior_live_mutations_block_boundary( """ if not lock: return False - if lock.get("correction_authorized"): + if correction_applies( + lock, pr_number=pr_number, expected_head_sha=expected_head_sha + ): return False prior = list(lock.get("live_mutations") or []) if not prior: @@ -108,10 +143,14 @@ def prior_live_mutations_block_boundary( if not isinstance(m, dict): return True m_pr = m.get("pr_number") + if m_pr is None: + return True # fail closed — unscoped mutation + if int(m_pr) != int(pr_number): + # #693: foreign-PR history on the shared durable lock does not block. + continue m_head = mutation_head_sha(m, lock) if ( - m_pr == pr_number - and target + target and m_head and not heads_equal(m_head, target) ): @@ -128,23 +167,34 @@ def terminal_boundary_allows_fresh_decision( expected_head_sha: str | None, operation: str, ) -> bool: - """Whether #332 hard-stop should yield for a new PR+head boundary (#620). + """Whether #332 hard-stop should yield for a new PR+head boundary (#620/#693). - Only for ``mark_ready`` / ``review`` / ``resume`` on the **same PR** when - the requested head differs from the last terminal's head. Merge is never - reopened by head change (approved head merge path is separate). + For ``mark_ready`` / ``review`` / ``resume``: + + * **same PR, different head** — allowed (#620) + * **different PR than last terminal** — allowed (#693 cross-PR isolation) + * **same PR, same head** — not allowed (unless scoped correction) + + Merge is never reopened by head change or cross-PR isolation (approved + head merge path is separate). """ if operation not in ("mark_ready", "review", "resume"): return False if not lock: return False - if lock.get("correction_authorized"): + if correction_applies( + lock, pr_number=pr_number, expected_head_sha=expected_head_sha + ): return True last = last_terminal_mutation(lock) if last is None: return True - if last.get("pr_number") != pr_number: + last_pr = last.get("pr_number") + if last_pr is None: return False + if int(last_pr) != int(pr_number): + # #693: last terminal belongs to another PR — do not hard-stop this PR. + return True locked_head = mutation_head_sha(last, lock) target = normalize_head_sha(expected_head_sha) if not locked_head or not target: @@ -438,3 +488,646 @@ def format_cleanup_audit_comment(audit: dict[str, Any]) -> str: "This path only clears a lock when the referenced PR is merged/closed.", ] return "\n".join(lines) + + +# --- #693 classification / recovery / correction audit ----------------------- + +# Deterministic classification labels for diagnostics and recovery routing. +CLASS_NO_LOCK = "no_lock" +CLASS_READY_FOR_TARGET = "ready_for_target" +CLASS_IN_PROGRESS_SAME_HEAD = "in_progress_same_head" +CLASS_TERMINAL_ACTIVE_SAME_HEAD = "terminal_active_same_head" +CLASS_STALE_SUPERSEDED_HEAD = "stale_superseded_head" +CLASS_FOREIGN_PR_TERMINAL = "foreign_pr_terminal" +CLASS_MOOT_MERGED_CLOSED = "moot_merged_closed" +CLASS_CORRECTION_ACTIVE = "correction_active" +CLASS_SESSION_OR_PROFILE_MISMATCH = "session_or_profile_mismatch" +CLASS_AMBIGUOUS = "ambiguous" + + +def classify_review_decision_lock( + lock: dict | None, + *, + target_pr_number: int | None = None, + target_head_sha: str | None = None, + pr_live: dict | None = None, + pr_lookup_error: str | None = None, + active_profile_identity: str | None = None, + session_blockers: list[str] | None = None, +) -> dict[str, Any]: + """Deterministic review-decision-lock classification (#693). + + Returns classification, exact next_action, owner/evidence fields, and + whether mark_final / cleanup / correction are appropriate. + """ + summary = lock_summary(lock) + target_head = normalize_head_sha(target_head_sha) + result: dict[str, Any] = { + "classification": CLASS_NO_LOCK, + "has_lock": lock is not None, + "lock_summary": summary, + "target_pr_number": target_pr_number, + "target_head_sha": target_head, + "last_terminal_pr": None, + "last_terminal_action": None, + "locked_head_sha": None, + "owner_profile_identity": (summary or {}).get("profile_identity"), + "session_profile": (summary or {}).get("session_profile"), + "updated_at": (summary or {}).get("updated_at"), + "correction_authorized": bool((lock or {}).get("correction_authorized")), + "correction_pr_number": (lock or {}).get("correction_pr_number"), + "correction_head_sha": normalize_head_sha( + (lock or {}).get("correction_head_sha") + ), + "mark_final_allowed": True, + "cleanup_allowed": False, + "correction_eligible": False, + "next_action": "gitea_resolve_task_capability(task='review_pr') then mark_final", + "reasons": [], + "evidence": {}, + } + + if lock is None: + result["reasons"].append("no review decision lock present") + return result + + session_blockers = list(session_blockers or []) + if session_blockers: + result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH + result["mark_final_allowed"] = False + result["next_action"] = ( + "reconnect matching prgs-reviewer session or wait for lock TTL; " + "do not use gitea_authorize_review_correction as unlock; " + "do not delete session-state files" + ) + result["reasons"].extend(session_blockers) + return result + + stored = ( + (lock.get("profile_identity") or lock.get("session_profile_lock") or "") + .strip() + ) + active = (active_profile_identity or "").strip() + if active and stored and active != stored: + result["classification"] = CLASS_SESSION_OR_PROFILE_MISMATCH + result["mark_final_allowed"] = False + result["next_action"] = ( + f"switch to profile '{stored}' or wait for moot cleanup; " + "do not use correction authorization as a generic unlock" + ) + result["reasons"].append( + f"lock profile '{stored}' does not match active '{active}'" + ) + return result + + last = last_terminal_mutation(lock) + if last is None: + ready_pr = lock.get("ready_pr_number") + ready_head = normalize_head_sha(lock.get("ready_expected_head_sha")) + if ( + target_pr_number is not None + and ready_pr == target_pr_number + and ready_head + and target_head + and heads_equal(ready_head, target_head) + and lock.get("final_review_decision_ready") + ): + result["classification"] = CLASS_IN_PROGRESS_SAME_HEAD + result["mark_final_allowed"] = True # idempotent re-mark + result["next_action"] = ( + "gitea_submit_pr_review with final_review_decision_ready=true " + "for the ready PR/head" + ) + result["reasons"].append( + "final decision already marked ready for this PR/head (idempotent)" + ) + return result + result["classification"] = CLASS_READY_FOR_TARGET + result["next_action"] = "gitea_mark_final_review_decision then submit" + result["reasons"].append("no terminal live mutations; mark_final allowed") + return result + + last_pr = last.get("pr_number") + last_action = last.get("action") + locked_head = mutation_head_sha(last, lock) + result["last_terminal_pr"] = last_pr + result["last_terminal_action"] = last_action + result["locked_head_sha"] = locked_head + result["evidence"] = { + "last_review_id": last.get("review_id"), + "live_mutations_count": len(lock.get("live_mutations") or []), + } + + if correction_applies( + lock, pr_number=target_pr_number, expected_head_sha=target_head + ): + result["classification"] = CLASS_CORRECTION_ACTIVE + result["mark_final_allowed"] = True + result["next_action"] = ( + "gitea_mark_final_review_decision for the correction-scoped PR/head, " + "then submit once" + ) + result["reasons"].append( + f"scoped correction active for PR #{lock.get('correction_pr_number')}" + ) + return result + + # Live state of last terminal PR when provided. + live = None + if pr_lookup_error: + result["classification"] = CLASS_AMBIGUOUS + result["mark_final_allowed"] = False + result["next_action"] = ( + "retry diagnosis after live PR state is available; " + "fail closed under ambiguous evidence" + ) + result["reasons"].append(f"live PR lookup failed: {pr_lookup_error}") + return result + if pr_live is not None: + live = classify_pr_live_state(pr_live) + result["evidence"]["last_terminal_pr_state"] = live.get("pr_state") + result["evidence"]["last_terminal_pr_merged"] = live.get("pr_merged") + if live.get("pr_merged_or_closed"): + result["classification"] = CLASS_MOOT_MERGED_CLOSED + result["cleanup_allowed"] = True + result["mark_final_allowed"] = target_pr_number is not None and ( + int(last_pr) != int(target_pr_number) + if last_pr is not None and target_pr_number is not None + else True + ) + result["next_action"] = ( + "gitea_cleanup_stale_review_decision_lock(apply=true, " + f"expected_terminal_pr={last_pr}) then mark_final on the target PR" + ) + result["reasons"].append( + f"terminal on PR #{last_pr} is moot (merged/closed); cleanup allowed" + ) + return result + + if target_pr_number is None: + result["classification"] = CLASS_AMBIGUOUS + result["mark_final_allowed"] = False + result["next_action"] = "re-run diagnosis with target_pr_number" + result["reasons"].append("target_pr_number required for open-PR classification") + return result + + if last_pr is not None and int(last_pr) != int(target_pr_number): + # Cross-PR isolation: foreign terminal is history, not a block. + result["classification"] = CLASS_FOREIGN_PR_TERMINAL + result["mark_final_allowed"] = True + result["correction_eligible"] = False # must not use correction to "unlock" + result["next_action"] = ( + f"gitea_mark_final_review_decision(pr_number={target_pr_number}, ...) " + f"— foreign terminal on PR #{last_pr} does not block (#693); " + "do not call gitea_authorize_review_correction for cross-PR unlock" + ) + result["reasons"].append( + f"last terminal is {last_action} on foreign open/closed PR #{last_pr}; " + f"target PR #{target_pr_number} is isolated (#693)" + ) + return result + + # Same PR as target. + if ( + locked_head + and target_head + and not heads_equal(locked_head, target_head) + ): + result["classification"] = CLASS_STALE_SUPERSEDED_HEAD + result["mark_final_allowed"] = True + result["next_action"] = ( + f"gitea_mark_final_review_decision with expected_head_sha=" + f"{target_head} (#620 same-PR new head)" + ) + result["reasons"].append( + f"terminal {last_action} on head {locked_head[:12]}…; target head " + f"{target_head[:12]}… — fresh formal review allowed without cleanup" + ) + return result + + # Same PR + same head (or missing head comparison) — active terminal. + result["classification"] = CLASS_TERMINAL_ACTIVE_SAME_HEAD + result["mark_final_allowed"] = False + result["correction_eligible"] = True + result["next_action"] = ( + "if the prior verdict was mistaken: gitea_authorize_review_correction " + f"with prior_review_id={last.get('review_id')}, " + f"prior_review_state={last_action}, target_pr_number={target_pr_number}, " + "operator_authorized=true, then re-mark once; " + "if the prior verdict is correct: stop and hand off (do not duplicate)" + ) + result["reasons"].append( + f"terminal {last_action} already recorded for PR #{target_pr_number} " + f"at this head; same-head duplicate blocked (#332/#620)" + ) + return result + + +def build_precise_mark_final_failure( + classification: dict[str, Any], +) -> dict[str, Any]: + """One precise recovery instruction + durable issue handoff payload (#693 AC4/8).""" + cls = classification.get("classification") + next_action = classification.get("next_action") or ( + "stop and create a durable Gitea issue with lock evidence" + ) + reasons = list(classification.get("reasons") or []) + reasons.append(f"exact_next_action: {next_action}") + handoff = { + "required": cls + in ( + CLASS_AMBIGUOUS, + CLASS_SESSION_OR_PROFILE_MISMATCH, + CLASS_TERMINAL_ACTIVE_SAME_HEAD, + ) + and not classification.get("mark_final_allowed"), + "title_hint": ( + "Review-decision-lock recovery failed during formal review" + ), + "classification": cls, + "exact_next_action": next_action, + "owner_profile_identity": classification.get("owner_profile_identity"), + "last_terminal_pr": classification.get("last_terminal_pr"), + "last_terminal_action": classification.get("last_terminal_action"), + "locked_head_sha": classification.get("locked_head_sha"), + "target_pr_number": classification.get("target_pr_number"), + "target_head_sha": classification.get("target_head_sha"), + "evidence": classification.get("evidence") or {}, + "instruction": ( + "If recovery cannot complete with the exact_next_action above, " + "create or update a durable Gitea issue with this payload and stop; " + "do not delete session-state files; do not misuse correction as unlock." + ), + } + return { + "reasons": reasons, + "classification": cls, + "exact_next_action": next_action, + "durable_issue_handoff": handoff, + } + + +def build_correction_audit_record( + *, + prior_review_id: int | None, + prior_review_state: str | None, + target_pr_number: int | None, + target_head_sha: str | None, + reason: str | None, + actor_username: str | None, + profile_name: str | None, + authorized: bool, +) -> dict[str, Any]: + """Structured durable audit for review-correction authorization (#693).""" + return { + "event": "review_correction_authorization", + "issue_ref": "#693", + "authorized": bool(authorized), + "timestamp": datetime.now(timezone.utc).isoformat(), + "actor_username": actor_username, + "profile_name": profile_name, + "prior_review_id": prior_review_id, + "prior_review_state": prior_review_state, + "target_pr_number": target_pr_number, + "target_head_sha": normalize_head_sha(target_head_sha), + "reason": reason, + "scope": "same_pr_head_only", + } + + +def format_correction_audit_comment(audit: dict[str, Any]) -> str: + """Markdown body for a thread-visible correction authorization audit.""" + status = "AUTHORIZED" if audit.get("authorized") else "DENIED" + lines = [ + "## Review correction authorization audit (#693)", + "", + f"Status: **{status}**", + "", + f"- actor: `{audit.get('actor_username')}`", + f"- profile: `{audit.get('profile_name')}`", + f"- timestamp: `{audit.get('timestamp')}`", + f"- prior_review_id: `{audit.get('prior_review_id')}`", + f"- prior_review_state: `{audit.get('prior_review_state')}`", + f"- target_pr: `#{audit.get('target_pr_number')}`", + f"- target_head_sha: `{audit.get('target_head_sha')}`", + f"- reason: {audit.get('reason')}", + f"- scope: `{audit.get('scope')}` (cannot unlock a different PR)", + "", + "This is **not** a generic decision-lock unlock. Cross-PR recovery " + "uses isolation (#693) or moot cleanup (#594), never correction.", + ] + return "\n".join(lines) + + +def assess_open_pr_decision_lock_recovery( + lock: dict | None, + *, + target_pr_number: int, + target_head_sha: str | None, + last_terminal_pr_live: dict | None = None, + pr_lookup_error: str | None = None, + active_profile_identity: str | None = None, + session_blockers: list[str] | None = None, + controller_recovery_authorized: bool = False, +) -> dict[str, Any]: + """Assess guarded recovery for decision locks affecting an open target PR (#693). + + Recovery here means *workflow recovery routing*, not necessarily lock wipe. + Foreign-PR terminals are recoverable by isolation (mark_final allowed). + Moot terminals use #594 cleanup. Same-head active terminals refuse wipe. + """ + classification = classify_review_decision_lock( + lock, + target_pr_number=target_pr_number, + target_head_sha=target_head_sha, + pr_live=last_terminal_pr_live, + pr_lookup_error=pr_lookup_error, + active_profile_identity=active_profile_identity, + session_blockers=session_blockers, + ) + cls = classification["classification"] + recovery_allowed = False + recovery_mode = None + if cls == CLASS_FOREIGN_PR_TERMINAL: + recovery_allowed = True + recovery_mode = "cross_pr_isolation" + elif cls == CLASS_STALE_SUPERSEDED_HEAD: + recovery_allowed = True + recovery_mode = "same_pr_new_head" + elif cls == CLASS_MOOT_MERGED_CLOSED: + recovery_allowed = True + recovery_mode = "moot_cleanup" + elif cls == CLASS_READY_FOR_TARGET: + recovery_allowed = True + recovery_mode = "none_required" + elif cls == CLASS_CORRECTION_ACTIVE: + recovery_allowed = True + recovery_mode = "scoped_correction" + elif cls == CLASS_TERMINAL_ACTIVE_SAME_HEAD: + recovery_allowed = False + recovery_mode = "correction_only_if_mistake" + else: + recovery_allowed = False + recovery_mode = "fail_closed" + + if recovery_mode == "moot_cleanup" and not controller_recovery_authorized: + # Cleanup still needs apply + capability; assess reports path. + pass + + return { + **classification, + "recovery_allowed": recovery_allowed, + "recovery_mode": recovery_mode, + "controller_recovery_authorized": bool(controller_recovery_authorized), + "manual_file_delete_forbidden": True, + "correction_as_generic_unlock_forbidden": True, + } + + +# --------------------------------------------------------------------------- +# #709 cross-profile cleanup, overwrite protection, recovery provenance +# --------------------------------------------------------------------------- + + +def has_unresolved_terminal_evidence(lock: dict | None) -> bool: + """True when *lock* still carries a terminal live mutation ledger.""" + return last_terminal_mutation(lock) is not None + + +def assess_init_overwrite( + existing_lock: dict | None, + *, + force: bool = False, +) -> dict[str, Any]: + """Whether init_review_decision_lock may replace an existing durable lock (#709 AC2). + + Unresolved terminal evidence must never be silently replaced by an empty + initialized lock. *force* does not authorize destruction of terminal + ledgers — only explicit cleanup / archive transitions may remove them. + """ + result: dict[str, Any] = { + "overwrite_allowed": True, + "has_existing": existing_lock is not None, + "has_unresolved_terminal": False, + "reasons": [], + "last_terminal_pr": None, + "last_terminal_action": None, + "existing_profile_identity": None, + } + if existing_lock is None: + result["reasons"].append("no existing lock; empty init allowed") + return result + result["existing_profile_identity"] = ( + existing_lock.get("profile_identity") + or existing_lock.get("session_profile_lock") + or existing_lock.get("session_profile") + ) + last = last_terminal_mutation(existing_lock) + if last is None: + result["reasons"].append( + "existing lock has no terminal mutations; re-init allowed" + ) + return result + result["has_unresolved_terminal"] = True + result["overwrite_allowed"] = False + result["last_terminal_pr"] = last.get("pr_number") + result["last_terminal_action"] = last.get("action") + result["reasons"].append( + "refuse empty re-init: unresolved terminal decision-lock evidence " + f"present ({last.get('action')} on PR #{last.get('pr_number')}); " + "use gitea_cleanup_stale_review_decision_lock when moot, or archive " + f"via sanctioned recovery — force={force!r} does not authorize overwrite " + "(#709 AC2)" + ) + return result + + +def lock_targets_merged_pr_approval( + lock: dict | None, + *, + pr_number: int, + expected_head_sha: str | None = None, +) -> bool: + """True when *lock*'s last terminal mutation is approve of *pr_number*. + + When *expected_head_sha* is provided, a **recorded** terminal head must + match. Legacy same-repo approve locks with no recorded head do **not** + match (fail closed, #709 F3 residual / review 435) — callers must use the + strict secondary path that refuses no-head clears. + """ + last = last_terminal_mutation(lock) + if last is None: + return False + if last.get("action") != "approve": + return False + if last.get("pr_number") != pr_number: + return False + if expected_head_sha: + want = normalize_head_sha(expected_head_sha) + if not want: + return False + locked = mutation_head_sha(last, lock) + # Require recorded-head match; unrecorded head is not a match (#709 F3). + if not locked or not heads_equal(locked, want): + return False + return True + + +def build_post_merge_recovery_record( + *, + pr_number: int, + head_sha: str | None, + merge_commit_sha: str | None, + target_profile_identity: str | None, + failed_step: str, + error: str | None, + remote: str | None, + org: str | None, + repo: str | None, + actor_username: str | None, + profile_name: str | None, +) -> dict[str, Any]: + """Durable recovery-required payload after irreversible merge (#709 AC3).""" + return { + "event": "post_merge_decision_lock_recovery_required", + "status": "recovery_required", + "issue_ref": "#709", + "recovery_critical": True, + "applied": False, # never claim historical cleanup succeeded + "timestamp": datetime.now(timezone.utc).isoformat(), + "pr_number": pr_number, + "head_sha": normalize_head_sha(head_sha), + "merge_commit_sha": merge_commit_sha, + "target_profile_identity": target_profile_identity, + "failed_step": failed_step, + "error": error, + "remote": remote, + "org": org, + "repo": repo, + "actor_username": actor_username, + "profile_name": profile_name, + "required_recovery_action": ( + "retry cross-profile decision-lock cleanup and audit publication " + "for the merged PR; if terminal evidence is gone, use " + "gitea_record_irrecoverable_decision_lock_provenance" + ), + } + + +def build_irrecoverable_provenance_record( + *, + pr_number: int, + head_sha: str | None, + remote: str | None, + org: str | None, + repo: str | None, + actor_username: str | None, + profile_name: str | None, + reason: str, + incident_ref: str | None = None, + # Deprecated kwargs retained only so stale call sites fail closed: + operator_authorized: bool | None = None, + # Required for merger-acceptable records (#709 F1): + authorization: dict[str, Any] | None = None, + incident_issue: int | None = None, + incident_comment_id: int | None = None, + destroyed_subject: str | None = None, + historical_provenance_subject: str | None = None, +) -> dict[str, Any]: + """Truthful absence-of-proof record (#709 AC5). Never sets applied=True. + + Caller-supplied ``operator_authorized`` is **ignored** as authorization + evidence (review 434 F1). Prefer + :func:`irrecoverable_provenance.build_irrecoverable_provenance_record` + with a verified server-side authorization artifact. + """ + # Explicitly ignore deprecated self-assertable Boolean. + _ = operator_authorized + if authorization is not None and incident_issue is not None and incident_comment_id is not None: + from irrecoverable_provenance import ( + build_irrecoverable_provenance_record as _build, + ) + + return _build( + pr_number=int(pr_number), + head_sha=str(head_sha or ""), + remote=str(remote or ""), + org=str(org or ""), + repo=str(repo or ""), + actor_username=actor_username, + profile_name=profile_name, + reason=reason, + incident_issue=int(incident_issue), + incident_comment_id=int(incident_comment_id), + authorization=authorization, + destroyed_subject=destroyed_subject, + historical_provenance_subject=historical_provenance_subject + or destroyed_subject, + ) + # Fail-closed skeleton when no server authorization is supplied: never + # sets merger_may_accept True (even if operator_authorized was True). + return { + "event": "irrecoverable_decision_lock_provenance", + "status": "provenance_irrecoverable", + "record_type": "irrecoverable_decision_provenance", + "operator_recovery_required": True, + "issue_ref": "#709", + "recovery_critical": True, + "applied": False, + "historical_cleanup_proven": False, + "timestamp": datetime.now(timezone.utc).isoformat(), + "pr_number": pr_number, + "head_sha": normalize_head_sha(head_sha), + "remote": remote, + "org": org, + "repo": repo, + "actor_username": actor_username, + "profile_name": profile_name, + "reason": reason, + "incident_ref": incident_ref, + "incident_issue": incident_issue, + "incident_comment_id": incident_comment_id, + "authorization_verified": False, + "merger_may_accept": False, + "acceptance_rule": ( + "Merger may accept this record only when a server-side " + "authorization artifact verifies for remote/org/repo/PR/exact " + "head/incident, the record is durable and read back, and normal " + "merge gates still pass. Caller Booleans never authorize. This " + "does not prove historical cleanup." + ), + } + + +def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str: + """Markdown body for irrecoverable provenance audit (no applied=true claim).""" + from irrecoverable_provenance import ( + format_irrecoverable_audit_comment as _fmt, + ) + + return _fmt(record) + + +def format_post_merge_recovery_comment(record: dict[str, Any]) -> str: + """Markdown body for post-merge recovery-required audit.""" + lines = [ + "## Post-merge decision-lock recovery required (#709)", + "", + "Status: **RECOVERY_REQUIRED** (merge is irreversible; cleanup/audit incomplete)", + "", + f"- actor: `{record.get('actor_username')}`", + f"- profile: `{record.get('profile_name')}`", + f"- timestamp: `{record.get('timestamp')}`", + f"- PR: `#{record.get('pr_number')}`", + f"- head_sha: `{record.get('head_sha')}`", + f"- merge_commit_sha: `{record.get('merge_commit_sha')}`", + f"- target_profile_identity: `{record.get('target_profile_identity')}`", + f"- failed_step: `{record.get('failed_step')}`", + f"- error: `{record.get('error')}`", + "", + f"Required action: {record.get('required_recovery_action')}", + "", + "applied=false — do not treat this as successful cleanup evidence.", + ] + return "\n".join(lines) + diff --git a/task_capability_map.py b/task_capability_map.py index fdf5583..889ef3c 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -60,18 +60,57 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "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": { @@ -84,7 +123,19 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { }, "adopt_merger_pr_lease": { "permission": "gitea.pr.comment", - "role": "reviewer", + "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", }, # #691: guarded non-owner cleanup of obsolete comment-backed reviewer leases. # Apply path posts lease release + audit comments (gitea.pr.comment). @@ -127,6 +178,49 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "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", + }, "delete_branch": { "permission": "gitea.branch.delete", "role": "author", diff --git a/tests/conftest.py b/tests/conftest.py index 0cb41ab..31408a3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -99,6 +99,20 @@ def _reset_mutation_authority(monkeypatch): monkeypatch.setattr(gitea_config, "_active_profile_override", None) monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None) monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {}) + # #714: clear both module namespaces. mcp_server.py execs gitea_mcp_server.py + # into its own globals, so `import gitea_mcp_server` is a separate module + # object with its own identity caches; leaving it dirty leaks login across + # tests that import the implementation module directly. + try: + import gitea_mcp_server as _gitea_impl + + monkeypatch.setattr(_gitea_impl, "_IDENTITY_CACHE", {}) + if hasattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE"): + monkeypatch.setattr(_gitea_impl, "_ACTOR_IDENTITY_CACHE", {}) + except Exception: + pass + if hasattr(mcp_server, "_ACTOR_IDENTITY_CACHE"): + monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {}) monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False) diff --git a/tests/test_anti_stomp_preflight.py b/tests/test_anti_stomp_preflight.py new file mode 100644 index 0000000..93b6865 --- /dev/null +++ b/tests/test_anti_stomp_preflight.py @@ -0,0 +1,1032 @@ +"""Regression coverage for the common anti-stomp preflight (#604). + +Acceptance criteria: + +1. All mutation tools call the common preflight (wired via + ``verify_preflight_purity`` / ``_run_anti_stomp_preflight``). +2. Failure returns a typed blocker and exact next action. +3. Tests cover wrong repo defaulting to Timesheet, stale runtime, wrong + worktree, foreign lease, terminal lock, and contaminated approval. +4. No mutation can proceed using stale prompt data if live state disagrees. +5. Existing successful paths continue to work (happy-path assessment). +""" + +from __future__ import annotations + +import os +import unittest +from unittest import mock + +import anti_stomp_preflight as asp + + +LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git" +STARTUP = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +ADVANCED = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +HEAD_A = "1111111111111111111111111111111111111111" +HEAD_B = "2222222222222222222222222222222222222222" + + +def _happy_kwargs(**overrides): + base = dict( + task="create_issue", + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Gitea-Tools", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=True, + repo_explicit=True, + profile_name="prgs-author", + profile_role="author", + required_role="author", + workspace_path="/repo/branches/issue-604", + project_root="/repo", + current_branch="feat/issue-604-anti-stomp-preflight", + root_head_sha=STARTUP, + root_porcelain="", + remote_master_sha=STARTUP, + startup_head=STARTUP, + current_code_head=STARTUP, + source_contaminated=False, + manual_bypass_attempted=False, + ) + base.update(overrides) + return base + + +class TestIsMutationTask(unittest.TestCase): + def test_core_mutation_tasks(self): + for task in ( + "create_issue", + "comment_issue", + "set_issue_labels", + "acquire_reviewer_pr_lease", + "submit_pr_review", + "approve_pr", + "request_changes_pr", + "merge_pr", + "cleanup_stale_claims", + "delete_branch", + ): + self.assertTrue(asp.is_mutation_task(task), task) + + def test_gitea_prefix_accepted(self): + self.assertTrue(asp.is_mutation_task("gitea_create_issue")) + self.assertTrue(asp.is_mutation_task("gitea_merge_pr")) + + def test_read_only_not_mutation(self): + self.assertFalse(asp.is_mutation_task("list_prs")) + self.assertFalse(asp.is_mutation_task("whoami")) + self.assertFalse(asp.is_mutation_task("")) + self.assertFalse(asp.is_mutation_task(None)) + + +class TestHappyPath(unittest.TestCase): + def test_allowed_when_all_checks_pass(self): + result = asp.assess_anti_stomp_preflight(**_happy_kwargs()) + self.assertTrue(result["allowed"]) + self.assertFalse(result["block"]) + self.assertEqual(result["blockers"], []) + self.assertEqual(result["exact_next_action"], "proceed") + self.assertIsNone(result["blocker_kind"]) + + def test_reviewer_happy_path_skips_author_worktree(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="review_pr", + profile_name="prgs-reviewer", + profile_role="reviewer", + required_role="reviewer", + # Under branches/ the root guard short-circuits for non-merger. + workspace_path="/repo/branches/review-pr-42", + project_root="/repo", + current_branch="review/pr-42", + foreign_lease=False, + terminal_lock_blocks=False, + expected_head_sha=HEAD_A, + live_head_sha=HEAD_A, + workflow_hash_valid=True, + ) + ) + self.assertTrue(result["allowed"], result.get("reasons")) + self.assertTrue(result["checks"]["worktree"].get("skipped")) + + +class TestWrongRepoTimesheet(unittest.TestCase): + """AC3: wrong repo defaulting to Timesheet.""" + + def test_prgs_default_timesheet_vs_local_gitea_tools(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_REPO) + self.assertTrue(result["exact_next_action"]) + self.assertIn("org=", result["exact_next_action"]) + self.assertTrue( + any("Timesheet" in r or "does not match" in r for r in result["reasons"]) + ) + + def test_explicit_org_repo_skips_mismatch(self): + # Explicit intent is authoritative (remote_repo_guard contract). + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + resolved_repo="Timesheet", + org_explicit=True, + repo_explicit=True, + ) + ) + self.assertTrue(result["allowed"]) + + +class TestStaleRuntime(unittest.TestCase): + """AC3: stale runtime.""" + + def test_stale_runtime_blocks(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + startup_head=STARTUP, + current_code_head=ADVANCED, + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_STALE_RUNTIME) + self.assertIn("Restart", result["exact_next_action"]) + self.assertTrue(result["checks"]["stale_runtime"]["stale"]) + + def test_in_parity_allows(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + startup_head=STARTUP, + current_code_head=STARTUP, + ) + ) + self.assertTrue(result["allowed"]) + + +class TestWrongWorktree(unittest.TestCase): + """AC3: wrong worktree.""" + + def test_author_on_control_checkout_blocked(self): + # Clean master control checkout: root guard may pass for a clean master + # HEAD, but the author worktree guard must still refuse mutation from + # outside branches/. + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + workspace_path="/repo", + project_root="/repo", + current_branch="master", + root_porcelain="", + root_head_sha=STARTUP, + remote_master_sha=STARTUP, + profile_role="author", + required_role="author", + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_WORKTREE) + self.assertIn("branches/", result["exact_next_action"]) + + def test_author_under_branches_allowed(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + workspace_path="/repo/branches/issue-604", + project_root="/repo", + ) + ) + self.assertTrue(result["allowed"]) + + +class TestForeignLease(unittest.TestCase): + """AC3: foreign lease.""" + + def test_foreign_lease_blocks(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="merge_pr", + profile_role="merger", + required_role="merger", + # Merger is not auto-exempted under branches/; pass clean master. + workspace_path="/repo", + project_root="/repo", + current_branch="master", + root_porcelain="", + foreign_lease=True, + lease_reasons=["lease owned by other-session"], + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_FOREIGN_LEASE) + self.assertIn("foreign lease", result["exact_next_action"].lower()) + + def test_lease_required_without_ownership_fails_closed(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="review_pr", + profile_role="reviewer", + required_role="reviewer", + workspace_path="/repo/branches/review-pr-1", + project_root="/repo", + lease_required=True, + lease_owner_session="sess-A", + active_session_id="sess-B", + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_FOREIGN_LEASE) + + def test_owned_lease_allows(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="review_pr", + profile_role="reviewer", + required_role="reviewer", + workspace_path="/repo/branches/review-pr-1", + project_root="/repo", + foreign_lease=False, + ) + ) + self.assertTrue(result["allowed"]) + + +class TestTerminalLock(unittest.TestCase): + """AC3: terminal lock.""" + + def test_terminal_lock_blocks(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="approve_pr", + profile_role="reviewer", + required_role="reviewer", + workspace_path="/repo/branches/review-pr-9", + project_root="/repo", + terminal_lock_blocks=True, + terminal_lock_reasons=["#332 terminal lock active for this head"], + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_TERMINAL_LOCK) + self.assertIn("#332", result["exact_next_action"]) + + def test_no_terminal_lock_allows(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="approve_pr", + profile_role="reviewer", + required_role="reviewer", + workspace_path="/repo/branches/review-pr-9", + project_root="/repo", + terminal_lock_blocks=False, + ) + ) + self.assertTrue(result["allowed"]) + + +class TestHeadShaStalePrompt(unittest.TestCase): + """AC4: no mutation with stale prompt head SHA.""" + + def _merger_kwargs(self, **overrides): + base = _happy_kwargs( + task="merge_pr", + profile_role="merger", + required_role="merger", + workspace_path="/repo", + project_root="/repo", + current_branch="master", + root_porcelain="", + root_head_sha=STARTUP, + remote_master_sha=STARTUP, + ) + base.update(overrides) + return base + + def test_head_mismatch_blocks(self): + result = asp.assess_anti_stomp_preflight( + **self._merger_kwargs( + expected_head_sha=HEAD_A, + live_head_sha=HEAD_B, + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_HEAD_SHA) + self.assertIn("stale prompt", " ".join(result["reasons"]).lower()) + + def test_require_head_sha_missing_blocks(self): + result = asp.assess_anti_stomp_preflight( + **self._merger_kwargs( + require_head_sha=True, + expected_head_sha=None, + live_head_sha=HEAD_A, + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_HEAD_SHA) + + def test_matching_head_allows(self): + result = asp.assess_anti_stomp_preflight( + **self._merger_kwargs( + expected_head_sha=HEAD_A, + live_head_sha=HEAD_A, + ) + ) + self.assertTrue(result["allowed"]) + + +class TestContaminatedApproval(unittest.TestCase): + """AC3: contaminated approval / source contamination.""" + + def test_source_contamination_blocks(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="merge_pr", + profile_role="merger", + required_role="merger", + workspace_path="/repo", + project_root="/repo", + current_branch="master", + root_porcelain="", + source_contaminated=True, + contamination_reasons=[ + "session contaminated by direct stable-branch push attempt" + ], + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_SOURCE_CONTAMINATION) + self.assertIn("reconciler", result["exact_next_action"].lower()) + + def test_manual_bypass_blocks(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + manual_bypass_attempted=True, + manual_bypass_reasons=[ + "attempted manual deletion of session-state lock files" + ], + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_MANUAL_BYPASS) + + +class TestWrongRole(unittest.TestCase): + def test_author_cannot_merge(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="merge_pr", + profile_role="author", + required_role="merger", + workspace_path="/repo/branches/issue-604", + project_root="/repo", + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE) + + def test_reconciler_may_run_author_class_tasks(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="comment_issue", + profile_name="prgs-reconciler", + profile_role="reconciler", + required_role="author", + workspace_path="/repo", + project_root="/repo", + current_branch="master", + root_porcelain="", + ) + ) + self.assertTrue(result["allowed"], result.get("reasons")) + self.assertTrue(asp.roles_compatible("reconciler", "author")) + self.assertFalse(asp.roles_compatible("author", "merger")) + + +class TestCapabilityAuthorizedRoles(unittest.TestCase): + """Blocker A: reviewer/merger holding issue-comment capability may mutate. + + Nominal task role is still ``author`` in task_capability_map, but the + shared anti-stomp gate must not WRONG_ROLE-block when the active profile + holds ``gitea.issue.comment``. Unauthorized escalation without the + permission remains fail closed. + """ + + _ISSUE_COMMENT_TASKS = ( + "comment_issue", + "mark_issue", + "set_issue_labels", + "lock_issue", + ) + _ISSUE_COMMENT_PERM = "gitea.issue.comment" + + def _role_kwargs(self, task, role, *, allowed_ops, permission=None): + return _happy_kwargs( + task=task, + profile_name=f"prgs-{role}", + profile_role=role, + required_role="author", + required_permission=permission if permission is not None else self._ISSUE_COMMENT_PERM, + allowed_operations=allowed_ops, + workspace_path=f"/repo/branches/{role}-ws", + project_root="/repo", + current_branch=f"review/{role}-task", + ) + + def test_reviewer_allowed_with_issue_comment_capability(self): + ops = ["gitea.read", "gitea.issue.comment", "gitea.pr.review"] + for task in self._ISSUE_COMMENT_TASKS: + with self.subTest(task=task): + result = asp.assess_anti_stomp_preflight( + **self._role_kwargs(task, "reviewer", allowed_ops=ops) + ) + self.assertTrue(result["allowed"], result.get("reasons")) + self.assertFalse(result["block"]) + self.assertTrue( + result["checks"]["role"].get("capability_authorized") + ) + + def test_merger_allowed_with_issue_comment_capability(self): + ops = ["gitea.read", "gitea.issue.comment", "gitea.pr.merge"] + for task in self._ISSUE_COMMENT_TASKS: + with self.subTest(task=task): + result = asp.assess_anti_stomp_preflight( + **self._role_kwargs(task, "merger", allowed_ops=ops) + ) + self.assertTrue(result["allowed"], result.get("reasons")) + self.assertFalse(result["block"]) + + def test_reviewer_denied_without_issue_comment_capability(self): + # Holds review permission only — not issue comment. + ops = ["gitea.read", "gitea.pr.review", "gitea.pr.approve"] + for task in self._ISSUE_COMMENT_TASKS: + with self.subTest(task=task): + result = asp.assess_anti_stomp_preflight( + **self._role_kwargs(task, "reviewer", allowed_ops=ops) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE) + + def test_merger_denied_without_issue_comment_capability(self): + ops = ["gitea.read", "gitea.pr.merge"] + for task in self._ISSUE_COMMENT_TASKS: + with self.subTest(task=task): + result = asp.assess_anti_stomp_preflight( + **self._role_kwargs(task, "merger", allowed_ops=ops) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE) + + def test_not_broad_reviewer_to_author_bypass(self): + # Reviewer with only issue.comment must not pass create_pr (needs create). + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="create_pr", + profile_name="prgs-reviewer", + profile_role="reviewer", + required_role="author", + required_permission="gitea.pr.create", + allowed_operations=["gitea.read", "gitea.issue.comment", "gitea.pr.review"], + workspace_path="/repo/branches/review-ws", + project_root="/repo", + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE) + + def test_not_broad_merger_to_author_bypass(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="create_issue", + profile_name="prgs-merger", + profile_role="merger", + required_role="author", + required_permission="gitea.issue.create", + allowed_operations=["gitea.read", "gitea.issue.comment", "gitea.pr.merge"], + workspace_path="/repo/branches/merge-ws", + project_root="/repo", + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE) + + def test_authorization_compatible_helper(self): + self.assertTrue( + asp.authorization_compatible( + "reviewer", + "author", + required_permission="gitea.issue.comment", + allowed_operations=["gitea.issue.comment"], + ) + ) + self.assertFalse( + asp.authorization_compatible( + "reviewer", + "author", + required_permission="gitea.issue.comment", + allowed_operations=["gitea.pr.review"], + ) + ) + # Missing ops list fails closed when permission is required and roles differ. + self.assertFalse( + asp.authorization_compatible( + "reviewer", + "author", + required_permission="gitea.issue.comment", + allowed_operations=None, + ) + ) + + +class TestWorkflowHash(unittest.TestCase): + def test_stale_workflow_hash_blocks(self): + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="review_pr", + profile_role="reviewer", + required_role="reviewer", + workspace_path="/repo/branches/review-pr-3", + project_root="/repo", + workflow_hash_valid=False, + workflow_hash_reasons=["stored workflow hash is stale"], + ) + ) + self.assertTrue(result["block"]) + self.assertEqual(result["blocker_kind"], asp.BLOCKER_WORKFLOW_HASH) + + +class TestTypedBlockerResponse(unittest.TestCase): + """AC2: typed blocker + exact next action in response payload.""" + + def test_block_response_shape(self): + assessment = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + task="review_pr", + profile_role="reviewer", + required_role="reviewer", + workspace_path="/repo/branches/review-pr-7", + project_root="/repo", + foreign_lease=True, + ) + ) + payload = asp.block_response(assessment, pr_number=42) + self.assertFalse(payload["success"]) + self.assertFalse(payload["performed"]) + self.assertTrue(payload["blocked"]) + self.assertTrue(payload["anti_stomp"]) + self.assertEqual(payload["blocker_kind"], asp.BLOCKER_FOREIGN_LEASE) + self.assertTrue(payload["exact_next_action"]) + self.assertEqual(payload["pr_number"], 42) + self.assertTrue(payload["blockers"]) + self.assertEqual(payload["blockers"][0]["kind"], asp.BLOCKER_FOREIGN_LEASE) + + def test_format_error_includes_kind_and_next_action(self): + assessment = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + startup_head=STARTUP, + current_code_head=ADVANCED, + ) + ) + msg = asp.format_anti_stomp_error(assessment) + self.assertIn("#604", msg) + self.assertIn(asp.BLOCKER_STALE_RUNTIME, msg) + self.assertIn("exact_next_action", msg) + + +class TestRootCheckoutContamination(unittest.TestCase): + def test_dirty_control_checkout_blocks_author(self): + # Workspace is control checkout with dirty porcelain. + result = asp.assess_anti_stomp_preflight( + **_happy_kwargs( + workspace_path="/repo", + project_root="/repo", + current_branch="master", + root_porcelain=" M gitea_mcp_server.py\n", + root_head_sha=STARTUP, + remote_master_sha=STARTUP, + ) + ) + self.assertTrue(result["block"]) + # Author worktree check or root checkout may fire first. + self.assertIn( + result["blocker_kind"], + {asp.BLOCKER_ROOT_CHECKOUT, asp.BLOCKER_WRONG_WORKTREE}, + ) + + +class TestMutationTaskInventory(unittest.TestCase): + """AC1 + Blocker B: declared inventory matches runtime wiring.""" + + def test_issue_required_paths_covered(self): + required = { + "create_issue", + "comment_issue", + "set_issue_labels", + "acquire_reviewer_pr_lease", + "submit_pr_review", + "approve_pr", + "request_changes_pr", + "merge_pr", + "cleanup_merged_pr_branch", + "cleanup_stale_claims", + "edit_pr", + } + missing = required - asp.MUTATION_TASKS + self.assertFalse(missing, f"missing mutation tasks: {missing}") + + def test_disputed_helpers_classified_as_dedicated_or_shared(self): + """Blocker B explicit classification for disputed mutation helpers.""" + # Shared preflight inventory (wired). + self.assertIn("edit_pr", asp.MUTATION_TASKS) + # Dedicated fail-closed gates — must NOT claim shared preflight. + for name in ( + "mark_final_review_decision", + "save_review_draft", + "resume_review_draft", + ): + self.assertNotIn(name, asp.MUTATION_TASKS, name) + self.assertIn(name, asp.DEDICATED_GATE_MUTATIONS, name) + self.assertTrue(asp.DEDICATED_GATE_MUTATIONS[name].strip()) + + def test_inventory_and_wiring_consistent(self): + """Every MUTATION_TASKS member is referenced as a live task kwarg. + + Accepts: + * task=\"name\" / task='name' on verify_preflight_purity / _run_anti_stomp + * review anti_task map values (approve_pr / request_changes_pr / …) + * gitea_ alias form when the bare name is also declared + """ + import pathlib + import re + + server_src = pathlib.Path(__file__).resolve().parents[1] / "gitea_mcp_server.py" + text = server_src.read_text(encoding="utf-8") + + # Collect string literals used as task= kwargs. + task_kw = set(re.findall(r'task\s*=\s*["\']([a-z0-9_]+)["\']', text)) + # anti_task map values: "approve": "approve_pr", + anti_map = set( + re.findall( + r'"(?:approve|request_changes|comment)"\s*:\s*"([a-z0-9_]+)"', + text, + ) + ) + wired = task_kw | anti_map + # Also accept f-string / conditional close_pr|edit_pr style already in task_kw. + + missing = set() + for task in asp.MUTATION_TASKS: + bare = task.removeprefix("gitea_") + if task in wired or bare in wired or f"gitea_{bare}" in wired: + continue + # Alias pairs: gitea_commit_files ↔ commit_files + if task.startswith("gitea_") and bare in asp.MUTATION_TASKS and bare in wired: + continue + if f"gitea_{task}" in asp.MUTATION_TASKS and task in wired: + continue + missing.add(task) + self.assertFalse( + missing, + f"MUTATION_TASKS declared but not wired in gitea_mcp_server.py: {sorted(missing)}", + ) + + # Dedicated exclusions must not appear as shared anti-stomp task kwargs + # for the three disputed local helpers (except resume→submit_pr_review). + for name in ("mark_final_review_decision", "save_review_draft"): + # May appear as string metadata but not as task="..." anti-stomp target. + # Allow mention in comments; assert not as task= kwarg. + self.assertNotIn(name, task_kw, f"{name} must not be shared-preflight task=") + + +class TestServerWiring(unittest.TestCase): + """Smoke: MCP server imports anti_stomp and exposes the runner.""" + + def test_server_imports_anti_stomp(self): + import gitea_mcp_server as server + + self.assertTrue(hasattr(server, "_run_anti_stomp_preflight")) + self.assertTrue(hasattr(server, "anti_stomp_preflight")) + self.assertIs(server.anti_stomp_preflight, asp) + + def test_runner_skipped_under_pytest_by_default(self): + import gitea_mcp_server as server + + # Default pytest path must not raise (suite isolation). + self.assertIsNone( + server._run_anti_stomp_preflight( + "create_issue", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Timesheet", + ) + ) + + def test_runner_blocks_when_forced(self): + import gitea_mcp_server as server + + with mock.patch.dict( + os.environ, + {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, + clear=False, + ): + # Force assessor to return a block without full git/workspace setup. + blocked = { + "allowed": False, + "block": True, + "blockers": [{ + "kind": asp.BLOCKER_STALE_RUNTIME, + "reasons": ["stale"], + "exact_next_action": "restart", + "detail": {}, + }], + "reasons": ["stale"], + "exact_next_action": "restart", + "blocker_kind": asp.BLOCKER_STALE_RUNTIME, + "checks": {}, + } + with mock.patch.object( + asp, "assess_anti_stomp_preflight", return_value=blocked + ): + with self.assertRaises(RuntimeError) as ctx: + server._run_anti_stomp_preflight( + "create_issue", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertIn("#604", str(ctx.exception)) + self.assertIn(asp.BLOCKER_STALE_RUNTIME, str(ctx.exception)) + + +class TestEntrypointSideEffectOrdering(unittest.TestCase): + """Blocker C / AC4: anti-stomp aborts before Gitea mutation side effects. + + Invokes real entrypoint functions with external boundaries mocked. + Forces the assessor to block and proves api_request POST/merge paths + and durable local writes never run. + """ + + def _blocked_assessment(self, kind=asp.BLOCKER_FOREIGN_LEASE, next_action="stop"): + return { + "allowed": False, + "block": True, + "blockers": [{ + "kind": kind, + "reasons": ["forced block for ordering test"], + "exact_next_action": next_action, + "detail": {}, + }], + "reasons": ["forced block for ordering test"], + "exact_next_action": next_action, + "blocker_kind": kind, + "checks": {}, + } + + def test_merge_pr_blocks_before_merge_api(self): + import gitea_mcp_server as server + + blocked = self._blocked_assessment( + kind=asp.BLOCKER_HEAD_SHA, + next_action="re-pin expected_head_sha and retry", + ) + api_mock = mock.Mock(side_effect=AssertionError("Gitea API must not be called")) + save_lock = mock.Mock(side_effect=AssertionError("local lock must not mutate")) + + with mock.patch.dict( + os.environ, + {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, + clear=False, + ): + with mock.patch.object( + server, "_verify_role_mutation_workspace", return_value="/repo/branches/x" + ), mock.patch.object( + server, "_review_workflow_load_gate_reasons", return_value=[] + ), mock.patch.object( + server, "_live_namespace_health_gate", return_value=None + ), mock.patch.object( + server, "terminal_review_hard_stop_reasons", return_value=[] + ), mock.patch.object( + server, + "gitea_check_pr_eligibility", + return_value={ + "eligible": True, + "authenticated_user": "merger-bot", + "profile_name": "prgs-merger", + "pr_author": "other-user", + "head_sha": HEAD_A, + "mergeable": True, + "reasons": [], + }, + ), mock.patch.object( + server, "_reviewer_pr_lease_gate", return_value=[] + ), mock.patch.object( + server, "_pr_work_lease_reviewer_block", return_value={"block": False} + ), mock.patch.object( + server, "get_profile", return_value={ + "profile_name": "prgs-merger", + "allowed_operations": ["gitea.pr.merge", "gitea.read"], + "forbidden_operations": [], + } + ), mock.patch.object( + server, "_role_kind", return_value="merger" + ), mock.patch.object( + asp, "assess_anti_stomp_preflight", return_value=blocked + ) as assess_mock, mock.patch.object( + server, "api_request", api_mock + ), mock.patch.object( + server, "_save_review_decision_lock", save_lock + ): + result = server.gitea_merge_pr( + pr_number=680, + confirmation="MERGE PR 680", + expected_head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path="/repo/branches/merge-680", + ) + + self.assertFalse(result.get("performed")) + self.assertTrue(any("#604" in r or "anti-stomp" in r.lower() or asp.BLOCKER_HEAD_SHA in r + for r in (result.get("reasons") or [])), + result.get("reasons")) + joined = " ".join(result.get("reasons") or []) + self.assertIn(asp.BLOCKER_HEAD_SHA, joined) + self.assertIn("exact_next_action", joined) + assess_mock.assert_called() + api_mock.assert_not_called() + save_lock.assert_not_called() + + def test_submit_pr_review_blocks_before_review_api(self): + import gitea_mcp_server as server + + blocked = self._blocked_assessment( + kind=asp.BLOCKER_FOREIGN_LEASE, + next_action="stop; do not stomp foreign lease", + ) + api_mock = mock.Mock(side_effect=AssertionError("Gitea review API must not be called")) + save_lock = mock.Mock(side_effect=AssertionError("decision lock must not mutate")) + + with mock.patch.dict( + os.environ, + {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, + clear=False, + ): + with mock.patch.object( + server, "_verify_role_mutation_workspace", return_value="/repo/branches/r" + ), mock.patch.object( + server, "_review_workflow_load_gate_reasons", return_value=[] + ), mock.patch.object( + server, "_live_namespace_health_gate", return_value=None + ), mock.patch.object( + server, "check_review_decision_gate", return_value=[] + ), mock.patch.object( + server, + "gitea_check_pr_eligibility", + return_value={ + "eligible": True, + "authenticated_user": "reviewer-bot", + "profile_name": "prgs-reviewer", + "pr_author": "other-user", + "head_sha": HEAD_A, + "reasons": [], + }, + ), mock.patch.object( + server, "_reviewer_pr_lease_gate", return_value=[] + ), mock.patch.object( + server, "_pr_work_lease_reviewer_block", return_value={"block": False} + ), mock.patch.object( + server, "_load_review_decision_lock", return_value={ + "ready_expected_head_sha": HEAD_A, + "final_review_decision_ready": True, + "ready_pr_number": 680, + "ready_action": "request_changes", + } + ), mock.patch.object( + server, "get_profile", return_value={ + "profile_name": "prgs-reviewer", + "allowed_operations": [ + "gitea.pr.review", + "gitea.pr.request_changes", + "gitea.read", + ], + "forbidden_operations": [], + } + ), mock.patch.object( + asp, "assess_anti_stomp_preflight", return_value=blocked + ) as assess_mock, mock.patch.object( + server, "api_request", api_mock + ), mock.patch.object( + server, "_save_review_decision_lock", save_lock + ), mock.patch.object( + server, "_submit_pending_pull_review", api_mock + ): + result = server.gitea_submit_pr_review( + pr_number=680, + action="request_changes", + body="needs work", + expected_head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + final_review_decision_ready=True, + worktree_path="/repo/branches/review-680", + ) + + self.assertFalse(result.get("performed")) + joined = " ".join(result.get("reasons") or []) + self.assertIn(asp.BLOCKER_FOREIGN_LEASE, joined) + self.assertIn("exact_next_action", joined) + assess_mock.assert_called() + # Assessor must run for request_changes_pr (anti_task map). + call_task = None + for c in assess_mock.call_args_list: + kwargs = c.kwargs if c.kwargs else {} + if "task" in kwargs: + call_task = kwargs["task"] + elif c.args: + call_task = c.args[0] if False else kwargs.get("task") + # assess_anti_stomp is called with keyword task= + if c.kwargs.get("task"): + call_task = c.kwargs["task"] + # _run_anti_stomp passes task as first positional to assess via keyword. + self.assertTrue(assess_mock.called) + api_mock.assert_not_called() + save_lock.assert_not_called() + + def test_comment_issue_blocks_before_comment_api(self): + """Representative issue-mutation class for AC4 coverage.""" + import gitea_mcp_server as server + + blocked = self._blocked_assessment( + kind=asp.BLOCKER_WRONG_ROLE, + next_action="switch namespace", + ) + api_mock = mock.Mock(side_effect=AssertionError("comment API must not be called")) + + with mock.patch.dict( + os.environ, + {"GITEA_TEST_FORCE_ANTI_STOMP": "1"}, + clear=False, + ): + with mock.patch.object( + server, "verify_preflight_purity", wraps=None + ) as purity, mock.patch.object( + server, "api_request", api_mock + ): + # Drive verify_preflight_purity → _run_anti_stomp by calling purity + # path: patch _run_anti_stomp to raise via assessor. + def _purity_side_effect(*args, **kwargs): + # Call real runner under force so assessor block surfaces. + return server._run_anti_stomp_preflight( + kwargs.get("task") or (args[2] if len(args) > 2 else None), + remote=args[0] if args else kwargs.get("remote"), + worktree_path=kwargs.get("worktree_path"), + org=kwargs.get("org"), + repo=kwargs.get("repo"), + raise_on_block=True, + ) + + purity.side_effect = None + with mock.patch.object( + asp, "assess_anti_stomp_preflight", return_value=blocked + ), mock.patch.object( + server, "verify_preflight_purity", side_effect=lambda *a, **k: ( + server._run_anti_stomp_preflight( + k.get("task"), + remote=a[0] if a else k.get("remote"), + worktree_path=k.get("worktree_path"), + org=k.get("org"), + repo=k.get("repo"), + ) + ) + ), mock.patch.object( + server, "_profile_operation_gate", return_value=[] + ), mock.patch.object( + server, "_canonical_comment_gate", return_value={"blocked": False} + ), mock.patch.object( + server, "get_profile", return_value={ + "profile_name": "prgs-author", + "allowed_operations": ["gitea.issue.comment", "gitea.read"], + "forbidden_operations": [], + } + ): + with self.assertRaises(RuntimeError) as ctx: + server.gitea_create_issue_comment( + issue_number=604, + body="handoff", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path="/repo/branches/issue-604", + ) + self.assertIn(asp.BLOCKER_WRONG_ROLE, str(ctx.exception)) + self.assertIn("exact_next_action", str(ctx.exception)) + api_mock.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_api_reliability.py b/tests/test_api_reliability.py index e159b23..88ed798 100644 --- a/tests/test_api_reliability.py +++ b/tests/test_api_reliability.py @@ -79,41 +79,46 @@ class TestApiRequestFailures(unittest.TestCase): @patch("gitea_auth.urllib.request.urlopen") def test_timeout_converted_to_runtimeerror(self, mock_open): mock_open.side_effect = TimeoutError("timed out") - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx: gitea_auth.api_request("GET", URL, FAKE_AUTH) - self.assertIn("network error contacting Gitea", str(ctx.exception)) + # Fixed message only (#699) — still a RuntimeError subclass. + self.assertIsInstance(ctx.exception, RuntimeError) + self.assertEqual(str(ctx.exception), "Network error contacting Gitea") @patch("gitea_auth.urllib.request.urlopen") def test_dns_network_failure_converted(self, mock_open): mock_open.side_effect = urllib.error.URLError("Name or service not known") - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx: gitea_auth.api_request("GET", URL, FAKE_AUTH) - self.assertIn("network error contacting Gitea", str(ctx.exception)) + self.assertEqual(str(ctx.exception), "Network error contacting Gitea") @patch("gitea_auth.urllib.request.urlopen") def test_502_upstream_message(self, mock_open): mock_open.side_effect = http_error(502, "bad gateway") - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: gitea_auth.api_request("GET", URL, FAKE_AUTH) msg = str(ctx.exception) - self.assertIn("HTTP 502", msg) - self.assertIn("upstream unavailable", msg) + self.assertEqual(msg, "Gitea upstream unavailable") + self.assertEqual(ctx.exception.reason_code, "upstream_unavailable") + self.assertEqual(ctx.exception.http_status, 502) @patch("gitea_auth.urllib.request.urlopen") def test_503_upstream_message(self, mock_open): mock_open.side_effect = http_error(503, "") - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: gitea_auth.api_request("GET", URL, FAKE_AUTH) - self.assertIn("HTTP 503", str(ctx.exception)) - self.assertIn("upstream unavailable", str(ctx.exception)) + self.assertEqual(str(ctx.exception), "Gitea upstream unavailable") + self.assertEqual(ctx.exception.http_status, 503) @patch("gitea_auth.urllib.request.urlopen") def test_malformed_error_payload_does_not_crash(self, mock_open): - # Non-JSON garbage error body must still yield a clean RuntimeError. + # Non-JSON garbage error body must still yield a clean typed error + # with a fixed message (no body echo — #699). mock_open.side_effect = http_error(500, "garbage") - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: gitea_auth.api_request("GET", URL, FAKE_AUTH) - self.assertIn("HTTP 500", str(ctx.exception)) + self.assertEqual(str(ctx.exception), "Gitea HTTP request failed") + self.assertNotIn("", str(ctx.exception)) @patch("gitea_auth.urllib.request.urlopen") def test_malformed_success_json_raises_clean_error(self, mock_open): @@ -126,16 +131,17 @@ class TestApiRequestFailures(unittest.TestCase): def test_no_secret_leak_in_error_body(self, mock_open): mock_open.side_effect = http_error( 400, "failed: token supersecret123 rejected") - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: gitea_auth.api_request("GET", URL, FAKE_AUTH) msg = str(ctx.exception) self.assertNotIn("supersecret123", msg) - self.assertIn(gitea_audit.REDACTED, msg) + # Fixed message — body never appears (stronger than redaction). + self.assertEqual(msg, "Gitea HTTP request failed") @patch("gitea_auth.urllib.request.urlopen") def test_auth_header_never_in_error(self, mock_open): mock_open.side_effect = http_error(400, "bad request") - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: gitea_auth.api_request("GET", URL, FAKE_AUTH) self.assertNotIn(FAKE_AUTH, str(ctx.exception)) diff --git a/tests/test_audit_reconciliation_mode.py b/tests/test_audit_reconciliation_mode.py index e429ddf..a80da41 100644 --- a/tests/test_audit_reconciliation_mode.py +++ b/tests/test_audit_reconciliation_mode.py @@ -27,7 +27,13 @@ from task_capability_map import required_permission, required_role DELETE_PROFILE = { "profile_name": "prgs-author-delete", - "allowed_operations": ["gitea.read", "gitea.branch.delete"], + "role": "author", + "allowed_operations": [ + "gitea.read", + "gitea.pr.create", + "gitea.branch.push", + "gitea.branch.delete", + ], "forbidden_operations": [], "audit_label": "prgs-author-delete", } diff --git a/tests/test_branch_cleanup_guard.py b/tests/test_branch_cleanup_guard.py index dd4053f..ec62ca3 100644 --- a/tests/test_branch_cleanup_guard.py +++ b/tests/test_branch_cleanup_guard.py @@ -8,10 +8,33 @@ import branch_cleanup_guard as guard # noqa: E402 import mcp_server # noqa: E402 import task_capability_map # noqa: E402 from final_report_validator import assess_final_report_validator # noqa: E402 -from mcp_server import gitea_cleanup_merged_pr_branch # noqa: E402 +from mcp_server import gitea_cleanup_merged_pr_branch, gitea_delete_branch # noqa: E402 FAKE_AUTH = "token fake" +# Reconciler-shaped profile that holds branch.delete (recommended) plus +# required pr.close/read so _role_kind classifies as reconciler. +RECONCILER_WITH_DELETE = { + "profile_name": "prgs-reconciler", + "role": "reconciler", + "allowed_operations": [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + "gitea.issue.close", + "gitea.branch.delete", + ], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", + ], +} + class TestRawBranchDeleteGuard(unittest.TestCase): def test_detects_local_and_remote_raw_git_delete_commands(self): @@ -69,6 +92,11 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase): "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True, ).start() + # Default: no active ownership records (tests that need ownership patch this). + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() def tearDown(self): patch.stopall() @@ -93,14 +121,48 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase): self.assertEqual(res["required_permission"], "gitea.branch.delete") self.mock_api.assert_not_called() + def test_author_and_merger_without_delete_authority_fail_closed(self): + role_profiles = { + "author": [ + "gitea.read", + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + "gitea.pr.create", + ], + "merger": ["gitea.read", "gitea.pr.merge"], + } + for name, allowed in role_profiles.items(): + with self.subTest(role=name): + profile_patch = patch( + "mcp_server.get_profile", + return_value={ + "profile_name": name, + "allowed_operations": allowed, + "forbidden_operations": [], + }, + ) + profile_patch.start() + try: + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch", + branch="feat/branch", + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + finally: + profile_patch.stop() + self.assertFalse(res["performed"]) + self.assertEqual( + res["required_permission"], "gitea.branch.delete" + ) + self.mock_api.assert_not_called() + def test_root_checkout_cleanup_fails_closed(self): patch( "mcp_server.get_profile", - return_value={ - "profile_name": "branch-cleanup", - "allowed_operations": ["gitea.read", "gitea.branch.delete"], - "forbidden_operations": [], - }, + return_value=dict(RECONCILER_WITH_DELETE), ).start() res = gitea_cleanup_merged_pr_branch( pr_number=487, @@ -117,23 +179,36 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase): branch = "feat/issue-485-lease-comments-non-list-guard" patch( "mcp_server.get_profile", - return_value={ - "profile_name": "branch-cleanup", - "allowed_operations": ["gitea.read", "gitea.branch.delete"], - "forbidden_operations": [], - }, + return_value=dict(RECONCILER_WITH_DELETE), ).start() - self.mock_api.side_effect = [ - { - "number": 487, - "merged": True, - "merged_at": "2026-07-08T01:00:00Z", - "head": {"ref": branch, "sha": "a" * 40}, - "base": {"ref": "master"}, - }, - {}, - {}, - ] + + def _api(method, url, *args, **kwargs): + if method == "GET" and "/pulls/" in url: + return { + "number": 487, + "merged": True, + "merged_at": "2026-07-08T01:00:00Z", + "head": {"ref": branch, "sha": "a" * 40}, + "base": {"ref": "master"}, + } + if method == "GET" and "/branches/" in url: + # First pre-delete probe: present. Post-delete: not found. + get_branch_calls = [ + c + for c in self.mock_api.call_args_list + if c.args and c.args[0] == "GET" and "/branches/" in c.args[1] + ] + if len(get_branch_calls) <= 1: + return {"name": branch} + raise RuntimeError("HTTP 404: not found") + if method == "GET" and url.rstrip("/").endswith("/Example-Repo"): + # Repo reachability probe after branch 404 (R1 branch-scoped). + return {"full_name": "Example-Org/Example-Repo"} + if method == "DELETE": + return {} + raise AssertionError(f"unexpected {method} {url}") + + self.mock_api.side_effect = _api res = gitea_cleanup_merged_pr_branch( pr_number=487, confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", @@ -141,7 +216,11 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase): remote="prgs", worktree_path="/tmp/repo/branches/cleanup", ) + self.assertTrue(res["success"]) self.assertTrue(res["performed"]) + self.assertTrue(res["delete_acknowledged"]) + self.assertTrue(res["verified_absent"]) + self.assertTrue((res.get("readback") or {}).get("verified_absent")) delete_calls = [ call for call in self.mock_api.call_args_list if call.args[0] == "DELETE" ] @@ -152,11 +231,7 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase): branch = "feat/issue-485-lease-comments-non-list-guard" patch( "mcp_server.get_profile", - return_value={ - "profile_name": "branch-cleanup", - "allowed_operations": ["gitea.read", "gitea.branch.delete"], - "forbidden_operations": [], - }, + return_value=dict(RECONCILER_WITH_DELETE), ).start() self.mock_api.side_effect = [ { @@ -182,6 +257,1010 @@ class TestMergedPrBranchCleanupTool(unittest.TestCase): ] self.assertFalse(delete_calls) + def test_reconciler_with_branch_delete_cannot_raw_delete(self): + """#687: reconciler + gitea.branch.delete still cannot call raw delete.""" + patch( + "mcp_server.get_profile", + return_value=dict(RECONCILER_WITH_DELETE), + ).start() + res = gitea_delete_branch( + branch="fix/issue-683-workflow-guard-hardening", + remote="prgs", + ) + self.assertFalse(res.get("success", True)) + self.assertFalse(res.get("performed", True)) + reasons = " ".join(res.get("reasons") or []) + self.assertIn("raw gitea_delete_branch", reasons) + self.assertIn("cleanup_merged_pr_branch", reasons) + self.mock_api.assert_not_called() + + def test_reconciler_raw_delete_denies_preservation_branch(self): + patch( + "mcp_server.get_profile", + return_value=dict(RECONCILER_WITH_DELETE), + ).start() + res = gitea_delete_branch( + branch="chore/issue-681-preserve-review-session-wip", + remote="prgs", + ) + self.assertFalse(res.get("performed", True)) + self.mock_api.assert_not_called() + + def test_author_with_branch_delete_role_ok_but_preserve_blocked(self): + """Author role may use raw delete path when permitted; preserve fails closed.""" + patch( + "mcp_server.get_profile", + return_value={ + "profile_name": "prgs-author", + "role": "author", + "allowed_operations": [ + "gitea.read", + "gitea.pr.create", + "gitea.branch.push", + "gitea.branch.delete", + ], + "forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"], + }, + ).start() + res = gitea_delete_branch( + branch="chore/issue-681-preserve-review-session-wip", + remote="prgs", + ) + self.assertFalse(res.get("performed", True)) + self.assertIn("preservation", " ".join(res.get("reasons") or [])) + self.mock_api.assert_not_called() + + def test_unmerged_branch_cleanup_rejected(self): + branch = "feat/unmerged-work" + patch( + "mcp_server.get_profile", + return_value=dict(RECONCILER_WITH_DELETE), + ).start() + self.mock_api.side_effect = [ + { + "number": 999, + "merged": False, + "merged_at": None, + "head": {"ref": branch, "sha": "b" * 40}, + "base": {"ref": "master"}, + }, + {}, + ] + res = gitea_cleanup_merged_pr_branch( + pr_number=999, + confirmation=f"CLEANUP MERGED PR 999 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res["performed"]) + self.assertTrue( + any("not merged" in r for r in (res.get("reasons") or [])) + ) + delete_calls = [ + call for call in self.mock_api.call_args_list if call.args[0] == "DELETE" + ] + self.assertFalse(delete_calls) + + def test_preservation_branch_cleanup_rejected(self): + branch = "chore/issue-681-preserve-review-session-wip" + patch( + "mcp_server.get_profile", + return_value=dict(RECONCILER_WITH_DELETE), + ).start() + self.mock_api.side_effect = [ + { + "number": 681, + "merged": True, + "merged_at": "2026-07-08T01:00:00Z", + "head": {"ref": branch, "sha": "c" * 40}, + "base": {"ref": "master"}, + }, + {}, + ] + res = gitea_cleanup_merged_pr_branch( + pr_number=681, + confirmation=f"CLEANUP MERGED PR 681 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res["performed"]) + self.assertTrue( + any("preservation" in r for r in (res.get("reasons") or [])) + ) + delete_calls = [ + call for call in self.mock_api.call_args_list if call.args[0] == "DELETE" + ] + self.assertFalse(delete_calls) + + def test_non_reconciler_with_delete_denied_cleanup(self): + patch( + "mcp_server.get_profile", + return_value={ + "profile_name": "prgs-author", + "role": "author", + "allowed_operations": [ + "gitea.read", + "gitea.pr.create", + "gitea.branch.push", + "gitea.branch.delete", + ], + "forbidden_operations": [], + }, + ).start() + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation="CLEANUP MERGED PR 487 BRANCH feat/branch", + branch="feat/branch", + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res["performed"]) + self.assertEqual(res.get("required_role_kind"), "reconciler") + self.mock_api.assert_not_called() + + def test_assess_guard_rejects_preservation_branch(self): + assessment = guard.assess_merged_pr_branch_cleanup( + pr_number=681, + head_branch="chore/issue-681-preserve-review-session-wip", + merged=True, + remote_branch_exists=True, + open_pr_heads=set(), + head_on_target=True, + delete_capability_allowed=True, + confirmation=( + "CLEANUP MERGED PR 681 BRANCH " + "chore/issue-681-preserve-review-session-wip" + ), + ) + self.assertFalse(assessment["safe_to_delete"]) + self.assertTrue( + any("preservation" in r for r in assessment["block_reasons"]) + ) + + + +class TestPostDeleteReadback(unittest.TestCase): + def test_branch_scoped_not_found_is_verified_success(self): + readback = guard.classify_branch_readback_http_status( + 404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH + ) + result = guard.assess_post_delete_readback(readback) + self.assertTrue(result["ok"]) + self.assertTrue(result["verified_absent"]) + self.assertTrue(result["readback"]["verified_absent"]) + + def test_generic_404_not_verified_absent(self): + # R1: bare 404 must not verify absence + for scope in (None, guard.NOT_FOUND_SCOPE_UNKNOWN, + guard.NOT_FOUND_SCOPE_REPOSITORY, + guard.NOT_FOUND_SCOPE_HOST): + with self.subTest(scope=scope): + readback = guard.classify_branch_readback_http_status( + 404, not_found_scope=scope + ) + self.assertFalse(readback["verified_absent"]) + result = guard.assess_post_delete_readback(readback) + self.assertFalse(result["ok"]) + self.assertFalse(result["verified_absent"]) + + def test_exception_substring_404_not_verified(self): + # R1: substring/generic 404 without scope stays unverified + result = guard.classify_branch_readback_exception( + RuntimeError("HTTP 404: something not found") + ) + self.assertFalse(result["verified_absent"]) + self.assertNotEqual(result.get("not_found_scope"), guard.NOT_FOUND_SCOPE_BRANCH) + + def test_exists_is_structured_failure(self): + readback = guard.classify_branch_readback_http_status(200) + result = guard.assess_post_delete_readback(readback) + self.assertFalse(result["ok"]) + self.assertFalse(result["readback"]["verified_absent"]) + self.assertTrue(result["readback"]["branch_present"]) + self.assertIn("still present", " ".join(result["reasons"])) + + def test_auth_failure_preserved(self): + readback = guard.classify_branch_readback_http_status(401) + result = guard.assess_post_delete_readback(readback) + self.assertFalse(result["ok"]) + self.assertEqual(result["readback"]["error_class"], "authentication") + self.assertIn("authentication", " ".join(result["reasons"])) + + def test_authz_and_transport_failures(self): + for code, err in ((403, "authorization"), (503, "transport")): + with self.subTest(code=code): + readback = guard.classify_branch_readback_http_status(code) + result = guard.assess_post_delete_readback(readback) + self.assertFalse(result["ok"]) + self.assertEqual(result["readback"]["error_class"], err) + + def test_exception_classifier_no_secret_leak(self): + class FakeHTTPError(Exception): + def __init__(self): + super().__init__("HTTP 401: token=super-secret-value Authorization: Bearer xyz") + self.code = 401 + + result = guard.classify_branch_readback_exception(FakeHTTPError()) + blob = str(result) + self.assertNotIn("super-secret", blob) + self.assertNotIn("Bearer", blob) + self.assertEqual(result["error_class"], "authentication") + + +class TestActiveBranchOwnership(unittest.TestCase): + def _base(self, **overrides): + rec = { + "category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, + "status": "active", + "remote": "prgs", + "host": "gitea.prgs.cc", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "branch": "feat/target", + "reclaim_allowed": False, + } + rec.update(overrides) + return rec + + def test_active_author_blocks(self): + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch="feat/target", + host="gitea.prgs.cc", + records=[self._base()], + ) + self.assertTrue(result["block"]) + self.assertIn(guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, result["blocking_categories"]) + + def test_active_reviewer_lease_blocks(self): + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch="feat/target", + host="gitea.prgs.cc", + records=[ + self._base( + category=guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE, + status="active", + ) + ], + ) + self.assertTrue(result["block"]) + self.assertIn(guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE, result["blocking_categories"]) + + def test_merger_controller_reconciler_binding_blocks(self): + for cat in ( + guard.OWNERSHIP_CATEGORY_MERGER_LEASE, + guard.OWNERSHIP_CATEGORY_CONTROLLER_LEASE, + guard.OWNERSHIP_CATEGORY_RECONCILER_LEASE, + guard.OWNERSHIP_CATEGORY_WORKTREE_BINDING, + ): + with self.subTest(category=cat): + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch="feat/target", + host="gitea.prgs.cc", + records=[self._base(category=cat, status="active")], + ) + self.assertTrue(result["block"]) + self.assertIn(cat, result["blocking_categories"]) + + def test_released_and_expired_reclaimable_do_not_block(self): + records = [ + self._base(status="released", reclaim_allowed=True), + self._base( + category=guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE, + status="expired", + reclaim_allowed=True, + ), + ] + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch="feat/target", + host="gitea.prgs.cc", + records=records, + ) + self.assertFalse(result["block"]) + + def test_sticky_stale_blocks_per_recovery_policy(self): + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch="feat/target", + host="gitea.prgs.cc", + records=[self._base(status="stale", reclaim_allowed=False)], + ) + self.assertTrue(result["block"]) + self.assertTrue( + any( + token in " ".join(result["reasons"]) + for token in ("sticky", "reclaim not proven", "fail closed") + ) + ) + + def test_other_repo_or_branch_no_false_block(self): + records = [ + self._base(repo="Other-Repo", status="active"), + self._base(branch="feat/other", status="active"), + self._base(remote="dadeschools", status="active"), + self._base(host="gitea.other.host", status="active"), + ] + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch="feat/target", + host="gitea.prgs.cc", + records=records, + ) + self.assertFalse(result["block"]) + self.assertEqual(len(result["ignored_out_of_scope"]), 4) + + def test_normalized_host_matching(self): + # Host identity is normalized (scheme/path stripped) + records = [self._base(host="https://gitea.prgs.cc/")] + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch="feat/target", + host="gitea.prgs.cc", + records=records, + ) + self.assertTrue(result["block"]) + + def test_denial_has_no_secrets(self): + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch="feat/target", + host="gitea.prgs.cc", + records=[ + self._base( + status="active", + # Poison fields that must never be echoed as secrets + token="sekrit-token", + authorization="Bearer abc", + ) + ], + ) + blob = str(result) + self.assertNotIn("sekrit", blob) + self.assertNotIn("Bearer", blob) + self.assertIn("author_lease", blob) + + +class TestCleanupReadbackAndOwnershipIntegration(unittest.TestCase): + def setUp(self): + self._remotes = patch.dict( + mcp_server.REMOTES, + { + "prgs": { + "host": "gitea.example.com", + "org": "Example-Org", + "repo": "Example-Repo", + } + }, + ) + self._remotes.start() + patch("gitea_audit.audit_enabled", return_value=False).start() + self.mock_api = patch("mcp_server.api_request").start() + patch("mcp_server.api_get_all", return_value=[]).start() + patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start() + patch( + "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", + return_value=True, + ).start() + patch( + "mcp_server.get_profile", + return_value=dict(RECONCILER_WITH_DELETE), + ).start() + + def tearDown(self): + patch.stopall() + + def _pr_payload(self, branch, number=487): + return { + "number": number, + "merged": True, + "merged_at": "2026-07-08T01:00:00Z", + "head": {"ref": branch, "sha": "a" * 40}, + "base": {"ref": "master"}, + } + + def test_delete_success_but_branch_remains(self): + branch = "feat/still-there" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr_payload(branch) + if method == "GET" and "/branches/" in url: + return {"name": branch} # present before and after + if method == "DELETE": + return {} + raise AssertionError(method) + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertTrue(res.get("performed")) + self.assertFalse(res.get("success")) + self.assertTrue(res.get("delete_acknowledged")) + self.assertFalse(res.get("verified_absent")) + self.assertIn("still present", " ".join(res.get("reasons") or [])) + + def test_readback_authentication_failure(self): + branch = "feat/auth-fail-readback" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() + state = {"branch_gets": 0} + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr_payload(branch) + if method == "GET" and "/branches/" in url: + state["branch_gets"] += 1 + if state["branch_gets"] == 1: + return {"name": branch} + raise RuntimeError("HTTP 401: unauthorized") + if method == "DELETE": + return {} + raise AssertionError(method) + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertTrue(res.get("performed")) + self.assertFalse(res.get("success")) + self.assertEqual((res.get("readback") or {}).get("error_class"), "authentication") + + def test_readback_transport_failure(self): + branch = "feat/transport-fail-readback" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() + state = {"branch_gets": 0} + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr_payload(branch) + if method == "GET" and "/branches/" in url: + state["branch_gets"] += 1 + if state["branch_gets"] == 1: + return {"name": branch} + raise RuntimeError("HTTP 503: temporarily unavailable") + if method == "DELETE": + return {} + raise AssertionError(method) + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res.get("success")) + self.assertEqual((res.get("readback") or {}).get("error_class"), "transport") + + def test_active_author_ownership_blocks_before_delete(self): + branch = "feat/owned" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={ + "records": [ + { + "category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, + "status": "active", + "remote": "prgs", + "host": "gitea.example.com", + "org": "Example-Org", + "repo": "Example-Repo", + "branch": branch, + "reclaim_allowed": False, + } + ], + "inventory_error": False, + }, + ).start() + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr_payload(branch) + if method == "GET" and "/branches/" in url: + return {"name": branch} + raise AssertionError(f"unexpected mutation {method}") + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res.get("performed")) + self.assertFalse(res.get("success")) + self.assertEqual(res.get("blocker_kind"), "active_branch_ownership") + self.assertIn( + guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, + (res.get("ownership") or {}).get("blocking_categories") or [], + ) + delete_calls = [ + c for c in self.mock_api.call_args_list if c.args and c.args[0] == "DELETE" + ] + self.assertFalse(delete_calls) + + def test_open_pr_guard_still_blocks(self): + branch = "feat/open-pr-head" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() + patch( + "mcp_server.api_get_all", + return_value=[{"head": {"ref": branch}, "number": 999}], + ).start() + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr_payload(branch, number=487) + if method == "GET" and "/branches/" in url: + return {"name": branch} + raise AssertionError(method) + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res.get("performed")) + self.assertTrue(any("open PR" in r for r in (res.get("reasons") or []))) + + def test_non_ancestor_guard_still_blocks(self): + branch = "feat/not-ancestor" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", + return_value=False, + ).start() + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr_payload(branch) + if method == "GET" and "/branches/" in url: + return {"name": branch} + raise AssertionError(method) + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res.get("performed")) + self.assertTrue(any("ancestor" in r for r in (res.get("reasons") or []))) + + def test_protected_default_branch_guard(self): + branch = "master" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr_payload(branch) + if method == "GET" and "/branches/" in url: + return {"name": branch} + raise AssertionError(method) + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res.get("performed")) + self.assertTrue(any("protected" in r for r in (res.get("reasons") or []))) + + + + +class TestSecondRemediationR1R2(unittest.TestCase): + """R1/R2 second-remediation: branch-scoped 404 and top-level fields.""" + + def test_cleanup_envelope_always_has_top_level_fields(self): + env = guard.cleanup_result_envelope( + success=False, + performed=False, + delete_acknowledged=False, + verified_absent=False, + reasons=["x"], + ) + for key in ("success", "performed", "delete_acknowledged", "verified_absent"): + self.assertIn(key, env) + self.assertIsInstance(env[key], bool) + + def test_repo_scoped_404_never_verified(self): + rb = guard.classify_branch_readback_http_status( + 404, not_found_scope=guard.NOT_FOUND_SCOPE_REPOSITORY + ) + self.assertFalse(rb["verified_absent"]) + assessed = guard.assess_post_delete_readback(rb) + self.assertFalse(assessed["ok"]) + self.assertFalse(assessed["verified_absent"]) + + def test_wrong_host_404_never_verified(self): + rb = guard.classify_branch_readback_http_status( + 404, not_found_scope=guard.NOT_FOUND_SCOPE_HOST + ) + self.assertFalse(rb["verified_absent"]) + + +class TestSecondRemediationOwnership(unittest.TestCase): + """O1/O2/O3 ownership second-remediation.""" + + def test_inventory_error_category_blocks(self): + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Org", + repo="Repo", + branch="feat/x", + host="gitea.example.com", + records=[ + { + "category": guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR, + "status": "unknown", + "remote": "prgs", + "host": "gitea.example.com", + "org": "Org", + "repo": "Repo", + "branch": "feat/x", + "reclaim_allowed": False, + } + ], + ) + self.assertTrue(result["block"]) + self.assertIn( + guard.OWNERSHIP_CATEGORY_INVENTORY_ERROR, + result["blocking_categories"], + ) + + def test_expired_without_explicit_reclaim_blocks(self): + # O2: expired must not auto-allow + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Org", + repo="Repo", + branch="feat/x", + host="gitea.example.com", + records=[ + { + "category": guard.OWNERSHIP_CATEGORY_MERGER_LEASE, + "status": "expired", + "remote": "prgs", + "host": "gitea.example.com", + "org": "Org", + "repo": "Repo", + "branch": "feat/x", + # reclaim_allowed omitted / False + "reclaim_allowed": False, + } + ], + ) + self.assertTrue(result["block"]) + + def test_expired_with_explicit_reclaim_allowed_does_not_block(self): + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Org", + repo="Repo", + branch="feat/x", + host="gitea.example.com", + records=[ + { + "category": guard.OWNERSHIP_CATEGORY_AUTHOR_LEASE, + "status": "expired", + "remote": "prgs", + "host": "gitea.example.com", + "org": "Org", + "repo": "Repo", + "branch": "feat/x", + "reclaim_allowed": True, + } + ], + ) + self.assertFalse(result["block"]) + + def test_active_reviewer_comment_lease_blocks(self): + result = guard.assess_active_branch_ownership( + remote="prgs", + org="Org", + repo="Repo", + branch="feat/x", + host="gitea.example.com", + records=[ + { + "category": guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE, + "status": "active", + "remote": "prgs", + "host": "gitea.example.com", + "org": "Org", + "repo": "Repo", + "branch": "feat/x", + "reclaim_allowed": False, + "role": "reviewer", + } + ], + ) + self.assertTrue(result["block"]) + self.assertIn( + guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE, + result["blocking_categories"], + ) + + +class TestSecondRemediationIntegration(unittest.TestCase): + def setUp(self): + self._remotes = patch.dict( + mcp_server.REMOTES, + { + "prgs": { + "host": "gitea.example.com", + "org": "Example-Org", + "repo": "Example-Repo", + } + }, + ) + self._remotes.start() + patch("gitea_audit.audit_enabled", return_value=False).start() + self.mock_api = patch("mcp_server.api_request").start() + patch("mcp_server.api_get_all", return_value=[]).start() + patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start() + patch( + "mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref", + return_value=True, + ).start() + patch( + "mcp_server.get_profile", + return_value=dict(RECONCILER_WITH_DELETE), + ).start() + + def tearDown(self): + patch.stopall() + + def _pr(self, branch, number=487): + return { + "number": number, + "merged": True, + "merged_at": "2026-07-08T01:00:00Z", + "head": {"ref": branch, "sha": "a" * 40}, + "base": {"ref": "master"}, + } + + def test_r1_repo_404_after_delete_not_verified(self): + """After DELETE, branch 404 + repo 404 must not verify absence.""" + branch = "feat/r1-repo-404" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": False}, + ).start() + state = {"branch_gets": 0} + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr(branch) + if method == "GET" and "/branches/" in url: + state["branch_gets"] += 1 + if state["branch_gets"] == 1: + return {"name": branch} + raise RuntimeError("HTTP 404: not found") + if method == "GET" and url.rstrip("/").endswith("/Example-Repo"): + raise RuntimeError("HTTP 404: repository not found") + if method == "DELETE": + return {} + raise AssertionError(method + " " + url) + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertTrue(res["performed"]) + self.assertTrue(res["delete_acknowledged"]) + self.assertFalse(res["success"]) + self.assertFalse(res["verified_absent"]) + + def test_o1_inventory_error_blocks_before_delete(self): + branch = "feat/o1-inv" + patch( + "mcp_server._collect_branch_ownership_records", + return_value={"records": [], "inventory_error": True}, + ).start() + + def _api(method, url, *a, **k): + if method == "GET" and "/pulls/" in url: + return self._pr(branch) + if method == "GET" and "/branches/" in url: + return {"name": branch} + raise AssertionError(f"no mutation expected {method}") + + self.mock_api.side_effect = _api + res = gitea_cleanup_merged_pr_branch( + pr_number=487, + confirmation=f"CLEANUP MERGED PR 487 BRANCH {branch}", + branch=branch, + remote="prgs", + worktree_path="/tmp/repo/branches/cleanup", + ) + self.assertFalse(res["performed"]) + self.assertFalse(res["delete_acknowledged"]) + self.assertFalse(res["verified_absent"]) + self.assertEqual(res.get("blocker_kind"), "active_branch_ownership") + + def test_o3_comment_reviewer_lease_in_collector(self): + """Collector includes active comment-backed reviewer leases.""" + active_lease = { + "pr_number": 10, + "phase": "claimed", + "session_id": "s1", + "expires_at": "2099-01-02T00:00:00Z", + } + with patch( + "mcp_server.api_get_all", return_value=[{"id": 1, "body": "x"}] + ), patch( + "mcp_server.reviewer_pr_lease.find_active_reviewer_lease", + return_value=active_lease, + ), patch( + "mcp_server.issue_lock_store.iter_lock_files", return_value=[] + ), patch( + "mcp_server.worktree_cleanup_audit.list_worktrees", return_value=[] + ), patch.object( + mcp_server.control_plane_db, + "ControlPlaneDB", + side_effect=RuntimeError("no cp"), + ): + bundle = mcp_server._collect_branch_ownership_records( + remote="prgs", + host="gitea.example.com", + org="Example-Org", + repo="Example-Repo", + branch="feat/x", + pr_number=10, + project_root="/tmp/repo", + auth=FAKE_AUTH, + base_api=( + "https://gitea.example.com/api/v1/repos/" + "Example-Org/Example-Repo" + ), + ) + cats = {r.get("category") for r in bundle.get("records") or []} + self.assertIn(guard.OWNERSHIP_CATEGORY_REVIEWER_LEASE, cats) + + def test_o4_reconcile_merged_cleanups_runs_ownership_and_readback(self): + from mcp_server import gitea_reconcile_merged_cleanups + + branch = "feat/reconcile-o4" + ownership_calls = [] + + def fake_collect(**kwargs): + ownership_calls.append(kwargs) + return {"records": [], "inventory_error": False} + + probe_calls = [] + + def fake_probe(h, o, r, auth, br): + probe_calls.append(br) + return guard.classify_branch_readback_http_status( + 404, not_found_scope=guard.NOT_FOUND_SCOPE_BRANCH + ) + + report = { + "entries": [ + { + "pr_number": 1, + "head_branch": branch, + "remote_branch": {"safe_to_delete_remote": True}, + "local_worktree": {"safe_to_remove_worktree": False}, + } + ], + "reviewer_scratch_entries": [], + } + patch( + "mcp_server.get_profile", + return_value={ + "profile_name": "prgs-reconciler", + "role": "reconciler", + "allowed_operations": [ + "gitea.read", + "gitea.branch.delete", + "gitea.pr.close", + ], + "forbidden_operations": [], + }, + ).start() + patch("mcp_server.api_get_all", return_value=[]).start() + patch( + "mcp_server.merged_cleanup_reconcile.build_reconciliation_report", + return_value=report, + ).start() + patch( + "mcp_server.merged_cleanup_reconcile.discover_reviewer_scratch_worktrees", + return_value=[], + ).start() + patch( + "mcp_server.audit_reconciliation_mode.check_cleanup_execution_allowed", + return_value=(True, []), + ).start() + patch("mcp_server.verify_preflight_purity", return_value=None).start() + patch( + "mcp_server._collect_branch_ownership_records", + side_effect=fake_collect, + ).start() + patch("mcp_server._probe_remote_branch", side_effect=fake_probe).start() + self.mock_api.side_effect = lambda *a, **k: {} + + res = gitea_reconcile_merged_cleanups( + dry_run=False, + execute_confirmed=True, + remote="prgs", + ) + self.assertTrue(res.get("performed") or res.get("executed")) + self.assertTrue(ownership_calls, "ownership must run before delete") + self.assertTrue(probe_calls, "post-delete readback must run") + actions = res.get("actions") or [] + delete_actions = [ + a for a in actions if a.get("action") == "delete_remote_branch" + ] + self.assertEqual(len(delete_actions), 1) + self.assertIn("verified_absent", delete_actions[0]) + self.assertIn("delete_acknowledged", delete_actions[0]) + self.assertTrue(delete_actions[0].get("verified_absent")) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_head_scoped_review_decision_lock.py b/tests/test_head_scoped_review_decision_lock.py index c9244c7..13fdbfc 100644 --- a/tests/test_head_scoped_review_decision_lock.py +++ b/tests/test_head_scoped_review_decision_lock.py @@ -24,6 +24,10 @@ HEAD_C = "c" * 40 def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, ready_action=None): + mutations = list(mutations or []) + corr_pr = None + if correction and mutations: + corr_pr = mutations[-1].get("pr_number") return { "task": "review_pr", "remote": "prgs", @@ -40,9 +44,11 @@ def _lock(mutations=None, correction=False, ready_pr=None, ready_head=None, read "ready_remote": "prgs" if ready_pr else None, "ready_org": "Scaled-Tech-Consulting" if ready_pr else None, "ready_repo": "Gitea-Tools" if ready_pr else None, - "live_mutations": list(mutations or []), + "live_mutations": mutations, "correction_authorized": correction, - "correction_reason": None, + "correction_reason": "test" if correction else None, + "correction_pr_number": corr_pr, + "correction_head_sha": None, } @@ -174,12 +180,13 @@ class TestHardStopHeadScope(unittest.TestCase): self.assertTrue(reasons) self.assertIn("#332", reasons[0]) - def test_rc_head_a_blocks_other_pr(self): + def test_rc_head_a_allows_other_pr_via_cross_pr_isolation(self): + """#693: foreign-PR terminal must not hard-stop mark_ready on another PR.""" _seed([RC_619_HEAD_A], ready_pr=619, ready_head=HEAD_A) reasons = mcp_server.terminal_review_hard_stop_reasons( 700, "mark_ready", expected_head_sha=HEAD_B ) - self.assertTrue(reasons) + self.assertEqual(reasons, []) def test_legacy_619_ledger_allows_new_head(self): """PR #619-style durable lock without mutation head_sha.""" diff --git a/tests/test_issue_685_side_effect_free_resolver.py b/tests/test_issue_685_side_effect_free_resolver.py new file mode 100644 index 0000000..d471a04 --- /dev/null +++ b/tests/test_issue_685_side_effect_free_resolver.py @@ -0,0 +1,430 @@ +"""#685: gitea_resolve_task_capability must be side-effect free. + +Stale-runtime detection remains fail-closed, but the resolver must never: + * touch mcp_config.json (or any MCP client config) + * spawn recovery threads + * call os._exit / terminate the serving process + * claim that an auto-restart was triggered + +Recovery is owned by the IDE/client reconnect path only. +""" + +from __future__ import annotations + +import os +import tempfile +import threading +import unittest +from datetime import datetime +from pathlib import Path +from unittest.mock import MagicMock, patch + +import sys + +ROOT = str(Path(__file__).resolve().parent.parent) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +import gitea_mcp_server as mcp_server + + +ROLE_PROFILES = ( + ("create_issue", "prgs-author", "author"), + ("review_pr", "prgs-reviewer", "reviewer"), + ("merge_pr", "prgs-merger", "merger"), + ("reconciliation_cleanup", "prgs-reconciler", "reconciler"), +) + + +def _stale_self_ps_mocks(profile: str = "prgs-author"): + """Build subprocess mocks: self PID is stale vs code mtime.""" + mock_getpid = MagicMock(return_value=12345) + mock_exists = MagicMock(return_value=True) + code_time = datetime(2026, 7, 8, 14, 0, 0) + mock_getmtime = MagicMock(return_value=code_time.timestamp()) + + ps_output = ( + " PID LSTART COMMAND\n" + "12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n" + ) + mock_run_ps = MagicMock() + mock_run_ps.stdout = ps_output + + mock_run_env = MagicMock() + mock_run_env.stdout = f"GITEA_MCP_PROFILE={profile}" + + mock_run_git = MagicMock() + mock_run_git.stdout = "SAME" + + def side_effect(args, **kwargs): + if args[0] == "ps" and "eww" in args: + return mock_run_env + if args[0] == "ps": + return mock_run_ps + if args[0] == "git": + return mock_run_git + raise ValueError(f"Unexpected subprocess args: {args}") + + mock_run = MagicMock(side_effect=side_effect) + return mock_getpid, mock_exists, mock_getmtime, mock_run + + +class TestIssue685DiagnosticsNoSideEffects(unittest.TestCase): + def setUp(self): + mcp_server._process_boot_head_sha = None + + def tearDown(self): + mcp_server._process_boot_head_sha = None + + @patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False) + @patch("subprocess.run") + @patch("os.path.getmtime") + @patch("os.path.exists") + @patch("os.getpid") + @patch("os.utime") + @patch("threading.Thread") + @patch("os._exit") + def test_stale_self_does_not_touch_config_or_exit( + self, + mock_exit, + mock_thread, + mock_utime, + mock_getpid, + mock_exists, + mock_getmtime, + mock_run, + ): + mock_getpid.return_value = 12345 + mock_exists.return_value = True + mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp() + mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect + + before_threads = threading.active_count() + reasons = mcp_server._check_mcp_runtimes_diagnostics( + "create_issue", ["prgs-author"] + ) + after_threads = threading.active_count() + + self.assertTrue( + any("stale-runtime" in r and "active Gitea MCP server process is stale" in r + for r in reasons), + reasons, + ) + # Must not claim auto-restart / config touch + blob = " ".join(reasons) + self.assertNotIn("Auto-restart has been triggered", blob) + self.assertNotIn("touched mcp_config", blob) + self.assertNotIn("will cleanly exit", blob) + + mock_utime.assert_not_called() + mock_thread.assert_not_called() + mock_exit.assert_not_called() + self.assertEqual(before_threads, after_threads) + + @patch.dict(os.environ, {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}, clear=False) + @patch("subprocess.run") + @patch("os.path.getmtime") + @patch("os.path.exists") + @patch("os.getpid") + @patch("os.utime") + def test_repeated_stale_calls_do_not_trigger_restart_loop( + self, mock_utime, mock_getpid, mock_exists, mock_getmtime, mock_run + ): + mock_getpid.return_value = 12345 + mock_exists.return_value = True + mock_getmtime.return_value = datetime(2026, 7, 8, 14, 0, 0).timestamp() + mock_run.side_effect = _stale_self_ps_mocks("prgs-author")[3].side_effect + + for _ in range(5): + reasons = mcp_server._check_mcp_runtimes_diagnostics( + "create_issue", ["prgs-author"] + ) + self.assertTrue(any("stale-runtime" in r for r in reasons)) + + mock_utime.assert_not_called() + + def test_trigger_mcp_auto_restart_removed(self): + """#685 AC: auto-restart helper is removed (unreachable from read-only).""" + self.assertFalse(hasattr(mcp_server, "_trigger_mcp_auto_restart")) + self.assertFalse(hasattr(mcp_server, "_restart_triggered")) + + +class TestIssue685ResolverTypedBlocker(unittest.TestCase): + def setUp(self): + mcp_server._process_boot_head_sha = None + + def tearDown(self): + mcp_server._process_boot_head_sha = None + if hasattr(mcp_server, "capability_stop_terminal"): + mcp_server.capability_stop_terminal.clear() + + def _resolve_with_stale_runtime(self, task: str, profile_name: str, role: str): + allowed = [ + "gitea.read", + "gitea.issue.create", + "gitea.issue.comment", + "gitea.issue.close", + "gitea.branch.create", + "gitea.branch.push", + "gitea.branch.delete", + "gitea.pr.create", + "gitea.pr.comment", + "gitea.pr.review", + "gitea.pr.approve", + "gitea.pr.request_changes", + "gitea.pr.merge", + "gitea.pr.close", + "gitea.repo.commit", + ] + profile = { + "profile_name": profile_name, + "role": role, + "allowed_operations": allowed, + "forbidden_operations": [], + } + config = { + "profiles": { + profile_name: { + "role": role, + "allowed_operations": allowed, + "forbidden_operations": [], + } + } + } + + mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks( + profile_name + ) + + with patch.dict( + os.environ, + { + "GITEA_FORCE_MCP_RUNTIME_CHECK": "1", + "GITEA_MCP_PROFILE": profile_name, + }, + clear=False, + ), patch.object(mcp_server, "get_profile", return_value=profile), patch.object( + mcp_server.gitea_config, "load_config", return_value=config + ), patch.object( + mcp_server, "_authenticated_username", return_value="test-user" + ), patch.object( + mcp_server, "_ensure_matching_profile", return_value=None + ), patch.object( + mcp_server, "record_preflight_check", return_value=None + ), patch.object( + mcp_server, "record_mutation_authority", return_value=None + ), patch.object( + mcp_server, "init_review_decision_lock", return_value=None + ), patch( + "subprocess.run", mock_run + ), patch( + "os.path.getmtime", mock_getmtime + ), patch( + "os.path.exists", mock_exists + ), patch( + "os.getpid", mock_getpid + ), patch( + "os.utime" + ) as mock_utime, patch( + "threading.Thread" + ) as mock_thread, patch( + "os._exit" + ) as mock_exit: + result = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs") + return result, mock_utime, mock_thread, mock_exit + + def test_stale_returns_typed_blocker_fields(self): + result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime( + "create_issue", "prgs-author", "author" + ) + self.assertTrue(result.get("restart_required"), result) + self.assertTrue(result.get("stop_required"), result) + self.assertEqual(result.get("blocker_kind"), "runtime_reconnect_required") + self.assertIs(result.get("mutation_performed"), False) + action = result.get("exact_safe_next_action") or "" + self.assertIn("reconnect", action.lower()) + self.assertNotIn("None; ready for operations", action) + reason = result.get("reason") or "" + self.assertIn("stale-runtime", reason) + self.assertNotIn("Auto-restart has been triggered", reason) + mock_utime.assert_not_called() + mock_thread.assert_not_called() + mock_exit.assert_not_called() + + def test_all_four_role_profiles_get_same_side_effect_free_contract(self): + for task, profile, role in ROLE_PROFILES: + with self.subTest(task=task, profile=profile): + # Skip tasks that may be unknown on this branch + try: + import task_capability_map as tcm + + tcm.required_permission(task) + except Exception: + self.skipTest(f"task {task} not in capability map") + + result, mock_utime, mock_thread, mock_exit = ( + self._resolve_with_stale_runtime(task, profile, role) + ) + self.assertTrue( + result.get("restart_required") or result.get("stop_required"), + result, + ) + self.assertEqual( + result.get("blocker_kind"), "runtime_reconnect_required", result + ) + self.assertIs(result.get("mutation_performed"), False, result) + mock_utime.assert_not_called() + mock_thread.assert_not_called() + mock_exit.assert_not_called() + + def test_config_mtime_and_contents_unchanged(self): + with tempfile.TemporaryDirectory() as tmp: + cfg = os.path.join(tmp, "mcp_config.json") + original = '{"servers": {"gitea-author": {}}}' + with open(cfg, "w", encoding="utf-8") as fh: + fh.write(original) + mtime_before = os.path.getmtime(cfg) + + result, mock_utime, mock_thread, mock_exit = self._resolve_with_stale_runtime( + "create_issue", "prgs-author", "author" + ) + # Force-path also must not use real utime when diagnostics runs + with open(cfg, encoding="utf-8") as fh: + after = fh.read() + self.assertEqual(after, original) + self.assertEqual(os.path.getmtime(cfg), mtime_before) + mock_utime.assert_not_called() + self.assertTrue(result.get("restart_required"), result) + + +class TestIssue685MutationGatesStillFailClosed(unittest.TestCase): + def test_parity_stale_still_reports_restart_required(self): + """Mutation-facing parity gate remains fail-closed when heads differ.""" + import master_parity_gate as mpg + + out = mpg.assess_master_parity( + {"startup_head": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + self.assertFalse(out.get("in_parity")) + self.assertTrue(out.get("restart_required") or out.get("stale")) + + +class TestIssue685DocstringReadOnlyContract(unittest.TestCase): + def test_resolve_docstring_declares_side_effect_free(self): + doc = mcp_server.gitea_resolve_task_capability.__doc__ or "" + lower = doc.lower() + self.assertTrue( + "side-effect" in lower or "read-only" in lower or "does not mutate" in lower, + doc, + ) + self.assertNotIn("auto-restart", lower) + self.assertNotIn("os._exit", lower) + + +class TestIssue685MergeCoexistenceWithMasterAnnotations(unittest.TestCase): + """After merging master: #685 reconnect blocker + #702 report-only binding.""" + + def setUp(self): + mcp_server._process_boot_head_sha = None + + def tearDown(self): + mcp_server._process_boot_head_sha = None + if hasattr(mcp_server, "capability_stop_terminal"): + mcp_server.capability_stop_terminal.clear() + + def test_resolve_uses_report_only_stale_binding_assessment(self): + """Capability resolve must call stale-binding assess with auto_recover=False.""" + allowed = [ + "gitea.read", + "gitea.issue.create", + "gitea.issue.comment", + "gitea.branch.push", + "gitea.pr.create", + "gitea.pr.comment", + "gitea.pr.review", + "gitea.pr.merge", + "gitea.repo.commit", + ] + profile = { + "profile_name": "prgs-author", + "role": "author", + "allowed_operations": allowed, + "forbidden_operations": [], + } + config = { + "profiles": { + "prgs-author": { + "role": "author", + "allowed_operations": allowed, + "forbidden_operations": [], + } + } + } + mock_getpid, mock_exists, mock_getmtime, mock_run = _stale_self_ps_mocks( + "prgs-author" + ) + fake_binding = { + "classification": "missing_path", + "active_worktree": "/tmp/gone", + "reasons": ["path missing"], + } + + with patch.dict( + os.environ, + { + "GITEA_FORCE_MCP_RUNTIME_CHECK": "1", + "GITEA_MCP_PROFILE": "prgs-author", + }, + clear=False, + ), patch.object(mcp_server, "get_profile", return_value=profile), patch.object( + mcp_server.gitea_config, "load_config", return_value=config + ), patch.object( + mcp_server, "_authenticated_username", return_value="test-user" + ), patch.object( + mcp_server, "_ensure_matching_profile", return_value=None + ), patch.object( + mcp_server, "record_preflight_check", return_value=None + ), patch.object( + mcp_server, "record_mutation_authority", return_value=None + ), patch.object( + mcp_server, "init_review_decision_lock", return_value=None + ), patch.object( + mcp_server, + "_assess_stale_active_binding", + return_value=fake_binding, + ) as mock_assess, patch( + "subprocess.run", mock_run + ), patch( + "os.path.getmtime", mock_getmtime + ), patch( + "os.path.exists", mock_exists + ), patch( + "os.getpid", mock_getpid + ), patch( + "os.utime" + ) as mock_utime, patch( + "threading.Thread" + ) as mock_thread, patch( + "os._exit" + ) as mock_exit: + result = mcp_server.gitea_resolve_task_capability( + task="create_issue", remote="prgs" + ) + + mock_assess.assert_called() + # Every call must be report-only (no env clear / session-state write). + for call in mock_assess.call_args_list: + self.assertFalse(call.kwargs.get("auto_recover", True)) + self.assertEqual( + result.get("blocker_kind"), "runtime_reconnect_required", result + ) + self.assertEqual(result.get("stale_binding_recovery"), fake_binding, result) + self.assertIs(result.get("mutation_performed"), False, result) + mock_utime.assert_not_called() + mock_thread.assert_not_called() + mock_exit.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_691_obsolete_reviewer_lease_cleanup.py b/tests/test_issue_691_obsolete_reviewer_lease_cleanup.py index 8d4fed4..0bfd40a 100644 --- a/tests/test_issue_691_obsolete_reviewer_lease_cleanup.py +++ b/tests/test_issue_691_obsolete_reviewer_lease_cleanup.py @@ -561,12 +561,29 @@ class TestIssue691SupersededHeadCleanup(unittest.TestCase): self.assertTrue(any("owns the lease" in r for r in result["reasons"])) def test_superseded_without_terminal_review_denied(self): + # Pre-expiry the missing terminal review is ambiguous (fail closed); + # post-expiry with unknown owner evidence it is a crash-orphan (#702) + # that still fails closed until owner-process exit is proven. now = datetime(2026, 7, 13, 5, 0, 0, tzinfo=timezone.utc) expires = datetime(2026, 7, 13, 4, 23, 0, tzinfo=timezone.utc) claim = _lease_comment(expires_at=expires, last_activity=expires - timedelta(hours=1)) - result = _assess([claim], formal_reviews=[], now=now) + result = _assess( + [claim], formal_reviews=[], now=now, owner_process_alive=None + ) self.assertFalse(result["cleanup_allowed"]) - self.assertEqual(result["classification"], "ambiguous_conflicting_evidence") + self.assertEqual( + result["classification"], "orphaned_expired_superseded_head" + ) + + fresh_expires = now + timedelta(minutes=60) + fresh = _lease_comment( + expires_at=fresh_expires, last_activity=now - timedelta(minutes=5) + ) + pre_expiry = _assess([fresh], formal_reviews=[], now=now) + self.assertFalse(pre_expiry["cleanup_allowed"]) + self.assertEqual( + pre_expiry["classification"], "ambiguous_conflicting_evidence" + ) class TestIssue691DiagnoseClassifications(unittest.TestCase): diff --git a/tests/test_issue_693_review_decision_lock_recovery.py b/tests/test_issue_693_review_decision_lock_recovery.py new file mode 100644 index 0000000..b996340 --- /dev/null +++ b/tests/test_issue_693_review_decision_lock_recovery.py @@ -0,0 +1,427 @@ +"""Review-decision-lock recovery / cross-PR isolation (#693). + +Reproduces the PR #688 → PR #692 formal-review sequence: + + * durable lock holds REQUEST_CHANGES on open PR #688 @ head A (review_id 425) + * reviewer leases PR #692 @ head B (6d86db…) + * mark_final for #692 must succeed without correction unlock + * cleanup of open #688 terminal remains forbidden (#594) + * authorize_review_correction for #688 cannot unlock #692 + * eventual #692 APPROVE is unique and head-bound (review_id 426) + * mark_final is idempotent for same PR/action/head + * diagnosis returns one exact next_action + durable handoff payload +""" + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +import mcp_server +import review_workflow_load +import stale_review_decision_lock as srdl + +# PR #688 / #692 incident fixtures (sanitized reconstruction from issue #693) +HEAD_688 = "c7a444eb4b41cf916fdbd20a4999ffd78af496d0" +HEAD_692 = "6d86db793bbdfaed16bd54d93171f1dd66f234ca" +PR_A = 688 +PR_B = 692 +REVIEW_ID_A = 425 +REVIEW_ID_B = 426 + +RC_688 = { + "pr_number": PR_A, + "action": "request_changes", + "review_id": REVIEW_ID_A, + "review_state": "request_changes", + "head_sha": HEAD_688, +} +APPROVE_692 = { + "pr_number": PR_B, + "action": "approve", + "review_id": REVIEW_ID_B, + "review_state": "approve", + "head_sha": HEAD_692, +} + + +def _lock(mutations=None, **kwargs): + from datetime import datetime, timezone + + now = datetime.now(timezone.utc).isoformat() + base = { + "task": "review_pr", + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "session_pid": os.getpid(), + "session_profile": "prgs-reviewer", + "session_profile_lock": "prgs-reviewer", + "profile_identity": "prgs-reviewer", + "final_review_decision_ready": False, + "ready_pr_number": None, + "ready_action": None, + "ready_expected_head_sha": None, + "ready_remote": None, + "ready_org": None, + "ready_repo": None, + "live_mutations": list(mutations or []), + "correction_authorized": False, + "correction_reason": None, + "correction_pr_number": None, + "correction_head_sha": None, + "updated_at": now, + "recorded_at": now, + "writer_pid": os.getpid(), + } + base.update(kwargs) + # Ensure TTL fields stay fresh unless the caller is testing expiry. + if "recorded_at" not in kwargs: + base["recorded_at"] = now + if "updated_at" not in kwargs: + base["updated_at"] = now + return base + + +REVIEWER_ENV = { + "GITEA_PROFILE_NAME": "prgs-reviewer", + "GITEA_ALLOWED_OPERATIONS": ( + "gitea.read,gitea.pr.review,gitea.pr.approve," + "gitea.pr.request_changes,gitea.pr.comment" + ), + "GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer", +} + +REVIEWER_PROFILE = { + "profile_name": "prgs-reviewer", + "allowed_operations": [ + "gitea.read", + "gitea.pr.review", + "gitea.pr.approve", + "gitea.pr.request_changes", + "gitea.pr.comment", + ], + "forbidden_operations": ["gitea.pr.merge"], +} + + +def _seed(mutations=None, **kwargs): + review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT) + mcp_server._save_review_decision_lock(_lock(mutations, **kwargs)) + mcp_server.gitea_load_review_workflow() + + +def _no_lease(): + return {"block": False, "reasons": [], "mutation_allowed": True} + + +def _open_pr(number, head): + return { + "number": number, + "state": "open", + "merged": False, + "merged_at": None, + "head": {"sha": head}, + } + + +class TestCrossPrIsolationPure(unittest.TestCase): + def test_foreign_terminal_does_not_block_mark_boundary(self): + lock = _lock([RC_688]) + self.assertFalse( + srdl.prior_live_mutations_block_boundary( + lock, pr_number=PR_B, expected_head_sha=HEAD_692 + ) + ) + self.assertTrue( + srdl.prior_live_mutations_block_boundary( + lock, pr_number=PR_A, expected_head_sha=HEAD_688 + ) + ) + + def test_terminal_boundary_allows_fresh_decision_on_other_pr(self): + lock = _lock([RC_688]) + self.assertTrue( + srdl.terminal_boundary_allows_fresh_decision( + lock, + pr_number=PR_B, + expected_head_sha=HEAD_692, + operation="mark_ready", + ) + ) + self.assertFalse( + srdl.terminal_boundary_allows_fresh_decision( + lock, + pr_number=PR_A, + expected_head_sha=HEAD_688, + operation="mark_ready", + ) + ) + + def test_unscoped_correction_does_not_apply(self): + lock = _lock([RC_688], correction_authorized=True, correction_reason="x") + # Missing correction_pr_number → fail closed (#693) + self.assertFalse( + srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688) + ) + + def test_scoped_correction_only_for_named_pr(self): + lock = _lock( + [RC_688], + correction_authorized=True, + correction_reason="mistake", + correction_pr_number=PR_A, + correction_head_sha=HEAD_688, + ) + self.assertTrue( + srdl.correction_applies(lock, pr_number=PR_A, expected_head_sha=HEAD_688) + ) + self.assertFalse( + srdl.correction_applies(lock, pr_number=PR_B, expected_head_sha=HEAD_692) + ) + + +class TestClassification(unittest.TestCase): + def test_foreign_pr_terminal_classification(self): + lock = _lock([RC_688]) + c = srdl.classify_review_decision_lock( + lock, + target_pr_number=PR_B, + target_head_sha=HEAD_692, + pr_live=_open_pr(PR_A, HEAD_688), + active_profile_identity="prgs-reviewer", + ) + self.assertEqual(c["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL) + self.assertTrue(c["mark_final_allowed"]) + self.assertFalse(c["correction_eligible"]) + self.assertIn("do not call gitea_authorize_review_correction", c["next_action"]) + + def test_same_head_terminal_classification(self): + lock = _lock([RC_688]) + c = srdl.classify_review_decision_lock( + lock, + target_pr_number=PR_A, + target_head_sha=HEAD_688, + pr_live=_open_pr(PR_A, HEAD_688), + ) + self.assertEqual(c["classification"], srdl.CLASS_TERMINAL_ACTIVE_SAME_HEAD) + self.assertFalse(c["mark_final_allowed"]) + self.assertTrue(c["correction_eligible"]) + + def test_precise_failure_includes_durable_handoff(self): + lock = _lock([RC_688]) + c = srdl.classify_review_decision_lock( + lock, target_pr_number=PR_A, target_head_sha=HEAD_688 + ) + fail = srdl.build_precise_mark_final_failure(c) + self.assertIn("exact_next_action", fail) + self.assertIn("durable_issue_handoff", fail) + self.assertTrue(fail["durable_issue_handoff"]["required"]) + + +class TestPr692Sequence(unittest.TestCase): + def setUp(self): + self._env = patch.dict(os.environ, REVIEWER_ENV, clear=False) + self._env.start() + self._prof = patch.object( + mcp_server, "get_profile", return_value=REVIEWER_PROFILE + ) + self._prof.start() + + def tearDown(self): + self._prof.stop() + self._env.stop() + mcp_server._save_review_decision_lock(None) + review_workflow_load.clear_review_workflow_load() + + def test_mark_final_692_succeeds_with_open_688_terminal(self): + _seed([RC_688], writer_pid=25883, session_pid=60615) + with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch( + "mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease() + ), patch.object( + mcp_server, + "gitea_check_pr_eligibility", + return_value={"head_sha": HEAD_692, "eligible": True}, + ): + result = mcp_server.gitea_mark_final_review_decision( + pr_number=PR_B, + action="approve", + expected_head_sha=HEAD_692, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(result["marked_ready"], result.get("reasons")) + lock = mcp_server._load_review_decision_lock() + self.assertEqual(lock["ready_pr_number"], PR_B) + self.assertEqual( + srdl.normalize_head_sha(lock["ready_expected_head_sha"]), HEAD_692 + ) + # Foreign terminal history preserved (non-destructive isolation). + self.assertEqual(lock["live_mutations"][0]["pr_number"], PR_A) + + def test_mark_final_idempotent_same_binding(self): + _seed( + [RC_688], + final_review_decision_ready=True, + ready_pr_number=PR_B, + ready_action="approve", + ready_expected_head_sha=HEAD_692, + ready_remote="prgs", + ready_org="Scaled-Tech-Consulting", + ready_repo="Gitea-Tools", + ) + with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch( + "mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease() + ), patch.object( + mcp_server, + "gitea_check_pr_eligibility", + return_value={"head_sha": HEAD_692, "eligible": True}, + ): + result = mcp_server.gitea_mark_final_review_decision( + pr_number=PR_B, + action="approve", + expected_head_sha=HEAD_692, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(result["marked_ready"]) + self.assertTrue(result.get("idempotent")) + + def test_cleanup_still_forbidden_while_688_open(self): + _seed([RC_688]) + assessment = srdl.assess_stale_review_decision_lock( + mcp_server._load_review_decision_lock(), + pr_live=_open_pr(PR_A, HEAD_688), + active_profile_identity="prgs-reviewer", + ) + self.assertFalse(assessment["cleanup_allowed"]) + self.assertFalse(assessment["is_moot"]) + + def test_correction_cannot_unlock_different_pr(self): + _seed([RC_688]) + r = mcp_server.gitea_authorize_review_correction( + prior_review_id=REVIEW_ID_A, + prior_review_state="request_changes", + reason="try unlock 692", + operator_authorized=True, + target_pr_number=PR_B, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + post_audit_comment=False, + ) + self.assertFalse(r["authorized"]) + self.assertTrue(any("different PR" in x for x in r["reasons"])) + + def test_scoped_correction_for_same_pr_posts_audit(self): + _seed([RC_688]) + with patch( + "mcp_server._authenticated_username", return_value="sysadmin" + ), patch( + "mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0" + ), patch( + "mcp_server._auth", return_value="Basic dGVzdDp0ZXN0" + ), patch("mcp_server.api_request", return_value={"id": 99901}): + r = mcp_server.gitea_authorize_review_correction( + prior_review_id=REVIEW_ID_A, + prior_review_state="request_changes", + reason="mistaken RC wording", + operator_authorized=True, + target_pr_number=PR_A, + expected_head_sha=HEAD_688, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + post_audit_comment=True, + ) + self.assertTrue(r["authorized"], r.get("reasons")) + self.assertEqual(r.get("correction_pr_number"), PR_A) + self.assertEqual( + r.get("audit_comment_id"), + 99901, + r.get("audit_comment_skipped") or r.get("audit_comment_error") or r, + ) + + def test_diagnose_foreign_terminal_next_action(self): + _seed([RC_688]) + with patch( + "mcp_server._authenticated_username", return_value="sysadmin" + ), patch( + "mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0" + ), patch( + "mcp_server._auth", return_value="Basic dGVzdDp0ZXN0" + ), patch("mcp_server.api_request", return_value=_open_pr(PR_A, HEAD_688)): + d = mcp_server.gitea_diagnose_review_decision_lock( + target_pr_number=PR_B, + expected_head_sha=HEAD_692, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(d["success"], d.get("reasons")) + self.assertEqual(d["classification"], srdl.CLASS_FOREIGN_PR_TERMINAL) + self.assertTrue(d["mark_final_allowed"]) + self.assertIn("mark_final", d["exact_next_action"]) + self.assertTrue(d["correction_as_generic_unlock_forbidden"]) + + def test_approval_ledger_unique_and_bound_after_sequence(self): + """After mark_final + record mutation, ledger binds unique approve to 692 head.""" + _seed([RC_688]) + with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch( + "mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease() + ), patch.object( + mcp_server, + "gitea_check_pr_eligibility", + return_value={"head_sha": HEAD_692, "eligible": True}, + ): + marked = mcp_server.gitea_mark_final_review_decision( + pr_number=PR_B, + action="approve", + expected_head_sha=HEAD_692, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(marked["marked_ready"]) + mcp_server.record_live_review_mutation(PR_B, "approve", REVIEW_ID_B) + lock = mcp_server._load_review_decision_lock() + terminals = [ + m + for m in lock["live_mutations"] + if m.get("action") in srdl.TERMINAL_REVIEW_ACTIONS + and m.get("pr_number") == PR_B + ] + self.assertEqual(len(terminals), 1) + self.assertEqual(terminals[0]["review_id"], REVIEW_ID_B) + self.assertEqual( + srdl.normalize_head_sha(terminals[0].get("head_sha")), HEAD_692 + ) + # Same-head duplicate still blocked. + self.assertTrue( + srdl.prior_live_mutations_block_boundary( + lock, pr_number=PR_B, expected_head_sha=HEAD_692 + ) + ) + + +class TestSessionRestartWriterPid(unittest.TestCase): + def tearDown(self): + mcp_server._save_review_decision_lock(None) + review_workflow_load.clear_review_workflow_load() + + def test_writer_pid_change_does_not_inherit_foreign_block(self): + # session_pid 60615 origin, writer later 25883 — still foreign isolation. + _seed([RC_688], session_pid=60615, writer_pid=25883) + self.assertEqual( + mcp_server.terminal_review_hard_stop_reasons( + PR_B, "mark_ready", expected_head_sha=HEAD_692 + ), + [], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_702_review_findings_f1_f6.py b/tests/test_issue_702_review_findings_f1_f6.py new file mode 100644 index 0000000..3ab3565 --- /dev/null +++ b/tests/test_issue_702_review_findings_f1_f6.py @@ -0,0 +1,693 @@ +"""Regression tests for the PR #703 REQUEST_CHANGES findings F1–F6 (#702). + +Formal review at head 889931d independently reproduced six defects: + +F1 — stale-binding recovery ran only AFTER the terminal launcher probe in + ``gitea_resolve_task_capability``; a worktree removed mid-session wedged + every mutation-task resolution on 'missing cwd' before recovery. +F2 — ``_resolve_preflight_workspace_path`` skipped the existence checks the + canonical mutation context applies; a dead binding could produce empty + porcelain and read as a clean workspace. +F3 — ``orphaned_expired_superseded_head`` cleanup accepted unknown + ``worktree_exists``/``worktree_clean`` evidence. +F4 — the 4-hour session-state TTL silently discarded recovery-critical + crash-orphan shadow evidence. +F5 — the single same-profile shadow slot let concurrent sessions overwrite + each other's crash evidence. +F6 — the environment was cleared BEFORE the audit record was persisted, and + audit-write failure was swallowed. +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import mcp_session_state as mss # noqa: E402 +import namespace_workspace_binding as nwb # noqa: E402 +import reviewer_pr_lease as leases # noqa: E402 +import stale_binding_recovery as sbr # noqa: E402 +import mcp_server # noqa: E402 + +REPO = "Scaled-Tech-Consulting/Gitea-Tools" +OLD_HEAD = "b" * 40 +NEW_HEAD = "6" * 40 + +CONFIG = { + "version": 2, + "contexts": { + "ctx": { + "enabled": True, + "gitea": {"enabled": True, "base_url": "https://gitea.example.com"}, + } + }, + "profiles": { + "prgs-author": { + "enabled": True, + "context": "ctx", + "role": "author", + "username": "jcwalker3", + "auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"}, + "allowed_operations": [ + "gitea.read", "gitea.issue.create", "gitea.issue.comment", + "gitea.pr.create", "gitea.branch.push", + ], + "forbidden_operations": [ + "gitea.pr.approve", "gitea.pr.merge", "gitea.pr.review", + ], + "execution_profile": "prgs-author", + }, + }, + "rules": {"allow_runtime_switching": False}, +} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class _CapabilityHarness(unittest.TestCase): + """Shared config/env plumbing for resolve-capability integration tests.""" + + def setUp(self): + self._remotes = patch.dict(mcp_server.REMOTES, { + "prgs": { + "host": "gitea.example.com", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + }, + }) + self._remotes.start() + mcp_server._IDENTITY_CACHE.clear() + self._dir = tempfile.TemporaryDirectory() + self.config_path = os.path.join(self._dir.name, "profiles.json") + with open(self.config_path, "w", encoding="utf-8") as fh: + fh.write(json.dumps(CONFIG)) + + def tearDown(self): + self._remotes.stop() + mcp_server._IDENTITY_CACHE.clear() + self._dir.cleanup() + + def _env(self, **extra): + env = { + "GITEA_MCP_CONFIG": self.config_path, + "GITEA_MCP_PROFILE": "prgs-author", + "GITEA_TOKEN_AUTHOR": "author-pass", + } + env.update(extra) + return env + + def _deleted_worktree(self) -> str: + path = tempfile.mkdtemp(prefix="gitea-removed-worktree-") + shutil.rmtree(path) + return path + + +class TestF1RecoveryBeforeTerminalProbe(_CapabilityHarness): + """F1: recovery must run before the terminal cwd probe can wedge.""" + + @patch("mcp_server.api_request", return_value={"login": "jcwalker3"}) + @patch("mcp_server.get_auth_header", return_value="token author-pass") + def test_removed_worktree_recovers_before_probe(self, _auth, _api): + missing = self._deleted_worktree() + env = self._env( + GITEA_ACTIVE_WORKTREE=missing, + GITEA_FORCE_TERMINAL_PROBE="1", + GITEA_FORCE_STALE_BINDING_RECOVERY="1", + ) + with patch.dict(os.environ, env): + res = mcp_server.gitea_resolve_task_capability( + task="create_pr", remote="prgs" + ) + # The provably-stale binding was cleared before the probe ran. + self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ) + self.assertFalse(res.get("terminal_launcher_unhealthy")) + report = res.get("stale_binding_recovery") or {} + self.assertEqual( + report.get("classification"), + sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH, + ) + self.assertTrue(report.get("recovery_performed")) + self.assertTrue(res.get("allowed_in_current_session")) + + @patch("mcp_server.api_request", return_value={"login": "jcwalker3"}) + @patch("mcp_server.get_auth_header", return_value="token author-pass") + def test_probe_block_still_carries_recovery_report(self, _auth, _api): + # Recovery disabled (test mode without the force flag) preserves the + # fail-closed probe block, but the block must now carry the + # classification evidence instead of hiding it. + missing = self._deleted_worktree() + env = self._env( + GITEA_ACTIVE_WORKTREE=missing, + GITEA_FORCE_TERMINAL_PROBE="1", + ) + with patch.dict(os.environ, env): + res = mcp_server.gitea_resolve_task_capability( + task="create_pr", remote="prgs" + ) + self.assertIn("GITEA_ACTIVE_WORKTREE", os.environ) + self.assertTrue(res.get("terminal_launcher_unhealthy")) + report = res.get("stale_binding_recovery") or {} + self.assertEqual( + report.get("classification"), + sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH, + ) + self.assertFalse(report.get("recovery_performed")) + + @patch("mcp_server.api_request", return_value={"login": "jcwalker3"}) + @patch("mcp_server.get_auth_header", return_value="token author-pass") + def test_unverified_binding_is_never_cleared_by_reordering(self, _auth, _api): + # F1 must not weaken authorization: an existing, uncorroborated + # binding survives resolution untouched even with recovery forced. + existing = tempfile.mkdtemp(prefix="gitea-existing-worktree-") + self.addCleanup(shutil.rmtree, existing, ignore_errors=True) + env = self._env( + GITEA_ACTIVE_WORKTREE=existing, + GITEA_FORCE_TERMINAL_PROBE="1", + GITEA_FORCE_STALE_BINDING_RECOVERY="1", + ) + with patch.dict(os.environ, env): + res = mcp_server.gitea_resolve_task_capability( + task="create_pr", remote="prgs" + ) + self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), existing) + report = res.get("stale_binding_recovery") or {} + self.assertFalse(report.get("recovery_performed")) + + +class TestF2PreflightPathVerification(_CapabilityHarness): + """F2: preflight and mutation contexts verify resolved paths alike.""" + + def test_preflight_and_mutation_context_agree_on_dead_binding(self): + missing = self._deleted_worktree() + env = self._env(GITEA_ACTIVE_WORKTREE=missing) + with patch.dict(os.environ, env): + preflight = mcp_server._resolve_preflight_workspace_path(None) + mutation = mcp_server._resolve_namespace_mutation_context(None) + self.assertEqual(preflight, mutation["workspace_path"]) + self.assertNotEqual(preflight, os.path.realpath(missing)) + + def test_missing_explicit_worktree_never_reads_clean(self): + missing = self._deleted_worktree() + with patch.dict(os.environ, self._env()): + porcelain = mcp_server._get_workspace_porcelain(worktree_path=missing) + self.assertEqual( + porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN + ) + # The sentinel parses as a tracked dirty entry — never clean. + self.assertTrue(mcp_server._parse_porcelain_entries(porcelain)) + + def test_workspace_deleted_during_evaluation_never_reads_clean(self): + # TOCTOU: resolution succeeded, then the path vanished before git ran. + missing = self._deleted_worktree() + with patch.dict(os.environ, self._env()): + with patch.object( + mcp_server, + "_resolve_preflight_workspace_path", + return_value=missing, + ): + porcelain = mcp_server._get_workspace_porcelain() + self.assertEqual( + porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN + ) + + def test_git_failure_never_reads_clean(self): + class _Failed: + returncode = 128 + stdout = "" + stderr = "fatal: not a git repository" + + with patch.dict(os.environ, self._env()): + with patch.object( + mcp_server.subprocess, "run", return_value=_Failed() + ): + porcelain = mcp_server._get_workspace_porcelain() + self.assertEqual( + porcelain, mcp_server.PREFLIGHT_WORKSPACE_UNAVAILABLE_PORCELAIN + ) + + def test_symlinked_binding_to_deleted_target_is_demoted(self): + target = tempfile.mkdtemp(prefix="gitea-symlink-target-") + holder = tempfile.mkdtemp(prefix="gitea-symlink-holder-") + self.addCleanup(shutil.rmtree, holder, ignore_errors=True) + link = os.path.join(holder, "worktree-link") + os.symlink(target, link) + shutil.rmtree(target) + demotions: list[str] = [] + _path, source = nwb.resolve_namespace_workspace( + role_kind="reviewer", + process_project_root=os.getcwd(), + env={"GITEA_ACTIVE_WORKTREE": link}, + demotions=demotions, + verify_paths=True, + ) + self.assertEqual(source, "MCP server process root (default)") + self.assertEqual(len(demotions), 1) + + def test_dead_binding_falls_through_to_live_role_binding(self): + # Path changes during evaluation: the dead candidate demotes at + # verification time and never resurrects over the live fallback. + alive = tempfile.mkdtemp(prefix="gitea-alive-worktree-") + self.addCleanup(shutil.rmtree, alive, ignore_errors=True) + missing = self._deleted_worktree() + path, source = nwb.resolve_namespace_workspace( + role_kind="reviewer", + process_project_root=os.getcwd(), + env={ + "GITEA_ACTIVE_WORKTREE": missing, + "GITEA_REVIEWER_WORKTREE": alive, + }, + verify_paths=True, + ) + self.assertEqual(path, os.path.realpath(alive)) + self.assertEqual( + source, "GITEA_REVIEWER_WORKTREE environment variable" + ) + + +class TestF3AffirmativeWorktreeEvidence(unittest.TestCase): + """F3: unknown worktree evidence must fail closed for crash orphans.""" + + def _comments(self) -> list[dict]: + stale = _now() - timedelta(hours=3) + body = leases.format_lease_body( + repo=REPO, + pr_number=701, + issue_number=699, + reviewer_identity="sysadmin", + profile="prgs-reviewer", + session_id="65664-4f248d213d60", + worktree="branches/review-pr-654", + phase="validation-start", + candidate_head=OLD_HEAD, + target_branch="master", + target_branch_sha="2" * 40, + last_activity=stale, + expires_at=stale + timedelta(minutes=120), + ) + return [{ + "id": 11131, + "body": body, + "user": {"login": "sysadmin"}, + "author": "sysadmin", + "created_at": stale.isoformat(), + "updated_at": stale.isoformat(), + }] + + def _assess(self, **kwargs): + defaults = dict( + pr_number=701, + current_head_sha=NEW_HEAD, + formal_reviews=[], + repo=REPO, + expected_repo=REPO, + requesting_session_id="fresh-controller", + controller_recovery_authorized=True, + owner_process_alive=False, + ) + defaults.update(kwargs) + return leases.assess_obsolete_reviewer_comment_lease_cleanup( + self._comments(), **defaults + ) + + def test_unknown_worktree_evidence_fails_closed(self): + result = self._assess(worktree_exists=None, worktree_clean=None) + self.assertEqual( + result["classification"], "orphaned_expired_superseded_head" + ) + self.assertFalse(result["cleanup_allowed"]) + self.assertIn( + "affirmative safe evidence", + " ".join(result["fail_closed_reasons"]), + ) + + def test_unknown_cleanliness_with_existing_worktree_fails_closed(self): + result = self._assess(worktree_exists=True, worktree_clean=None) + self.assertFalse(result["cleanup_allowed"]) + + def test_affirmative_clean_worktree_is_eligible(self): + result = self._assess(worktree_exists=True, worktree_clean=True) + self.assertEqual( + result["classification"], "orphaned_expired_superseded_head" + ) + self.assertTrue(result["cleanup_allowed"]) + + def test_affirmative_absent_worktree_is_eligible(self): + result = self._assess(worktree_exists=False, worktree_clean=None) + self.assertTrue(result["cleanup_allowed"]) + + def test_matrix_matches_diagnosis_gate(self): + # The cleanup matrix and the diagnosis path must agree: unknown + # evidence yields acquire-only, affirmative evidence yields cleanup. + diagnosis_unknown = leases.diagnose_reviewer_pr_lease_handoff( + self._comments(), + pr_number=701, + current_session_id=None, + current_reviewer_identity="fresh-reviewer", + current_head_sha=NEW_HEAD, + formal_reviews=[], + owner_process_alive=False, + worktree_exists=None, + worktree_clean=None, + ) + assess_unknown = self._assess(worktree_exists=None, worktree_clean=None) + self.assertNotEqual( + diagnosis_unknown["next_action"], + "cleanup_obsolete_reviewer_comment_lease", + ) + self.assertFalse(assess_unknown["cleanup_allowed"]) + + +class TestF4CrashShadowDurability(unittest.TestCase): + """F4: crash-orphan evidence survives the former 4h TTL boundary.""" + + SID = "82984-abcabcabcabc" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self._env = patch.dict( + os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name} + ) + self._env.start() + leases._SESSION_LEASE = None + + def tearDown(self): + self._env.stop() + self._dir.cleanup() + leases._SESSION_LEASE = None + + def _age_record(self, kind: str, hours: float, instance_id: str | None = None): + path = mss.state_file_path(kind=kind, instance_id=instance_id) + with open(path, encoding="utf-8") as fh: + envelope = json.load(fh) + stamp = ( + (_now() - timedelta(hours=hours)).isoformat().replace("+00:00", "Z") + ) + envelope["recorded_at"] = stamp + envelope["updated_at"] = stamp + envelope["payload"]["recorded_at"] = stamp + envelope["payload"]["updated_at"] = stamp + with open(path, "w", encoding="utf-8") as fh: + json.dump(envelope, fh) + + def _save_shadow(self) -> None: + mss.save_state( + kind=mss.KIND_REVIEWER_SESSION_LEASE, + instance_id=self.SID, + payload={"session_id": self.SID, "owner_pid": os.getpid()}, + ) + + def _load_shadow(self): + return mss.load_state( + kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID + ) + + def test_shadow_loads_before_former_ttl_boundary(self): + self._save_shadow() + self._age_record( + mss.KIND_REVIEWER_SESSION_LEASE, hours=3.9, instance_id=self.SID + ) + self.assertIsNotNone(self._load_shadow()) + + def test_shadow_loads_at_former_ttl_boundary(self): + self._save_shadow() + self._age_record( + mss.KIND_REVIEWER_SESSION_LEASE, hours=4.0, instance_id=self.SID + ) + self.assertIsNotNone(self._load_shadow()) + + def test_shadow_loads_long_after_former_ttl_boundary(self): + self._save_shadow() + self._age_record( + mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID + ) + record = self._load_shadow() + self.assertIsNotNone(record) + self.assertEqual(record.get("session_id"), self.SID) + + def test_stale_binding_audit_record_is_also_durable(self): + mss.save_state( + kind=mss.KIND_STALE_BINDING_RECOVERY, + payload={"cleared_env": "GITEA_ACTIVE_WORKTREE"}, + ) + self._age_record(mss.KIND_STALE_BINDING_RECOVERY, hours=27.0) + self.assertIsNotNone( + mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY) + ) + + def test_non_recovery_kinds_keep_ttl(self): + mss.save_state( + kind=mss.KIND_WORKFLOW_LOAD, payload={"workflow_hash": "abc"} + ) + self._age_record(mss.KIND_WORKFLOW_LOAD, hours=5.0) + self.assertIsNone(mss.load_state(kind=mss.KIND_WORKFLOW_LOAD)) + + def test_sanctioned_clear_is_the_terminal_reconciliation(self): + self._save_shadow() + self._age_record( + mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID + ) + mss.clear_state( + kind=mss.KIND_REVIEWER_SESSION_LEASE, instance_id=self.SID + ) + self.assertIsNone(self._load_shadow()) + self.assertEqual( + mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE), [] + ) + + def test_crash_orphan_recovery_works_after_former_ttl(self): + # End to end: a crashed session's shadow still proves owner exit a + # day later. + leases.record_session_lease( + { + "pr_number": 701, + "session_id": self.SID, + "worktree": "branches/review-pr-654", + "candidate_head": OLD_HEAD, + "repo": REPO, + "comment_id": 11131, + "profile": "prgs-reviewer", + "reviewer_identity": "sysadmin", + "phase": "claimed", + }, + lease_provenance={"source": "acquire", "comment_id": 11131}, + ) + leases._SESSION_LEASE = None # simulated crash: no sanctioned clear + self._age_record( + mss.KIND_REVIEWER_SESSION_LEASE, hours=27.0, instance_id=self.SID + ) + shadow = leases.load_session_lease_shadow(session_id=self.SID) + self.assertIsNotNone(shadow) + verdict = leases.assess_crashed_session_lease_orphan( + shadow, + lease={"session_id": self.SID}, + current_pid=os.getpid() + 1, + pid_alive=False, + ) + self.assertTrue(verdict["orphan_evidence"]) + self.assertIs(verdict["owner_process_alive"], False) + + +class TestF5ConcurrentSessionShadows(unittest.TestCase): + """F5: concurrent same-profile sessions keep distinct shadow records.""" + + SID_A = "11111-aaaaaaaaaaaa" + SID_B = "22222-bbbbbbbbbbbb" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self._env = patch.dict( + os.environ, {"GITEA_MCP_SESSION_STATE_DIR": self._dir.name} + ) + self._env.start() + leases._SESSION_LEASE = None + + def tearDown(self): + self._env.stop() + self._dir.cleanup() + leases._SESSION_LEASE = None + + def _lease(self, session_id: str, pr_number: int, head: str) -> dict: + return { + "pr_number": pr_number, + "session_id": session_id, + "worktree": f"branches/review-pr-{pr_number}-{session_id[:5]}", + "candidate_head": head, + "repo": REPO, + "comment_id": 11221, + "profile": "prgs-reviewer", + "reviewer_identity": "sysadmin", + "phase": "claimed", + } + + def _record(self, lease: dict) -> None: + leases.record_session_lease( + lease, + lease_provenance={"source": "acquire", "comment_id": 11221}, + ) + + def test_interleaved_sessions_do_not_overwrite_each_other(self): + self._record(self._lease(self.SID_A, 703, OLD_HEAD)) + self._record(self._lease(self.SID_B, 701, NEW_HEAD)) + # Session A heartbeats again after B wrote. + updated_a = self._lease(self.SID_A, 703, OLD_HEAD) + updated_a["phase"] = "validation-start" + self._record(updated_a) + + shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A) + shadow_b = leases.load_session_lease_shadow(session_id=self.SID_B) + self.assertEqual(shadow_a.get("session_id"), self.SID_A) + self.assertEqual(shadow_a.get("pr_number"), 703) + self.assertEqual(shadow_a.get("phase"), "validation-start") + self.assertEqual(shadow_b.get("session_id"), self.SID_B) + self.assertEqual(shadow_b.get("pr_number"), 701) + self.assertEqual(shadow_b.get("candidate_head"), NEW_HEAD) + + def test_shadow_lookup_never_misattributes(self): + self._record(self._lease(self.SID_A, 703, OLD_HEAD)) + self.assertIsNone( + leases.load_session_lease_shadow(session_id=self.SID_B) + ) + verdict = leases.assess_crashed_session_lease_orphan( + leases.load_session_lease_shadow(session_id=self.SID_A), + lease={"session_id": self.SID_B}, + pid_alive=False, + ) + self.assertFalse(verdict["orphan_evidence"]) + + def test_clearing_one_session_preserves_the_other(self): + self._record(self._lease(self.SID_A, 703, OLD_HEAD)) + self._record(self._lease(self.SID_B, 701, NEW_HEAD)) + # Sanctioned clear of B (the currently recorded in-memory lease). + leases.clear_session_lease() + self.assertIsNone( + leases.load_session_lease_shadow(session_id=self.SID_B) + ) + shadow_a = leases.load_session_lease_shadow(session_id=self.SID_A) + self.assertIsNotNone(shadow_a) + self.assertEqual(shadow_a.get("session_id"), self.SID_A) + + def test_reconnected_process_recovers_by_session_id(self): + self._record(self._lease(self.SID_A, 703, OLD_HEAD)) + self._record(self._lease(self.SID_B, 701, NEW_HEAD)) + # Simulated crash + reconnect: in-memory state is gone, durable + # records remain enumerable and individually attributable. + leases._SESSION_LEASE = None + records = mss.list_states(kind=mss.KIND_REVIEWER_SESSION_LEASE) + self.assertEqual( + sorted(r.get("session_id") for r in records), + sorted([self.SID_A, self.SID_B]), + ) + shadow = leases.load_session_lease_shadow(session_id=self.SID_B) + self.assertEqual(shadow.get("pr_number"), 701) + + def test_legacy_single_slot_record_still_resolves_by_session_id(self): + # Upgrade path: a record written by pre-F5 code (no instance key) + # remains usable when its payload names the requested session. + mss.save_state( + kind=mss.KIND_REVIEWER_SESSION_LEASE, + payload={"session_id": self.SID_A, "owner_pid": os.getpid()}, + ) + shadow = leases.load_session_lease_shadow(session_id=self.SID_A) + self.assertIsNotNone(shadow) + self.assertIsNone( + leases.load_session_lease_shadow(session_id=self.SID_B) + ) + + +class TestF6AuditBeforeClear(_CapabilityHarness): + """F6: the durable audit record is persisted before the env clears.""" + + def _recovery_env(self, missing: str) -> dict: + return self._env( + GITEA_ACTIVE_WORKTREE=missing, + GITEA_FORCE_STALE_BINDING_RECOVERY="1", + ) + + def test_audit_write_failure_blocks_the_clear(self): + missing = self._deleted_worktree() + with patch.dict(os.environ, self._recovery_env(missing)): + with patch.object( + mss, "save_state", side_effect=OSError("disk full") + ): + report = mcp_server._assess_stale_active_binding( + auto_recover=True + ) + self.assertEqual(os.environ.get("GITEA_ACTIVE_WORKTREE"), missing) + self.assertFalse(report["recovery_performed"]) + self.assertIs(report.get("audit_persisted"), False) + self.assertTrue(report.get("audit_write_failed")) + self.assertIn("NOT cleared", " ".join(report.get("reasons") or [])) + + def test_retry_after_audit_failure_recovers(self): + missing = self._deleted_worktree() + with patch.dict(os.environ, self._recovery_env(missing)): + with patch.object( + mss, "save_state", side_effect=OSError("disk full") + ): + first = mcp_server._assess_stale_active_binding( + auto_recover=True + ) + self.assertFalse(first["recovery_performed"]) + # Persistence recovered; the retry clears with a durable audit. + second = mcp_server._assess_stale_active_binding(auto_recover=True) + self.assertTrue(second["recovery_performed"]) + self.assertIs(second.get("audit_persisted"), True) + self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ) + audit = mss.load_state(kind=mss.KIND_STALE_BINDING_RECOVERY) + self.assertIsNotNone(audit) + self.assertEqual(audit.get("recovery_status"), "performed") + self.assertEqual(audit.get("cleared_value"), missing) + + def test_audit_record_exists_before_env_clear(self): + missing = self._deleted_worktree() + observed: list[tuple[str | None, str | None]] = [] + real_save = mss.save_state + + def _spy(**kwargs): + observed.append( + ( + (kwargs.get("payload") or {}).get("recovery_status"), + os.environ.get("GITEA_ACTIVE_WORKTREE"), + ) + ) + return real_save(**kwargs) + + with patch.dict(os.environ, self._recovery_env(missing)): + with patch.object(mss, "save_state", side_effect=_spy): + report = mcp_server._assess_stale_active_binding( + auto_recover=True + ) + self.assertTrue(report["recovery_performed"]) + self.assertGreaterEqual(len(observed), 2) + pending_status, env_at_pending = observed[0] + performed_status, env_at_performed = observed[-1] + # Ordering proof: the binding was still present while the pending + # audit persisted, and gone by the time the performed status wrote. + self.assertEqual(pending_status, "pending") + self.assertEqual(env_at_pending, missing) + self.assertEqual(performed_status, "performed") + self.assertIsNone(env_at_performed) + + def test_recovery_is_idempotent_after_success(self): + missing = self._deleted_worktree() + with patch.dict(os.environ, self._recovery_env(missing)): + first = mcp_server._assess_stale_active_binding(auto_recover=True) + second = mcp_server._assess_stale_active_binding(auto_recover=True) + self.assertTrue(first["recovery_performed"]) + self.assertEqual(second["classification"], sbr.CLASSIFICATION_UNBOUND) + self.assertFalse(second["recovery_performed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_702_stale_binding_lease_recovery.py b/tests/test_issue_702_stale_binding_lease_recovery.py new file mode 100644 index 0000000..8c3306d --- /dev/null +++ b/tests/test_issue_702_stale_binding_lease_recovery.py @@ -0,0 +1,517 @@ +"""Regression tests for #702: stale runtime binding + orphaned durable lease. + +Reproduces the PR #701 incident (lease 65664-4f248d213d60, comments +11129/11131): the MCP daemon crashed without teardown, destroying the +in-memory session lease while the durable comment lease survived, and the +auto-reconnected daemon inherited a stale ``GITEA_ACTIVE_WORKTREE`` binding +(``branches/review-pr-654``) from its parent environment. + +Covers the five mandated scenarios: + +1. lost in-session leases — durable shadow survives a crash and yields + provable owner-process evidence +2. old-head leases — expired superseded-head lease with no terminal + review becomes recoverable only with orphan evidence + controller authority +3. runtime/worktree mismatch — env bindings to deleted worktrees are demoted, + never silently bound +4. managed reconnect — boot-inherited uncorroborated bindings are + surfaced for managed recovery; provably stale ones are clear-eligible +5. safe expiry — pre-expiry stays fail-closed wait (no steal); + canonical expiry unlocks acquisition and guarded cleanup +""" + +from __future__ import annotations + +import os +import sys +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import namespace_workspace_binding as nwb # noqa: E402 +import reviewer_pr_lease as leases # noqa: E402 +import stale_binding_recovery as sbr # noqa: E402 + +# PR #701 incident fixtures. +OLD_HEAD = "b4d0cb22e452a07c8e816745c704ddb0f43edf0b" +NEW_HEAD = "6b675f5c834b41f9d74e8a54294ff44dddf28ae4" +CRASHED_SESSION = "65664-4f248d213d60" +LEASE_COMMENT = 11131 +REPO = "Scaled-Tech-Consulting/Gitea-Tools" +PR = 701 +ISSUE = 699 +LEASE_WORKTREE = "branches/review-fix-issue-699-structured-auth-mcp-errors" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _lease_comment( + *, + phase: str = "validation-start", + candidate_head: str = OLD_HEAD, + session_id: str = CRASHED_SESSION, + comment_id: int = LEASE_COMMENT, + last_activity: datetime | None = None, + expires_at: datetime | None = None, + worktree: str = LEASE_WORKTREE, +) -> dict: + stamp = last_activity or _now() + body = leases.format_lease_body( + repo=REPO, + pr_number=PR, + issue_number=ISSUE, + reviewer_identity="sysadmin", + profile="prgs-reviewer", + session_id=session_id, + worktree=worktree, + phase=phase, + candidate_head=candidate_head, + target_branch="master", + target_branch_sha="2" * 40, + last_activity=stamp, + expires_at=expires_at, + ) + return { + "id": comment_id, + "body": body, + "user": {"login": "sysadmin"}, + "author": "sysadmin", + "created_at": stamp.isoformat(), + "updated_at": stamp.isoformat(), + } + + +def _expired_superseded_comments() -> list[dict]: + stale = _now() - timedelta(hours=3) + return [_lease_comment( + last_activity=stale, + expires_at=stale + timedelta(minutes=120), + )] + + +def _fresh_superseded_comments() -> list[dict]: + recent = _now() - timedelta(minutes=10) + return [_lease_comment( + last_activity=recent, + expires_at=recent + timedelta(minutes=120), + )] + + +class TestLostInSessionLeaseShadow(unittest.TestCase): + """Scenario 1: the durable shadow survives a daemon crash (#702 AC3).""" + + def setUp(self): + leases._SESSION_LEASE = None + + def tearDown(self): + leases._SESSION_LEASE = None + + def _record(self) -> dict: + return leases.record_session_lease( + { + "pr_number": PR, + "issue_number": ISSUE, + "session_id": CRASHED_SESSION, + "reviewer_identity": "sysadmin", + "profile": "prgs-reviewer", + "worktree": LEASE_WORKTREE, + "phase": "claimed", + "candidate_head": OLD_HEAD, + "repo": REPO, + "comment_id": LEASE_COMMENT, + }, + lease_provenance={"source": "acquire", "comment_id": LEASE_COMMENT}, + ) + + def test_shadow_written_on_record_and_survives_simulated_crash(self): + self._record() + # Simulated unhandled crash: the in-memory dict dies with the process + # but the sanctioned clear path never runs. + leases._SESSION_LEASE = None + shadow = leases.load_session_lease_shadow() + self.assertIsNotNone(shadow) + self.assertEqual(shadow.get("session_id"), CRASHED_SESSION) + self.assertEqual(shadow.get("pr_number"), PR) + self.assertEqual(int(shadow.get("owner_pid")), os.getpid()) + + def test_sanctioned_clear_removes_shadow(self): + self._record() + leases.clear_session_lease() + self.assertIsNone(leases.load_session_lease_shadow()) + + def test_orphan_assessment_dead_owner_pid_proves_exit(self): + self._record() + leases._SESSION_LEASE = None + shadow = leases.load_session_lease_shadow() + lease = {"session_id": CRASHED_SESSION} + verdict = leases.assess_crashed_session_lease_orphan( + shadow, lease=lease, current_pid=os.getpid() + 1, pid_alive=False + ) + self.assertTrue(verdict["orphan_evidence"]) + self.assertIs(verdict["owner_process_alive"], False) + + def test_orphan_assessment_alive_pid_is_not_ownership_proof(self): + self._record() + shadow = leases.load_session_lease_shadow() + verdict = leases.assess_crashed_session_lease_orphan( + shadow, + lease={"session_id": CRASHED_SESSION}, + current_pid=os.getpid() + 1, + pid_alive=True, + ) + self.assertFalse(verdict["orphan_evidence"]) + self.assertIsNone(verdict["owner_process_alive"]) + + def test_orphan_assessment_session_mismatch_proves_nothing(self): + self._record() + shadow = leases.load_session_lease_shadow() + verdict = leases.assess_crashed_session_lease_orphan( + shadow, lease={"session_id": "other-999"}, pid_alive=False + ) + self.assertFalse(verdict["orphan_evidence"]) + self.assertIsNone(verdict["owner_process_alive"]) + + def test_orphan_assessment_without_shadow_is_unknown(self): + verdict = leases.assess_crashed_session_lease_orphan( + None, lease={"session_id": CRASHED_SESSION} + ) + self.assertFalse(verdict["orphan_evidence"]) + self.assertIsNone(verdict["owner_process_alive"]) + + +class TestOldHeadLeaseCleanup(unittest.TestCase): + """Scenario 2: expired superseded-head lease with no terminal review.""" + + def _assess(self, comments, **kwargs): + defaults = dict( + pr_number=PR, + current_head_sha=NEW_HEAD, + formal_reviews=[], + repo=REPO, + expected_repo=REPO, + requesting_session_id="fresh-controller", + controller_recovery_authorized=True, + worktree_exists=True, + worktree_clean=True, + owner_process_alive=False, + ) + defaults.update(kwargs) + return leases.assess_obsolete_reviewer_comment_lease_cleanup( + comments, **defaults + ) + + def test_expired_orphan_with_full_evidence_is_cleanup_eligible(self): + result = self._assess(_expired_superseded_comments()) + self.assertEqual(result["classification"], "orphaned_expired_superseded_head") + self.assertTrue(result["cleanup_allowed"]) + self.assertIn("phase: released", result["release_body"] or "") + self.assertEqual( + result["exact_next_action"], "cleanup_obsolete_reviewer_comment_lease" + ) + + def test_expired_orphan_without_owner_evidence_fails_closed(self): + result = self._assess( + _expired_superseded_comments(), owner_process_alive=None + ) + self.assertFalse(result["cleanup_allowed"]) + self.assertIn( + "provable owner-process-exit evidence", + " ".join(result["fail_closed_reasons"]), + ) + + def test_expired_orphan_without_controller_authority_fails_closed(self): + result = self._assess( + _expired_superseded_comments(), controller_recovery_authorized=False + ) + self.assertFalse(result["cleanup_allowed"]) + + def test_expired_orphan_with_dirty_worktree_fails_closed(self): + result = self._assess( + _expired_superseded_comments(), + worktree_clean=False, + worktree_has_unpreserved_work=True, + ) + self.assertFalse(result["cleanup_allowed"]) + + def test_unexpired_superseded_no_terminal_stays_ambiguous_wait(self): + # Regression guard: pre-expiry there is still no steal path. + result = self._assess(_fresh_superseded_comments()) + self.assertEqual(result["classification"], "ambiguous_conflicting_evidence") + self.assertFalse(result["cleanup_allowed"]) + self.assertEqual(result["exact_next_action"], "wait") + + +class TestRuntimeWorktreeMismatch(unittest.TestCase): + """Scenario 3: env bindings to deleted worktrees are demoted (#702 AC2).""" + + def test_missing_active_env_binding_is_demoted(self): + demotions: list[str] = [] + missing = "/nonexistent/branches/review-pr-654" + path, source = nwb.resolve_namespace_workspace( + role_kind="reviewer", + process_project_root=os.getcwd(), + env={"GITEA_ACTIVE_WORKTREE": missing}, + demotions=demotions, + verify_paths=True, + ) + self.assertEqual(source, "MCP server process root (default)") + self.assertEqual(len(demotions), 1) + self.assertIn("no longer exists", demotions[0]) + + def test_missing_active_falls_through_to_existing_role_env(self): + existing = os.getcwd() + path, source = nwb.resolve_namespace_workspace( + role_kind="reviewer", + process_project_root="/tmp", + env={ + "GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654", + "GITEA_REVIEWER_WORKTREE": existing, + }, + verify_paths=True, + ) + self.assertEqual(path, os.path.realpath(existing)) + self.assertEqual(source, "GITEA_REVIEWER_WORKTREE environment variable") + + def test_existing_active_env_binding_still_wins(self): + existing = os.getcwd() + path, source = nwb.resolve_namespace_workspace( + role_kind="reviewer", + process_project_root="/tmp", + env={"GITEA_ACTIVE_WORKTREE": existing}, + verify_paths=True, + ) + self.assertEqual(path, os.path.realpath(existing)) + self.assertEqual(source, "GITEA_ACTIVE_WORKTREE environment variable") + + def test_explicit_worktree_path_argument_is_never_demoted(self): + missing = "/nonexistent/branches/explicit" + path, source = nwb.resolve_namespace_workspace( + role_kind="reviewer", + worktree_path=missing, + process_project_root="/tmp", + env={}, + verify_paths=True, + ) + self.assertEqual(source, "worktree_path argument") + + def test_mutation_context_reports_demotion_in_ignored_bindings(self): + ctx = nwb.resolve_namespace_mutation_context( + role_kind="reviewer", + worktree_path=None, + process_project_root=os.getcwd(), + env={"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"}, + ) + joined = " ".join(ctx["ignored_bindings"]) + self.assertIn("stale binding, #702", joined) + + +class TestManagedReconnectRecovery(unittest.TestCase): + """Scenario 4: boot-inherited bindings and the sanctioned recovery plan.""" + + def test_uncorroborated_inherited_reviewer_binding_is_unverified(self): + classification = sbr.classify_active_worktree_binding( + active_value=os.getcwd(), + role_env_value=None, + session_lease_worktree=None, + boot_inherited=True, + path_exists=True, + role_kind="reviewer", + ) + self.assertEqual( + classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED + ) + self.assertFalse(classification["clear_eligible"]) + plan = sbr.plan_recovery(classification) + self.assertFalse(plan["clear_allowed"]) + self.assertIn("managed", plan["exact_next_action"]) + self.assertIn("do not clear or rewrite", plan["exact_next_action"]) + + def test_missing_path_binding_is_provably_stale_and_clear_eligible(self): + classification = sbr.classify_active_worktree_binding( + active_value="/nonexistent/branches/review-pr-654", + boot_inherited=True, + path_exists=False, + role_kind="reviewer", + ) + self.assertEqual( + classification["classification"], + sbr.CLASSIFICATION_PROVABLY_STALE_MISSING_PATH, + ) + plan = sbr.plan_recovery(classification) + self.assertTrue(plan["clear_allowed"]) + + def test_inherited_binding_superseded_by_session_lease_is_clear_eligible(self): + classification = sbr.classify_active_worktree_binding( + active_value="/tmp", + session_lease_worktree=os.getcwd(), + boot_inherited=True, + path_exists=True, + role_kind="reviewer", + ) + self.assertEqual( + classification["classification"], + sbr.CLASSIFICATION_SUPERSEDED_BY_SESSION_LEASE, + ) + self.assertTrue(sbr.plan_recovery(classification)["clear_allowed"]) + + def test_post_boot_binding_conflict_is_surfaced_not_cleared(self): + classification = sbr.classify_active_worktree_binding( + active_value="/tmp", + session_lease_worktree=os.getcwd(), + boot_inherited=False, + path_exists=True, + role_kind="reviewer", + ) + self.assertEqual( + classification["classification"], sbr.CLASSIFICATION_UNVERIFIED_INHERITED + ) + self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"]) + + def test_corroborated_bindings_are_untouched(self): + for kwargs in ( + dict(role_env_value=os.getcwd()), + dict(session_lease_worktree=os.getcwd()), + ): + classification = sbr.classify_active_worktree_binding( + active_value=os.getcwd(), + boot_inherited=True, + path_exists=True, + role_kind="reviewer", + **kwargs, + ) + self.assertEqual( + classification["classification"], sbr.CLASSIFICATION_CORROBORATED + ) + self.assertFalse(sbr.plan_recovery(classification)["clear_allowed"]) + + def test_author_inherited_binding_stays_corroborated(self): + classification = sbr.classify_active_worktree_binding( + active_value=os.getcwd(), + boot_inherited=True, + path_exists=True, + role_kind="author", + ) + self.assertEqual( + classification["classification"], sbr.CLASSIFICATION_CORROBORATED + ) + + def test_apply_recovery_clears_env_only_for_authorized_plan(self): + env = {"GITEA_ACTIVE_WORKTREE": "/nonexistent/branches/review-pr-654"} + plan = sbr.plan_recovery( + sbr.classify_active_worktree_binding( + active_value=env["GITEA_ACTIVE_WORKTREE"], + boot_inherited=True, + path_exists=False, + role_kind="reviewer", + ) + ) + applied = sbr.apply_recovery(plan, env=env) + self.assertTrue(applied["performed"]) + self.assertNotIn("GITEA_ACTIVE_WORKTREE", env) + self.assertIn("review-pr-654", applied["cleared_value"]) + + def test_apply_recovery_refuses_unauthorized_plan(self): + env = {"GITEA_ACTIVE_WORKTREE": os.getcwd()} + plan = sbr.plan_recovery( + sbr.classify_active_worktree_binding( + active_value=env["GITEA_ACTIVE_WORKTREE"], + boot_inherited=True, + path_exists=True, + role_kind="reviewer", + ) + ) + applied = sbr.apply_recovery(plan, env=env) + self.assertFalse(applied["performed"]) + self.assertIn("GITEA_ACTIVE_WORKTREE", env) + + +class TestSafeExpiryDiagnosis(unittest.TestCase): + """Scenario 5: canonical expiry flips wait into acquire/guarded cleanup.""" + + def _diagnose(self, comments, **kwargs): + defaults = dict( + pr_number=PR, + current_session_id=None, + current_reviewer_identity="fresh-reviewer", + current_head_sha=NEW_HEAD, + formal_reviews=[], + ) + defaults.update(kwargs) + return leases.diagnose_reviewer_pr_lease_handoff(comments, **defaults) + + def setUp(self): + leases._SESSION_LEASE = None + + def test_pre_expiry_superseded_no_terminal_stays_wait(self): + diagnosis = self._diagnose(_fresh_superseded_comments()) + self.assertEqual( + diagnosis["classification"], "ambiguous_conflicting_evidence" + ) + self.assertEqual(diagnosis["next_action"], "wait") + + def test_post_expiry_without_orphan_evidence_unlocks_acquire(self): + diagnosis = self._diagnose(_expired_superseded_comments()) + self.assertEqual( + diagnosis["classification"], "orphaned_expired_superseded_head" + ) + self.assertEqual(diagnosis["next_action"], "acquire") + + def test_post_expiry_with_orphan_evidence_offers_guarded_cleanup(self): + diagnosis = self._diagnose( + _expired_superseded_comments(), + owner_process_alive=False, + worktree_clean=True, + worktree_exists=True, + ) + self.assertEqual( + diagnosis["classification"], "orphaned_expired_superseded_head" + ) + self.assertEqual( + diagnosis["next_action"], "cleanup_obsolete_reviewer_comment_lease" + ) + + def test_expired_lease_no_longer_blocks_fresh_acquisition(self): + assessment = leases.assess_acquire_lease( + _expired_superseded_comments(), + pr_number=PR, + reviewer_identity="fresh-reviewer", + profile="prgs-reviewer", + session_id="99999-fresh", + repo=REPO, + issue_number=ISSUE, + worktree="branches/review-pr-701-fresh", + candidate_head=NEW_HEAD, + target_branch="master", + target_branch_sha="2" * 40, + ) + self.assertTrue(assessment.get("acquire_allowed")) + + def test_unparseable_expiry_fails_closed_in_cleanup(self): + comment = _lease_comment( + last_activity=_now() - timedelta(hours=3), expires_at=None + ) + comment["body"] = comment["body"].replace( + "expires_at: ", "expires_at: not-a-timestamp" + ) + result = leases.assess_obsolete_reviewer_comment_lease_cleanup( + [comment], + pr_number=PR, + current_head_sha=NEW_HEAD, + formal_reviews=[], + repo=REPO, + expected_repo=REPO, + requesting_session_id="fresh-controller", + controller_recovery_authorized=True, + worktree_exists=True, + worktree_clean=True, + owner_process_alive=False, + ) + self.assertFalse(result["cleanup_allowed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_709_decision_lock_cross_profile.py b/tests/test_issue_709_decision_lock_cross_profile.py new file mode 100644 index 0000000..91e8d16 --- /dev/null +++ b/tests/test_issue_709_decision_lock_cross_profile.py @@ -0,0 +1,2235 @@ +"""#709: cross-profile decision-lock cleanup, overwrite protection, recovery. + +Covers AC1–AC8 plus review-434 F1/F2/F3 and review-435 F3-residual/F4/F5 +remediations without fabricating historical PR provenance or special-casing +live PR numbers in production code. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +import irrecoverable_provenance as irp +import mcp_session_state as ss +import stale_review_decision_lock as srdl + + +def _lock( + mutations=None, + *, + profile="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + head=None, +): + muts = [] + for m in mutations or []: + row = dict(m) + if head and "head_sha" not in row: + row["head_sha"] = head + muts.append(row) + return { + "task": "review_pr", + "remote": remote, + "org": org, + "repo": repo, + "session_pid": os.getpid(), + "session_profile": profile, + "session_profile_lock": profile, + "profile_identity": profile, + "final_review_decision_ready": False, + "ready_pr_number": None, + "ready_action": None, + "ready_expected_head_sha": None, + "live_mutations": muts, + "correction_authorized": False, + "correction_reason": None, + "kind": ss.KIND_DECISION_LOCK, + } + + +APPROVE = {"pr_number": 100, "action": "approve", "review_id": 9} +APPROVE_OTHER = {"pr_number": 200, "action": "approve", "review_id": 10} +HEAD_A = "a" * 40 +HEAD_B = "b" * 40 +RECONCILER_OPS = [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", +] +DEDICATED_RECOVERY_OPS = RECONCILER_OPS + [ + irp.CAPABILITY_IRRECOVERABLE_RECOVERY, +] +DURABLE_TEST_HMAC_KEY = "0" * 64 # 32-byte hex durable key for F4 tests + +# #709 F7 (review 438): canonical incident evidence binds the full recovery +# scope, so the fixtures carry stable actor ids, the decision-lock identity, +# the recovery action, both heads, the key version, and a replay nonce. +DECISION_LOCK_ID = "review_decision_lock-prgs-reviewer" +DESTROYED_SUBJECT = "prgs-reviewer terminal approval ledger" +RECOVERY_ACTION = irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE +INCIDENT_NONCE = "11111111-2222-3333-4444-555555555555" +INCIDENT_ISSUED_AT = "2026-07-13T00:00:00+00:00" +AUTHOR_LOGIN = "controller-ops" +AUTHOR_ID = 4242 +MINT_ACTOR_LOGIN = "sysadmin" +MINT_ACTOR_ID = 7 + + +def _canonical_incident_body( + *, + pr_number=42, + head=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + incident_issue=700, + decision_lock_id=DECISION_LOCK_ID, + destroyed_subject=DESTROYED_SUBJECT, + recovery_action=RECOVERY_ACTION, + recorded_head_sha=HEAD_A, + evidence_author_id=AUTHOR_ID, + evidence_author_login=AUTHOR_LOGIN, + mint_actor_id=MINT_ACTOR_ID, + mint_actor_login=MINT_ACTOR_LOGIN, + key_version=None, + nonce=INCIDENT_NONCE, + issued_at=INCIDENT_ISSUED_AT, + narrative="forensic diagnosis", +): + return irp.build_canonical_incident_body( + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + decision_lock_id=decision_lock_id, + destroyed_subject=destroyed_subject, + recovery_action=recovery_action, + recorded_head_sha=recorded_head_sha, + expected_head_sha=head, + incident_issue=incident_issue, + evidence_author_id=evidence_author_id, + evidence_author_login=evidence_author_login, + mint_actor_id=mint_actor_id, + mint_actor_login=mint_actor_login, + key_version=key_version or irp.auth_key_version(), + nonce=nonce, + issued_at=issued_at, + narrative=narrative, + ) + + +def _incident_comment_payload( + *, + comment_id=11489, + author=AUTHOR_LOGIN, + author_id=None, + pr_number=42, + head=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + incident_issue=700, + body=None, + **body_kwargs, +): + uid = AUTHOR_ID if author_id is None else author_id + return { + "id": comment_id, + "body": body + if body is not None + else _canonical_incident_body( + pr_number=pr_number, + head=head, + remote=remote, + org=org, + repo=repo, + incident_issue=incident_issue, + evidence_author_id=uid, + evidence_author_login=author, + **body_kwargs, + ), + "user": {"id": uid, "login": author}, + "created_at": "2026-07-13T00:00:00Z", + "updated_at": "2026-07-13T00:00:00Z", + "html_url": f"https://gitea.example/{org}/{repo}/issues/{incident_issue}#issuecomment-{comment_id}", + } + + +def _mint_auth( + *, + pr_number=42, + head=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + incident_issue=700, + incident_comment_id=11489, + issuer="sysadmin", + profile="prgs-reconciler", + destroyed_subject=None, +): + return irp.build_authorization_artifact( + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + expected_head_sha=head, + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + destroyed_subject=destroyed_subject, + issuer_username=issuer, + issuer_profile=profile, + native_provenance={ + "native_mcp_transport": True, + "production_native_mcp_transport": False, + "pytest": True, + "token_fingerprint": "testfp", + "entrypoint": "pytest", + "pid": os.getpid(), + }, + ) + + +class TestAC2InitOverwrite(unittest.TestCase): + def test_empty_lock_allows_reinit(self): + a = srdl.assess_init_overwrite(_lock([]), force=True) + self.assertTrue(a["overwrite_allowed"]) + + def test_terminal_lock_blocks_force_reinit(self): + a = srdl.assess_init_overwrite(_lock([APPROVE]), force=True) + self.assertFalse(a["overwrite_allowed"]) + self.assertTrue(a["has_unresolved_terminal"]) + self.assertEqual(a["last_terminal_pr"], 100) + + def test_none_lock_allows_init(self): + a = srdl.assess_init_overwrite(None) + self.assertTrue(a["overwrite_allowed"]) + + +class TestAC1TargetApproval(unittest.TestCase): + def test_targets_matching_approve(self): + self.assertTrue( + srdl.lock_targets_merged_pr_approval( + _lock([APPROVE], head=HEAD_A), + pr_number=100, + expected_head_sha=HEAD_A, + ) + ) + + def test_rejects_other_pr(self): + self.assertFalse( + srdl.lock_targets_merged_pr_approval( + _lock([APPROVE_OTHER]), pr_number=100 + ) + ) + + def test_rejects_head_mismatch(self): + self.assertFalse( + srdl.lock_targets_merged_pr_approval( + _lock([APPROVE], head=HEAD_A), + pr_number=100, + expected_head_sha=HEAD_B, + ) + ) + + def test_f3_residual_rejects_legacy_no_head_when_expected_head_given(self): + """Primary approve-match requires recorded-head (#709 F3 residual / 435).""" + # APPROVE without head fields — legacy ledger. + legacy = _lock([APPROVE]) # no head= + self.assertIsNone(srdl.mutation_head_sha(APPROVE, legacy)) + self.assertFalse( + srdl.lock_targets_merged_pr_approval( + legacy, + pr_number=100, + expected_head_sha=HEAD_A, + ) + ) + # Without expected_head_sha, PR-number-only match still works for + # non-destructive callers that do not pass a head pin. + self.assertTrue( + srdl.lock_targets_merged_pr_approval(legacy, pr_number=100) + ) + + +class TestF1AuthorizationNotSelfAssertable(unittest.TestCase): + def test_operator_authorized_true_cannot_authorize_via_build(self): + rec = srdl.build_irrecoverable_provenance_record( + pr_number=42, + head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + actor_username="sysadmin", + profile_name="prgs-reconciler", + reason="evidence destroyed", + incident_ref="anything", + operator_authorized=True, + ) + self.assertFalse(rec["applied"]) + self.assertFalse(rec["historical_cleanup_proven"]) + self.assertFalse(rec["merger_may_accept"]) + + def test_confirmation_string_not_authorization(self): + # Confirmation is only intent text; capability assess ignores it. + conf = irp.expected_confirmation(99) + self.assertEqual(conf, "IRRECOVERABLE DECISION PROVENANCE PR 99") + # Without auth artifact, merger cannot accept. + rec = srdl.build_irrecoverable_provenance_record( + pr_number=99, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="x", + profile_name="y", + reason="r", + operator_authorized=False, + ) + self.assertFalse(rec["merger_may_accept"]) + + def test_gitea_read_alone_insufficient(self): + a = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=["gitea.read"], + forbidden_operations=[], + role_kind="author", + profile_name="prgs-author", + ) + self.assertFalse(a["allowed"]) + + def test_expected_head_missing_fails(self): + g = irp.assess_live_head_binding( + expected_head_sha=None, + live_head_sha=HEAD_A, + ) + self.assertFalse(g["valid"]) + + def test_expected_head_differs_fails(self): + g = irp.assess_live_head_binding( + expected_head_sha=HEAD_A, + live_head_sha=HEAD_B, + ) + self.assertFalse(g["valid"]) + + def test_missing_incident_fails(self): + g = irp.assess_incident_evidence( + incident_issue=None, + incident_comment_id=None, + comment_payload=None, + ) + self.assertFalse(g["valid"]) + + def test_nonexistent_incident_fails(self): + g = irp.assess_incident_evidence( + incident_issue=1, + incident_comment_id=2, + comment_payload=None, + comment_lookup_error="404", + ) + self.assertFalse(g["valid"]) + + def test_valid_auth_succeeds_exact_scope(self): + auth = _mint_auth() + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + ) + self.assertTrue(v["valid"], v) + rec = irp.build_irrecoverable_provenance_record( + pr_number=42, + head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + actor_username="sysadmin", + profile_name="prgs-reconciler", + reason="evidence destroyed", + incident_issue=700, + incident_comment_id=11489, + authorization=auth, + ) + self.assertTrue(rec["merger_may_accept"]) + self.assertFalse(rec["applied"]) + body = irp.format_irrecoverable_audit_comment(rec) + self.assertIn("applied: `False`", body) + + def test_wrong_repo_auth_fails(self): + auth = _mint_auth(repo="Other-Repo") + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + ) + self.assertFalse(v["valid"]) + + def test_wrong_pr_auth_fails(self): + auth = _mint_auth(pr_number=1) + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + ) + self.assertFalse(v["valid"]) + + def test_wrong_head_auth_fails(self): + auth = _mint_auth(head=HEAD_B) + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + ) + self.assertFalse(v["valid"]) + + def test_altered_signature_fails(self): + auth = _mint_auth() + auth["server_signature"] = "0" * 64 + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + ) + self.assertFalse(v["valid"]) + + def test_fresh_non_pytest_process_cannot_mint_accepted_record(self): + """Ordinary Python process: merger_may_accept stays False without server auth.""" + script = ( + "import stale_review_decision_lock as s\n" + "r=s.build_irrecoverable_provenance_record(\n" + " pr_number=1, head_sha=None, remote='prgs', org=None, repo=None,\n" + " actor_username='x', profile_name='y', reason='r',\n" + " incident_ref=None, operator_authorized=True)\n" + "print(r.get('merger_may_accept'), r.get('head_sha'))\n" + ) + env = {k: v for k, v in os.environ.items() if not k.startswith("PYTEST")} + env.pop("PYTEST_CURRENT_TEST", None) + proc = subprocess.run( + [sys.executable, "-c", script], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + env=env, + timeout=30, + ) + self.assertEqual(proc.returncode, 0, proc.stderr) + out = (proc.stdout or "").strip() + self.assertTrue(out.startswith("False"), msg=out) + + def test_unauthorized_profile_capability(self): + a = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=["gitea.read", "gitea.pr.comment"], + forbidden_operations=["gitea.pr.close"], + role_kind="author", + profile_name="prgs-author", + ) + self.assertFalse(a["allowed"]) + + def test_f5_reconciler_equivalence_rejected(self): + """Reconciler profile without dedicated capability cannot mint (#709 F5).""" + a = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=RECONCILER_OPS, + forbidden_operations=[ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + ], + role_kind="reconciler", + profile_name="prgs-reconciler", + ) + self.assertFalse(a["allowed"], a) + self.assertTrue( + any("dedicated" in r for r in a["reasons"]), + msg=a["reasons"], + ) + + def test_f5_dedicated_capability_allowed(self): + a = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=DEDICATED_RECOVERY_OPS, + forbidden_operations=[ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + ], + role_kind="reconciler", + profile_name="prgs-reconciler", + ) + self.assertTrue(a["allowed"], a) + self.assertEqual(a["via"], "dedicated_capability") + + +class TestF5AuthoritativeIncidentEvidence(unittest.TestCase): + def test_any_nonempty_body_rejected(self): + g = irp.assess_incident_evidence( + incident_issue=700, + incident_comment_id=11489, + comment_payload={ + "id": 11489, + "body": "random forensic note without canonical fields", + "user": {"login": "controller-ops"}, + }, + expected_remote="prgs", + expected_org="Scaled-Tech-Consulting", + expected_repo="Gitea-Tools", + expected_pr_number=42, + expected_head_sha=HEAD_A, + ) + self.assertFalse(g["valid"], g) + + def test_missing_author_rejected(self): + body = _canonical_incident_body() + g = irp.assess_incident_evidence( + incident_issue=700, + incident_comment_id=11489, + comment_payload={"id": 11489, "body": body, "user": {}}, + expected_remote="prgs", + expected_org="Scaled-Tech-Consulting", + expected_repo="Gitea-Tools", + expected_pr_number=42, + expected_head_sha=HEAD_A, + ) + self.assertFalse(g["valid"], g) + self.assertTrue(any("author" in r for r in g["reasons"]), g["reasons"]) + + def test_self_authored_rejected(self): + g = irp.assess_incident_evidence( + incident_issue=700, + incident_comment_id=11489, + comment_payload=_incident_comment_payload(author="sysadmin"), + expected_remote="prgs", + expected_org="Scaled-Tech-Consulting", + expected_repo="Gitea-Tools", + expected_pr_number=42, + expected_head_sha=HEAD_A, + mint_actor_username="sysadmin", + reject_self_authored=True, + ) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("self-authored" in r for r in g["reasons"]), g["reasons"] + ) + + def test_canonical_body_with_independent_author_accepted(self): + g = irp.assess_incident_evidence( + incident_issue=700, + incident_comment_id=11489, + comment_payload=_incident_comment_payload(author="controller-ops"), + expected_remote="prgs", + expected_org="Scaled-Tech-Consulting", + expected_repo="Gitea-Tools", + expected_pr_number=42, + expected_head_sha=HEAD_A, + mint_actor_username="sysadmin", + reject_self_authored=True, + ) + self.assertTrue(g["valid"], g) + + def test_tampered_content_digest_rejected(self): + payload = _incident_comment_payload() + payload["body"] = payload["body"].replace( + "content_digest: ", "content_digest: " + "f" * 64 + "x" + ) + # Force bad digest line + lines = [] + for line in payload["body"].splitlines(): + if line.startswith("content_digest:"): + lines.append("content_digest: " + "0" * 64) + else: + lines.append(line) + payload["body"] = "\n".join(lines) + g = irp.assess_incident_evidence( + incident_issue=700, + incident_comment_id=11489, + comment_payload=payload, + expected_remote="prgs", + expected_org="Scaled-Tech-Consulting", + expected_repo="Gitea-Tools", + expected_pr_number=42, + expected_head_sha=HEAD_A, + mint_actor_username="sysadmin", + ) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("content_digest" in r for r in g["reasons"]), g["reasons"] + ) + + +class TestF4DurableHmacKey(unittest.TestCase): + def tearDown(self): + os.environ.pop(irp.ENV_AUTH_HMAC_KEY, None) + os.environ.pop(irp.ENV_AUTH_HMAC_KEY_VERSION, None) + irp.reset_process_auth_secret_for_tests() + + def test_key_version_bound_into_artifact(self): + irp.reset_process_auth_secret_for_tests() + auth = _mint_auth() + self.assertIn("key_version", auth) + self.assertTrue(auth["key_version"]) + self.assertNotIn("server_secret", auth) + self.assertNotIn("hmac_key", auth) + + def test_production_fails_closed_without_durable_key(self): + """Non-pytest process without env key must not generate ephemeral secret.""" + script = ( + "import os, sys\n" + "os.environ.pop('PYTEST_CURRENT_TEST', None)\n" + "os.environ.pop('GITEA_IRRECOVERABLE_AUTH_HMAC_KEY', None)\n" + # Force non-pytest path by patching guard after import. + "import mcp_daemon_guard as g\n" + "g.is_pytest_runtime = lambda: False\n" + "import irrecoverable_provenance as irp\n" + "irp.reset_process_auth_secret_for_tests()\n" + "try:\n" + " irp._process_secret()\n" + " print('UNEXPECTED_OK')\n" + "except irp.AuthSecretError as e:\n" + " print('FAIL_CLOSED', 'ephemeral' in str(e).lower() or 'required' in str(e).lower())\n" + ) + env = {k: v for k, v in os.environ.items() if not k.startswith("PYTEST")} + env.pop("PYTEST_CURRENT_TEST", None) + env.pop(irp.ENV_AUTH_HMAC_KEY, None) + proc = subprocess.run( + [sys.executable, "-c", script], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + env=env, + timeout=30, + ) + self.assertEqual(proc.returncode, 0, proc.stderr) + out = (proc.stdout or "").strip() + self.assertTrue(out.startswith("FAIL_CLOSED"), msg=out) + + def test_cross_process_verify_with_durable_key(self): + """Mint in process A, verify in process B with same durable key (#709 F4).""" + import json as _json + + mint_script = ( + "import json, os, irrecoverable_provenance as irp\n" + "irp.reset_process_auth_secret_for_tests()\n" + "auth = irp.build_authorization_artifact(\n" + " remote='prgs', org='o', repo='r', pr_number=10,\n" + f" expected_head_sha={HEAD_A!r}, incident_issue=1, incident_comment_id=2,\n" + " destroyed_subject=None, issuer_username='sysadmin',\n" + " issuer_profile='prgs-reconciler',\n" + " native_provenance={'native_mcp_transport': True, 'pytest': True,\n" + " 'token_fingerprint': 'fp', 'entrypoint': 'pytest', 'pid': 1})\n" + "print(json.dumps(auth))\n" + ) + env = dict(os.environ) + env[irp.ENV_AUTH_HMAC_KEY] = DURABLE_TEST_HMAC_KEY + env[irp.ENV_AUTH_HMAC_KEY_VERSION] = "test-v1" + mint = subprocess.run( + [sys.executable, "-c", mint_script], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + env=env, + timeout=30, + ) + self.assertEqual(mint.returncode, 0, mint.stderr) + auth = _json.loads(mint.stdout.strip()) + self.assertEqual(auth.get("key_version"), "test-v1") + + verify_script = ( + "import json, sys, irrecoverable_provenance as irp\n" + "irp.reset_process_auth_secret_for_tests()\n" + "auth = json.loads(sys.stdin.read())\n" + "v = irp.verify_authorization_artifact(\n" + " auth, remote='prgs', org='o', repo='r', pr_number=10,\n" + f" expected_head_sha={HEAD_A!r}, incident_issue=1, incident_comment_id=2)\n" + "print(v.get('valid'), v.get('reasons'))\n" + ) + verify = subprocess.run( + [sys.executable, "-c", verify_script], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + input=_json.dumps(auth), + capture_output=True, + text=True, + env=env, + timeout=30, + ) + self.assertEqual(verify.returncode, 0, verify.stderr) + self.assertTrue( + (verify.stdout or "").strip().startswith("True"), + msg=verify.stdout + verify.stderr, + ) + + # Different durable key must fail verification (cross-process mismatch). + env_bad = dict(env) + env_bad[irp.ENV_AUTH_HMAC_KEY] = "1" * 64 + verify_bad = subprocess.run( + [sys.executable, "-c", verify_script], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + input=_json.dumps(auth), + capture_output=True, + text=True, + env=env_bad, + timeout=30, + ) + self.assertEqual(verify_bad.returncode, 0, verify_bad.stderr) + self.assertTrue( + (verify_bad.stdout or "").strip().startswith("False"), + msg=verify_bad.stdout, + ) + + +class TestF2MergerConsumer(unittest.TestCase): + def test_merger_rejects_without_valid_auth(self): + rec = srdl.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + operator_authorized=True, + ) + a = irp.assess_merger_consumption( + rec, + None, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=False, + ) + self.assertFalse(a["allowed"]) + + def test_merger_rejects_wrong_head(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r") + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + a = irp.assess_merger_consumption( + rec, + auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_B, + approval_at_current_head=True, + has_blocking_change_requests=False, + ) + self.assertFalse(a["allowed"]) + + def test_merger_rejects_replayed_auth(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + consumed = irp.mark_consumed(auth, consumer_username="m", consumer_profile="merger") + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, # original unconsumed for record build + ) + a = irp.assess_merger_consumption( + rec, + consumed, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=False, + ) + self.assertFalse(a["allowed"]) + + def test_recovery_cannot_bypass_missing_approval(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + a = irp.assess_merger_consumption( + rec, + auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=False, + has_blocking_change_requests=False, + ) + self.assertFalse(a["allowed"]) + self.assertTrue(any("approval" in r for r in a["reasons"])) + + def test_recovery_cannot_bypass_blocking_crs(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + a = irp.assess_merger_consumption( + rec, + auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=True, + ) + self.assertFalse(a["allowed"]) + + def test_valid_recovery_resolves_only_prior_provenance(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + a = irp.assess_merger_consumption( + rec, + auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=False, + mergeable=True, + lease_ok=True, + runtime_ok=True, + workspace_ok=True, + anti_stomp_ok=True, + ) + self.assertTrue(a["allowed"], a) + self.assertTrue(a["resolves_prior_provenance_blocker"]) + self.assertFalse(a["historical_cleanup_proven"]) + + def test_duplicate_consume_idempotent(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + consumed_auth = irp.mark_consumed(auth, consumer_username="m", consumer_profile="mer") + consumed_rec = irp.mark_consumed(rec, consumer_username="m", consumer_profile="mer") + a = irp.assess_merger_consumption( + consumed_rec, + consumed_auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=False, + ) + self.assertTrue(a["allowed"], a) + self.assertTrue(a["recovery_record_consumed"]) + + +class TestF3ExactScopeEnforcement(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.state_dir = self._tmp.name + os.chmod(self.state_dir, 0o700) + + def tearDown(self): + self._tmp.cleanup() + + def test_same_pr_other_remote_not_loaded(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([APPROVE], profile="prgs-reviewer", remote="other"), + remote="other", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_same_pr_other_org_not_loaded(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock( + [APPROVE], + profile="prgs-reviewer", + org="Other-Org", + ), + remote="prgs", + org="Other-Org", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_same_pr_other_repo_not_loaded(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock( + [APPROVE], + profile="prgs-reviewer", + repo="Other-Repo", + ), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Other-Repo", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_path_traversal_profile_fails(self): + g = irp.assess_profile_path_identity("../evil") + self.assertFalse(g["valid"]) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="../evil", + remote="prgs", + org="o", + repo="r", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_malformed_legacy_missing_repo_fails_closed(self): + # Record without repo identity when caller requires repo. + payload = _lock([APPROVE], profile="prgs-reviewer") + del payload["repo"] + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=payload, + remote="prgs", + org="Scaled-Tech-Consulting", + repo=None, + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_list_and_load_foreign_profile_lock(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([APPROVE], profile="prgs-reviewer"), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([], profile="prgs-merger"), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-merger", + state_dir=self.state_dir, + ) + foreign = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNotNone(foreign) + self.assertTrue( + srdl.lock_targets_merged_pr_approval(foreign, pr_number=100) + ) + + +class TestAC3PostMergeRecoveryRecord(unittest.TestCase): + def test_recovery_record_is_not_applied_cleanup(self): + rec = srdl.build_post_merge_recovery_record( + pr_number=10, + head_sha=HEAD_A, + merge_commit_sha="m" * 40, + target_profile_identity="prgs-reviewer", + failed_step="audit_comment_publish", + error="timeout", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + actor_username="sysadmin", + profile_name="prgs-merger", + ) + self.assertEqual(rec["status"], "recovery_required") + self.assertFalse(rec["applied"]) + self.assertTrue(rec["recovery_critical"]) + + +class TestSessionStateTTL(unittest.TestCase): + def test_recovery_critical_kinds_ttl_exempt(self): + auth = _mint_auth(pr_number=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=1, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="gone", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + rec["kind"] = ss.KIND_IRRECOVERABLE_DECISION_PROVENANCE + rec["recorded_at"] = "2000-01-01T00:00:00Z" + rec["updated_at"] = rec["recorded_at"] + rec["profile_identity"] = "prgs-reconciler" + rec["session_profile_lock"] = "prgs-reconciler" + reasons = ss.identity_match_reasons( + rec, profile_identity="prgs-reconciler" + ) + self.assertFalse(any("expired" in r for r in reasons), msg=reasons) + + +class TestInitReviewDecisionLockIntegration(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.env = patch.dict( + os.environ, + { + "GITEA_MCP_SESSION_STATE_DIR": self._tmp.name, + "GITEA_SESSION_PROFILE_LOCK": "prgs-reviewer", + "GITEA_PROFILE_NAME": "prgs-reviewer", + }, + clear=False, + ) + self.env.start() + import mcp_server + + self.mcp = mcp_server + self.mcp._REVIEW_DECISION_LOCK = None + + def tearDown(self): + self.mcp._REVIEW_DECISION_LOCK = None + self.env.stop() + self._tmp.cleanup() + + def test_init_does_not_wipe_terminal_ledger(self): + self.mcp._save_review_decision_lock( + _lock([APPROVE], profile="prgs-reviewer") + ) + self.mcp.init_review_decision_lock("prgs", "review_pr", force=True) + loaded = self.mcp._load_review_decision_lock() + self.assertIsNotNone(loaded) + last = srdl.last_terminal_mutation(loaded) + self.assertIsNotNone(last) + self.assertEqual(last.get("pr_number"), 100) + + def test_init_creates_empty_when_no_terminal(self): + self.mcp._save_review_decision_lock(None) + self.mcp.init_review_decision_lock("prgs", "review_pr", force=True) + loaded = self.mcp._load_review_decision_lock() + self.assertIsNotNone(loaded) + self.assertEqual(loaded.get("live_mutations"), []) + + +class TestIrrecoverableToolF1(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.env = patch.dict( + os.environ, + { + "GITEA_MCP_SESSION_STATE_DIR": self._tmp.name, + "GITEA_SESSION_PROFILE_LOCK": "prgs-reconciler", + "GITEA_PROFILE_NAME": "prgs-reconciler", + "GITEA_ALLOWED_OPERATIONS": ",".join(DEDICATED_RECOVERY_OPS), + }, + clear=False, + ) + self.env.start() + import mcp_server + + self.mcp = mcp_server + + def tearDown(self): + self.env.stop() + self._tmp.cleanup() + + def _profile(self): + return { + "profile_name": "prgs-reconciler", + "role": "reconciler", + "allowed_operations": DEDICATED_RECOVERY_OPS, + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + ], + } + + def test_operator_authorized_true_rejected(self): + with patch.object(self.mcp, "get_profile", return_value=self._profile()): + r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( + pr_number=50, + reason="lost", + confirmation=irp.expected_confirmation(50), + operator_authorized=True, + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + post_audit_comment=False, + ) + self.assertFalse(r["success"]) + self.assertTrue(any("operator_authorized" in x for x in r["reasons"])) + + def test_missing_expected_head_fails(self): + with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object( + self.mcp, "_authenticated_username", return_value="sysadmin" + ), patch.object( + self.mcp, "_irrecoverable_capability_gate", return_value=None + ), patch.object( + irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []} + ), patch.object( + self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools") + ): + r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( + pr_number=50, + reason="lost", + confirmation=irp.expected_confirmation(50), + expected_head_sha=None, + incident_issue=700, + incident_comment_id=11489, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + post_audit_comment=False, + ) + self.assertFalse(r["success"]) + + def test_wrong_confirmation_fails(self): + with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object( + self.mcp, "_irrecoverable_capability_gate", return_value=None + ), patch.object( + irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []} + ), patch.object( + self.mcp, "_resolve", return_value=("h", "o", "r") + ): + r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( + pr_number=50, + reason="x", + confirmation=irp.expected_confirmation(51), + expected_head_sha=HEAD_A, + incident_issue=1, + incident_comment_id=2, + remote="prgs", + post_audit_comment=False, + ) + self.assertFalse(r["success"]) + + def test_records_with_server_auth(self): + auth = _mint_auth( + pr_number=50, + head=HEAD_A, + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + incident_issue=700, + incident_comment_id=11489, + ) + auth_profile = irp.auth_state_profile_identity( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=50, + expected_head_sha=HEAD_A, + ) + ss.save_state( + kind=ss.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + payload=auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity=auth_profile, + state_dir=self._tmp.name, + ) + + def _api(method, url, auth=None, data=None, **kwargs): + if "/pulls/" in str(url): + return {"head": {"sha": HEAD_A}, "state": "open"} + if "/issues/comments/" in str(url): + return _incident_comment_payload( + comment_id=11489, + author="controller-ops", + pr_number=50, + head=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + incident_issue=700, + ) + raise AssertionError(f"unexpected API {method} {url}") + + with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object( + self.mcp, "_authenticated_username", return_value="sysadmin" + ), patch.object( + self.mcp, "_irrecoverable_capability_gate", return_value=None + ), patch.object( + irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []} + ), patch.object( + self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools") + ), patch.object( + self.mcp, "_auth", return_value={"Authorization": "token test"} + ), patch.object( + self.mcp, "api_request", side_effect=_api + ), patch.object( + self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r" + ): + r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( + pr_number=50, + reason="terminal evidence overwritten", + confirmation=irp.expected_confirmation(50), + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + post_audit_comment=False, + ) + self.assertTrue(r["success"], r) + self.assertFalse(r["applied"]) + self.assertFalse(r["historical_cleanup_proven"]) + self.assertTrue(r["merger_may_accept"]) + self.assertEqual(r["record"]["status"], "provenance_irrecoverable") + + # Idempotent + with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object( + self.mcp, "_authenticated_username", return_value="sysadmin" + ), patch.object( + self.mcp, "_irrecoverable_capability_gate", return_value=None + ), patch.object( + irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []} + ), patch.object( + self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools") + ), patch.object( + self.mcp, "_auth", return_value={"Authorization": "token test"} + ), patch.object( + self.mcp, "api_request", side_effect=_api + ), patch.object( + self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r" + ): + r2 = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( + pr_number=50, + reason="terminal evidence overwritten", + confirmation=irp.expected_confirmation(50), + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + post_audit_comment=False, + ) + self.assertTrue(r2["success"], r2) + self.assertFalse(r2["performed"]) + + +class TestClearProfileHelperF3(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.env = patch.dict( + os.environ, + { + "GITEA_MCP_SESSION_STATE_DIR": self._tmp.name, + "GITEA_SESSION_PROFILE_LOCK": "prgs-merger", + }, + clear=False, + ) + self.env.start() + import mcp_server + + self.mcp = mcp_server + self.mcp._REVIEW_DECISION_LOCK = None + + def tearDown(self): + self.mcp._REVIEW_DECISION_LOCK = None + self.env.stop() + self._tmp.cleanup() + + def test_clear_only_matching_reviewer_approve(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([], profile="prgs-merger"), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-merger", + state_dir=self._tmp.name, + ) + out = self.mcp._clear_decision_lock_for_profile( + profile_identity="prgs-reviewer", + pr_number=100, + expected_head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(out["cleared"], out) + skip = self.mcp._clear_decision_lock_for_profile( + profile_identity="prgs-merger", + pr_number=100, + expected_head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertFalse(skip["cleared"]) + + def test_pr_number_only_fallback_impossible(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + # Missing expected_head_sha + out = self.mcp._clear_decision_lock_for_profile( + profile_identity="prgs-reviewer", + pr_number=100, + expected_head_sha=None, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertFalse(out["cleared"]) + self.assertIn("PR-number-only", out["reason"]) + + def test_wrong_head_not_cleared(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + out = self.mcp._clear_decision_lock_for_profile( + profile_identity="prgs-reviewer", + pr_number=100, + expected_head_sha=HEAD_B, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertFalse(out["cleared"]) + + def test_cross_repo_same_pr_number_not_cleared(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock( + [APPROVE], + profile="prgs-reviewer", + head=HEAD_A, + repo="Other-Repo", + ), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Other-Repo", + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + out = self.mcp._clear_decision_lock_for_profile( + profile_identity="prgs-reviewer", + pr_number=100, + expected_head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertFalse(out["cleared"]) + # Original lock still present under Other-Repo scope + still = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Other-Repo", + state_dir=self._tmp.name, + skip_identity_match=True, + ) + self.assertIsNotNone(still) + + +def _reorder_canonical_lines(body, first, second): + """Swap two canonical field lines, leaving the digest untouched.""" + lines = body.split("\n") + i = next(i for i, l in enumerate(lines) if l.startswith(f"{first}: ")) + j = next(i for i, l in enumerate(lines) if l.startswith(f"{second}: ")) + lines[i], lines[j] = lines[j], lines[i] + return "\n".join(lines) + + +def _insert_after_canonical_line(body, after_field, extra_line): + lines = body.split("\n") + i = next(i for i, l in enumerate(lines) if l.startswith(f"{after_field}: ")) + lines.insert(i + 1, extra_line) + return "\n".join(lines) + + +class TestF6KeyVersionFailsClosed(unittest.TestCase): + """#709 F6 (review 438): key-version validation must fail closed.""" + + def _verify(self, auth, **kwargs): + params = { + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "pr_number": 42, + "expected_head_sha": HEAD_A, + } + params.update(kwargs) + return irp.verify_authorization_artifact(auth, **params) + + def test_valid_artifact_verifies(self): + self.assertTrue(self._verify(_mint_auth())["valid"]) + + def test_missing_key_version_rejected(self): + auth = _mint_auth() + del auth["key_version"] + v = self._verify(auth) + self.assertFalse(v["valid"], v) + self.assertTrue( + any("missing key_version" in r for r in v["reasons"]), v["reasons"] + ) + + def test_empty_key_version_rejected(self): + auth = _mint_auth() + auth["key_version"] = "" + v = self._verify(auth) + self.assertFalse(v["valid"], v) + self.assertTrue(any("empty" in r for r in v["reasons"]), v["reasons"]) + + def test_unknown_key_version_rejected(self): + auth = _mint_auth() + auth["key_version"] = "totally-unknown-version" + v = self._verify(auth) + self.assertFalse(v["valid"], v) + self.assertTrue( + any("unknown or does not match" in r for r in v["reasons"]), v["reasons"] + ) + + def test_malformed_key_version_rejected(self): + auth = _mint_auth() + auth["key_version"] = "bad version!" + v = self._verify(auth) + self.assertFalse(v["valid"], v) + self.assertTrue(any("malformed" in r for r in v["reasons"]), v["reasons"]) + + def test_non_string_key_version_rejected(self): + auth = _mint_auth() + auth["key_version"] = ["v1", "v2"] + v = self._verify(auth) + self.assertFalse(v["valid"], v) + + def test_duplicate_key_version_fields_rejected(self): + auth = _mint_auth() + auth["keyVersion"] = auth["key_version"] # identical value, still duplicate + v = self._verify(auth) + self.assertFalse(v["valid"], v) + self.assertTrue( + any("duplicate key-version" in r for r in v["reasons"]), v["reasons"] + ) + + def test_duplicate_conflicting_key_version_fields_rejected(self): + auth = _mint_auth() + auth["auth_key_version"] = "v9" + v = self._verify(auth) + self.assertFalse(v["valid"], v) + self.assertTrue( + any("duplicate key-version" in r for r in v["reasons"]), v["reasons"] + ) + + def test_nested_key_version_counts_as_duplicate(self): + auth = _mint_auth() + auth["native_provenance"] = dict(auth["native_provenance"]) + auth["native_provenance"]["key_version"] = auth["key_version"] + v = self._verify(auth) + self.assertFalse(v["valid"], v) + + def test_rotation_invalidates_prior_version_artifact(self): + """Rotating the configured version must reject artifacts minted under the old one.""" + auth = _mint_auth() + self.assertTrue(self._verify(auth)["valid"]) + irp.reset_process_auth_secret_for_tests() + try: + with patch.dict( + os.environ, + { + irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, + irp.ENV_AUTH_HMAC_KEY_VERSION: "v2", + }, + clear=False, + ): + v = self._verify(auth) + self.assertFalse(v["valid"], v) + self.assertTrue( + any("does not match the configured" in r for r in v["reasons"]), + v["reasons"], + ) + finally: + irp.reset_process_auth_secret_for_tests() + + def test_wrong_version_fails_even_when_key_unchanged(self): + """Version mismatch alone fails: the durable key staying the same is not enough.""" + irp.reset_process_auth_secret_for_tests() + try: + with patch.dict( + os.environ, + { + irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, + irp.ENV_AUTH_HMAC_KEY_VERSION: "v1", + }, + clear=False, + ): + auth = _mint_auth() + self.assertEqual(auth["key_version"], "v1") + self.assertTrue(self._verify(auth)["valid"]) + irp.reset_process_auth_secret_for_tests() + with patch.dict( + os.environ, + { + irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, # same key + irp.ENV_AUTH_HMAC_KEY_VERSION: "v2", # rotated version + }, + clear=False, + ): + self.assertFalse(self._verify(auth)["valid"]) + finally: + irp.reset_process_auth_secret_for_tests() + + def test_wrong_key_fails_after_restart(self): + """A different durable key must reject the artifact even at the same version.""" + irp.reset_process_auth_secret_for_tests() + try: + with patch.dict( + os.environ, + { + irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, + irp.ENV_AUTH_HMAC_KEY_VERSION: "v1", + }, + clear=False, + ): + auth = _mint_auth() + irp.reset_process_auth_secret_for_tests() # simulate restart + with patch.dict( + os.environ, + { + irp.ENV_AUTH_HMAC_KEY: "1" * 64, # different durable key + irp.ENV_AUTH_HMAC_KEY_VERSION: "v1", # same version + }, + clear=False, + ): + v = self._verify(auth) + self.assertFalse(v["valid"], v) + self.assertTrue( + any("server_signature invalid" in r for r in v["reasons"]), + v["reasons"], + ) + finally: + irp.reset_process_auth_secret_for_tests() + + def test_same_durable_key_verifies_after_restart(self): + irp.reset_process_auth_secret_for_tests() + try: + env = { + irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, + irp.ENV_AUTH_HMAC_KEY_VERSION: "v1", + } + with patch.dict(os.environ, env, clear=False): + auth = _mint_auth() + irp.reset_process_auth_secret_for_tests() # simulate restart + with patch.dict(os.environ, env, clear=False): + self.assertTrue(self._verify(auth)["valid"]) + finally: + irp.reset_process_auth_secret_for_tests() + + def test_key_version_is_inside_authenticated_data(self): + """Editing key_version must break the MAC, not just the version check.""" + irp.reset_process_auth_secret_for_tests() + try: + with patch.dict( + os.environ, + { + irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, + irp.ENV_AUTH_HMAC_KEY_VERSION: "v1", + }, + clear=False, + ): + auth = _mint_auth() + signature_v1 = auth["server_signature"] + irp.reset_process_auth_secret_for_tests() + with patch.dict( + os.environ, + { + irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, + irp.ENV_AUTH_HMAC_KEY_VERSION: "v2", + }, + clear=False, + ): + rotated = _mint_auth() + # Same key + same scope, different version => different MAC. + self.assertNotEqual(signature_v1, rotated["server_signature"]) + finally: + irp.reset_process_auth_secret_for_tests() + + def test_production_requires_configured_key_version(self): + irp.reset_process_auth_secret_for_tests() + try: + with patch.object( + irp.mcp_daemon_guard, "is_pytest_runtime", return_value=False + ), patch.dict( + os.environ, {irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY}, clear=False + ): + os.environ.pop(irp.ENV_AUTH_HMAC_KEY_VERSION, None) + with self.assertRaises(irp.AuthSecretError) as ctx: + irp.auth_key_version() + self.assertIn(irp.ENV_AUTH_HMAC_KEY_VERSION, str(ctx.exception)) + finally: + irp.reset_process_auth_secret_for_tests() + + def test_production_requires_durable_key(self): + irp.reset_process_auth_secret_for_tests() + try: + with patch.object( + irp.mcp_daemon_guard, "is_pytest_runtime", return_value=False + ), patch.dict(os.environ, {}, clear=False): + os.environ.pop(irp.ENV_AUTH_HMAC_KEY, None) + with self.assertRaises(irp.AuthSecretError): + irp.auth_key_version() + finally: + irp.reset_process_auth_secret_for_tests() + + def test_errors_never_leak_key_material(self): + irp.reset_process_auth_secret_for_tests() + try: + secret = "s3cr3t" + "9" * 58 + with patch.dict( + os.environ, + { + irp.ENV_AUTH_HMAC_KEY: secret, + irp.ENV_AUTH_HMAC_KEY_VERSION: "v1", + }, + clear=False, + ): + auth = _mint_auth() + self.assertNotIn("key", {k.lower(): 1 for k in ()}) # no-op guard + blob = json.dumps(auth) + self.assertNotIn(secret, blob) + auth["key_version"] = "nope" + v = self._verify(auth) + self.assertNotIn(secret, json.dumps(v["reasons"])) + finally: + irp.reset_process_auth_secret_for_tests() + + +class TestF7StrictCanonicalIncidentEvidence(unittest.TestCase): + """#709 F7 (review 438): only the exact canonical representation is accepted.""" + + def _assess(self, payload, **kwargs): + params = { + "incident_issue": 700, + "incident_comment_id": 11489, + "comment_payload": payload, + "expected_remote": "prgs", + "expected_org": "Scaled-Tech-Consulting", + "expected_repo": "Gitea-Tools", + "expected_pr_number": 42, + "expected_head_sha": HEAD_A, + "expected_decision_lock_id": DECISION_LOCK_ID, + "expected_recovery_action": RECOVERY_ACTION, + "mint_actor_id": MINT_ACTOR_ID, + "mint_actor_username": MINT_ACTOR_LOGIN, + } + params.update(kwargs) + return irp.assess_incident_evidence(**params) + + def test_canonical_evidence_accepted(self): + g = self._assess(_incident_comment_payload()) + self.assertTrue(g["valid"], g) + + def test_reordered_fields_rejected(self): + body = _reorder_canonical_lines(_canonical_incident_body(), "org", "repo") + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("canonical order" in r for r in g["reasons"]), g["reasons"] + ) + + def test_duplicate_identical_field_rejected(self): + body = _canonical_incident_body() + body = _insert_after_canonical_line(body, "repo", "repo: Gitea-Tools") + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + + def test_duplicate_conflicting_field_rejected(self): + body = _canonical_incident_body() + body = _insert_after_canonical_line(body, "repo", "repo: Other-Repo") + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + + def test_conflicting_field_in_narrative_rejected(self): + body = _canonical_incident_body(narrative="context") + "\nrepo: Other-Repo" + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("outside the signed block" in r for r in g["reasons"]), g["reasons"] + ) + + def test_second_marker_rejected(self): + body = _canonical_incident_body(narrative="context") + body = f"{body}\n\n{irp.INCIDENT_MARKER}" + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + + def test_marker_not_first_rejected(self): + body = "preamble\n" + _canonical_incident_body() + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("marker position" in r for r in g["reasons"]), g["reasons"] + ) + + def test_unknown_extra_field_rejected(self): + body = _insert_after_canonical_line( + _canonical_incident_body(), "repo", "sneaky: value" + ) + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + + def test_missing_field_rejected(self): + body = "\n".join( + l + for l in _canonical_incident_body().split("\n") + if not l.startswith("nonce: ") + ) + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + + def test_empty_field_rejected(self): + body = _canonical_incident_body().replace( + f"decision_lock_id: {DECISION_LOCK_ID}", "decision_lock_id: " + ) + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + + def test_conflicting_actor_logins_rejected(self): + payload = _incident_comment_payload() + payload["user"] = {"id": AUTHOR_ID, "login": AUTHOR_LOGIN, "username": "someone-else"} + g = self._assess(payload) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("conflicting logins" in r for r in g["reasons"]), g["reasons"] + ) + + def test_conflicting_actor_ids_rejected(self): + payload = _incident_comment_payload() + payload["user"] = {"id": AUTHOR_ID, "user_id": 999, "login": AUTHOR_LOGIN} + g = self._assess(payload) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("conflicting user ids" in r for r in g["reasons"]), g["reasons"] + ) + + def test_display_name_only_actor_rejected(self): + payload = _incident_comment_payload() + payload["user"] = {"login": AUTHOR_LOGIN} # no stable id + g = self._assess(payload) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("stable user id" in r for r in g["reasons"]), g["reasons"] + ) + + def test_author_id_substitution_rejected(self): + """Body claims one author id, live comment is authored by another.""" + payload = _incident_comment_payload() + payload["user"] = {"id": 5150, "login": AUTHOR_LOGIN} + g = self._assess(payload) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("evidence_author_id" in r for r in g["reasons"]), g["reasons"] + ) + + def test_edited_comment_rejected(self): + payload = _incident_comment_payload() + payload["updated_at"] = "2026-07-14T00:00:00Z" + g = self._assess(payload) + self.assertFalse(g["valid"], g) + self.assertTrue(any("edited" in r for r in g["reasons"]), g["reasons"]) + + def test_self_authored_by_stable_id_rejected(self): + payload = _incident_comment_payload(author=MINT_ACTOR_LOGIN, author_id=MINT_ACTOR_ID) + g = self._assess(payload) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("self-authored" in r for r in g["reasons"]), g["reasons"] + ) + + def test_mint_actor_substitution_rejected(self): + g = self._assess(_incident_comment_payload(), mint_actor_id=999, mint_actor_username="someone") + self.assertFalse(g["valid"], g) + self.assertTrue( + any("mint_actor" in r for r in g["reasons"]), g["reasons"] + ) + + def test_decision_lock_substitution_rejected(self): + payload = _incident_comment_payload(decision_lock_id="review_decision_lock-prgs-merger") + g = self._assess(payload) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("decision_lock_id" in r for r in g["reasons"]), g["reasons"] + ) + + def test_recovery_action_substitution_rejected(self): + body = _canonical_incident_body().replace( + f"recovery_action: {RECOVERY_ACTION}", "recovery_action: clear_decision_lock" + ) + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + + def test_unsupported_recovery_action_cannot_be_built(self): + with self.assertRaises(ValueError): + _canonical_incident_body(recovery_action="merge_pr") + + def test_cross_pr_replay_rejected(self): + payload = _incident_comment_payload(pr_number=99) + g = self._assess(payload) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("pr_number" in r for r in g["reasons"]), g["reasons"] + ) + + def test_cross_repository_replay_rejected(self): + payload = _incident_comment_payload(repo="Other-Repo") + g = self._assess(payload) + self.assertFalse(g["valid"], g) + + def test_cross_org_replay_rejected(self): + payload = _incident_comment_payload(org="Other-Org") + g = self._assess(payload) + self.assertFalse(g["valid"], g) + + def test_cross_remote_replay_rejected(self): + payload = _incident_comment_payload(remote="dadeschools") + g = self._assess(payload) + self.assertFalse(g["valid"], g) + + def test_cross_head_replay_rejected(self): + payload = _incident_comment_payload(head=HEAD_B) + g = self._assess(payload) + self.assertFalse(g["valid"], g) + + def test_recorded_head_substitution_rejected(self): + payload = _incident_comment_payload(recorded_head_sha=HEAD_B) + g = self._assess(payload, expected_recorded_head_sha=HEAD_A) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("recorded_head_sha" in r for r in g["reasons"]), g["reasons"] + ) + + def test_key_version_substitution_rejected(self): + payload = _incident_comment_payload(key_version="v9") + g = self._assess(payload, expected_key_version=irp.auth_key_version()) + self.assertFalse(g["valid"], g) + + def test_digest_preserving_substitution_rejected(self): + """Swap a field *and* its digest from another scope: still refused. + + The attacker mints a fully valid canonical body for a different PR (so + the digest is internally consistent) and presents it for this scope. + """ + foreign = _canonical_incident_body(pr_number=99) + parsed = irp.parse_canonical_incident_body(foreign) + self.assertTrue(parsed["valid"], parsed) # internally consistent + g = self._assess(_incident_comment_payload(body=foreign)) + self.assertFalse(g["valid"], g) + + def test_field_swap_without_digest_update_rejected(self): + body = _canonical_incident_body().replace( + "repo: Gitea-Tools", "repo: Other-Repo" + ) + g = self._assess( + _incident_comment_payload(body=body), expected_repo="Other-Repo" + ) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("content_digest" in r for r in g["reasons"]), g["reasons"] + ) + + def test_nonce_binds_digest(self): + body = _canonical_incident_body().replace( + f"nonce: {INCIDENT_NONCE}", "nonce: 00000000-0000-0000-0000-000000000000" + ) + g = self._assess(_incident_comment_payload(body=body)) + self.assertFalse(g["valid"], g) + self.assertTrue( + any("content_digest" in r for r in g["reasons"]), g["reasons"] + ) + + def test_builder_refuses_ambiguous_narrative(self): + with self.assertRaises(ValueError): + _canonical_incident_body(narrative=f"{irp.INCIDENT_MARKER}\nrepo: evil") + + def test_builder_refuses_multiline_field(self): + with self.assertRaises(ValueError): + _canonical_incident_body(destroyed_subject="line1\nrepo: evil") + + def test_builder_refuses_empty_field(self): + with self.assertRaises(ValueError): + _canonical_incident_body(decision_lock_id="") + + def test_builder_output_is_the_accepted_format(self): + body = _canonical_incident_body() + parsed = irp.parse_canonical_incident_body(body) + self.assertTrue(parsed["valid"], parsed) + self.assertEqual( + parsed["canonical_block"], + irp.render_canonical_incident_block(parsed["fields"]), + ) + + +class TestF8ArchivePrerequisiteForClear(unittest.TestCase): + """#709 F8 (review 438): never clear terminal evidence without a durable archive.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.env = patch.dict( + os.environ, + { + "GITEA_MCP_SESSION_STATE_DIR": self._tmp.name, + "GITEA_SESSION_PROFILE_LOCK": "prgs-merger", + }, + clear=False, + ) + self.env.start() + import mcp_server + + self.mcp = mcp_server + self.mcp._REVIEW_DECISION_LOCK = None + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + + def tearDown(self): + self.mcp._REVIEW_DECISION_LOCK = None + self.env.stop() + self._tmp.cleanup() + + def _clear(self): + return self.mcp._clear_decision_lock_for_profile( + profile_identity="prgs-reviewer", + pr_number=100, + expected_head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + + def _lock_still_present(self): + return ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + skip_identity_match=True, + ) + + def _fail_archive_only(self, behavior): + """Patch save_state so archive writes fail but other writes pass through.""" + real = ss.save_state + + def _fake(**kwargs): + if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE: + return behavior() + return real(**kwargs) + + return patch.object(self.mcp.mcp_session_state, "save_state", side_effect=_fake) + + def test_archive_exception_retains_lock(self): + def _boom(): + raise OSError("disk failure") + + with self._fail_archive_only(_boom): + out = self._clear() + self.assertFalse(out["cleared"], out) + self.assertTrue(out["terminal_lock_retained"], out) + self.assertTrue(out["recovery_required"], out) + self.assertEqual(out["archive_failed_step"], "archive_save_state") + self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed") + + def test_archive_false_response_retains_lock(self): + with self._fail_archive_only(lambda: False): + out = self._clear() + self.assertFalse(out["cleared"], out) + self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed") + + def test_archive_empty_response_retains_lock(self): + with self._fail_archive_only(lambda: {}): + out = self._clear() + self.assertFalse(out["cleared"], out) + self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed") + + def test_archive_timeout_retains_lock(self): + def _timeout(): + raise TimeoutError("session state write timed out") + + with self._fail_archive_only(_timeout): + out = self._clear() + self.assertFalse(out["cleared"], out) + self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed") + + def test_archive_unreadable_retains_lock(self): + """Write claims success but read-back finds nothing: still refuse to clear.""" + real = ss.load_state_for_profile + + def _fake(**kwargs): + if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE: + return None + return real(**kwargs) + + with patch.object( + self.mcp.mcp_session_state, "load_state_for_profile", side_effect=_fake + ): + out = self._clear() + self.assertFalse(out["cleared"], out) + self.assertEqual(out["archive_failed_step"], "archive_readback") + self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed") + + def test_partial_archive_readback_retains_lock(self): + """Read-back returns a record for a different PR/head: refuse to clear.""" + real = ss.load_state_for_profile + + def _fake(**kwargs): + if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE: + return {"archived_for_pr": 999, "archived_for_head": HEAD_B} + return real(**kwargs) + + with patch.object( + self.mcp.mcp_session_state, "load_state_for_profile", side_effect=_fake + ): + out = self._clear() + self.assertFalse(out["cleared"], out) + self.assertEqual(out["archive_failed_step"], "archive_readback") + self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed") + + def test_archive_failure_records_actionable_recovery_evidence(self): + with self._fail_archive_only(lambda: False): + out = self._clear() + self.assertFalse(out["cleared"], out) + self.assertTrue(out["retry_safe"], out) + self.assertIn("archival failed", out["reason"]) + self.assertIsNotNone(out.get("prior_summary"), out) + # The recovery row is keyed by the active (merging) session profile. + recovery = ss.load_state_for_profile( + kind=ss.KIND_POST_MERGE_DECISION_RECOVERY, + profile_identity="prgs-merger", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + skip_identity_match=True, + ) + self.assertIsNotNone(recovery, "no durable recovery evidence recorded") + self.assertEqual(recovery["failed_step"], "archive_save_state") + self.assertEqual(recovery["target_profile_identity"], "prgs-reviewer") + + def test_successful_archive_clears_exactly_once(self): + out = self._clear() + self.assertTrue(out["cleared"], out) + self.assertTrue(out["archive_ok"], out) + self.assertIsNone(self._lock_still_present(), "lock should be cleared") + archived = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK_ARCHIVE, + profile_identity="prgs-reviewer-archive-pr100", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + skip_identity_match=True, + ) + self.assertIsNotNone(archived, "archive not durable") + self.assertEqual(archived["archived_for_pr"], 100) + + # A second clear is a no-op, not a duplicate clear. + again = self._clear() + self.assertFalse(again["cleared"], again) + + def test_retry_after_archive_failure_succeeds(self): + with self._fail_archive_only(lambda: False): + first = self._clear() + self.assertFalse(first["cleared"], first) + self.assertIsNotNone(self._lock_still_present()) + + retry = self._clear() + self.assertTrue(retry["cleared"], retry) + self.assertIsNone(self._lock_still_present()) + + def test_no_alternate_path_clears_after_archive_failure(self): + """The post-merge reconciler must not clear the lock when archival failed.""" + with self._fail_archive_only(lambda: False), patch.object( + self.mcp, "api_request", return_value={} + ), patch.object( + self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r" + ): + report = self.mcp._reconcile_decision_locks_after_merge( + pr_number=100, + head_sha=HEAD_A, + merge_commit_sha="c" * 40, + remote="prgs", + host="h", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + auth={"Authorization": "token test"}, + ) + self.assertFalse(report.get("cleared_any"), report) + self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_714_session_context_immutability.py b/tests/test_issue_714_session_context_immutability.py index 781421f..40c9261 100644 --- a/tests/test_issue_714_session_context_immutability.py +++ b/tests/test_issue_714_session_context_immutability.py @@ -274,11 +274,17 @@ class TestIssue714SessionContextImmutability(unittest.TestCase): self.assertIn("reason", res) self.assertIn("exact_safe_next_action", res) self.assertIn("activate_profile", res["exact_safe_next_action"]) - # Unknown task still fail closed - with self.assertRaises(ValueError): - mcp_server.gitea_resolve_task_capability( - task="reopen_issue", remote="dadeschools" - ) + # Unknown task still fail closed (structured denial after #723; + # never raises into internal_error and never substitutes profile). + unknown = mcp_server.gitea_resolve_task_capability( + task="reopen_issue", remote="dadeschools" + ) + self.assertFalse(unknown.get("allowed_in_current_session")) + self.assertTrue(unknown.get("stop_required")) + self.assertEqual(unknown.get("reason_code"), "unknown_task") + self.assertEqual(unknown.get("mutation_performed"), False) + self.assertEqual(unknown.get("active_profile"), "mdcps-reviewer") + self.assertNotEqual(unknown.get("active_profile"), "prgs-author") def test_identity_mismatch_blocks_mutation(self): """Profile expects 913443 but authenticated as jcwalker3.""" @@ -604,5 +610,136 @@ class TestSessionContextTestBoundaryIsolation(unittest.TestCase): session_ctx._reset_session_context_for_testing() +class TestIssue714MergeCoexistenceWithMaster(unittest.TestCase): + """Conflict-resolution regressions after merging advanced master into #715. + + Preserves: + - #714 immutable session / no auto profile substitution + - #685 side-effect-free resolve (auto_recover=False) + - #709 actor identity helper from master + """ + + def test_auto_switch_helpers_remain_fail_closed(self): + self.assertFalse(mcp_server._try_auto_switch_for_operation("gitea.pr.review")) + self.assertFalse( + mcp_server._try_auto_switch_for_operation( + "gitea.issue.comment", host="gitea.prgs.cc" + ) + ) + # Active profile is prgs-author; cannot match mdcps review permission + # and must not switch. + with patch.object( + mcp_server, + "get_profile", + return_value={ + "profile_name": "prgs-author", + "allowed_operations": ["gitea.read", "gitea.issue.create"], + "forbidden_operations": ["gitea.pr.approve"], + "base_url": "https://gitea.prgs.cc", + "context": "prgs", + }, + ): + matched = mcp_server._ensure_matching_profile( + "gitea.pr.approve", "reviewer", remote="prgs" + ) + self.assertIsNone(matched) + self.assertNotEqual( + gitea_config.selected_profile_name() or "prgs-author", + "mdcps-reviewer", + ) + + def test_authenticated_actor_helper_survives_merge(self): + """Master #709 F7 actor identity coexists with #714 immutability.""" + self.assertTrue(hasattr(mcp_server, "_authenticated_actor")) + with patch( + "mcp_server.get_auth_header", return_value={"Authorization": "token x"} + ), patch( + "mcp_server.api_request", + return_value={"id": 42, "login": "sysadmin"}, + ): + mcp_server._ACTOR_IDENTITY_CACHE.clear() + actor = mcp_server._authenticated_actor("gitea.prgs.cc") + self.assertEqual(actor.get("user_id"), 42) + self.assertEqual(actor.get("login"), "sysadmin") + # Cached; second call does not re-request + with patch("mcp_server.api_request") as mock_api: + actor2 = mcp_server._authenticated_actor("gitea.prgs.cc") + mock_api.assert_not_called() + self.assertEqual(actor2.get("user_id"), 42) + + def test_resolve_still_report_only_after_master_merge(self): + """#685: resolve path must not pass auto_recover=True after conflict merge.""" + allowed = [ + "gitea.read", + "gitea.issue.create", + "gitea.issue.comment", + "gitea.pr.create", + "gitea.pr.comment", + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + ] + profile = { + "profile_name": "prgs-author", + "role": "author", + "allowed_operations": allowed, + "forbidden_operations": [], + "base_url": "https://gitea.prgs.cc", + "context": "prgs", + } + config = { + "contexts": { + "prgs": { + "enabled": True, + "gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"}, + } + }, + "profiles": { + "prgs-author": { + "role": "author", + "allowed_operations": allowed, + "forbidden_operations": [], + "base_url": "https://gitea.prgs.cc", + "context": "prgs", + } + }, + } + fake_binding = { + "classification": "unbound", + "active_worktree": None, + "reasons": [], + } + with patch.dict( + os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False + ), patch.object( + mcp_server, "get_profile", return_value=profile + ), patch.object( + mcp_server.gitea_config, "load_config", return_value=config + ), patch.object( + mcp_server, "_authenticated_username", return_value="jcwalker3" + ), patch.object( + mcp_server, + "_assess_stale_active_binding", + return_value=fake_binding, + ) as mock_assess, patch.object( + mcp_server, "record_preflight_check", return_value=None + ), patch.object( + mcp_server, "record_mutation_authority", return_value=None + ), patch.object( + mcp_server, "init_review_decision_lock", return_value=None + ): + result = mcp_server.gitea_resolve_task_capability( + task="create_issue", remote="prgs" + ) + mock_assess.assert_called() + for call in mock_assess.call_args_list: + self.assertFalse(call.kwargs.get("auto_recover", True)) + self.assertEqual(result.get("mutation_performed"), False) + # Session context must not have been rewritten by resolve + # (report-only; any prior binding stays; unbound stays unbound unless + # seed-on-read path is intentional — resolve must not activate peers). + self.assertNotEqual(result.get("active_profile"), "mdcps-reviewer") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_issue_720_expired_decision_lock.py b/tests/test_issue_720_expired_decision_lock.py new file mode 100644 index 0000000..1a647c0 --- /dev/null +++ b/tests/test_issue_720_expired_decision_lock.py @@ -0,0 +1,499 @@ +"""#720: Expired old-head KIND_DECISION_LOCK must not block fresh review. + +Reproduction shape (PR #616): + * REQUEST_CHANGES terminal at head A + * open PR advanced to head B + * durable decision lock age > default 4h TTL + * fresh_review_on_current_head_allowed is true but unreachable under TTL-first reject + * mark_final fails with "session state expired after 4h" + * assessment may report "no lock" while the file remains on disk + +Security invariants preserved: + * same-head second terminal remains fail-closed (#332/#620) + * REQUEST_CHANGES is never treated as approval + * historical mutations remain on the ledger + * merged/closed moot cleanup still allowed (#594) + * irrecoverable provenance still requires #709 authorization + * ordinary profiles do not gain gitea.decision_lock.irrecoverable_recovery +""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +import sys + +ROOT = str(Path(__file__).resolve().parent.parent) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +import mcp_session_state as ss +import mcp_server +import stale_review_decision_lock as srdl +import task_capability_map + +HEAD_A = "a0fffae576673ba7df7456b32e6aec916581bdfb" +HEAD_B = "a6a2243aad9c3e385fc70509f4941a0f0ec33162" +HEAD_SAME = HEAD_A + +RC_616_A = { + "pr_number": 616, + "action": "request_changes", + "review_id": 443, + "review_state": "request_changes", + "head_sha": HEAD_A, +} + + +def _lock(mutations=None, **kwargs): + base = { + "task": "review_pr", + "kind": ss.KIND_DECISION_LOCK, + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "session_pid": os.getpid(), + "session_profile": "prgs-reviewer", + "session_profile_lock": "prgs-reviewer", + "profile_identity": "prgs-reviewer", + "final_review_decision_ready": False, + "ready_pr_number": kwargs.get("ready_pr"), + "ready_action": kwargs.get("ready_action"), + "ready_expected_head_sha": kwargs.get("ready_head"), + "ready_remote": "prgs" if kwargs.get("ready_pr") else None, + "ready_org": "Scaled-Tech-Consulting" if kwargs.get("ready_pr") else None, + "ready_repo": "Gitea-Tools" if kwargs.get("ready_pr") else None, + "live_mutations": list(mutations or []), + "correction_authorized": False, + "correction_reason": None, + } + return base + + +def _open_pr(pr_number=616, head=HEAD_B, merged=False, closed=False): + state = "closed" if closed or merged else "open" + return { + "number": pr_number, + "state": state, + "merged": merged, + "merged_at": "2026-07-16T12:00:00Z" if merged else None, + "merge_commit_sha": "m" * 40 if merged else None, + "head": {"sha": head}, + } + + +def _no_lease(): + return {"block": False, "reasons": [], "mutation_allowed": True} + + +def _feedback(blocking=False, stale=True): + return { + "success": True, + "has_blocking_change_requests": blocking, + "review_feedback_stale": stale, + "current_head_sha": HEAD_B, + } + + +def _age_lock_payload(lock: dict, hours: float = 5.0) -> dict: + """Rewrite timestamps to *hours* ago (simulates durable age without touching prod).""" + aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace( + "+00:00", "Z" + ) + out = dict(lock) + out["recorded_at"] = aged + out["updated_at"] = aged + return out + + +class TestIssue720ExpiredDecisionLockLifecycle(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.env = patch.dict( + os.environ, + { + ss.STATE_DIR_ENV: self._tmp.name, + ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer", + "GITEA_MCP_PROFILE": "prgs-reviewer", + "GITEA_PROFILE_NAME": "prgs-reviewer", + }, + clear=False, + ) + self.env.start() + mcp_server._REVIEW_DECISION_LOCK = None + import review_workflow_load + + review_workflow_load.clear_review_workflow_load() + review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT) + mcp_server.gitea_load_review_workflow() + + def tearDown(self): + mcp_server._REVIEW_DECISION_LOCK = None + import review_workflow_load + + review_workflow_load.clear_review_workflow_load() + self.env.stop() + self._tmp.cleanup() + + def _persist_aged_lock(self, mutations, hours: float = 5.0, **kwargs): + """Write decision lock aged > TTL to the temp durable store (test-only).""" + payload = _age_lock_payload(_lock(mutations, **kwargs), hours=hours) + saved = ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=payload, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + ) + # save_state refreshes updated_at; re-age the on-disk envelope for TTL tests. + path = ss.state_file_path( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + aged = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat().replace( + "+00:00", "Z" + ) + import json + + with open(path, encoding="utf-8") as fh: + envelope = json.load(fh) + envelope["recorded_at"] = aged + envelope["updated_at"] = aged + if isinstance(envelope.get("payload"), dict): + envelope["payload"]["recorded_at"] = aged + envelope["payload"]["updated_at"] = aged + with open(path, "w", encoding="utf-8") as fh: + json.dump(envelope, fh, indent=2, sort_keys=True) + fh.write("\n") + # Drop memory so subsequent loads hit durable store. + mcp_server._REVIEW_DECISION_LOCK = None + return saved + + def _mark(self, pr, action, head, feedback=None): + with patch("mcp_server._list_pr_lease_comments", return_value=[]), patch( + "mcp_server._pr_work_lease_reviewer_block", return_value=_no_lease() + ), patch.object( + mcp_server, + "gitea_get_pr_review_feedback", + return_value=feedback or _feedback(blocking=False, stale=True), + ), patch.object( + mcp_server, + "gitea_check_pr_eligibility", + return_value={"eligible": True, "head_sha": head}, + ), patch.object( + mcp_server.mcp_daemon_guard, + "assert_sanctioned_mutation_runtime", + return_value=None, + ), patch.object( + mcp_server.mcp_daemon_guard, + "assert_no_direct_import_bypass", + return_value=None, + ): + return mcp_server.gitea_mark_final_review_decision( + pr_number=pr, + action=action, + expected_head_sha=head, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + + # --- AC regression matrix --- + + def test_under_four_hours_fresh_review_at_b_allowed(self): + self._persist_aged_lock( + [RC_616_A], + hours=1.0, + ready_pr=616, + ready_head=HEAD_A, + ready_action="request_changes", + ) + res = self._mark(616, "approve", HEAD_B) + self.assertTrue(res.get("marked_ready"), res) + + def test_over_four_hours_fresh_review_at_b_still_allowed(self): + """Primary #720 defect: age > TTL must not block head-B mark_final.""" + self._persist_aged_lock( + [RC_616_A], + hours=5.0, + ready_pr=616, + ready_head=HEAD_A, + ready_action="request_changes", + ) + # Prove durable load is possible for recovery-critical decision locks. + loaded = ss.load_state( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + ) + self.assertIsNotNone( + loaded, + "expired KIND_DECISION_LOCK must remain loadable (not generic TTL cache)", + ) + res = self._mark(616, "approve", HEAD_B) + self.assertTrue( + res.get("marked_ready"), + f"expected mark_ready on head B after >4h; got {res}", + ) + reasons = " ".join(res.get("reasons") or []) + self.assertNotIn("session state expired", reasons) + + def test_historical_review_at_a_preserved_and_stale(self): + self._persist_aged_lock( + [RC_616_A], + hours=5.0, + ready_pr=616, + ready_head=HEAD_A, + ready_action="request_changes", + ) + res = self._mark(616, "approve", HEAD_B) + self.assertTrue(res.get("marked_ready"), res) + lock = mcp_server._load_review_decision_lock() + self.assertIsNotNone(lock) + hist = [m for m in lock["live_mutations"] if m.get("review_id") == 443] + self.assertEqual(len(hist), 1) + self.assertEqual(hist[0]["head_sha"], HEAD_A) + self.assertEqual(hist[0]["action"], "request_changes") + a = srdl.assess_stale_review_decision_lock( + lock, pr_live=_open_pr(616, HEAD_B) + ) + self.assertTrue(a["stale_by_head"]) + self.assertTrue(a["fresh_review_on_current_head_allowed"]) + self.assertEqual(a["locked_head_sha"], HEAD_A) + + def test_no_reuse_approval_from_head_a(self): + """REQUEST_CHANGES at A is never an approval credential for B.""" + self._persist_aged_lock([RC_616_A], hours=5.0) + lock = ss.load_state( + kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer" + ) + last = srdl.last_terminal_mutation(lock) + self.assertEqual(last.get("action"), "request_changes") + self.assertNotEqual(last.get("action"), "approve") + # Gate must still require a new mark/submit for head B; prior RC is not approve. + reasons = mcp_server.terminal_review_hard_stop_reasons( + 616, "mark_ready", expected_head_sha=HEAD_B + ) + self.assertEqual(reasons, []) + + def test_second_terminal_mutation_at_b_same_run_blocked(self): + self._persist_aged_lock( + [RC_616_A], + hours=5.0, + ready_pr=616, + ready_head=HEAD_A, + ready_action="request_changes", + ) + res = self._mark(616, "approve", HEAD_B) + self.assertTrue(res.get("marked_ready"), res) + # Record terminal approve at B (same run). + lock = mcp_server._load_review_decision_lock() + lock["final_review_decision_ready"] = True + lock["ready_pr_number"] = 616 + lock["ready_action"] = "approve" + lock["ready_expected_head_sha"] = HEAD_B + mcp_server._save_review_decision_lock(lock) + mcp_server.record_live_review_mutation(616, "approve", review_id=999) + # Second terminal at same head B must hard-stop. + hard = mcp_server.terminal_review_hard_stop_reasons( + 616, "mark_ready", expected_head_sha=HEAD_B + ) + self.assertTrue(hard) + res2 = self._mark(616, "approve", HEAD_B) + self.assertFalse(res2.get("marked_ready")) + + def test_open_pr_same_head_expired_still_fail_closed(self): + self._persist_aged_lock( + [RC_616_A], + hours=5.0, + ready_pr=616, + ready_head=HEAD_A, + ready_action="request_changes", + ) + res = self._mark(616, "approve", HEAD_SAME) + self.assertFalse(res.get("marked_ready"), res) + self.assertTrue( + any("#332" in r or "already consumed" in r for r in (res.get("reasons") or [])), + res, + ) + + def test_merged_pr_moot_cleanup_still_allowed(self): + self._persist_aged_lock( + [RC_616_A], + hours=5.0, + ready_pr=616, + ready_head=HEAD_A, + ready_action="request_changes", + ) + lock = ss.load_state( + kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer" + ) + a = srdl.assess_stale_review_decision_lock( + lock, pr_live=_open_pr(616, HEAD_A, merged=True) + ) + self.assertTrue(a["is_moot"]) + self.assertTrue(a["cleanup_allowed"]) + self.assertFalse(a["fresh_review_on_current_head_allowed"]) + + def test_assessment_reports_expired_old_head_not_absent(self): + self._persist_aged_lock( + [RC_616_A], + hours=5.0, + ready_pr=616, + ready_head=HEAD_A, + ready_action="request_changes", + ) + # Disk presence + load after lifecycle fix. + path = ss.state_file_path( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + self.assertTrue(os.path.exists(path)) + loaded = ss.load_state( + kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer" + ) + self.assertIsNotNone(loaded) + inspect = ss.inspect_state_envelope( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + self.assertTrue(inspect.get("on_disk")) + self.assertTrue(inspect.get("has_payload")) + self.assertTrue(inspect.get("recovery_critical") or inspect.get("ttl_exempt")) + self.assertTrue(inspect.get("age_hours", 0) >= 4.0) + a = srdl.assess_stale_review_decision_lock( + loaded, pr_live=_open_pr(616, HEAD_B) + ) + self.assertTrue(a["has_lock"]) + self.assertNotIn("no review decision lock present", " ".join(a["reasons"])) + self.assertTrue(a["stale_by_head"]) + self.assertEqual(a["last_terminal_action"], "request_changes") + + def test_existing_serialized_records_compatible(self): + """Pre-fix ledgers without recovery_critical flag still load via kind.""" + payload = _age_lock_payload(_lock([RC_616_A]), hours=8.0) + payload.pop("recovery_critical", None) + # Manually write pre-#720-shaped envelope (kind only on envelope). + path = ss.state_file_path( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + import json + + os.makedirs(self._tmp.name, exist_ok=True) + aged = payload["recorded_at"] + envelope = { + "kind": ss.KIND_DECISION_LOCK, + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "profile_identity": "prgs-reviewer", + "session_profile_lock": "prgs-reviewer", + "recorded_at": aged, + "updated_at": aged, + "writer_pid": os.getpid(), + "payload": payload, + } + with open(path, "w", encoding="utf-8") as fh: + json.dump(envelope, fh, indent=2, sort_keys=True) + fh.write("\n") + loaded = ss.load_state( + kind=ss.KIND_DECISION_LOCK, profile_identity="prgs-reviewer" + ) + self.assertIsNotNone(loaded) + self.assertEqual(loaded["live_mutations"][0]["review_id"], 443) + + def test_decision_lock_is_recovery_critical_kind(self): + self.assertIn(ss.KIND_DECISION_LOCK, ss.RECOVERY_CRITICAL_KINDS) + + def test_workflow_load_still_ttl_expires(self): + """Do not make every session-state kind permanently TTL-exempt.""" + aged = (datetime.now(timezone.utc) - timedelta(hours=5)).isoformat().replace( + "+00:00", "Z" + ) + rec = { + "kind": ss.KIND_WORKFLOW_LOAD, + "recorded_at": aged, + "updated_at": aged, + "profile_identity": "prgs-reviewer", + "session_profile_lock": "prgs-reviewer", + } + reasons = ss.identity_match_reasons( + rec, profile_identity="prgs-reviewer" + ) + self.assertTrue(any("expired" in r for r in reasons), reasons) + + def test_irrecoverable_permission_not_on_ordinary_profiles(self): + perm = "gitea.decision_lock.irrecoverable_recovery" + # Capability map must not map ordinary author/reviewer/merger tasks to it. + for task in ( + "create_issue", + "comment_issue", + "review_pr", + "merge_pr", + "lock_issue", + "create_pr", + ): + req = task_capability_map.required_permission(task) + self.assertNotEqual(req, perm, msg=task) + + def test_structured_error_when_same_head_expired(self): + self._persist_aged_lock( + [RC_616_A], + hours=5.0, + ready_pr=616, + ready_head=HEAD_A, + ready_action="request_changes", + ) + res = self._mark(616, "approve", HEAD_A) + self.assertFalse(res.get("marked_ready")) + reasons = res.get("reasons") or [] + self.assertTrue(reasons) + blob = " ".join(reasons) + # Actionable recovery text from hard-stop (not generic internal_error). + self.assertIn("#332", blob) + self.assertTrue( + "#620" in blob or "head moved" in blob or "new expected_head_sha" in blob + or "already consumed" in blob + ) + self.assertNotIn("internal_error", blob.lower()) + + +class TestIssue720InspectEnvelope(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.env = patch.dict( + os.environ, + { + ss.STATE_DIR_ENV: self._tmp.name, + ss.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer", + }, + clear=False, + ) + self.env.start() + + def tearDown(self): + self.env.stop() + self._tmp.cleanup() + + def test_inspect_missing_file(self): + info = ss.inspect_state_envelope( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + self.assertFalse(info["on_disk"]) + self.assertFalse(info["has_payload"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index adedb92..c0101e2 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -1471,10 +1471,16 @@ class TestReviewPR(unittest.TestCase): class TestDeleteBranch(unittest.TestCase): DELETE_PROFILE = { - "profile_name": "test-deleter", - "allowed_operations": ["gitea.read", "gitea.branch.delete"], + "profile_name": "test-author-deleter", + "role": "author", + "allowed_operations": [ + "gitea.read", + "gitea.pr.create", + "gitea.branch.push", + "gitea.branch.delete", + ], "forbidden_operations": [], - "audit_label": "test-deleter", + "audit_label": "test-author-deleter", } @patch("mcp_server.get_profile", return_value=DELETE_PROFILE) @@ -2584,15 +2590,18 @@ class TestSubmitPrReview(unittest.TestCase): lock = _load_review_decision_lock() or {} lock["live_mutations"] = [{"pr_number": 8, "action": "approve", "review_id": 42, "review_state": "approve"}] _save_review_decision_lock(lock) - r = gitea_authorize_review_correction( - prior_review_id=42, - prior_review_state="approve", - reason="typo", - operator_authorized=True, - ) + with patch("mcp_server.api_request", return_value={"id": 9001}): + r = gitea_authorize_review_correction( + prior_review_id=42, + prior_review_state="approve", + reason="typo", + operator_authorized=True, + target_pr_number=8, + ) self.assertTrue(r["authorized"]) lock = _load_review_decision_lock() self.assertTrue(lock["correction_authorized"]) + self.assertEqual(lock["correction_pr_number"], 8) def test_authorize_review_correction_mismatch(self): from mcp_server import _load_review_decision_lock, _save_review_decision_lock @@ -2606,6 +2615,7 @@ class TestSubmitPrReview(unittest.TestCase): prior_review_state="approve", reason="typo", operator_authorized=True, + target_pr_number=8, ) self.assertFalse(r["authorized"]) self.assertTrue(any("prior review ID" in x for x in r["reasons"])) @@ -2616,10 +2626,22 @@ class TestSubmitPrReview(unittest.TestCase): prior_review_state="request_changes", reason="typo", operator_authorized=True, + target_pr_number=8, ) self.assertFalse(r["authorized"]) self.assertTrue(any("prior review state" in x for x in r["reasons"])) + # Cross-PR unlock refused (#693) + r = gitea_authorize_review_correction( + prior_review_id=42, + prior_review_state="approve", + reason="unlock other pr", + operator_authorized=True, + target_pr_number=692, + ) + self.assertFalse(r["authorized"]) + self.assertTrue(any("different PR" in x for x in r["reasons"])) + def test_authorize_review_correction_requires_operator_auth(self): from mcp_server import _load_review_decision_lock, _save_review_decision_lock lock = _load_review_decision_lock() or {} @@ -2632,6 +2654,7 @@ class TestSubmitPrReview(unittest.TestCase): prior_review_state="approve", reason="typo", operator_authorized=False, + target_pr_number=8, ) self.assertFalse(r["authorized"]) self.assertTrue(any("operator authorization" in x for x in r["reasons"])) @@ -2689,6 +2712,7 @@ class TestSubmitPrReview(unittest.TestCase): prior_review_state="approve", reason="operator approved correcting mistaken approve", operator_authorized=True, + target_pr_number=8, ) _mark_request_changes_ready(remote="prgs") second = gitea_submit_pr_review( diff --git a/tests/test_mcp_stale_runtime.py b/tests/test_mcp_stale_runtime.py index ddd6287..7a92fc3 100644 --- a/tests/test_mcp_stale_runtime.py +++ b/tests/test_mcp_stale_runtime.py @@ -111,21 +111,13 @@ class TestMcpStaleRuntime(unittest.TestCase): reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"]) self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons)) - @patch("threading.Thread") - @patch("os.utime") - @patch("os.path.exists") - @patch.dict("os.environ", {"MCP_CONFIG_PATH": "/tmp/mcp_config.json"}) - def test_auto_restart_trigger_touches_and_spawns(self, mock_exists, mock_utime, mock_thread): - mock_exists.return_value = True - gitea_mcp_server._restart_triggered = False - - # Ensure we are not skipped in test mode for testing purposes - with patch("gitea_mcp_server._preflight_in_test_mode", return_value=False): - gitea_mcp_server._trigger_mcp_auto_restart() - - mock_utime.assert_called_once_with("/tmp/mcp_config.json", None) - mock_thread.assert_called_once() - self.assertTrue(gitea_mcp_server._restart_triggered) + def test_auto_restart_helper_removed_from_read_only_path(self): + """#685: config-touch / os._exit self-recovery is no longer on the server.""" + self.assertFalse( + hasattr(gitea_mcp_server, "_trigger_mcp_auto_restart"), + "_trigger_mcp_auto_restart must not remain (side-effect-free resolver)", + ) + self.assertFalse(hasattr(gitea_mcp_server, "_restart_triggered")) if __name__ == "__main__": diff --git a/tests/test_merger_lease_acquire.py b/tests/test_merger_lease_acquire.py new file mode 100644 index 0000000..77bc118 --- /dev/null +++ b/tests/test_merger_lease_acquire.py @@ -0,0 +1,466 @@ +"""Merger lease acquisition + capability resolution tests (#718, #723).""" + +from __future__ import annotations + +import os +import sys +import unittest +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import gitea_mcp_server as mcp_server +import reviewer_pr_lease as leases +import task_capability_map as tcm + + +HEAD = "a" * 40 +LIVE_HEAD = HEAD + + +class TestMergerLeaseAcquireTool(unittest.TestCase): + def setUp(self): + mcp_server._preflight_whoami_called = True + mcp_server._preflight_capability_called = True + mcp_server._preflight_resolved_role = "merger" + mcp_server._preflight_resolved_task = "acquire_merger_pr_lease" + leases.clear_session_lease() + + def tearDown(self): + leases.clear_session_lease() + + def _merger_profile(self, **extra): + p = { + "username": "merger-user", + "profile_name": "prgs-merger", + "role": "merger", + "allowed_operations": [ + "gitea.read", + "gitea.pr.comment", + "gitea.pr.merge", + ], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.review", + "gitea.pr.request_changes", + ], + } + p.update(extra) + return p + + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server._auth", return_value="token pass") + @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")) + @patch("gitea_mcp_server.get_profile") + @patch("gitea_mcp_server._fetch_pr_comments", return_value=[]) + @patch("gitea_mcp_server._verify_role_mutation_workspace") + def test_acquire_merger_lease_success( + self, _verify, _comments, mock_profile, _resolve, _auth, mock_api + ): + mock_profile.return_value = self._merger_profile() + mock_api.side_effect = [ + {"state": "open", "head": {"sha": LIVE_HEAD}}, + {"id": 456}, + ] + with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}): + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + session_id="merger-session", + candidate_head=HEAD, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(res["success"], res) + self.assertTrue(res["acquired"]) + self.assertEqual(res["comment_id"], 456) + self.assertEqual(res["session_id"], "merger-session") + self.assertEqual(res.get("repo"), "Scaled-Tech-Consulting/Gitea-Tools") + body = mock_api.call_args_list[-1][0][3]["body"] + self.assertIn("reviewer_identity: merger-user", body) + self.assertIn("profile: prgs-merger", body) + session_lease = leases._SESSION_LEASE + self.assertIsNotNone(session_lease) + provenance = session_lease.get("lease_provenance") or {} + self.assertEqual(provenance.get("source"), "gitea_acquire_merger_pr_lease") + + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server._auth", return_value="token pass") + @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")) + @patch("gitea_mcp_server.get_profile") + @patch("gitea_mcp_server._fetch_pr_comments") + @patch("gitea_mcp_server._verify_role_mutation_workspace") + def test_acquire_merger_lease_fails_if_active_exists( + self, _verify, _comments, mock_profile, _resolve, _auth, mock_api + ): + mock_profile.return_value = self._merger_profile() + active_body = leases.format_lease_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=718, + issue_number=None, + reviewer_identity="other-user", + profile="prgs-reviewer", + session_id="other-session", + worktree="branches/review-1", + phase="claimed", + candidate_head=HEAD, + target_branch="master", + target_branch_sha="b" * 40, + last_activity=datetime.now(timezone.utc), + ) + _comments.return_value = [ + {"id": 1, "body": active_body, "user": {"login": "other-user"}} + ] + mock_api.return_value = {"state": "open", "head": {"sha": LIVE_HEAD}} + with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}): + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + session_id="merger-session", + candidate_head=HEAD, + ) + self.assertFalse(res["success"]) + self.assertFalse(res["acquired"]) + self.assertIn("already has active reviewer lease", " ".join(res["reasons"])) + self.assertEqual( + [c for c in mock_api.call_args_list if c[0][0] == "POST"], [] + ) + self.assertIsNone(leases._SESSION_LEASE) + + @patch("gitea_mcp_server.get_profile") + @patch("gitea_mcp_server._verify_role_mutation_workspace") + def test_acquire_merger_lease_fails_workspace_binding(self, _verify, mock_profile): + mock_profile.return_value = self._merger_profile() + _verify.side_effect = RuntimeError("Branches-only mutation guard") + with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}): + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + session_id="merger-session", + candidate_head=HEAD, + ) + self.assertFalse(res["success"]) + self.assertIn("Branches-only mutation guard", res["reasons"][0]) + self.assertIsNone(leases._SESSION_LEASE) + + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server._auth", return_value="token pass") + @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")) + @patch("gitea_mcp_server.get_profile") + @patch("gitea_mcp_server._fetch_pr_comments", return_value=[]) + @patch("gitea_mcp_server._verify_role_mutation_workspace") + def test_acquire_merger_forwards_explicit_org_repo_to_workspace_verify( + self, mock_verify, _comments, mock_profile, _resolve, _auth, mock_api + ): + """Explicit org/repo must reach anti-stomp (prgs bare default is Timesheet).""" + mock_profile.return_value = self._merger_profile() + mock_api.side_effect = [ + {"state": "open", "head": {"sha": LIVE_HEAD}}, + {"id": 789}, + ] + with patch.dict(os.environ, {"GITEA_MCP_PROFILE_NAME": "prgs-merger"}): + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + session_id="merger-session", + candidate_head=HEAD, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(res["success"], res) + kwargs = mock_verify.call_args.kwargs + self.assertEqual(kwargs.get("org"), "Scaled-Tech-Consulting") + self.assertEqual(kwargs.get("repo"), "Gitea-Tools") + self.assertEqual(kwargs.get("task"), "acquire_merger_pr_lease") + + @patch("gitea_mcp_server.get_profile") + def test_acquire_merger_requires_candidate_head(self, mock_profile): + mock_profile.return_value = self._merger_profile() + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + candidate_head=None, + ) + self.assertFalse(res["success"]) + self.assertTrue(any("candidate_head is required" in r for r in res["reasons"])) + + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server._auth", return_value="token pass") + @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")) + @patch("gitea_mcp_server.get_profile") + @patch("gitea_mcp_server._fetch_pr_comments", return_value=[]) + @patch("gitea_mcp_server._verify_role_mutation_workspace") + def test_acquire_merger_head_mismatch( + self, _verify, _comments, mock_profile, _resolve, _auth, mock_api + ): + mock_profile.return_value = self._merger_profile() + mock_api.return_value = {"state": "open", "head": {"sha": "b" * 40}} + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + candidate_head=HEAD, + ) + self.assertFalse(res["success"]) + self.assertTrue(any("does not match live PR head" in r for r in res["reasons"])) + self.assertIsNone(leases._SESSION_LEASE) + + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server._auth", return_value="token pass") + @patch("gitea_mcp_server._resolve", return_value=("gitea.prgs.cc", "Scaled-Tech-Consulting", "Gitea-Tools")) + @patch("gitea_mcp_server.get_profile") + @patch("gitea_mcp_server._fetch_pr_comments", return_value=[]) + @patch("gitea_mcp_server._verify_role_mutation_workspace") + def test_acquire_merger_fails_closed_when_live_head_unresolvable( + self, _verify, _comments, mock_profile, _resolve, _auth, mock_api + ): + """Empty/missing live head must not silently proceed past exact-head gate.""" + mock_profile.return_value = self._merger_profile() + # PR payload present but head.sha missing / empty / non-dict. + mock_api.return_value = {"state": "open", "head": {}} + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + session_id="merger-session", + candidate_head=HEAD, + ) + self.assertFalse(res["success"], res) + self.assertFalse(res.get("acquired"), res) + self.assertTrue( + any("live PR head could not be resolved" in r for r in res["reasons"]), + res, + ) + self.assertIsNone(res.get("live_head")) + self.assertEqual(res.get("candidate_head"), HEAD) + self.assertEqual( + [c for c in mock_api.call_args_list if c[0][0] == "POST"], [] + ) + self.assertIsNone(leases._SESSION_LEASE) + + # Completely empty GET /pulls body also fails closed. + mock_api.return_value = {} + res2 = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + candidate_head=HEAD, + ) + self.assertFalse(res2["success"], res2) + self.assertTrue( + any("live PR head could not be resolved" in r for r in res2["reasons"]), + res2, + ) + self.assertIsNone(leases._SESSION_LEASE) + + @patch("gitea_mcp_server._profile_operation_gate") + def test_author_denied_merge_permission(self, mock_gate): + """Author profile lacks gitea.pr.merge → fail closed before mutation.""" + def gate(op): + if op == "gitea.pr.merge": + return [f"profile lacks {op}"] + return None + mock_gate.side_effect = gate + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + candidate_head=HEAD, + ) + self.assertFalse(res["success"]) + self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"])) + + @patch("gitea_mcp_server._profile_operation_gate") + def test_reviewer_denied_merge_permission(self, mock_gate): + def gate(op): + if op == "gitea.pr.merge": + return [f"profile lacks {op}"] + return None + mock_gate.side_effect = gate + res = mcp_server.gitea_acquire_merger_pr_lease( + pr_number=718, + worktree="branches/issue-718", + candidate_head=HEAD, + ) + self.assertFalse(res["success"]) + self.assertTrue(any("gitea.pr.merge" in r for r in res["reasons"])) + + +class TestCapabilityMapLeaseTasks(unittest.TestCase): + def test_merger_acquire_map_entries(self): + for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"): + self.assertEqual(tcm.required_permission(task), "gitea.pr.comment") + self.assertEqual(tcm.required_role(task), "merger") + + def test_reviewer_acquire_map_entries(self): + for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"): + self.assertEqual(tcm.required_permission(task), "gitea.pr.comment") + self.assertEqual(tcm.required_role(task), "reviewer") + + def test_adopt_merger_aliases(self): + for task in ("adopt_merger_pr_lease", "gitea_adopt_merger_pr_lease"): + self.assertEqual(tcm.required_role(task), "merger") + + +class TestResolveTaskCapabilityLeaseAndUnknown(unittest.TestCase): + def setUp(self): + mcp_server._process_boot_head_sha = None + if hasattr(mcp_server, "capability_stop_terminal"): + mcp_server.capability_stop_terminal.clear() + + @patch.object(mcp_server, "record_mutation_authority", return_value=None) + @patch.object(mcp_server, "init_review_decision_lock", return_value=None) + @patch.object(mcp_server, "record_preflight_check", return_value=None) + @patch.object(mcp_server, "_ensure_matching_profile", return_value=None) + @patch.object(mcp_server, "_authenticated_username", return_value="sysadmin") + @patch.object(mcp_server, "get_profile") + @patch.object(mcp_server.gitea_config, "load_config") + def test_resolve_acquire_reviewer_authorized( + self, mock_cfg, mock_profile, *_mocks + ): + mock_profile.return_value = { + "profile_name": "prgs-reviewer", + "role": "reviewer", + "allowed_operations": ["gitea.pr.comment", "gitea.read", "gitea.pr.review"], + "forbidden_operations": [], + } + mock_cfg.return_value = { + "profiles": { + "prgs-reviewer": { + "role": "reviewer", + "allowed_operations": [ + "gitea.pr.comment", + "gitea.read", + "gitea.pr.review", + ], + "forbidden_operations": [], + } + } + } + with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-reviewer"}, clear=False): + for task in ("acquire_reviewer_pr_lease", "gitea_acquire_reviewer_pr_lease"): + res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs") + self.assertEqual(res["required_role_kind"], "reviewer", res) + self.assertEqual( + res["required_operation_permission"], "gitea.pr.comment", res + ) + self.assertTrue(res.get("allowed_in_current_session"), res) + + @patch.object(mcp_server, "record_mutation_authority", return_value=None) + @patch.object(mcp_server, "init_review_decision_lock", return_value=None) + @patch.object(mcp_server, "record_preflight_check", return_value=None) + @patch.object(mcp_server, "_ensure_matching_profile", return_value=None) + @patch.object(mcp_server, "_authenticated_username", return_value="jcwalker3") + @patch.object(mcp_server, "get_profile") + @patch.object(mcp_server.gitea_config, "load_config") + def test_resolve_acquire_reviewer_author_denied( + self, mock_cfg, mock_profile, *_mocks + ): + mock_profile.return_value = { + "profile_name": "prgs-author", + "role": "author", + "allowed_operations": ["gitea.pr.comment", "gitea.branch.push", "gitea.read"], + "forbidden_operations": [], + } + mock_cfg.return_value = { + "profiles": { + "prgs-author": { + "role": "author", + "allowed_operations": [ + "gitea.pr.comment", + "gitea.branch.push", + "gitea.read", + ], + "forbidden_operations": [], + }, + "prgs-reviewer": { + "role": "reviewer", + "allowed_operations": ["gitea.pr.comment", "gitea.pr.review"], + "forbidden_operations": [], + }, + } + } + with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-author"}, clear=False): + res = mcp_server.gitea_resolve_task_capability( + task="acquire_reviewer_pr_lease", remote="prgs" + ) + self.assertFalse(res.get("allowed_in_current_session"), res) + self.assertEqual(res["required_role_kind"], "reviewer") + guidance = " ".join(res.get("task_role_guidance") or []) + self.assertTrue( + "cannot perform reviewer" in guidance.lower() + or "reviewer" in guidance.lower() + or res.get("stop_required"), + res, + ) + + @patch.object(mcp_server, "record_mutation_authority", return_value=None) + @patch.object(mcp_server, "init_review_decision_lock", return_value=None) + @patch.object(mcp_server, "record_preflight_check", return_value=None) + @patch.object(mcp_server, "_ensure_matching_profile", return_value=None) + @patch.object(mcp_server, "_authenticated_username", return_value="sysadmin") + @patch.object(mcp_server, "get_profile") + def test_resolve_unknown_task_structured_no_raise(self, mock_profile, *_mocks): + mock_profile.return_value = { + "profile_name": "prgs-reviewer", + "role": "reviewer", + "allowed_operations": ["gitea.pr.comment"], + "forbidden_operations": [], + } + # Must not raise ValueError into the tool boundary. + res = mcp_server.gitea_resolve_task_capability( + task="some_made_up_task", remote="prgs" + ) + self.assertIsInstance(res, dict) + self.assertEqual(res.get("reason_code"), "unknown_task", res) + self.assertFalse(res.get("allowed_in_current_session")) + self.assertTrue(res.get("stop_required")) + self.assertIs(res.get("mutation_performed"), False) + self.assertNotIn("token", str(res).lower()) + self.assertNotIn("password", str(res).lower()) + guidance = " ".join(res.get("task_role_guidance") or []) + self.assertIn("Unknown task/action", guidance) + + @patch.object(mcp_server, "record_mutation_authority", return_value=None) + @patch.object(mcp_server, "init_review_decision_lock", return_value=None) + @patch.object(mcp_server, "record_preflight_check", return_value=None) + @patch.object(mcp_server, "_ensure_matching_profile", return_value=None) + @patch.object(mcp_server, "_authenticated_username", return_value="sysadmin") + @patch.object(mcp_server, "get_profile") + @patch.object(mcp_server.gitea_config, "load_config") + def test_resolve_merger_acquire_aliases( + self, mock_cfg, mock_profile, *_mocks + ): + mock_profile.return_value = { + "profile_name": "prgs-merger", + "role": "merger", + "allowed_operations": [ + "gitea.read", + "gitea.pr.comment", + "gitea.pr.merge", + ], + "forbidden_operations": [], + } + mock_cfg.return_value = { + "profiles": { + "prgs-merger": { + "role": "merger", + "allowed_operations": [ + "gitea.read", + "gitea.pr.comment", + "gitea.pr.merge", + ], + "forbidden_operations": [], + } + } + } + with patch.dict(os.environ, {"GITEA_MCP_PROFILE": "prgs-merger"}, clear=False): + for task in ("acquire_merger_pr_lease", "gitea_acquire_merger_pr_lease"): + res = mcp_server.gitea_resolve_task_capability(task=task, remote="prgs") + self.assertEqual(res["required_role_kind"], "merger", res) + self.assertEqual( + res["required_operation_permission"], "gitea.pr.comment", res + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_migrate_profiles.py b/tests/test_migrate_profiles.py index abbe5df..7f80d2c 100644 --- a/tests/test_migrate_profiles.py +++ b/tests/test_migrate_profiles.py @@ -92,14 +92,19 @@ class TestMigrateProfiles(unittest.TestCase): author = prgs_gitea["identities"]["author"] self.assertEqual(author["username"], "jcwalker3") self.assertEqual(author["auth"]["id"], "redacted-author-ref") - self.assertEqual(author["allowed_operations"], ["read", "comment"]) - self.assertEqual(author["forbidden_operations"], ["approve", "merge"]) + self.assertEqual( + author["allowed_operations"], ["gitea.read", "gitea.pr.comment"] + ) + self.assertEqual( + author["forbidden_operations"], + ["gitea.pr.approve", "gitea.pr.merge"], + ) reviewer = prgs_gitea["identities"]["reviewer"] self.assertEqual(reviewer["role"], "reviewer") self.assertEqual(reviewer["username"], "sysadmin") self.assertEqual(reviewer["auth"]["id"], "redacted-reviewer-ref") - self.assertIn("merge", reviewer["allowed_operations"]) + self.assertIn("gitea.pr.merge", reviewer["allowed_operations"]) def test_alias_generation(self): """Test that aliases are correctly generated to support old profile names.""" @@ -188,7 +193,7 @@ class TestMigrateProfiles(unittest.TestCase): self.assertNotIn("token", stdout_output.lower()) def test_explicit_operations_are_preserved(self): - """Explicit v1 permissions must not be replaced by role defaults.""" + """Explicit v1 permissions are canonicalized, not replaced by role defaults.""" v1_data = json.loads(json.dumps(self.v1_content)) v1_data["profiles"]["prgs-reviewer"]["allowed_operations"] = ["read"] v1_data["profiles"]["prgs-reviewer"]["forbidden_operations"] = ["merge"] @@ -198,8 +203,8 @@ class TestMigrateProfiles(unittest.TestCase): v2_data["environments"]["prgs"]["services"]["gitea"] ["identities"]["reviewer"] ) - self.assertEqual(reviewer["allowed_operations"], ["read"]) - self.assertEqual(reviewer["forbidden_operations"], ["merge"]) + self.assertEqual(reviewer["allowed_operations"], ["gitea.read"]) + self.assertEqual(reviewer["forbidden_operations"], ["gitea.pr.merge"]) def test_inferred_role_defaults_only_when_unambiguous(self): """Role defaults are allowed only for clear author/reviewer profiles.""" @@ -306,6 +311,171 @@ class TestMigrateProfiles(unittest.TestCase): migrate_profiles.main() self.assertEqual(cm.exception.code, 1) + def test_reconciler_profile_migration(self): + """Legacy reconciler shorthands migrate to valid canonical operations.""" + import gitea_config + import reconciler_profile + + v1_data = { + "version": 1, + "profiles": { + "prgs-reconciler": { + "base_url": "redacted-prgs-service", + "username": "reconciler-agent", + "auth": {"type": "keychain", "id": "reconciler-ref"}, + "execution_profile": "prgs-reconciler", + "allowed_operations": [ + "read", + "pr.close", + "pr.comment", + "issue.comment", + "issue.close", + "gitea.branch.delete", + ], + "forbidden_operations": [ + "merge", + "approve", + "review", + "pr.create", + "branch.push", + "commit", + ], + } + } + } + v2_data = migrate_profiles.migrate_v1_to_v2(v1_data) + reconciler = ( + v2_data["environments"]["prgs"]["services"]["gitea"] + ["identities"]["reconciler"] + ) + self.assertEqual(reconciler["role"], "reconciler") + allowed = reconciler["allowed_operations"] + forbidden = reconciler["forbidden_operations"] + # No invalid shorthand remains + for bad in ("pr.close", "pr.comment", "issue.close", "read", "merge"): + self.assertNotIn(bad, allowed) + self.assertNotIn(bad, forbidden) + for required in ( + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + "gitea.issue.close", + "gitea.branch.delete", + ): + self.assertIn(required, allowed) + # Production loader accepts every allowed op + self.assertEqual( + gitea_config.normalize_operation(required), required + ) + self.assertEqual(v2_data["aliases"]["prgs-reconciler"], "prgs.gitea.reconciler") + assessment = reconciler_profile.assess_reconciler_profile(allowed, forbidden) + self.assertTrue(assessment["valid"]) + self.assertTrue(migrate_profiles.validate_v2_data(v2_data)) + + def test_reconciler_profile_defaults(self): + """Reconciler defaults are fully canonical and loader-valid.""" + import gitea_config + import reconciler_profile + + v1_data = { + "version": 1, + "profiles": { + "prgs-reconciler": { + "base_url": "redacted-prgs-service", + "username": "reconciler-agent", + "auth": {"type": "keychain", "id": "reconciler-ref"}, + "execution_profile": "prgs-reconciler", + } + } + } + v2_data = migrate_profiles.migrate_v1_to_v2(v1_data) + reconciler = ( + v2_data["environments"]["prgs"]["services"]["gitea"] + ["identities"]["reconciler"] + ) + self.assertEqual(reconciler["role"], "reconciler") + self.assertEqual( + reconciler["allowed_operations"], + migrate_profiles.RECONCILER_DEFAULT_ALLOWED, + ) + self.assertEqual( + reconciler["forbidden_operations"], + migrate_profiles.RECONCILER_DEFAULT_FORBIDDEN, + ) + for op in reconciler["allowed_operations"]: + self.assertEqual(gitea_config.normalize_operation(op), op) + self.assertTrue(op.startswith("gitea.")) + assessment = reconciler_profile.assess_reconciler_profile( + reconciler["allowed_operations"], + reconciler["forbidden_operations"], + ) + self.assertTrue(assessment["valid"]) + self.assertNotIn( + "gitea.branch.delete", assessment["missing_recommended_operations"] + ) + + def test_reconciler_migration_idempotent_canonicalize(self): + """Second canonicalize of already-canonical ops is a no-op.""" + first = migrate_profiles.canonicalize_operations( + list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED) + ) + second = migrate_profiles.canonicalize_operations(first) + self.assertEqual(first, second) + self.assertEqual(first, list(migrate_profiles.RECONCILER_DEFAULT_ALLOWED)) + + def test_reconciler_missing_required_fails_visibly(self): + """Missing gitea.pr.close after migration fails closed (not silent drop).""" + v1_data = { + "version": 1, + "profiles": { + "prgs-reconciler": { + "base_url": "redacted-prgs-service", + "username": "reconciler-agent", + "auth": {"type": "keychain", "id": "reconciler-ref"}, + "execution_profile": "prgs-reconciler", + "allowed_operations": ["read", "gitea.branch.delete"], + "forbidden_operations": ["merge"], + } + }, + } + with self.assertRaisesRegex(ValueError, "missing required"): + migrate_profiles.migrate_v1_to_v2(v1_data) + + def test_unknown_operation_fails_visibly(self): + v1_data = { + "version": 1, + "profiles": { + "prgs-author": { + "base_url": "redacted-prgs-service", + "username": "jcwalker3", + "auth": {"type": "keychain", "id": "hidden-author-ref"}, + "execution_profile": "prgs-author", + "allowed_operations": ["read", "not.a.real.op"], + "forbidden_operations": ["merge"], + } + }, + } + with self.assertRaisesRegex(ValueError, "cannot be canonicalized"): + migrate_profiles.migrate_v1_to_v2(v1_data) + + def test_role_inference_author_reviewer_merger_reconciler(self): + self.assertEqual( + migrate_profiles.infer_role("prgs-author", "prgs-author"), "author" + ) + self.assertEqual( + migrate_profiles.infer_role("prgs-reviewer", "prgs-reviewer"), + "reviewer", + ) + self.assertEqual( + migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"), + "reconciler", + ) + self.assertIsNone( + migrate_profiles.infer_role("prgs-merger", "prgs-merger") + ) + if __name__ == "__main__": unittest.main() + diff --git a/tests/test_pr_ownership_issue_pr_mismatch.py b/tests/test_pr_ownership_issue_pr_mismatch.py new file mode 100644 index 0000000..2286dae --- /dev/null +++ b/tests/test_pr_ownership_issue_pr_mismatch.py @@ -0,0 +1,196 @@ +"""#727 / PR #728: author ownership when tracking issue number != PR number. + +Regression for REQUEST_CHANGES: gitea_update_pr_branch_by_merge must not require +issue_number == pr_number. Ownership is proven via linked issue + lock/branch +context and fails closed for unrelated issues. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import issue_lock_store +import gitea_mcp_server as mcp + + +def _live_lock( + *, + issue_number: int, + branch_name: str = "feat/issue-727-pr-sync-status", + worktree_path: str = "/tmp/branches/issue-727-pr-sync-status", + remote: str = "prgs", + org: str = "Scaled-Tech-Consulting", + repo: str = "Gitea-Tools", +) -> dict: + now = datetime.now(timezone.utc) + return { + "issue_number": issue_number, + "branch_name": branch_name, + "worktree_path": worktree_path, + "remote": remote, + "org": org, + "repo": repo, + "operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE, + "acquired_at": now.isoformat(), + "expires_at": (now + timedelta(hours=2)).isoformat(), + "owner_pid": os.getpid(), + "status": "active", + } + + +class TestAuthorOwnershipIssuePrMismatch(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="gitea-issue-locks-") + self.lock_dir = self._tmp.name + os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir + + def tearDown(self): + # Clear session pointer for this process. + try: + ptr = issue_lock_store.session_pointer_path(self.lock_dir) + if os.path.isfile(ptr): + os.unlink(ptr) + except Exception: + pass + os.environ.pop("GITEA_ISSUE_LOCK_DIR", None) + self._tmp.cleanup() + + def test_issue_727_pr_728_session_lock_accepted(self): + """Tracking issue #727 lock is valid ownership for PR #728.""" + lock = _live_lock(issue_number=727) + issue_lock_store.bind_session_lock(lock) + result = mcp._prove_author_ownership_for_pr( + pr_number=728, + pr_title="feat: pr sync", + pr_body="Closes #727\n\nNative PR sync lifecycle.", + source_branch="feat/issue-727-pr-sync-status", + remote="prgs", + host=None, + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path=lock["worktree_path"], + ) + self.assertTrue(result["proven"], result) + self.assertTrue(result["has_author_lock"]) + self.assertEqual(result["matched_issue"], 727) + self.assertEqual(result["matched_via"], "session_lock") + self.assertIn(727, result["linked_issues"]) + + def test_issue_727_pr_728_durable_lock_accepted(self): + lock = _live_lock(issue_number=727) + path = issue_lock_store.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=727, + lock_dir=self.lock_dir, + ) + issue_lock_store.save_lock_file(path, lock) + result = mcp._prove_author_ownership_for_pr( + pr_number=728, + pr_title="feat: pr sync", + pr_body="Fixes #727", + source_branch="feat/issue-727-pr-sync-status", + remote="prgs", + host=None, + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertTrue(result["proven"], result) + self.assertEqual(result["matched_issue"], 727) + self.assertEqual(result["matched_via"], "durable_lock") + + def test_legacy_same_number_still_accepted(self): + lock = _live_lock( + issue_number=100, + branch_name="fix/issue-100", + worktree_path="/tmp/branches/issue-100", + ) + issue_lock_store.bind_session_lock(lock) + result = mcp._prove_author_ownership_for_pr( + pr_number=100, + pr_title="fix something", + pr_body="Closes #100", + source_branch="fix/issue-100", + remote="prgs", + host=None, + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path=lock["worktree_path"], + ) + self.assertTrue(result["proven"], result) + self.assertEqual(result["matched_issue"], 100) + + def test_unrelated_issue_lock_rejected(self): + """Lock for issue #999 must not authorize PR #728 linked only to #727.""" + lock = _live_lock( + issue_number=999, + branch_name="feat/issue-999", + worktree_path="/tmp/branches/issue-999", + ) + issue_lock_store.bind_session_lock(lock) + path = issue_lock_store.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=999, + lock_dir=self.lock_dir, + ) + issue_lock_store.save_lock_file(path, lock) + result = mcp._prove_author_ownership_for_pr( + pr_number=728, + pr_title="feat: pr sync", + pr_body="Closes #727", + source_branch="feat/issue-727-pr-sync-status", + remote="prgs", + host=None, + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path=lock["worktree_path"], + ) + self.assertFalse(result["proven"], result) + self.assertFalse(result["has_author_lock"]) + self.assertIsNone(result["matched_issue"]) + self.assertTrue(result["reasons"]) + + def test_branch_mismatch_on_session_lock_fail_closed(self): + lock = _live_lock( + issue_number=727, + branch_name="feat/other-branch", + ) + issue_lock_store.bind_session_lock(lock) + result = mcp._prove_author_ownership_for_pr( + pr_number=728, + pr_title="feat: pr sync", + pr_body="Closes #727", + source_branch="feat/issue-727-pr-sync-status", + remote="prgs", + host=None, + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path=lock["worktree_path"], + ) + self.assertFalse(result["proven"], result) + self.assertTrue(any("branch" in r for r in result["reasons"])) + + def test_no_lock_fail_closed(self): + result = mcp._prove_author_ownership_for_pr( + pr_number=728, + pr_title="feat: pr sync", + pr_body="Closes #727", + source_branch="feat/issue-727-pr-sync-status", + remote="prgs", + host=None, + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertFalse(result["proven"], result) + self.assertTrue(any("no live author issue lock" in r for r in result["reasons"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pr_sync_status.py b/tests/test_pr_sync_status.py new file mode 100644 index 0000000..02a2e31 --- /dev/null +++ b/tests/test_pr_sync_status.py @@ -0,0 +1,338 @@ +"""Hermetic tests for PR synchronization / conflict-remediation lifecycle.""" + +from __future__ import annotations + +import unittest + +from pr_sync_status import ( + ACTION_AUTHOR_CONFLICT_REMEDIATION, + ACTION_BLOCKED, + ACTION_FRESH_REVIEW_REQUIRED, + ACTION_MERGE_NOW, + ACTION_UPDATE_BRANCH_BY_MERGE, + assess_post_update_head_transition, + assess_pr_sync_status, + assess_sequential_queue_step, + assess_update_pr_branch_preflight, + lease_usable_at_head, + merge_approval_usable_at_head, +) + + +def _sha(prefix: str) -> str: + return (prefix + "0" * 40)[:40] + + +PR_HEAD = _sha("aaaaaaaa") +BASE_HEAD = _sha("bbbbbbbb") +NEW_HEAD = _sha("cccccccc") +OLD_HEAD = _sha("dddddddd") + + +def _base_kwargs(**overrides): + data = { + "host": "gitea.prgs.cc", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "pr_number": 100, + "pr_state": "open", + "source_branch": "fix/issue-100", + "pr_head_sha": PR_HEAD, + "base_head_sha": BASE_HEAD, + "commits_behind": 0, + "mergeable": True, + "has_conflicts": False, + "branch_protection_requires_current_base": False, + "approval_at_current_head": True, + "checks_status": "success", + "active_author_lock": True, + "active_reviewer_lease": False, + "active_merger_lease": False, + } + data.update(overrides) + return data + + +class TestAssessPrSyncStatus(unittest.TestCase): + def test_approved_current_mergeable_merge_now(self): + result = assess_pr_sync_status(**_base_kwargs()) + self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW) + self.assertTrue(result["approval_valid_for_merge"]) + self.assertFalse(result["stale_approval"]) + self.assertEqual(result["pr_head_sha"], PR_HEAD) + self.assertEqual(result["base_head_sha"], BASE_HEAD) + + def test_approved_outdated_update_not_required_merge_now(self): + result = assess_pr_sync_status( + **_base_kwargs( + commits_behind=3, + branch_protection_requires_current_base=False, + ) + ) + self.assertEqual(result["recommended_next_action"], ACTION_MERGE_NOW) + self.assertTrue(result["approval_valid_for_merge"]) + self.assertTrue(any("does not require" in r for r in result["reasons"])) + + def test_outdated_update_required_no_conflict(self): + result = assess_pr_sync_status( + **_base_kwargs( + commits_behind=2, + branch_protection_requires_current_base=True, + mergeable=True, + has_conflicts=False, + ) + ) + self.assertEqual( + result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE + ) + self.assertFalse(result["approval_valid_for_merge"]) + self.assertTrue(any("update_branch_by_merge" in r for r in result["reasons"])) + + def test_outdated_has_conflicts_author_remediation(self): + result = assess_pr_sync_status( + **_base_kwargs( + commits_behind=5, + branch_protection_requires_current_base=True, + mergeable=False, + has_conflicts=True, + ) + ) + self.assertEqual( + result["recommended_next_action"], ACTION_AUTHOR_CONFLICT_REMEDIATION + ) + self.assertFalse(result["approval_valid_for_merge"]) + + def test_stale_approval_fresh_review_required(self): + result = assess_pr_sync_status( + **_base_kwargs( + approval_at_current_head=False, + commits_behind=0, + ) + ) + self.assertEqual( + result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED + ) + self.assertTrue(result["stale_approval"]) + self.assertFalse(result["approval_valid_for_merge"]) + + def test_stale_prepared_verdict_cannot_cross_heads(self): + result = assess_pr_sync_status( + **_base_kwargs( + approval_at_current_head=True, + prepared_verdict_head_sha=OLD_HEAD, + ) + ) + self.assertEqual( + result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED + ) + self.assertTrue(result["stale_prepared_verdict"]) + self.assertFalse(result["approval_valid_for_merge"]) + + def test_missing_heads_fail_closed(self): + result = assess_pr_sync_status( + **_base_kwargs(pr_head_sha="short", base_head_sha=None) + ) + self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED) + self.assertTrue(any("PR head" in r for r in result["reasons"])) + + def test_closed_pr_blocked(self): + result = assess_pr_sync_status(**_base_kwargs(pr_state="closed")) + self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED) + + def test_failed_checks_block_merge_now(self): + result = assess_pr_sync_status(**_base_kwargs(checks_status="failure")) + self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED) + + def test_stale_approval_with_update_required_routes_update(self): + result = assess_pr_sync_status( + **_base_kwargs( + approval_at_current_head=False, + commits_behind=1, + branch_protection_requires_current_base=True, + mergeable=True, + has_conflicts=False, + ) + ) + self.assertEqual( + result["recommended_next_action"], ACTION_UPDATE_BRANCH_BY_MERGE + ) + + +class TestUpdatePrBranchPreflight(unittest.TestCase): + def _ok_kwargs(self, **overrides): + data = { + "role_kind": "author", + "expected_pr_head_sha": PR_HEAD, + "live_pr_head_sha": PR_HEAD, + "expected_base_head_sha": BASE_HEAD, + "live_base_head_sha": BASE_HEAD, + "has_conflicts": False, + "mergeable": True, + "style": "merge", + "has_author_lock": True, + "worktree_path": "/Users/x/Development/Gitea-Tools/branches/issue-100", + "worktree_owns_source_branch": True, + "control_checkout_clean": True, + "master_parity_ok": True, + "force_push": False, + "rebase": False, + } + data.update(overrides) + return data + + def test_author_allowed_when_preflight_clean(self): + result = assess_update_pr_branch_preflight(**self._ok_kwargs()) + self.assertTrue(result["mutation_allowed"]) + self.assertFalse(result["head_race"]) + self.assertFalse(result["base_race"]) + + def test_head_race_fail_closed(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(live_pr_head_sha=NEW_HEAD) + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(result["head_race"]) + self.assertTrue(any("PR head race" in r for r in result["reasons"])) + + def test_base_race_fail_closed(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(live_base_head_sha=NEW_HEAD) + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(result["base_race"]) + self.assertTrue(any("base head race" in r for r in result["reasons"])) + + def test_conflicts_structured_handoff_no_mutation(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(has_conflicts=True, mergeable=False) + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(result["author_remediation_handoff"]) + self.assertTrue(any("conflicts" in r.lower() for r in result["reasons"])) + + def test_reviewer_denied(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(role_kind="reviewer") + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(any("author-only" in r for r in result["reasons"])) + + def test_merger_denied(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(role_kind="merger") + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(any("author-only" in r for r in result["reasons"])) + + def test_no_force_push(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(force_push=True) + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(any("force-push" in r for r in result["reasons"])) + + def test_no_rebase(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(style="rebase", rebase=True) + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(any("rebase" in r for r in result["reasons"])) + + def test_missing_lock_fail_closed(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(has_author_lock=False) + ) + self.assertFalse(result["mutation_allowed"]) + + def test_worktree_not_under_branches(self): + result = assess_update_pr_branch_preflight( + **self._ok_kwargs(worktree_path="/tmp/not-a-branches-path") + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(any("branches/" in r for r in result["reasons"])) + + +class TestPostUpdateHeadTransition(unittest.TestCase): + def test_update_invalidates_approval_and_requires_fresh_review(self): + result = assess_post_update_head_transition( + former_pr_head_sha=PR_HEAD, + new_pr_head_sha=NEW_HEAD, + former_approval_head_sha=PR_HEAD, + former_reviewer_lease_head_sha=PR_HEAD, + former_merger_lease_head_sha=PR_HEAD, + prepared_verdict_head_sha=PR_HEAD, + ) + self.assertTrue(result["head_changed"]) + self.assertTrue(result["approval_invalidated"]) + self.assertTrue(result["reviewer_lease_superseded"]) + self.assertTrue(result["merger_lease_superseded"]) + self.assertTrue(result["prepared_verdict_invalidated"]) + self.assertEqual( + result["recommended_next_action"], ACTION_FRESH_REVIEW_REQUIRED + ) + + def test_same_head_unexpected(self): + result = assess_post_update_head_transition( + former_pr_head_sha=PR_HEAD, + new_pr_head_sha=PR_HEAD, + ) + self.assertFalse(result["head_changed"]) + self.assertEqual(result["recommended_next_action"], ACTION_BLOCKED) + + +class TestStaleArtifacts(unittest.TestCase): + def test_stale_approval_cannot_authorize_merge(self): + result = merge_approval_usable_at_head( + current_head_sha=NEW_HEAD, + approved_head_sha=PR_HEAD, + ) + self.assertFalse(result["approval_usable"]) + + def test_fresh_approval_usable(self): + result = merge_approval_usable_at_head( + current_head_sha=NEW_HEAD, + approved_head_sha=NEW_HEAD, + ) + self.assertTrue(result["approval_usable"]) + + def test_stale_reviewer_lease_cannot_cross_heads(self): + result = lease_usable_at_head( + lease_kind="reviewer", + lease_head_sha=PR_HEAD, + current_head_sha=NEW_HEAD, + ) + self.assertFalse(result["lease_usable"]) + + def test_stale_merger_lease_cannot_cross_heads(self): + result = lease_usable_at_head( + lease_kind="merger", + lease_head_sha=PR_HEAD, + current_head_sha=NEW_HEAD, + ) + self.assertFalse(result["lease_usable"]) + + +class TestSequentialQueue(unittest.TestCase): + def test_reassess_after_merge_and_master_refresh(self): + result = assess_sequential_queue_step( + just_merged=True, + master_refreshed=True, + next_pr_number=101, + ) + self.assertTrue(result["reassess_allowed"]) + self.assertTrue(result["process_next"]) + self.assertTrue(result["post_merge_cleanup_handoff"]) + self.assertEqual(result["next_pr_number"], 101) + + def test_block_reassess_without_master_refresh(self): + result = assess_sequential_queue_step( + just_merged=True, + master_refreshed=False, + next_pr_number=101, + ) + self.assertFalse(result["reassess_allowed"]) + self.assertTrue(any("master must be refreshed" in r for r in result["reasons"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reconciler_profile.py b/tests/test_reconciler_profile.py index 1c3086d..1c3b6af 100644 --- a/tests/test_reconciler_profile.py +++ b/tests/test_reconciler_profile.py @@ -82,6 +82,42 @@ class TestReconcilerProfileModel(unittest.TestCase): "reconciler", ) + def test_branch_delete_is_recommended_for_reconciler(self): + self.assertIn( + "gitea.branch.delete", + reconciler_profile.RECONCILER_RECOMMENDED_OPERATIONS, + ) + self.assertNotIn( + "gitea.branch.delete", + reconciler_profile.RECONCILER_REQUIRED_OPERATIONS, + ) + + def test_reconciler_with_branch_delete_stays_valid(self): + allowed = PRGS_RECONCILER_ALLOWED + ["gitea.branch.delete"] + result = reconciler_profile.assess_reconciler_profile( + allowed, + PRGS_RECONCILER_FORBIDDEN, + ) + self.assertTrue(result["is_reconciler_profile"]) + self.assertTrue(result["valid"]) + self.assertNotIn( + "gitea.branch.delete", result["missing_recommended_operations"] + ) + self.assertEqual( + mcp_server._role_kind(allowed, PRGS_RECONCILER_FORBIDDEN), + "reconciler", + ) + + def test_reconciler_without_branch_delete_reports_missing_recommended(self): + result = reconciler_profile.assess_reconciler_profile( + PRGS_RECONCILER_ALLOWED, + PRGS_RECONCILER_FORBIDDEN, + ) + self.assertTrue(result["valid"]) + self.assertIn( + "gitea.branch.delete", result["missing_recommended_operations"] + ) + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/tests/test_resolve_task_capability.py b/tests/test_resolve_task_capability.py index 979f5fe..10e6e25 100644 --- a/tests/test_resolve_task_capability.py +++ b/tests/test_resolve_task_capability.py @@ -231,9 +231,16 @@ class TestResolveTaskCapability(unittest.TestCase): self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"]) def test_resolve_unknown_task_fails_closed(self): + # #723: unknown tasks return structured unknown_task (no ValueError escape). with patch.dict(os.environ, self._env("author-profile")): - with self.assertRaises(ValueError): - mcp_server.gitea_resolve_task_capability(task="invalid_task_xyz", remote="prgs") + res = mcp_server.gitea_resolve_task_capability( + task="invalid_task_xyz", remote="prgs" + ) + self.assertIsInstance(res, dict) + self.assertEqual(res.get("reason_code"), "unknown_task", res) + self.assertFalse(res.get("allowed_in_current_session")) + self.assertTrue(res.get("stop_required")) + self.assertIs(res.get("mutation_performed"), False) # Additional regression tests per #145 for permission boundaries and structured guidance def test_issue_comment_does_not_imply_close(self): @@ -439,8 +446,11 @@ class TestResolveTaskCapability(unittest.TestCase): res = mcp_server.gitea_resolve_task_capability(task="close_pr", remote="prgs") self.assertEqual(res["required_operation_permission"], "gitea.pr.close") for unknown in ("close_pull_request", "close", "pr_close"): - with self.assertRaises(ValueError): - mcp_server.gitea_resolve_task_capability(task=unknown, remote="prgs") + unk = mcp_server.gitea_resolve_task_capability( + task=unknown, remote="prgs" + ) + self.assertEqual(unk.get("reason_code"), "unknown_task", unk) + self.assertFalse(unk.get("allowed_in_current_session")) if __name__ == "__main__": unittest.main() diff --git a/tests/test_retry_backoff.py b/tests/test_retry_backoff.py index f77b39a..fdf4982 100644 --- a/tests/test_retry_backoff.py +++ b/tests/test_retry_backoff.py @@ -122,9 +122,11 @@ class TestApiRequestRetry(unittest.TestCase): sleep.assert_not_called() def test_non_429_error_raises_immediately(self): - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: _call([_http_error(500, body=b"boom")]) - self.assertIn("HTTP 500", str(ctx.exception)) + # Fixed message only (#699) — no response body echo. + self.assertEqual(str(ctx.exception), "Gitea HTTP request failed") + self.assertEqual(ctx.exception.http_status, 500) def test_non_429_error_does_not_sleep(self): sleep = MagicMock() @@ -171,9 +173,12 @@ class TestApiRequestRetry(unittest.TestCase): # max_retries=3 -> 3 sleeps, then the 4th failure raises. errors = [_http_error(429, retry_after="1") for _ in range(4)] sleep = MagicMock() - with self.assertRaises(RuntimeError) as ctx: + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: _call(errors, sleep_func=sleep, max_retries=3) - self.assertIn("HTTP 429", str(ctx.exception)) + # After retries are exhausted, 429 is a typed HTTP error with fixed + # message (#699); status metadata carries 429. + self.assertEqual(str(ctx.exception), "Gitea HTTP request failed") + self.assertEqual(ctx.exception.http_status, 429) self.assertEqual(sleep.call_count, 3) def test_no_infinite_loop_when_always_429(self): diff --git a/tests/test_review_drafts.py b/tests/test_review_drafts.py index c70a0a7..417c689 100644 --- a/tests/test_review_drafts.py +++ b/tests/test_review_drafts.py @@ -68,8 +68,6 @@ class TestReviewDrafts(unittest.TestCase): @patch("gitea_mcp_server.api_request") def test_save_draft_success(self, mock_api): - print("GITEA_MCP_SERVER FILE:", gitea_mcp_server.__file__) - print("DIR OF GITEA_MCP_SERVER:", dir(gitea_mcp_server)) mock_api.return_value = { "head": {"sha": "headsha1234567890"}, "base": {"ref": "master", "sha": "basesha0987654321"}, @@ -285,3 +283,104 @@ class TestReviewDrafts(unittest.TestCase): ) self.assertFalse(res.get("success")) self.assertIn("worktree binding mismatch", res.get("reasons")[0]) + + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server._fetch_pr_comments") + def test_resume_draft_foreign_lease(self, mock_comments, mock_api): + mock_api.return_value = { + "state": "open", + "head": {"sha": "headsha1234567890"}, + "base": {"ref": "master", "sha": "basesha0987654321"}, + } + gitea_mcp_server.gitea_save_review_draft( + pr_number=587, + action="approve", + body="Good changes", + expected_head_sha="headsha1234567890", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path="/tmp/test-worktree", + ) + reviewer_pr_lease.record_session_lease({ + "pr_number": 587, + "session_id": "my-session", + }) + mock_comments.return_value = [ + { + "id": 9, + "user": {"username": "sysadmin"}, + "body": ( + "\n" + "repo: Scaled-Tech-Consulting/Gitea-Tools\n" + "pr: #587\n" + "reviewer_identity: sysadmin\n" + "profile: prgs-reviewer\n" + "session_id: foreign-session-999\n" + "phase: claimed\n" + ), + } + ] + res = gitea_mcp_server.gitea_resume_review_draft( + pr_number=587, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path="/tmp/test-worktree", + ) + self.assertFalse(res.get("success")) + self.assertTrue( + any("foreign-session-999" in r or "session_id" in r for r in (res.get("reasons") or [])) + ) + + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server._fetch_pr_comments") + @patch("gitea_mcp_server.terminal_review_hard_stop_reasons") + def test_resume_draft_terminal_lock_blocks(self, mock_hard_stop, mock_comments, mock_api): + mock_api.return_value = { + "state": "open", + "head": {"sha": "headsha1234567890"}, + "base": {"ref": "master", "sha": "basesha0987654321"}, + } + gitea_mcp_server.gitea_save_review_draft( + pr_number=587, + action="approve", + body="Good changes", + expected_head_sha="headsha1234567890", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path="/tmp/test-worktree", + ) + reviewer_pr_lease.record_session_lease({ + "pr_number": 587, + "session_id": "session-1234", + }) + mock_comments.return_value = [ + { + "id": 1, + "user": {"username": "sysadmin"}, + "body": ( + "\n" + "repo: Scaled-Tech-Consulting/Gitea-Tools\n" + "pr: #587\n" + "reviewer_identity: sysadmin\n" + "profile: prgs-reviewer\n" + "session_id: session-1234\n" + "phase: claimed\n" + ), + } + ] + mock_hard_stop.return_value = [ + "terminal review mutation already recorded for PR #587 (approve); #332 hard-stop" + ] + res = gitea_mcp_server.gitea_resume_review_draft( + pr_number=587, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path="/tmp/test-worktree", + ) + self.assertFalse(res.get("success")) + self.assertTrue(any("#332" in r or "terminal" in r for r in (res.get("reasons") or []))) + diff --git a/tests/test_sentry_observability.py b/tests/test_sentry_observability.py new file mode 100644 index 0000000..a5461b2 --- /dev/null +++ b/tests/test_sentry_observability.py @@ -0,0 +1,412 @@ +"""Tests for optional self-hosted Sentry observability (#606). + +Covers the pure module (config, redaction, event/check-in builders) and the +runtime capture paths using a fake ``sentry_sdk``, so nothing ever touches the +network. Critically proves the feature is a no-op when disabled or DSN-less +(acceptance criterion 1) and that secrets/paths/session-state are never sent +(criterion 5). +""" + +import sys +import contextlib +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import sentry_observability as so # noqa: E402 + + +# ── Fake SDK ──────────────────────────────────────────────────────────────── +class _FakeScope: + def __init__(self): + self.tags = {} + self.extras = {} + + def set_tag(self, k, v): + self.tags[k] = v + + def set_extra(self, k, v): + self.extras[k] = v + + +class FakeSentrySDK: + """Minimal stand-in exposing the SDK surface sentry_observability uses.""" + + def __init__(self): + self.init_kwargs = None + self.events = [] + self.exceptions = [] + self.checkins = [] + self.last_scope = None + + def init(self, **kwargs): + self.init_kwargs = kwargs + + def capture_event(self, event): + self.events.append(event) + + def capture_exception(self, exc): + self.exceptions.append(exc) + + def capture_checkin(self, **payload): + self.checkins.append(payload) + + @contextlib.contextmanager + def push_scope(self): + self.last_scope = _FakeScope() + yield self.last_scope + + +@pytest.fixture(autouse=True) +def _reset_state(): + """Isolate module init state between tests.""" + so.reset_for_tests() + yield + so.reset_for_tests() + + +@pytest.fixture +def fake_sdk(monkeypatch): + sdk = FakeSentrySDK() + monkeypatch.setattr(so, "sentry_sdk", sdk) + return sdk + + +# ── Config / gating ───────────────────────────────────────────────────────── +def test_disabled_by_default_empty_env(): + cfg = so.load_config(env={}) + assert cfg.enabled is False + assert cfg.active is False + + +def test_enabled_flag_without_dsn_is_not_active(): + cfg = so.load_config(env={"MCP_SENTRY_ENABLED": "1"}) + assert cfg.enabled is True + assert cfg.dsn is None + assert cfg.active is False # DSN required + + +def test_dsn_without_enabled_flag_is_not_active(): + cfg = so.load_config(env={"SENTRY_DSN": "https://k@sentry.prgs.cc/1"}) + assert cfg.active is False + + +def test_active_requires_enabled_and_dsn(): + cfg = so.load_config( + env={"MCP_SENTRY_ENABLED": "true", "SENTRY_DSN": "https://k@sentry.prgs.cc/1"} + ) + assert cfg.active is True + + +def test_truthy_variants(): + for val in ("1", "true", "YES", "On"): + cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val}) + assert cfg.enabled is True + for val in ("0", "false", "no", "", "off"): + cfg = so.load_config(env={"MCP_SENTRY_ENABLED": val}) + assert cfg.enabled is False + + +def test_traces_sample_rate_parsed_and_clamped(): + assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "0.25"}).traces_sample_rate == 0.25 + assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "5"}).traces_sample_rate == 1.0 + assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "-1"}).traces_sample_rate == 0.0 + assert so.load_config(env={"MCP_SENTRY_TRACES_SAMPLE_RATE": "junk"}).traces_sample_rate == 0.0 + + +def test_safe_summary_has_no_dsn_value(): + cfg = so.load_config( + env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://secret@sentry.prgs.cc/1"} + ) + summary = cfg.safe_summary() + assert summary["dsn_present"] is True + assert "secret" not in repr(summary) + assert "dsn" not in summary # only presence, never the value + + +# ── init_sentry ───────────────────────────────────────────────────────────── +def test_init_noop_when_disabled(fake_sdk): + status = so.init_sentry(so.load_config(env={})) + assert status["initialized"] is False + assert fake_sdk.init_kwargs is None # no SDK init + assert so.is_initialized() is False + + +def test_init_noop_when_enabled_but_missing_dsn(fake_sdk): + status = so.init_sentry(so.load_config(env={"MCP_SENTRY_ENABLED": "1"})) + assert status["initialized"] is False + assert fake_sdk.init_kwargs is None + + +def test_init_reports_missing_sdk(monkeypatch): + monkeypatch.setattr(so, "sentry_sdk", None) + status = so.init_sentry( + so.load_config( + env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://k@sentry.prgs.cc/1"} + ) + ) + assert status["initialized"] is False + assert "not installed" in status["reason"] + + +def test_init_configures_sdk_with_scrubber(fake_sdk): + status = so.init_sentry( + so.load_config( + env={ + "MCP_SENTRY_ENABLED": "1", + "SENTRY_DSN": "https://k@sentry.prgs.cc/1", + "SENTRY_ENVIRONMENT": "prod", + "MCP_SENTRY_TRACES_SAMPLE_RATE": "0.1", + } + ) + ) + assert status["initialized"] is True + assert so.is_initialized() is True + kw = fake_sdk.init_kwargs + assert kw["dsn"] == "https://k@sentry.prgs.cc/1" + assert kw["environment"] == "prod" + assert kw["traces_sample_rate"] == 0.1 + assert kw["before_send"] is so.scrub_event + assert kw["send_default_pii"] is False + + +def test_init_enable_logs_wires_log_scrubber(fake_sdk): + so.init_sentry( + so.load_config( + env={ + "MCP_SENTRY_ENABLED": "1", + "SENTRY_DSN": "https://k@sentry.prgs.cc/1", + "MCP_SENTRY_ENABLE_LOGS": "1", + } + ) + ) + exp = fake_sdk.init_kwargs["_experiments"] + assert exp["enable_logs"] is True + assert exp["before_send_log"] is so.scrub_event + + +def test_init_never_raises_on_sdk_failure(monkeypatch): + class Boom: + def init(self, **kwargs): + raise RuntimeError("sentry down") + + monkeypatch.setattr(so, "sentry_sdk", Boom()) + status = so.init_sentry( + so.load_config( + env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://k@sentry.prgs.cc/1"} + ) + ) + assert status["initialized"] is False + assert "init failed" in status["reason"] + + +# ── Redaction (fail closed) ───────────────────────────────────────────────── +def test_build_tags_allowlist_only(): + tags = so.build_tags(role="author", secret_thing="leak", pid=123) + assert tags["role"] == "author" + assert tags["pid"] == "123" + assert "secret_thing" not in tags + + +def test_build_tags_hashes_session_id(): + tags = so.build_tags(session_id="prgs-author-20479-cf9ac178") + assert "session_id" not in tags + assert "session_id_hash" in tags + assert tags["session_id_hash"] != "prgs-author-20479-cf9ac178" + assert len(tags["session_id_hash"]) == 12 + + +def test_build_tags_collapses_worktree_path(): + tags = so.build_tags( + worktree_path="/Users/x/Development/Gitea-Tools/branches/issue-606-sentry-observability" + ) + assert "worktree_path" not in tags + assert tags["worktree_category"] == "author" + + +def test_build_tags_drops_value_that_scrubs_to_redacted(): + # A tag value that is itself a token gets scrubbed then dropped. + tags = so.build_tags(capability="token abcdef1234567890") + assert "capability" not in tags + + +def test_sanitize_path_categories(): + assert so.sanitize_path("/repo/branches/review-pr-654") == "reviewer" + assert so.sanitize_path("/repo/branches/merge-pr-1") == "merger" + assert so.sanitize_path("/repo/branches/reconcile-pr-1") == "reconciler" + assert so.sanitize_path("/repo/branches/feat-issue-606") == "author" + assert so.sanitize_path("/x/y/Gitea-Tools") == "root" + + +def test_redact_value_scrubs_secrets_and_paths(): + out = so.redact_value( + { + "token": "abc123", + "note": "Authorization: Bearer sk_live_abcdefgh12345", + "path": "/Users/jasonwalker/Development/Gitea-Tools/secret", + "dsn": "https://k@sentry.prgs.cc/1", + "safe": "hello", + } + ) + assert out["token"] == so.REDACTED + assert out["dsn"] == so.REDACTED + assert "sk_live" not in out["note"] + assert so.REDACTED_PATH in out["path"] + assert "/Users/" not in out["path"] + assert out["safe"] == "hello" + + +def test_redact_value_forbidden_prompt_and_session_state(): + out = so.redact_value( + {"prompt": "full body", "session_state": "{...}", "keep": "ok"} + ) + assert out["prompt"] == so.REDACTED + assert out["session_state"] == so.REDACTED + assert out["keep"] == "ok" + + +def test_scrub_event_redacts_nested_and_drops_server_name(): + event = { + "server_name": "some-host", + "message": "boom", + "extra": {"token": "leak", "ok": "1"}, + } + scrubbed = so.scrub_event(event) + assert "server_name" not in scrubbed + assert scrubbed["extra"]["token"] == so.REDACTED + assert scrubbed["extra"]["ok"] == "1" + + +def test_scrub_event_drops_non_dict(): + assert so.scrub_event("not a dict") is None + assert so.scrub_event(None) is None + + +# ── Event builders ────────────────────────────────────────────────────────── +def test_build_blocker_event_structure_and_next_action(): + event = so.build_blocker_event( + "active_foreign_lease", + message="blocked by foreign lease", + next_action="wait or adopt via allocator", + level="warning", + tags={"pr_number": 606, "session_id": "s-123"}, + ) + assert event["tags"]["blocker_type"] == "active_foreign_lease" + assert event["tags"]["pr_number"] == "606" + assert "session_id" not in event["tags"] + assert event["tags"]["session_id_hash"] + assert event["extra"]["canonical_next_action"] == "wait or adopt via allocator" + assert event["fingerprint"] == ["workflow-blocker", "active_foreign_lease"] + + +def test_build_blocker_event_invalid_level_defaults_warning(): + event = so.build_blocker_event("x", level="nonsense") + assert event["level"] == "warning" + + +def test_build_checkin_payload_maps_slugs(): + for key, slug in so.MONITOR_SLUGS.items(): + payload = so.build_checkin_payload(key, "ok") + assert payload["monitor_slug"] == slug + assert payload["status"] == "ok" + + +def test_build_checkin_payload_explicit_slug_passthrough(): + payload = so.build_checkin_payload("custom-slug", "in_progress", duration=1.5) + assert payload["monitor_slug"] == "custom-slug" + assert payload["duration"] == 1.5 + + +def test_build_checkin_payload_rejects_bad_status(): + with pytest.raises(ValueError): + so.build_checkin_payload("allocator_health", "bogus") + + +def test_all_six_monitors_registered(): + assert set(so.MONITOR_SLUGS) == { + "stale_lease_scan", + "terminal_lock_scan", + "allocator_health", + "namespace_health", + "dashboard_freshness", + "reconciler_cleanup", + } + + +# ── Capture paths (fail open) ─────────────────────────────────────────────── +def test_capture_workflow_blocker_noop_when_disabled(fake_sdk): + # not initialised + event = so.capture_workflow_blocker("some_blocker", message="x") + assert isinstance(event, dict) # still returns redacted event + assert fake_sdk.events == [] # but nothing sent + + +def test_capture_workflow_blocker_sends_when_initialised(fake_sdk): + so.init_sentry( + so.load_config( + env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://k@sentry.prgs.cc/1"} + ) + ) + so.capture_workflow_blocker("terminal_lock_occupied", message="held") + assert len(fake_sdk.events) == 1 + assert fake_sdk.events[0]["tags"]["blocker_type"] == "terminal_lock_occupied" + + +def test_capture_exception_noop_when_disabled(fake_sdk): + assert so.capture_exception(ValueError("x")) is False + assert fake_sdk.exceptions == [] + + +def test_capture_exception_sends_scrubbed_tags(fake_sdk): + so.init_sentry( + so.load_config( + env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://k@sentry.prgs.cc/1"} + ) + ) + ok = so.capture_exception( + RuntimeError("bad"), tags={"mutation_tool": "gitea_merge_pr", "leaky": "x"} + ) + assert ok is True + assert len(fake_sdk.exceptions) == 1 + assert fake_sdk.last_scope.tags["mutation_tool"] == "gitea_merge_pr" + assert "leaky" not in fake_sdk.last_scope.tags + + +def test_capture_exception_never_raises(monkeypatch, fake_sdk): + so.init_sentry( + so.load_config( + env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://k@sentry.prgs.cc/1"} + ) + ) + + def boom(exc): + raise RuntimeError("sdk exploded") + + monkeypatch.setattr(fake_sdk, "capture_exception", boom) + # Must swallow the SDK failure (fail open). + assert so.capture_exception(ValueError("y")) is False + + +def test_monitor_checkin_noop_when_disabled(fake_sdk): + payload = so.monitor_checkin("allocator_health", "ok") + assert payload["monitor_slug"] == "gitea-mcp-allocator-health" + assert fake_sdk.checkins == [] # not sent while disabled + + +def test_monitor_checkin_sends_when_initialised(fake_sdk): + so.init_sentry( + so.load_config( + env={"MCP_SENTRY_ENABLED": "1", "SENTRY_DSN": "https://k@sentry.prgs.cc/1"} + ) + ) + so.monitor_checkin("stale_lease_scan", "ok") + assert fake_sdk.checkins == [ + {"monitor_slug": "gitea-mcp-stale-lease-scan", "status": "ok"} + ] + + +def test_monitor_checkin_invalid_status_returns_none(fake_sdk): + assert so.monitor_checkin("allocator_health", "bogus") is None + assert fake_sdk.checkins == [] diff --git a/tests/test_stable_control_runtime_policy_docs.py b/tests/test_stable_control_runtime_policy_docs.py new file mode 100644 index 0000000..b533aeb --- /dev/null +++ b/tests/test_stable_control_runtime_policy_docs.py @@ -0,0 +1,139 @@ +"""Documentation acceptance for the stable control runtime ADR (#615 / PR #616). + +Enforces review 443 remediation: + +* F1 — operator guide / runbooks cross-link the ADR (issue #615 AC2). +* F2 — LLM sessions are not instructed to kill/restart/relaunch MCP; process + restart is operator-owned; ADR is the authoritative split. +* F3 — routine post-merge master-parity staleness has a sanctioned response + (stop → report → operator reload → re-verify parity) and is not a full + promotion ledger requirement. +""" +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +ADR = ( + REPO_ROOT + / "docs" + / "architecture" + / "mcp-stable-control-runtime-policy-adr.md" +) +ADR_REL = "architecture/mcp-stable-control-runtime-policy-adr.md" +ADR_BASENAME = "mcp-stable-control-runtime-policy-adr.md" + +CROSS_LINK_DOCS = ( + REPO_ROOT / "docs" / "wiki" / "Operator-Guide.md", + REPO_ROOT / "docs" / "wiki" / "Runbooks.md", + REPO_ROOT / "docs" / "llm-workflow-runbooks.md", +) + + +def _read(path: Path) -> str: + assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}" + return path.read_text(encoding="utf-8") + + +def test_adr_exists_with_policy_core(): + text = _read(ADR) + assert text.lstrip().startswith("#"), "ADR lacks a title" + assert "#615" in text + assert "stable control runtime" in text.lower() + assert "2.3" in text and "2.4" in text and "2.5" in text and "2.6" in text + + +def test_f1_operator_docs_cross_link_adr(): + for path in CROSS_LINK_DOCS: + text = _read(path) + assert ADR_BASENAME in text, ( + f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} " + f"(issue #615 acceptance criterion 2)" + ) + + +def test_f1_adr_lists_cross_links_as_acceptance_not_optional_tooling(): + text = _read(ADR) + # Acceptance section must require guide/runbook cross-links. + assert "Operator guide / runbooks cross-link" in text or ( + "operator guide" in text.lower() and "cross-link" in text.lower() + and "Acceptance" in text + ) + # Cross-link must not remain only as optional tooling item #4. + optional = text.split("## 5. Implementation follow-ups", 1)[-1].split( + "## 6. Acceptance", 1 + )[0] + assert "Operator Guide wiki cross-link to this ADR" not in optional, ( + "ADR §5 must not list operator-guide cross-link as optional tooling" + ) + assert "Not optional" in text or "must** cross-link" in text.lower() or ( + "must cross-link" in text.lower() + ) + + +def test_f2_runbook_fallback_is_operator_owned_not_llm_restart(): + runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md") + # Forbidden historical self-service instruction. + forbidden = ( + "the LLM must relaunch or restart the client/MCP with the correct " + "profile environment variable before claiming or working on any tasks" + ) + assert forbidden not in runbooks, ( + "llm-workflow-runbooks must not instruct the LLM to relaunch/restart MCP" + ) + assert "operator-owned" in runbooks.lower() or "Operator-owned" in runbooks + assert ADR_BASENAME in runbooks + + +def test_f2_workspace_rebind_does_not_require_llm_process_relaunch(): + runbooks = _read(REPO_ROOT / "docs" / "llm-workflow-runbooks.md") + section = runbooks.split("### Safe reconnect / rebind procedure", 1)[-1] + section = section.split("## Safety notes", 1)[0] + # Must not tell the LLM alone to relaunch the MCP process as step 2. + assert "Reconnect or relaunch the correct namespace MCP server from the intended" not in section + assert "Operator-owned" in section or "operator" in section.lower() + assert "worktree_path" in section + collapsed = " ".join(section.lower().split()) + assert "client reconnect" in collapsed + + +def test_f2_adr_forbids_llm_restart_and_supersedes_self_service_relaunch(): + text = _read(ADR) + lower = text.lower() + assert "must not" in lower and "restart" in lower + assert "operator" in lower and "reload" in lower + assert "supersedes" in lower + assert "llm" in lower + + +def test_f3_adr_defines_post_merge_parity_staleness_response(): + text = _read(ADR) + lower = text.lower() + assert "2.6" in text + assert "post-merge" in lower or "post merge" in lower + assert "parity" in lower and "stale" in lower + # Sanctioned steps: stop, report, operator reload, re-verify. + assert "stop" in lower + assert "report" in lower + assert "operator" in lower + assert "parity is verified" in lower or "re-verif" in lower or ( + "startup/current-head" in lower + ) + # Not a full promotion ledger for routine reload. + assert "not a §2.4 promotion" in lower or "not a section 2.4 promotion" in lower or ( + "not a §2.4" in text or "Not a §2.4 promotion" in text + ) + # Stale parity listed among unhealthy triggers. + assert "master parity is stale" in lower or "stale master parity" in lower + + +def test_f3_adr_forbids_llm_bypass_of_parity_gate(): + text = _read(ADR) + lower = text.lower() + assert "bypass" in lower or "self-reset" in lower or "self-service" in lower + assert "parity" in lower + + +def test_cross_links_do_not_embed_secrets_or_raw_hosts_in_wiki_snippets(): + for path in CROSS_LINK_DOCS: + text = _read(path) + for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"): + assert marker not in text, f"{path} contains {marker!r}" diff --git a/tests/test_stale_review_decision_lock_cleanup.py b/tests/test_stale_review_decision_lock_cleanup.py index 7beed2b..d9236c2 100644 --- a/tests/test_stale_review_decision_lock_cleanup.py +++ b/tests/test_stale_review_decision_lock_cleanup.py @@ -184,20 +184,32 @@ class TestActiveHardStopPreserved(unittest.TestCase): mcp_server._save_review_decision_lock(None) mcp_server.review_workflow_load.clear_review_workflow_load() - def test_open_approve_still_blocks_other_pr_mark_ready(self): - _seed([APPROVED_OPEN]) - reasons = mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready") - self.assertTrue(reasons) - self.assertIn("#332", reasons[0]) - self.assertIn("gitea_cleanup_stale_review_decision_lock", reasons[0]) + def test_open_approve_blocks_same_pr_not_other_pr_mark_ready(self): + _seed([APPROVED_OPEN]) # approve on PR #700 + # Same PR still hard-stopped for mark_ready. + reasons_same = mcp_server.terminal_review_hard_stop_reasons( + 700, "mark_ready" + ) + self.assertTrue(reasons_same) + self.assertIn("#332", reasons_same[0]) + # #693: other PR may mark_ready without cleanup. + self.assertEqual( + mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"), + [], + ) - def test_request_changes_still_blocks_everything(self): - _seed([RC_OPEN]) + def test_request_changes_still_blocks_same_pr(self): + _seed([RC_OPEN]) # request_changes on PR #701 for op in ("merge", "mark_ready", "review"): self.assertTrue( - mcp_server.terminal_review_hard_stop_reasons(592, op), + mcp_server.terminal_review_hard_stop_reasons(701, op), msg=op, ) + # Foreign PR review path is open (#693). + self.assertEqual( + mcp_server.terminal_review_hard_stop_reasons(592, "mark_ready"), + [], + ) # --------------------------------------------------------------------------- # diff --git a/tests/test_structured_auth_mcp_errors.py b/tests/test_structured_auth_mcp_errors.py new file mode 100644 index 0000000..c5b46de --- /dev/null +++ b/tests/test_structured_auth_mcp_errors.py @@ -0,0 +1,426 @@ +"""Structured MCP auth errors and stdio transport survival (#699 / PR #701). + +Covers original AC plus reviewer-ratified regressions: + +1. Adversarial response-body, Keychain-content, and daemon-log secret checks +2. Real stdio authentication failure followed by a successful second call +3. UrlElicitationRequiredError framework re-raise behavior +4. Unexpected parser RuntimeError is not authentication +5. Generic HTTP 403 is authorization (not internal) +6. Repeated install/import is idempotent +7. Author and reconciler profiles +8. Native provenance non-bypass +""" +from __future__ import annotations + +import asyncio +import io +import json +import os +import subprocess +import sys +import tempfile +import textwrap +import unittest +import urllib.error +from unittest.mock import patch + +import gitea_auth +import mcp_tool_error_boundary as boundary +from tests.test_api_reliability import FAKE_AUTH, URL, FakeResp, http_error + +ADVERSARIAL_BODY = ( + 'secret-token-value-ABC123 keychain-password=hunter2 ' + 'Authorization: token ghp_leaked_secret_xyz ' + '{"message":"invalid username, password or token","token":"supersecretXYZ"}' +) +KEYCHAIN_BLOB = "keychain-item-password=sekrit-from-security-find-generic" + + +# --------------------------------------------------------------------------- +# api_request / HTTP classification +# --------------------------------------------------------------------------- +class TestHttpClassification(unittest.TestCase): + @patch("gitea_auth.urllib.request.urlopen") + def test_401_typed_auth_fixed_message_no_body(self, mock_open): + mock_open.side_effect = http_error(401, ADVERSARIAL_BODY) + with self.assertRaises(gitea_auth.GiteaAuthError) as ctx: + gitea_auth.api_request("GET", URL, FAKE_AUTH) + exc = ctx.exception + self.assertEqual(exc.reason_code, "auth_invalid_token") + self.assertEqual(exc.error_class, "authentication") + self.assertEqual(exc.http_status, 401) + msg = str(exc) + self.assertNotIn("supersecretXYZ", msg) + self.assertNotIn("ghp_leaked", msg) + self.assertNotIn("hunter2", msg) + self.assertNotIn(ADVERSARIAL_BODY, msg) + self.assertEqual( + msg, "Gitea authentication failed: invalid or revoked credentials" + ) + + @patch("gitea_auth.urllib.request.urlopen") + def test_403_scope_is_authz_scope(self, mock_open): + mock_open.side_effect = http_error( + 403, + '{"message":"token does not have at least one of required scope(s)"}', + ) + with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx: + gitea_auth.api_request("GET", URL, FAKE_AUTH) + self.assertEqual(ctx.exception.reason_code, "authz_insufficient_scope") + self.assertEqual(ctx.exception.error_class, "authorization") + self.assertNotIn("token does not have", str(ctx.exception)) + + @patch("gitea_auth.urllib.request.urlopen") + def test_generic_403_is_authz_not_internal(self, mock_open): + mock_open.side_effect = http_error(403, '{"message":"user has no permission"}') + with self.assertRaises(gitea_auth.GiteaAuthzError) as ctx: + gitea_auth.api_request("GET", URL, FAKE_AUTH) + self.assertEqual(ctx.exception.reason_code, "authz_denied") + self.assertEqual(ctx.exception.error_class, "authorization") + self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError) + self.assertNotIn("user has no permission", str(ctx.exception)) + + @patch("gitea_auth.urllib.request.urlopen") + def test_network_fixed_message(self, mock_open): + mock_open.side_effect = TimeoutError("timed out contacting secret.example") + with self.assertRaises(gitea_auth.GiteaNetworkError) as ctx: + gitea_auth.api_request("GET", URL, FAKE_AUTH) + self.assertEqual(str(ctx.exception), "Network error contacting Gitea") + self.assertNotIn("secret.example", str(ctx.exception)) + + @patch("gitea_auth.urllib.request.urlopen") + def test_malformed_json_not_auth(self, mock_open): + mock_open.return_value = FakeResp("not-json{") + with self.assertRaises(RuntimeError) as ctx: + gitea_auth.api_request("GET", URL, FAKE_AUTH) + self.assertNotIsInstance(ctx.exception, gitea_auth.GiteaAuthError) + self.assertIn("malformed JSON", str(ctx.exception)) + + def test_classify_http_status_central(self): + cls, reason, status = gitea_auth.classify_http_status(401) + self.assertIs(cls, gitea_auth.GiteaAuthError) + self.assertEqual(reason, "auth_invalid_token") + self.assertEqual(status, 401) + + cls, reason, status = gitea_auth.classify_http_status(403) + self.assertIs(cls, gitea_auth.GiteaAuthzError) + self.assertEqual(reason, "authz_denied") + + cls, reason, status = gitea_auth.classify_http_status( + 403, body_hint="missing scope write:issue" + ) + self.assertEqual(reason, "authz_insufficient_scope") + + cls, reason, status = gitea_auth.classify_http_status(502) + self.assertIs(cls, gitea_auth.GiteaHttpError) + self.assertEqual(reason, "upstream_unavailable") + + +# --------------------------------------------------------------------------- +# Boundary classification — no heuristics, no secret leakage +# --------------------------------------------------------------------------- +class TestBoundaryClassification(unittest.TestCase): + def test_auth_payload_fixed_message(self): + exc = gitea_auth.GiteaAuthError(reason_code="auth_invalid_token") + # Even if someone mutates __str__ path, classification uses fixed text. + result = boundary.to_call_tool_result( + exc, tool_name="gitea_whoami", profile_name="prgs-author", log=False + ) + self.assertTrue(result.isError) + p = result.structuredContent + self.assertEqual(p["reason_code"], "auth_invalid_token") + self.assertEqual(p["error_class"], "authentication") + self.assertEqual(p["message"], boundary.fixed_message("auth_invalid_token")) + self.assertNotIn("supersecret", json.dumps(p)) + self.assertEqual(p["profile"], "prgs-author") + + def test_adversarial_exception_text_not_in_payload_or_log(self): + """Poisoned exception text must never appear in result or daemon log.""" + + class PoisonedAuth(gitea_auth.GiteaAuthError): + def __str__(self): + return ADVERSARIAL_BODY + + exc = PoisonedAuth(reason_code="auth_invalid_token") + buf = io.StringIO() + result = boundary.to_call_tool_result( + exc, tool_name="gitea_whoami", log=True + ) + # Force log with poisoned classification attempt + c = boundary.classify_exception(exc) + boundary.log_sanitized_daemon_reason(c, tool_name="gitea_whoami", stream=buf) + blob = json.dumps(result.structuredContent) + result.content[0].text + buf.getvalue() + for secret in ( + "supersecretXYZ", + "ghp_leaked", + "hunter2", + "keychain-password", + ADVERSARIAL_BODY[:40], + ): + self.assertNotIn(secret, blob) + self.assertIn("reason_code=auth_invalid_token", buf.getvalue()) + self.assertNotIn("detail=", buf.getvalue()) + + def test_keychain_content_not_in_daemon_log(self): + buf = io.StringIO() + # Simulate a classification that a buggy path might try to put secrets into + poisoned = { + "reason_code": "auth_invalid_token", + "error_class": "authentication", + "http_status": 401, + "message": KEYCHAIN_BLOB, + } + boundary.log_sanitized_daemon_reason( + poisoned, tool_name="gitea_whoami", stream=buf + ) + line = buf.getvalue() + self.assertNotIn("sekrit", line) + self.assertNotIn("keychain-item", line) + self.assertIn("reason_code=auth_invalid_token", line) + + def test_unexpected_parser_runtimeerror_not_auth(self): + """Reviewer finding #3: parser RuntimeError must not become authentication.""" + exc = RuntimeError( + "HTTP 401: invalid username, password or token while parsing" + ) + c = boundary.classify_exception(exc) + self.assertEqual(c["error_class"], "internal") + self.assertEqual(c["reason_code"], "internal_error") + self.assertNotEqual(c["error_class"], "authentication") + self.assertFalse(boundary.is_known_client_failure(exc)) + + def test_is_known_client_failure_only_typed(self): + self.assertTrue( + boundary.is_known_client_failure(gitea_auth.GiteaAuthError()) + ) + self.assertTrue( + boundary.is_known_client_failure(gitea_auth.GiteaAuthzError()) + ) + self.assertFalse(boundary.is_known_client_failure(RuntimeError("x"))) + self.assertFalse(boundary.is_known_client_failure(ValueError("y"))) + + def test_authz_distinct_from_auth(self): + c = boundary.classify_exception( + gitea_auth.GiteaAuthzError(reason_code="authz_denied") + ) + self.assertEqual(c["error_class"], "authorization") + self.assertNotEqual(c["error_class"], "authentication") + + def test_author_and_reconciler_profiles(self): + exc = gitea_auth.GiteaAuthError() + for profile in ("prgs-author", "prgs-reconciler"): + r = boundary.to_call_tool_result( + exc, tool_name="gitea_whoami", profile_name=profile, log=False + ) + self.assertEqual(r.structuredContent["profile"], profile) + + def test_sanitize_failure_fails_closed(self): + """If build_structured_error_payload is poisoned, fixed internal path wins.""" + bad = { + "reason_code": "auth_invalid_token", + "error_class": "authentication", + "message": ADVERSARIAL_BODY, # must be replaced with fixed constant + "http_status": 401, + } + payload = boundary.build_structured_error_payload(bad) + self.assertEqual( + payload["message"], boundary.fixed_message("auth_invalid_token") + ) + self.assertNotIn("supersecret", payload["message"]) + + +# --------------------------------------------------------------------------- +# Tool.run boundary — framework semantics +# --------------------------------------------------------------------------- +class TestToolRunBoundary(unittest.TestCase): + def setUp(self): + from mcp.server.fastmcp.tools.base import Tool + + boundary.install_tool_run_boundary(Tool) + self.Tool = Tool + + def _tool(self, fn, name="demo_tool"): + return self.Tool.from_function(fn, name=name) + + def test_auth_returns_is_error(self): + def boom() -> dict: + raise gitea_auth.GiteaAuthError() + + result = asyncio.run(self._tool(boom, "gitea_whoami").run({}, convert_result=True)) + self.assertTrue(result.isError) + self.assertEqual(result.structuredContent["reason_code"], "auth_invalid_token") + + def test_url_elicitation_re_raised(self): + from mcp.shared.exceptions import UrlElicitationRequiredError + from mcp.types import ElicitRequestURLParams + + def boom() -> dict: + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + elicitationId="e1", + url="https://example.invalid/elicit", + message="need auth", + ) + ] + ) + + tool = self._tool(boom, "elicitation_tool") + + async def _run(): + return await tool.run({}, convert_result=True) + + with self.assertRaises(UrlElicitationRequiredError): + asyncio.run(_run()) + + def test_transport_survives_second_call(self): + state = {"n": 0} + + def flaky() -> dict: + state["n"] += 1 + if state["n"] == 1: + raise gitea_auth.GiteaAuthError() + return {"ok": True, "call": state["n"]} + + tool = self._tool(flaky, "gitea_whoami") + + async def _both(): + first = await tool.run({}, convert_result=True) + second = await tool.run({}, convert_result=True) + return first, second + + first, second = asyncio.run(_both()) + self.assertTrue(first.isError) + self.assertEqual(first.structuredContent["error_class"], "authentication") + self.assertFalse(getattr(second, "isError", False)) + self.assertIsNotNone(second) + + def test_os_exit_not_called(self): + def boom() -> dict: + raise gitea_auth.GiteaAuthError() + + with patch("os._exit") as mock_exit: + result = asyncio.run(self._tool(boom).run({}, convert_result=True)) + mock_exit.assert_not_called() + self.assertTrue(result.isError) + + def test_internal_not_labeled_auth(self): + def boom() -> dict: + raise KeyError("unexpected internal bug") + + result = asyncio.run(self._tool(boom).run({}, convert_result=True)) + self.assertTrue(result.isError) + self.assertEqual(result.structuredContent["error_class"], "internal") + self.assertEqual(result.structuredContent["reason_code"], "internal_error") + + def test_idempotent_install(self): + from mcp.server.fastmcp.tools.base import Tool + + first = boundary.install_tool_run_boundary(Tool) + second = boundary.install_tool_run_boundary(Tool) + # After setUp, boundary is installed; both calls should be no-ops (False) + # or first True only if somehow reset — either way second must be False. + self.assertFalse(second) + self.assertTrue(boundary.boundary_is_installed(Tool)) + + +# --------------------------------------------------------------------------- +# Real stdio subprocess: auth error then successful second call +# --------------------------------------------------------------------------- +class TestStdioTransportSurvival(unittest.TestCase): + def test_stdio_auth_error_then_second_call(self): + """Minimal FastMCP stdio-like in-process loop with the boundary installed. + + Uses Tool.run (same path as FastMCP tool execution) to prove: + 1) auth failure → isError structured result + 2) subsequent call still returns normally + without starting a full MCP daemon (unit-speed, no network). + """ + from mcp.server.fastmcp.tools.base import Tool + + boundary.install_tool_run_boundary(Tool) + calls = {"n": 0} + + def whoami() -> dict: + calls["n"] += 1 + if calls["n"] == 1: + # Simulate revoked credential → typed auth failure + raise gitea_auth.GiteaAuthError(reason_code="auth_invalid_token") + return {"authenticated": True, "username": "demo"} + + tool = Tool.from_function(whoami, name="gitea_whoami") + + async def session(): + r1 = await tool.run({}, convert_result=True) + r2 = await tool.run({}, convert_result=True) + return r1, r2 + + r1, r2 = asyncio.run(session()) + self.assertTrue(r1.isError) + self.assertEqual(r1.structuredContent["reason_code"], "auth_invalid_token") + self.assertTrue(r1.structuredContent["transport_survives"]) + # Second call survives and returns success content + self.assertFalse(getattr(r2, "isError", False)) + + +# --------------------------------------------------------------------------- +# Provenance non-bypass +# --------------------------------------------------------------------------- +class TestNativeProvenanceNonBypass(unittest.TestCase): + def test_env_flags_cannot_disable_classification(self): + env_keys = ( + "GITEA_OFFLINE", + "GITEA_SKIP_AUTH_BOUNDARY", + "GITEA_MCP_OFFLINE", + "GITEA_BYPASS_NATIVE_MCP", + ) + saved = {k: os.environ.get(k) for k in env_keys} + try: + for k in env_keys: + os.environ[k] = "1" + exc = gitea_auth.GiteaAuthError() + c = boundary.classify_exception(exc) + self.assertEqual(c["error_class"], "authentication") + r = boundary.to_call_tool_result(exc, log=False) + self.assertTrue(r.isError) + self.assertEqual(r.structuredContent["reason_code"], "auth_invalid_token") + finally: + for k, v in saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def test_no_bypass_surface(self): + for name in dir(boundary): + lower = name.lower() + self.assertFalse( + lower.startswith("bypass") or lower.startswith("skip_native"), + msg=f"unexpected bypass surface: {name}", + ) + + +# --------------------------------------------------------------------------- +# api_reliability regressions still hold for non-401 paths +# --------------------------------------------------------------------------- +class TestApiReliabilityCompat(unittest.TestCase): + @patch("gitea_auth.urllib.request.urlopen") + def test_502_upstream_typed(self, mock_open): + mock_open.side_effect = http_error(502, "bad gateway secret=xyz") + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: + gitea_auth.api_request("GET", URL, FAKE_AUTH) + self.assertEqual(ctx.exception.reason_code, "upstream_unavailable") + self.assertNotIn("secret=xyz", str(ctx.exception)) + + @patch("gitea_auth.urllib.request.urlopen") + def test_auth_header_never_in_error(self, mock_open): + mock_open.side_effect = http_error(400, "bad request") + with self.assertRaises(gitea_auth.GiteaHttpError) as ctx: + gitea_auth.api_request("GET", URL, FAKE_AUTH) + self.assertNotIn(FAKE_AUTH, str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_task_capability_role_invariants.py b/tests/test_task_capability_role_invariants.py new file mode 100644 index 0000000..a77f570 --- /dev/null +++ b/tests/test_task_capability_role_invariants.py @@ -0,0 +1,205 @@ +"""Invariant tests pinning task_capability_map role assignments (#722/#723). + +Added with the operator-authorized break-glass repair for incident #722: +commit 970e68b remapped ten reviewer tasks to ``role="merger"`` while every +configured merger profile forbids the review permissions, so no configured +profile could resolve any formal review task (``matching_configured_profile`` +was empty repository-wide). These tests fail loudly if that class of +regression recurs: + +- every role-exclusive formal-review task must be satisfiable by at least one + canonical role profile (permission AND role together); +- the capability map must agree with ``role_session_router`` task sets; +- ``adopt_merger_pr_lease`` stays merger-only (the legitimate hunk of + 970e68b, preserved by the repair); +- merger profiles must not be able to resolve review_pr/approve_pr. +""" + +import unittest + +import gitea_config +from role_session_router import MERGER_TASKS, REVIEWER_TASKS +from task_capability_map import required_permission, required_role + +# Canonical role-profile permission shape. Mirrors the configured +# author/reviewer/merger/reconciler profiles (profiles.json v2 role split): +# reviewers review/approve/request changes but never merge; mergers merge but +# never review/approve/request changes. +CANONICAL_ROLE_PROFILES = { + "author": { + "allowed": [ + "gitea.read", + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + "gitea.pr.create", + "gitea.pr.comment", + "gitea.issue.create", + "gitea.issue.comment", + "gitea.issue.close", + ], + "forbidden": [ + "gitea.pr.approve", + "gitea.pr.request_changes", + "gitea.pr.merge", + ], + }, + "reviewer": { + "allowed": [ + "gitea.read", + "gitea.pr.review", + "gitea.pr.approve", + "gitea.pr.request_changes", + "gitea.pr.comment", + "gitea.issue.comment", + ], + "forbidden": [ + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + "gitea.pr.create", + "gitea.pr.merge", + ], + }, + "merger": { + "allowed": [ + "gitea.read", + "gitea.pr.merge", + "gitea.pr.comment", + "gitea.issue.comment", + ], + "forbidden": [ + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + "gitea.pr.create", + "gitea.pr.approve", + "gitea.pr.review", + "gitea.pr.request_changes", + ], + }, + "reconciler": { + "allowed": [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + "gitea.branch.delete", + "gitea.decision_lock.irrecoverable_recovery", + ], + "forbidden": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + "gitea.pr.request_changes", + "gitea.pr.create", + "gitea.branch.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + }, +} + +# Role-exclusive formal-review tasks (mirrors the resolver's role-exclusive +# handling for review work): permission alone is not enough — the profile's +# role kind must also match, so both dimensions are pinned here. +FORMAL_REVIEW_TASKS = ( + "review_pr", + "approve_pr", + "request_changes_pr", + "blind_pr_queue_review", + "pr_queue_cleanup", + "pr-queue-cleanup", +) + + +def _profile_satisfies(role_name, task): + """True when the canonical *role_name* profile can perform *task*.""" + profile = CANONICAL_ROLE_PROFILES[role_name] + ok, _reason = gitea_config.check_operation( + required_permission(task), profile["allowed"], profile["forbidden"] + ) + return ok and role_name == required_role(task) + + +class TestFormalReviewProfileCoverage(unittest.TestCase): + """#722: some configured profile must be able to formally review.""" + + def test_every_formal_review_task_has_a_satisfying_role_profile(self): + for task in FORMAL_REVIEW_TASKS: + with self.subTest(task=task): + satisfying = [ + role + for role in CANONICAL_ROLE_PROFILES + if _profile_satisfies(role, task) + ] + self.assertTrue( + satisfying, + f"no canonical role profile satisfies both permission " + f"{required_permission(task)!r} and role " + f"{required_role(task)!r} for task {task!r} — formal " + f"review would be impossible for every configured " + f"profile (incident #722)", + ) + + def test_formal_review_tasks_are_reviewer_role(self): + for task in FORMAL_REVIEW_TASKS: + with self.subTest(task=task): + self.assertEqual(required_role(task), "reviewer") + + +class TestMapRouterAgreement(unittest.TestCase): + """#723 AC2: the map and the role session router must not drift.""" + + def test_reviewer_tasks_map_to_reviewer_role(self): + for task in sorted(REVIEWER_TASKS): + with self.subTest(task=task): + self.assertEqual( + required_role(task), + "reviewer", + f"router classifies {task!r} as a reviewer task but the " + f"capability map assigns role {required_role(task)!r}", + ) + + def test_merger_tasks_map_to_merger_role(self): + for task in sorted(MERGER_TASKS): + with self.subTest(task=task): + self.assertEqual( + required_role(task), + "merger", + f"router classifies {task!r} as a merger task but the " + f"capability map assigns role {required_role(task)!r}", + ) + + +class TestMergerBoundary(unittest.TestCase): + """Preserve the legitimate hunk of 970e68b and the merger fence.""" + + def test_adopt_merger_pr_lease_requires_merger_role(self): + self.assertEqual(required_role("adopt_merger_pr_lease"), "merger") + self.assertEqual( + required_permission("adopt_merger_pr_lease"), "gitea.pr.comment" + ) + + def test_merge_pr_requires_merger_role(self): + self.assertEqual(required_role("merge_pr"), "merger") + self.assertEqual(required_permission("merge_pr"), "gitea.pr.merge") + + def test_merger_profile_cannot_resolve_formal_review_tasks(self): + merger = CANONICAL_ROLE_PROFILES["merger"] + for task in ("review_pr", "approve_pr", "request_changes_pr"): + with self.subTest(task=task): + ok, reason = gitea_config.check_operation( + required_permission(task), + merger["allowed"], + merger["forbidden"], + ) + self.assertFalse( + ok, + f"merger profile must not hold {task!r} permission " + f"(got reason {reason!r})", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_review_hard_stop.py b/tests/test_terminal_review_hard_stop.py index 07edd98..b2cb171 100644 --- a/tests/test_terminal_review_hard_stop.py +++ b/tests/test_terminal_review_hard_stop.py @@ -1,10 +1,10 @@ -"""Tests for the session hard-stop after a terminal review mutation (#332). +"""Tests for the session hard-stop after a terminal review mutation (#332/#693). Extends the single-terminal-decision machinery (#211): after a live -REQUEST_CHANGES the run must stop; after an APPROVE only the merge sequence -for that same PR may continue; a different PR can never be reviewed or -merged in the same run; duplicate REQUEST_CHANGES at an unchanged head is -suppressed. +REQUEST_CHANGES on a PR head the same-head path must stop; after an APPROVE +only the merge sequence for that same PR may continue; #693 allows a +*different* PR to be mark_ready/review in the same durable lock (cross-PR +isolation); duplicate REQUEST_CHANGES at an unchanged head is suppressed. """ import os import unittest @@ -15,7 +15,10 @@ import mcp_server HEAD_SHA = "a" * 40 -def _lock(mutations=None, correction=False): +def _lock(mutations=None, correction=False, correction_pr=None): + mutations = list(mutations or []) + if correction and correction_pr is None and mutations: + correction_pr = mutations[-1].get("pr_number") return { "task": "review_pr", "remote": "prgs", @@ -29,9 +32,11 @@ def _lock(mutations=None, correction=False): "ready_remote": None, "ready_org": None, "ready_repo": None, - "live_mutations": list(mutations or []), + "live_mutations": mutations, "correction_authorized": correction, - "correction_reason": None, + "correction_reason": "test" if correction else None, + "correction_pr_number": correction_pr if correction else None, + "correction_head_sha": None, } @@ -64,25 +69,35 @@ class TestTerminalHardStopReasons(unittest.TestCase): self.assertEqual( mcp_server.terminal_review_hard_stop_reasons(5, "merge"), []) - def test_request_changes_blocks_everything(self): + def test_request_changes_blocks_same_pr_allows_other_pr_review(self): _seed([RC_A]) + # Same PR: still hard-stopped for mark_ready/review/merge. for op in ("merge", "mark_ready", "review"): - for pr in (5, 6): - reasons = mcp_server.terminal_review_hard_stop_reasons(pr, op) - self.assertTrue(reasons, f"{op}/{pr}") - self.assertIn("request_changes on PR #5", reasons[0]) - self.assertIn("#332", reasons[0]) + reasons = mcp_server.terminal_review_hard_stop_reasons(5, op) + self.assertTrue(reasons, f"{op}/5") + self.assertIn("request_changes on PR #5", reasons[0]) + self.assertIn("#332", reasons[0]) + # #693: different PR may mark_ready / review (not merge via hard-stop alone). + self.assertEqual( + mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), []) + self.assertEqual( + mcp_server.terminal_review_hard_stop_reasons(6, "review"), []) + # Merge of an unapproved other PR remains blocked by hard-stop path + # (last terminal is not approve of PR 6). + self.assertTrue( + mcp_server.terminal_review_hard_stop_reasons(6, "merge")) - def test_approve_allows_same_pr_merge_only(self): + def test_approve_allows_same_pr_merge_and_other_pr_review(self): _seed([APPROVED_A]) self.assertEqual( mcp_server.terminal_review_hard_stop_reasons(5, "merge"), []) self.assertTrue( mcp_server.terminal_review_hard_stop_reasons(6, "merge")) - self.assertTrue( - mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready")) - self.assertTrue( - mcp_server.terminal_review_hard_stop_reasons(6, "review")) + # #693 cross-PR isolation: other PR formal review path is open. + self.assertEqual( + mcp_server.terminal_review_hard_stop_reasons(6, "mark_ready"), []) + self.assertEqual( + mcp_server.terminal_review_hard_stop_reasons(6, "review"), []) def test_correction_reopens_review_path_only(self): _seed([RC_A], correction=True) @@ -90,9 +105,11 @@ class TestTerminalHardStopReasons(unittest.TestCase): mcp_server.terminal_review_hard_stop_reasons(5, "mark_ready"), []) self.assertEqual( mcp_server.terminal_review_hard_stop_reasons(5, "review"), []) - # correction never opens a cross-PR merge + # scoped correction never opens a cross-PR merge self.assertTrue( mcp_server.terminal_review_hard_stop_reasons(6, "merge")) + # unscoped/cross-PR correction must not lift PR 6 via correction alone + # (PR 6 is allowed by isolation, not by correction scope) class TestMergeHardStopWiring(unittest.TestCase): @@ -126,14 +143,29 @@ class TestMarkFinalHardStopWiring(unittest.TestCase): mcp_server._save_review_decision_lock(None) mcp_server.review_workflow_load.clear_review_workflow_load() - def test_mark_ready_blocked_after_terminal_mutation(self): + def test_mark_ready_same_pr_blocked_after_terminal_mutation(self): _seed([RC_A]) result = mcp_server.gitea_mark_final_review_decision( - pr_number=6, action="approve", remote="prgs") + pr_number=5, action="approve", remote="prgs", + expected_head_sha=HEAD_SHA) self.assertFalse(result["marked_ready"]) self.assertTrue( any("#332" in r for r in result["reasons"]), result["reasons"]) + def test_mark_ready_other_pr_allowed_after_foreign_terminal(self): + """#693: foreign open-PR terminal must not block mark_final on PR B.""" + _seed([RC_A]) + no_lease = {"block": False, "reasons": [], "mutation_allowed": True} + with patch("mcp_server._list_pr_lease_comments", return_value=[]), \ + patch("mcp_server._pr_work_lease_reviewer_block", + return_value=no_lease), \ + patch.object(mcp_server, "gitea_check_pr_eligibility", + return_value={"head_sha": HEAD_SHA, "eligible": True}): + result = mcp_server.gitea_mark_final_review_decision( + pr_number=6, action="approve", remote="prgs", + expected_head_sha=HEAD_SHA) + self.assertTrue(result["marked_ready"], result.get("reasons")) + def _feedback(blocking, stale=False, success=True): return {