"""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 []), }