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
+83 -1
View File
@@ -194,6 +194,7 @@ MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
import namespace_workspace_binding as nwb # noqa: E402
import canonical_repository_root as crr # noqa: E402 # #706 cross-repo canonical root
import mcp_namespace_health # noqa: E402
import stale_binding_recovery # noqa: E402
@@ -376,6 +377,22 @@ def _assess_stale_active_binding(auto_recover: bool = False) -> dict:
return report
def _configured_canonical_root() -> tuple[str | None, str | None]:
"""Declared cross-repository canonical root for the active namespace (#706).
Returns ``(value, source)`` from the profile/env binding, or ``(None, None)``
for the single-repo default. The raw declared value is threaded into
workspace resolution so the branches-only / membership guards evaluate
against the configured target repository; the strict identity/existence
checks run separately in :func:`_enforce_canonical_repository_root`.
"""
try:
profile = get_profile()
except Exception:
profile = {}
return crr.configured_canonical_root(profile, os.environ)
def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
"""Resolve the namespace-scoped workspace root inspected by pre-flight guards.
@@ -399,8 +416,9 @@ def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str:
def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dict:
"""Canonical namespace workspace + repository root for guards (#460/#510)."""
"""Canonical namespace workspace + repository root for guards (#460/#510/#706)."""
role = _effective_workspace_role()
configured_root, _source = _configured_canonical_root()
return nwb.resolve_namespace_mutation_context(
role_kind=role,
worktree_path=worktree_path,
@@ -409,6 +427,7 @@ def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dic
_reviewer_session_worktree() if role in {"reviewer", "merger"} else None
),
profile_name=get_profile().get("profile_name"),
configured_canonical_root=configured_root,
)
@@ -721,6 +740,54 @@ def record_preflight_check(
_preflight_resolved_task = resolved_task
def _enforce_canonical_repository_root(
worktree_path: str | None = None,
*,
remote: str | None = None,
) -> None:
"""#706: validate the immutable cross-repository canonical root binding.
A no-op for the single-repo default (no configured binding). When a
namespace declares a ``canonical_repository_root`` (profile/env), the
configured target repository must exist, be a git repository, and carry a
repository identity matching the session's authorized repository. Missing,
conflicting, or forged bindings fail closed, and the validated root is
checked against the immutable session pin so it cannot be swapped mid
session.
"""
configured_value, source = _configured_canonical_root()
if not configured_value:
return
bound = session_ctx.get_session_context() or {}
expected_slug = session_ctx.format_repository_slug(
bound.get("org"), bound.get("repository")
)
assessment = crr.assess_canonical_repository_root(
configured_value=configured_value,
source=source,
expected_slug=expected_slug,
process_project_root=PROJECT_ROOT,
remote=remote,
require_binding=True,
)
if assessment["block"]:
raise RuntimeError(
crr.format_canonical_repository_root_error(assessment)
)
drift = session_ctx.assess_session_context(
profile_name=get_profile().get("profile_name"),
remote=remote,
canonical_repository_root=assessment["canonical_repo_root"],
)
if drift["block"]:
raise RuntimeError(
"Canonical repository root guard (#706): "
+ "; ".join(drift["reasons"])
)
def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None:
"""#274: author file/branch mutations must run from a branches/ worktree.
@@ -1069,6 +1136,7 @@ def verify_preflight_purity(
)
# Historical path: root + branches after purity-order when dirty paths live.
_enforce_canonical_repository_root(worktree_path, remote=remote)
_enforce_root_checkout_guard(worktree_path)
_enforce_branches_only_author_mutation(worktree_path)
_enforce_issue_scope_guard(
@@ -1102,6 +1170,7 @@ def verify_preflight_purity(
# #683: under pytest unit isolation, FORCE_PRODUCTION_GUARDS still runs
# production root + branches + issue scope (no silent no-op of guards).
if production_active:
_enforce_canonical_repository_root(worktree_path, remote=remote)
_enforce_root_checkout_guard(worktree_path)
_enforce_branches_only_author_mutation(worktree_path)
_enforce_issue_scope_guard(
@@ -1598,6 +1667,18 @@ def _seed_session_context(
"""
expected = (profile.get("username") or "").strip() or None
trusted = _trusted_session_repository(profile, remote)
# #706: pin the configured cross-repository canonical root immutably so a
# later call cannot forge/swap it. Store the resolved toplevel so drift
# checks compare against the same value the mutation gate validates.
configured_value, _crr_source = crr.configured_canonical_root(
profile, os.environ
)
canonical_root_pin = None
if configured_value:
canonical_root_pin = (
crr.resolve_repo_toplevel(configured_value)
or os.path.realpath(configured_value)
)
return session_ctx.seed_session_context_if_unbound(
profile_name=profile.get("profile_name") or "",
remote=remote,
@@ -1608,6 +1689,7 @@ def _seed_session_context(
role_kind=_profile_role_kind(profile),
expected_username=expected,
source=source,
canonical_repository_root=canonical_root_pin,
)
import issue_work_duplicate_gate # noqa: E402
import issue_workflow_labels # noqa: E402