Files
Gitea-Tools/remote_repo_guard.py
T
sysadminandClaude Opus 4.8 fd2bc018ff fix: guard remote/repo mismatch vs local git remote (Closes #530)
Bare remote=prgs resolved to the hardcoded REMOTES default
Scaled-Tech-Consulting/Timesheet instead of the Gitea-Tools repository
the local git remote points at, causing false 404s and risking mutation
of a different repository.

Add remote_repo_guard.assess_remote_repo_match (pure) + formatter, wired
into gitea_mcp_server._resolve so every read/lookup/mutation tool fails
closed when the MCP-resolved org/repo disagrees with the local git remote
URL and the caller did not pass explicit org/repo. The guard is
best-effort: it skips when both org and repo are explicit, when the local
remote URL is unavailable, and is bypassed under pytest unless
GITEA_FORCE_REMOTE_REPO_CHECK is set.

Add tests/test_remote_repo_guard.py (pure-function cases + server wiring
proving a lookup tool cannot silently query a different repository), and
update author/reviewer/merger handoff templates to require explicit
remote/org/repo.

Closes #530

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 15:49:31 -04:00

99 lines
3.5 KiB
Python

"""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,
}