feat(mcp): immutable canonical_repository_root for cross-repo namespaces (Closes #706)

The MCP server derived the canonical repository root from the install
checkout the server script lives in (PROJECT_ROOT), so any namespace that
ran the server against an external repository (e.g. eagenda-author, or the
mcp-control-plane reviewer/merger namespaces) failed every mutation: the
branches-only / worktree-membership guards (#274) compared the task
workspace against the Gitea-Tools .git directory it can never belong to.

Separate the two concepts:
- PROJECT_ROOT stays the immutable code/install root.
- A new, optional, namespace-scoped canonical_repository_root binds the
  session to the working root of the target repository.

Implementation:
- canonical_repository_root.py: resolve the binding from profile/env
  (GITEA_CANONICAL_REPOSITORY_ROOT overrides the profile field), validate
  existence + git identity, fail closed on missing/conflicting/forged
  bindings. Preserves the single-repo default when unconfigured.
- session_context_binding.py: pin canonical_repository_root into the
  immutable #714 session context; drift fails closed.
- namespace_workspace_binding.py: route the configured target root through
  resolve_namespace_mutation_context so #274 / membership guards evaluate
  against the target repository.
- gitea_mcp_server.py: seed + enforce the binding in the mutation preflight
  (both purity-order and FORCE_PRODUCTION_GUARDS paths); validate repository
  identity and git common-directory membership.
- gitea_config.py: validate the optional absolute-path profile field.
- gitea_auth.py: surface the config-sourced field through get_profile.

No weakening of root-checkout, remote/repository, or anti-stomp guards; the
single-repo Gitea-Tools path is unchanged.

Tests: two distinct real git repositories/worktrees prove cross-repository
bindings resolve, enforce membership, and fail closed on forged/conflicting
identity and simultaneous prgs/mdcps isolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-17 23:31:39 -04:00
co-authored by Claude Opus 4.8
parent 11d1d2e99f
commit 0d8a2c2b1d
7 changed files with 839 additions and 3 deletions
+267
View File
@@ -0,0 +1,267 @@
"""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,
}