"""Remote/repo mismatch guard (#530). Bare ``remote`` names resolve to a default ``org``/``repo`` via the ``REMOTES`` table in :mod:`gitea_auth`. For the ``prgs`` instance the hardcoded default repo is ``Timesheet``, but the tools in this project operate on ``Scaled-Tech-Consulting/Gitea-Tools``. When a session runs inside a Gitea-Tools worktree and calls a tool with a bare ``remote=prgs`` (no explicit ``org``/``repo``), the resolved target silently points at the wrong repository, producing false 404s and risking mutation of a different repo. This module provides a pure assessment that compares the MCP-resolved ``org/repo`` against the local git remote URL and fails closed on a genuine mismatch, unless the caller supplied explicit ``org``/``repo`` (in which case their intent is authoritative) or the local remote URL is unavailable (best-effort corroboration only). """ from __future__ import annotations REMEDIATION = ( "Pass explicit org= and repo= matching the local git remote, " "e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools." ) def assess_remote_repo_match( *, remote: str, resolved_org: str, resolved_repo: str, local_remote_url: str | None, org_explicit: bool, repo_explicit: bool, ) -> dict: """Fail closed when the resolved org/repo disagrees with the local git remote. The guard is intentionally conservative: * When the caller passed both ``org`` and ``repo`` explicitly, their intent is authoritative and the guard never blocks. * When the local git remote URL is unavailable (``None``/empty), corroboration is impossible, so the guard does not block (best-effort only). * Otherwise, the resolved ``org/repo`` slug must appear in the local remote URL (case-insensitive); if it does not, the guard blocks. """ reasons: list[str] = [] if org_explicit and repo_explicit: return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) url = (local_remote_url or "").strip() if not url: return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) expected_slug = f"{resolved_org}/{resolved_repo}".lower() if expected_slug in url.lower(): return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) reasons.append( f"MCP-resolved repository '{resolved_org}/{resolved_repo}' for remote " f"'{remote}' does not match the local git remote URL '{url}'" ) return _assessment(False, reasons, remote, resolved_org, resolved_repo, local_remote_url) def format_remote_repo_guard_error(assessment: dict) -> str: """Single RuntimeError message for the MCP resolver gate.""" reasons = "; ".join( assessment.get("reasons") or ["remote/repo resolution mismatch"] ) resolved = ( f"{assessment.get('resolved_org')}/{assessment.get('resolved_repo')}" ) local = assessment.get("local_remote_url") or "(unknown)" return ( f"Remote/repo guard (#530): {reasons}. " f"Resolved target: {resolved}; local git remote: {local}. " f"{REMEDIATION}" ) def _assessment( proven: bool, reasons: list[str], remote: str, resolved_org: str, resolved_repo: str, local_remote_url: str | None, ) -> dict: return { "proven": proven, "block": not proven, "reasons": reasons, "remote": remote, "resolved_org": resolved_org, "resolved_repo": resolved_repo, "local_remote_url": local_remote_url, "remediation": REMEDIATION, }