"""Immutable canonical repository root for cross-repository namespaces (#706). The Gitea-Tools MCP server historically derived the ``canonical_repo_root`` from the *install checkout* the server script lives in (``PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))``). A namespace that runs the same server script against an *external* repository (e.g. ``eagenda-author`` targeting ``eAgenda``) then failed every mutation: the branches-only / worktree-membership guards (#274) compared the task workspace against the Gitea-Tools ``.git`` directory, which it can never belong to. This module separates two distinct concepts: * the immutable code/install root (``PROJECT_ROOT``) — where the server lives, and * the namespace-scoped **canonical repository root** — the working root of the repository whose issues/PRs the namespace mutates. The canonical repository root is configured per namespace (profile field or an environment variable, typically set alongside the namespace ``cwd`` in the MCP config). It is validated (existence, git identity, git common-directory membership) and pinned immutably into the session context so a later call cannot forge or swap it. When *no* binding is configured the single-repo default is preserved unchanged: the canonical root is derived from the process checkout. """ from __future__ import annotations import os import subprocess from typing import Mapping import remote_repo_guard # Namespace-scoped override, typically exported next to the server ``cwd`` in the # MCP config for a cross-repository namespace. CANONICAL_ROOT_ENV = "GITEA_CANONICAL_REPOSITORY_ROOT" # Candidate git remote names probed when deriving repository identity. _IDENTITY_REMOTE_CANDIDATES = ("prgs", "origin", "dadeschools", "mdcps") def configured_canonical_root( profile: Mapping | None, env: Mapping | None, ) -> tuple[str | None, str | None]: """Return ``(value, source)`` for the declared canonical repository root. Precedence: the ``GITEA_CANONICAL_REPOSITORY_ROOT`` environment variable (namespace-scoped) overrides the profile ``canonical_repository_root`` field. Blank values are treated as unset. Returns ``(None, None)`` when no binding is declared (the single-repo default). """ env_map = env if env is not None else os.environ env_val = (env_map.get(CANONICAL_ROOT_ENV) or "").strip() if env_val: return env_val, f"{CANONICAL_ROOT_ENV} environment variable" if profile: prof_val = (profile.get("canonical_repository_root") or "").strip() if prof_val: return prof_val, "profile canonical_repository_root" return None, None def resolve_repo_toplevel(path: str) -> str | None: """Realpath of the git working-tree top level for *path*, or None.""" text = (path or "").strip() if not text: return None try: res = subprocess.run( ["git", "-C", text, "rev-parse", "--show-toplevel"], capture_output=True, text=True, check=True, ) except Exception: return None top = (res.stdout or "").strip() return os.path.realpath(top) if top else None def repository_identity_slug(path: str, *, remote: str | None = None) -> str | None: """``owner/repository`` derived from a git remote configured at *path*. Tries the caller-named remote first, then a small set of known remote names, then whatever remote the repository actually has. Returns None when no remote URL is parseable (identity cannot be proven). """ text = (path or "").strip() if not text: return None ordered: list[str] = [] for name in (remote, *_IDENTITY_REMOTE_CANDIDATES): clean = (name or "").strip() if clean and clean not in ordered: ordered.append(clean) try: listed = subprocess.run( ["git", "-C", text, "remote"], capture_output=True, text=True, check=True, ).stdout.split() except Exception: listed = [] for name in listed: if name and name not in ordered: ordered.append(name) for name in ordered: try: url = subprocess.run( ["git", "-C", text, "remote", "get-url", name], capture_output=True, text=True, check=True, ).stdout.strip() except Exception: continue parsed = remote_repo_guard.parse_org_repo_from_remote_url(url) if parsed: return f"{parsed[0]}/{parsed[1]}" return None def assess_canonical_repository_root( *, configured_value: str | None, source: str | None, expected_slug: str | None, process_project_root: str, remote: str | None = None, require_binding: bool = False, ) -> dict: """Validate the canonical repository root binding, failing closed on forgery. Returns a dict with ``proven`` / ``block`` / ``reasons`` plus the resolved ``canonical_repo_root`` (the value downstream guards must use), ``configured`` (whether a cross-repo binding was declared), ``resolved_slug`` and ``source``. Without a configured binding the single-repo default is preserved: the canonical root is derived from *process_project_root* and never blocks (unless *require_binding* explicitly demands one). With a configured binding the path must exist, be a git repository, and — when *expected_slug* is known — carry a matching repository identity. A mismatched or (when *require_binding*) unprovable identity is a forged or conflicting binding and fails closed. """ process_root = os.path.realpath(process_project_root) declared = (configured_value or "").strip() if not declared: if require_binding: return _assessment( proven=False, reasons=[ "no canonical_repository_root configured for a cross-repository " f"namespace; set {CANONICAL_ROOT_ENV} or the profile " "canonical_repository_root field (fail closed)" ], configured=False, canonical_repo_root=process_root, resolved_slug=None, source=None, ) # Single-repo default: canonical root follows the install checkout. derived = resolve_repo_toplevel(process_root) or process_root return _assessment( proven=True, reasons=[], configured=False, canonical_repo_root=derived, resolved_slug=None, source=None, ) real = os.path.realpath(os.path.abspath(declared)) if not os.path.isdir(real): return _assessment( proven=False, reasons=[ f"configured canonical repository root '{real}' does not exist " "or is not a directory (fail closed)" ], configured=True, canonical_repo_root=real, resolved_slug=None, source=source, ) toplevel = resolve_repo_toplevel(real) if not toplevel: return _assessment( proven=False, reasons=[ f"configured canonical repository root '{real}' is not a git " "repository (fail closed)" ], configured=True, canonical_repo_root=real, resolved_slug=None, source=source, ) resolved_slug = repository_identity_slug(toplevel, remote=remote) reasons: list[str] = [] expected = (expected_slug or "").strip() or None if expected: if resolved_slug and resolved_slug.lower() != expected.lower(): reasons.append( f"canonical repository root identity mismatch: '{toplevel}' resolves " f"to repository '{resolved_slug}' but the session is authorized for " f"'{expected}' (forged or conflicting binding, fail closed)" ) elif not resolved_slug and require_binding: reasons.append( f"canonical repository root '{toplevel}' has no resolvable git " f"remote identity to confirm authorization for '{expected}' " "(fail closed)" ) return _assessment( proven=not reasons, reasons=reasons, configured=True, canonical_repo_root=toplevel, resolved_slug=resolved_slug, source=source, ) def format_canonical_repository_root_error(assessment: Mapping) -> str: """Single RuntimeError message for MCP preflight gates.""" root = assessment.get("canonical_repo_root") or "(unknown)" source = assessment.get("source") or "(unconfigured)" reasons = "; ".join( assessment.get("reasons") or ["unknown canonical repository root violation"] ) return ( f"Canonical repository root guard (#706): {reasons}. " f"binding source: {source}; canonical repository root: {root}. " "Configure a valid canonical_repository_root for the target repository " "and relaunch; do not point it at the Gitea-Tools install checkout." ) def _assessment( *, proven: bool, reasons: list[str], configured: bool, canonical_repo_root: str, resolved_slug: str | None, source: str | None, ) -> dict: return { "proven": proven, "block": not proven, "reasons": list(reasons), "configured": configured, "canonical_repo_root": canonical_repo_root, "resolved_slug": resolved_slug, "source": source, }