"""Branches-only author mutation worktree guard (#274) with durable resolution (#618). Author/coder mutations must run from a session-owned worktree under the project's ``branches/`` directory, never from the stable control checkout. #618 durable resolution: - Prefer an explicit validated ``worktree_path`` argument. - Else derive the workspace from the active author issue lock's worktree. - Env bindings (``GITEA_ACTIVE_WORKTREE`` / ``GITEA_AUTHOR_WORKTREE``) may bind when present and valid. - Author mutations never silently fall back to the control checkout or master. - Missing configured bindings fail closed with a clear operator recovery action. """ from __future__ import annotations import os import subprocess BASE_BRANCHES = frozenset({"master", "main", "dev"}) ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE" AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE" # Author-only: reviewer/merger/reconciler namespaces use role-specific env vars # via namespace_workspace_binding (#510). BOUND_WORKTREE_MISSING = "bound_worktree_missing" BOUND_WORKTREE_MISSING_MESSAGE = ( "bound worktree missing; operator must recreate or repoint the worktree " "and reconnect" ) OPERATOR_RECOVERY_RECREATE_REPOINT = ( "Recreate the worktree under branches/ (scripts/worktree-start or " "git worktree add), set GITEA_AUTHOR_WORKTREE / GITEA_ACTIVE_WORKTREE " "to that path (or pass worktree_path on mutation tools), keep the control " "checkout clean on master, then reconnect the author MCP session and re-run." ) def _normalize_path(path: str) -> str: return (path or "").replace("\\", "/").rstrip("/") def is_path_under_branches(path: str, project_root: str | None = None) -> bool: """True when *path* resolves inside ``/branches/``.""" normalized = _normalize_path(path) if not normalized: return False if "/branches/" in f"{normalized}/": return True if normalized.endswith("/branches"): return True if project_root: root = _normalize_path(os.path.realpath(project_root)) real = _normalize_path(os.path.realpath(path)) if real.startswith(f"{root}/"): rel = real[len(root) + 1 :] return rel == "branches" or rel.startswith("branches/") return False def resolve_mutation_workspace( worktree_path: str | None, project_root: str, *, active_worktree_env: str | None = None, author_worktree_env: str | None = None, ) -> str: """Resolve the workspace path inspected before author mutations. Legacy helper: returns the first non-empty candidate path. Prefer :func:`resolve_durable_author_worktree` for mutation guards (#618). """ for candidate in (worktree_path, active_worktree_env, author_worktree_env): text = (candidate or "").strip() if text: return os.path.realpath(os.path.abspath(text)) return os.path.realpath(project_root) def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str: """Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*.""" raw = (common_dir or "").strip() if not raw: return raw if os.path.isabs(raw): return os.path.realpath(raw) return os.path.realpath(os.path.join(workspace_path, raw)) def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str: """Return the stable repository root for *workspace_path* via git metadata (#460).""" path = (workspace_path or "").strip() fallback = os.path.realpath(fallback_project_root) if not path: return fallback try: res = subprocess.run( ["git", "-C", path, "rev-parse", "--git-common-dir"], capture_output=True, text=True, check=True, ) common = _realpath_git_common_dir(path, res.stdout) except Exception: return fallback if common.endswith(f"{os.sep}.git"): return os.path.dirname(common) if os.path.basename(common) == ".git": return os.path.dirname(common) return fallback def resolve_author_mutation_context( worktree_path: str | None, process_project_root: str, *, active_worktree_env: str | None = None, author_worktree_env: str | None = None, ) -> dict: """Shared workspace resolution for runtime_context and mutation guards (#460).""" workspace = resolve_mutation_workspace( worktree_path, process_project_root, active_worktree_env=active_worktree_env, author_worktree_env=author_worktree_env, ) process_root = os.path.realpath(process_project_root) # Canonical repository identity comes from the MCP process checkout (#460), # not from the declared task workspace being validated. canonical_root = resolve_canonical_repo_root(process_root, process_root) return { "workspace_path": workspace, "process_project_root": process_root, "canonical_repo_root": canonical_root, "roots_aligned": canonical_root == process_root, } def assess_workspace_repo_membership( *, workspace_path: str, canonical_repo_root: str, ) -> dict: """Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*.""" workspace = os.path.realpath(workspace_path) root = os.path.realpath(canonical_repo_root) reasons: list[str] = [] if not os.path.exists(workspace): reasons.append(f"worktree path '{workspace}' does not exist") return _membership_assessment(False, reasons, workspace, root, None) if not os.path.isdir(workspace): reasons.append(f"worktree path '{workspace}' is not a directory") return _membership_assessment(False, reasons, workspace, root, None) try: res = subprocess.run( ["git", "-C", workspace, "rev-parse", "--git-common-dir"], capture_output=True, text=True, check=True, ) common_dir = _realpath_git_common_dir(workspace, res.stdout) except Exception: reasons.append(f"worktree '{workspace}' is not a valid git repository") return _membership_assessment(False, reasons, workspace, root, None) expected_dir = os.path.realpath(os.path.join(root, ".git")) if common_dir != expected_dir: reasons.append( f"worktree '{workspace}' does not belong to the target repository '{root}'" ) return _membership_assessment(not reasons, reasons, workspace, root, common_dir) def _membership_assessment( proven: bool, reasons: list[str], workspace: str, root: str, common_dir: str | None, ) -> dict: return { "proven": proven, "block": not proven, "reasons": reasons, "workspace_path": workspace, "canonical_repo_root": root, "git_common_dir": common_dir, } def format_workspace_repo_membership_error(assessment: dict) -> str: workspace = assessment.get("workspace_path") or "(unknown)" root = assessment.get("canonical_repo_root") or "(unknown)" reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"]) return ( f"Branches-only mutation guard (#274): {reasons} (fail closed). " f"canonical repository root: {root}; workspace: {workspace}." ) def assess_author_mutation_worktree( *, workspace_path: str, project_root: str, current_branch: str | None = None, base_branches: frozenset[str] | None = None, ) -> dict: """Fail closed when author mutations are not rooted under ``branches/``.""" bases = base_branches or BASE_BRANCHES reasons: list[str] = [] root = os.path.realpath(project_root) workspace = os.path.realpath(workspace_path) branch = (current_branch or "").strip() under_branches = is_path_under_branches(workspace, root) if not under_branches: if workspace == root: reasons.append( "author mutation blocked: workspace is the stable control checkout; " "create or switch to a session-owned worktree under branches/" ) else: reasons.append( f"author mutation blocked: workspace '{workspace}' is not under " f"'{root}/branches/'; create a branches/ worktree first" ) if not under_branches and workspace == root and branch and branch not in bases: reasons.append( f"control checkout drift: branch '{branch}' is not a stable base " f"branch ({'/'.join(sorted(bases))})" ) proven = not reasons return { "proven": proven, "block": not proven, "reasons": reasons, "project_root": root, "workspace_path": workspace, "under_branches": is_path_under_branches(workspace, root), "current_branch": branch or None, } def format_author_mutation_worktree_error(assessment: dict) -> str: """Single RuntimeError message for MCP preflight gates.""" workspace = assessment.get("workspace_path") or "(unknown)" root = assessment.get("project_root") or "(unknown)" reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"]) return ( f"Branches-only mutation guard (#274): {reasons}. " f"project root: {root}; workspace: {workspace}. " "Create a session-owned worktree under branches/ before mutating." ) # --------------------------------------------------------------------------- # #618 durable author worktree resolution # --------------------------------------------------------------------------- def _abs_real(path: str) -> str: return os.path.realpath(os.path.abspath((path or "").strip())) def assess_path_traversal_safety( *, path: str, canonical_repo_root: str, ) -> dict: """Fail closed on traversal/symlink escapes outside the target repository. Uses ``realpath`` so intermediate symlinks cannot walk outside ``canonical_repo_root``. Author mutation workspaces must also land under ``branches/`` of that root (enforced separately by the branches-only guard). """ reasons: list[str] = [] raw = (path or "").strip() if not raw: return { "proven": False, "block": True, "reasons": ["worktree path is empty (fail closed)"], "workspace_path": None, "canonical_repo_root": os.path.realpath(canonical_repo_root), } if "\x00" in raw: return { "proven": False, "block": True, "reasons": ["worktree path contains a null byte (fail closed)"], "workspace_path": raw, "canonical_repo_root": os.path.realpath(canonical_repo_root), } root = os.path.realpath(canonical_repo_root) # Resolve without requiring existence first: abspath then realpath of parents. abs_path = os.path.abspath(raw) try: real = os.path.realpath(abs_path) except OSError as exc: return { "proven": False, "block": True, "reasons": [f"worktree path could not be resolved safely: {exc}"], "workspace_path": abs_path, "canonical_repo_root": root, } root_norm = _normalize_path(root) real_norm = _normalize_path(real) if real_norm != root_norm and not real_norm.startswith(f"{root_norm}/"): reasons.append( f"worktree path '{real}' escapes canonical repository root '{root}' " "(traversal/symlink safety, fail closed)" ) return { "proven": not reasons, "block": bool(reasons), "reasons": reasons, "workspace_path": real, "canonical_repo_root": root, } def list_git_worktree_paths(canonical_repo_root: str) -> list[str]: """Return realpaths registered in ``git worktree list --porcelain``.""" root = os.path.realpath(canonical_repo_root) try: res = subprocess.run( ["git", "-C", root, "worktree", "list", "--porcelain"], capture_output=True, text=True, check=False, ) except Exception: return [] if res.returncode != 0: return [] paths: list[str] = [] for line in (res.stdout or "").splitlines(): if line.startswith("worktree "): raw = line[len("worktree ") :].strip() if raw: paths.append(os.path.realpath(raw)) return paths def path_in_git_worktree_list(path: str, canonical_repo_root: str) -> bool | None: """True/False when inventory is available; None when git inventory fails. An empty inventory with a working git root is treated as inconclusive (``None``) so unit tests and partial sandboxes are not false-negative blocked when ``git worktree list`` is mocked/unavailable. """ root = os.path.realpath(canonical_repo_root) try: res = subprocess.run( ["git", "-C", root, "worktree", "list", "--porcelain"], capture_output=True, text=True, check=False, ) except Exception: return None if res.returncode != 0: return None inventory: list[str] = [] for line in (res.stdout or "").splitlines(): if line.startswith("worktree "): raw = line[len("worktree ") :].strip() if raw: inventory.append(os.path.realpath(raw)) if not inventory: return None return os.path.realpath(path) in inventory def assess_bound_worktree_existence( *, configured_path: str, binding_source: str, canonical_repo_root: str | None = None, role_kind: str = "author", profile_name: str | None = None, ) -> dict: """Fail closed when a configured role-bound worktree path is missing (#618).""" raw = (configured_path or "").strip() if not raw: return { "proven": True, "block": False, "bound_worktree_missing": False, "path_exists": None, "in_git_worktree_list": None, "inspected_git_root": None, "reasons": [], "configured_path": None, "binding_source": binding_source, "role_kind": role_kind, "profile_name": profile_name, "blocker_kind": None, "operator_recovery": None, } try: real = _abs_real(raw) except OSError: real = os.path.abspath(raw) path_exists = os.path.isdir(real) in_list: bool | None = None inspected_git_root: str | None = None root = (canonical_repo_root or "").strip() if root: in_list = path_in_git_worktree_list(real, root) if path_exists else False if path_exists: try: res = subprocess.run( ["git", "-C", real, "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False, ) if res.returncode == 0: inspected_git_root = (res.stdout or "").strip() or None except Exception: inspected_git_root = None if path_exists: return { "proven": True, "block": False, "bound_worktree_missing": False, "path_exists": True, "in_git_worktree_list": in_list, "inspected_git_root": inspected_git_root, "reasons": [], "configured_path": real, "binding_source": binding_source, "role_kind": role_kind, "profile_name": profile_name, "blocker_kind": None, "operator_recovery": None, } reasons = [ BOUND_WORKTREE_MISSING_MESSAGE, ( f"role/profile '{profile_name or role_kind}' binding via {binding_source} " f"points to '{real}' which does not exist on disk" ), f"path_exists=false; in_git_worktree_list={in_list}; inspected_git_root=null", ] return { "proven": False, "block": True, "bound_worktree_missing": True, "path_exists": False, "in_git_worktree_list": False if in_list is not None else False, "inspected_git_root": None, "reasons": reasons, "configured_path": real, "binding_source": binding_source, "role_kind": role_kind, "profile_name": profile_name, "blocker_kind": BOUND_WORKTREE_MISSING, "operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT, } def format_bound_worktree_missing_error(assessment: dict) -> str: """Canonical operator-facing message for a missing author worktree binding.""" reasons = list(assessment.get("reasons") or [BOUND_WORKTREE_MISSING_MESSAGE]) recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT profile = assessment.get("profile_name") or assessment.get("role_kind") or "author" source = ( assessment.get("binding_source") or assessment.get("workspace_binding_source") or "unknown binding" ) path = ( assessment.get("configured_path") or assessment.get("workspace_path") or "(unknown)" ) return ( f"Author worktree binding unhealthy (#618): {'; '.join(reasons)}. " f"role/profile: {profile}; binding_source: {source}; configured_path: {path}. " f"Operator recovery: {recovery}" ) def assess_lock_worktree_ownership( *, workspace_path: str, session_lock_worktree: str | None, ) -> dict: """When a live lock records a worktree, mutation workspace must match it.""" locked = (session_lock_worktree or "").strip() if not locked: return { "proven": True, "block": False, "reasons": [], "workspace_path": os.path.realpath(workspace_path) if workspace_path else None, "lock_worktree_path": None, } workspace = os.path.realpath(workspace_path) locked_real = os.path.realpath(locked) if workspace != locked_real: return { "proven": False, "block": True, "reasons": [ f"active author issue lock worktree '{locked_real}' does not match " f"mutation workspace '{workspace}' (lock ownership, fail closed)" ], "workspace_path": workspace, "lock_worktree_path": locked_real, } return { "proven": True, "block": False, "reasons": [], "workspace_path": workspace, "lock_worktree_path": locked_real, } def resolve_durable_author_worktree( *, worktree_path: str | None = None, worktree: str | None = None, process_project_root: str, active_worktree_env: str | None = None, author_worktree_env: str | None = None, session_lock_worktree: str | None = None, canonical_repo_root: str | None = None, profile_name: str | None = None, validate: bool = True, ) -> dict: """Resolve author mutation workspace without silent control-checkout fallback (#618). Candidate priority: 1. explicit ``worktree_path`` argument 2. ``worktree`` argument 3. ``GITEA_ACTIVE_WORKTREE`` 4. ``GITEA_AUTHOR_WORKTREE`` 5. active author issue lock ``worktree_path`` 6. process project root **only** when it is already under ``branches/`` Configured bindings that point at a missing path fail closed immediately (no demotion to the control checkout). Validation (when *validate*) covers existence, traversal/symlink safety, repository identity, branches/ containment, and lock ownership. """ process_root = os.path.realpath(process_project_root) canonical = os.path.realpath(canonical_repo_root or process_root) reasons: list[str] = [] role = "author" candidates: list[tuple[str | None, str, bool]] = [ (worktree_path, "worktree_path argument", False), (worktree, "worktree argument", False), (active_worktree_env, f"{ACTIVE_WORKTREE_ENV} environment variable", True), (author_worktree_env, f"{AUTHOR_WORKTREE_ENV} environment variable", True), (session_lock_worktree, "active author issue lock worktree", False), ] selected_path: str | None = None selected_source: str | None = None existence: dict | None = None for candidate, source, _configured in candidates: text = (candidate or "").strip() if not text: continue try: real = _abs_real(text) except OSError: real = os.path.abspath(text) existence = assess_bound_worktree_existence( configured_path=real, binding_source=source, canonical_repo_root=canonical, role_kind=role, profile_name=profile_name, ) if existence["block"]: # Missing configured binding: fail closed, never fall back (#618). return { "proven": False, "block": True, "workspace_path": real, "workspace_binding_source": source, "process_project_root": process_root, "canonical_repo_root": canonical, "bound_worktree_missing": True, "path_exists": False, "in_git_worktree_list": existence.get("in_git_worktree_list"), "inspected_git_root": None, "reasons": list(existence.get("reasons") or []), "blocker_kind": BOUND_WORKTREE_MISSING, "operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT, "silent_control_fallback": False, } selected_path = real selected_source = source break if selected_path is None: # No explicit/env/lock binding. Allow process root only when it is a # branches/ worktree (MCP launched from the task worktree). Never # silently bind the stable control checkout. if is_path_under_branches(process_root, canonical): selected_path = process_root selected_source = "MCP process root under branches/ (session-owned)" else: return { "proven": False, "block": True, "workspace_path": process_root, "workspace_binding_source": "no author worktree binding", "process_project_root": process_root, "canonical_repo_root": canonical, "bound_worktree_missing": False, "path_exists": os.path.isdir(process_root), "in_git_worktree_list": None, "inspected_git_root": None, "reasons": [ "author mutation blocked: workspace is the stable control checkout; " "author mutation requires an explicit validated worktree_path " "or a worktree derived from the active author issue lock; " "silent fallback to the control checkout/master is forbidden (#618)" ], "blocker_kind": "author_worktree_unbound_control_checkout", "operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT, "silent_control_fallback": False, } workspace = selected_path source = selected_source or "unknown" path_exists = os.path.isdir(workspace) inspected_git_root: str | None = None in_list: bool | None = None if not validate: return { "proven": True, "block": False, "workspace_path": workspace, "workspace_binding_source": source, "process_project_root": process_root, "canonical_repo_root": canonical, "bound_worktree_missing": False, "path_exists": path_exists, "in_git_worktree_list": None, "inspected_git_root": None, "reasons": [], "blocker_kind": None, "operator_recovery": None, "silent_control_fallback": False, } # Traversal / symlink safety safety = assess_path_traversal_safety( path=workspace, canonical_repo_root=canonical ) if safety["block"]: reasons.extend(safety["reasons"]) else: workspace = safety["workspace_path"] or workspace # Existence + git inventory if not path_exists: reasons.append(BOUND_WORKTREE_MISSING_MESSAGE) reasons.append(f"resolved worktree '{workspace}' does not exist") else: in_list = path_in_git_worktree_list(workspace, canonical) try: res = subprocess.run( ["git", "-C", workspace, "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=False, ) if res.returncode == 0: inspected_git_root = (res.stdout or "").strip() or None except Exception: inspected_git_root = None if in_list is False: # Only hard-fail when inventory was obtained and the path is absent. reasons.append( f"worktree '{workspace}' is not listed in git worktree list for " f"'{canonical}' (fail closed)" ) # Repository identity if path_exists: membership = assess_workspace_repo_membership( workspace_path=workspace, canonical_repo_root=canonical, ) if membership["block"]: reasons.extend(membership["reasons"]) # branches/ containment branches = assess_author_mutation_worktree( workspace_path=workspace, project_root=canonical, ) if branches["block"]: reasons.extend(branches["reasons"]) # Lock ownership (when a lock worktree is recorded) lock_own = assess_lock_worktree_ownership( workspace_path=workspace, session_lock_worktree=session_lock_worktree, ) if lock_own["block"]: reasons.extend(lock_own["reasons"]) # Forbid resolved control checkout even if somehow selected if workspace == canonical or workspace == process_root: if not is_path_under_branches(workspace, canonical): if not any("control checkout" in r for r in reasons): reasons.append( "author mutation blocked: resolved workspace is the stable " "control checkout; silent fallback forbidden (#618)" ) block = bool(reasons) bound_missing = any("does not exist" in r or BOUND_WORKTREE_MISSING_MESSAGE in r for r in reasons) return { "proven": not block, "block": block, "workspace_path": workspace, "workspace_binding_source": source, "process_project_root": process_root, "canonical_repo_root": canonical, "bound_worktree_missing": bound_missing, "path_exists": path_exists, "in_git_worktree_list": in_list, "inspected_git_root": inspected_git_root, "reasons": reasons, "blocker_kind": BOUND_WORKTREE_MISSING if bound_missing else ( "author_worktree_validation_failed" if block else None ), "operator_recovery": OPERATOR_RECOVERY_RECREATE_REPOINT if block else None, "silent_control_fallback": False, } def format_durable_author_worktree_error(assessment: dict) -> str: """Format fail-closed error for durable author worktree resolution.""" if assessment.get("bound_worktree_missing") or assessment.get("blocker_kind") == BOUND_WORKTREE_MISSING: return format_bound_worktree_missing_error(assessment) workspace = assessment.get("workspace_path") or "(unknown)" source = assessment.get("workspace_binding_source") or "unknown" reasons = "; ".join( assessment.get("reasons") or ["author worktree resolution failed"] ) recovery = assessment.get("operator_recovery") or OPERATOR_RECOVERY_RECREATE_REPOINT return ( f"Durable author worktree resolution blocked (#618): {reasons}. " f"workspace: {workspace}; binding_source: {source}. " f"Operator recovery: {recovery}" )