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
+18
View File
@@ -32,6 +32,7 @@ class _SessionContext:
expected_username: str | None
source: str
pid: int
canonical_repository_root: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
@@ -45,6 +46,7 @@ class _SessionContext:
"expected_username": self.expected_username,
"source": self.source,
"pid": self.pid,
"canonical_repository_root": self.canonical_repository_root,
}
@@ -143,6 +145,7 @@ def bind_session_context(
role_kind: str | None = None,
expected_username: str | None = None,
source: str = "bind",
canonical_repository_root: str | None = None,
) -> dict[str, Any]:
"""Atomically bind/re-bind context (the explicit activation path)."""
with _SESSION_CONTEXT_LOCK:
@@ -156,6 +159,7 @@ def bind_session_context(
role_kind=role_kind,
expected_username=expected_username,
source=source,
canonical_repository_root=canonical_repository_root,
)
@@ -170,6 +174,7 @@ def _bind_session_context_unlocked(
role_kind: str | None,
expected_username: str | None,
source: str,
canonical_repository_root: str | None = None,
) -> dict[str, Any]:
"""Store a complete immutable context while the caller holds the lock."""
global _SESSION_CONTEXT
@@ -184,6 +189,7 @@ def _bind_session_context_unlocked(
expected_username=(expected_username or "").strip() or None,
source=source,
pid=os.getpid(),
canonical_repository_root=(canonical_repository_root or "").strip() or None,
)
return _SESSION_CONTEXT.as_dict()
@@ -199,6 +205,7 @@ def seed_session_context_if_unbound(
role_kind: str | None = None,
expected_username: str | None = None,
source: str = "seed",
canonical_repository_root: str | None = None,
) -> dict[str, Any]:
"""Atomically bind only when this process has no current context.
@@ -219,6 +226,7 @@ def seed_session_context_if_unbound(
role_kind=role_kind,
expected_username=expected_username,
source=source,
canonical_repository_root=canonical_repository_root,
)
return _SESSION_CONTEXT.as_dict()
@@ -232,6 +240,7 @@ def assess_session_context(
repository: str | None = None,
org: str | None = None,
expected_username: str | None = None,
canonical_repository_root: str | None = None,
require_bound: bool = False,
require_complete: bool = False,
) -> dict[str, Any]:
@@ -272,6 +281,7 @@ def assess_session_context(
live_identity = (identity or "").strip() or None
live_repo = (repository or "").strip() or None
live_org = (org or "").strip() or None
live_canonical = (canonical_repository_root or "").strip() or None
if ctx.get("profile_name") and live_profile and live_profile != ctx.get("profile_name"):
reasons.append(
@@ -307,6 +317,13 @@ def assess_session_context(
f"org drift: live '{live_org}' != bound "
f"'{ctx.get('org')}' (fail closed)"
)
bound_canonical = ctx.get("canonical_repository_root")
if bound_canonical and live_canonical and live_canonical != bound_canonical:
reasons.append(
f"canonical repository root drift: live '{live_canonical}' != bound "
f"'{bound_canonical}' (forged or conflicting cross-repository "
"binding, fail closed)"
)
expected = expected_username or ctx.get("expected_username")
if expected and live_identity and live_identity != expected:
@@ -580,6 +597,7 @@ def mutation_context_audit_fields(
"session_org": data.get("org"),
"session_role_kind": data.get("role_kind"),
"session_context_source": data.get("source"),
"session_canonical_repository_root": data.get("canonical_repository_root"),
}