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:
+83
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user