Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c0d73cd1 | ||
|
|
6a0d7bbef4 | ||
|
|
29d96c8946 | ||
|
|
0d8a2c2b1d |
@@ -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,
|
||||
}
|
||||
@@ -802,6 +802,10 @@ def get_profile():
|
||||
# environment variable must never widen or forge the set of
|
||||
# repositories a session may bind to.
|
||||
"allowed_repositories": _json_list("allowed_repositories"),
|
||||
# #706 cross-repository canonical root binding. Config-sourced here (the
|
||||
# namespace-scoped GITEA_CANONICAL_REPOSITORY_ROOT env override is applied
|
||||
# by canonical_repository_root.configured_canonical_root, not widened here).
|
||||
"canonical_repository_root": jp.get("canonical_repository_root") or None,
|
||||
"audit_label": audit_label,
|
||||
"token_source_name": token_source,
|
||||
"auth_source_type": auth_type,
|
||||
|
||||
@@ -502,6 +502,30 @@ def _validate_allowed_repositories(name, raw):
|
||||
)
|
||||
|
||||
|
||||
def _validate_canonical_repository_root(name, raw):
|
||||
"""Validate the optional per-profile canonical repository root (#706).
|
||||
|
||||
``canonical_repository_root`` binds a cross-repository namespace to the
|
||||
working root of its target repository (separate from the immutable
|
||||
Gitea-Tools install checkout). It is an absolute filesystem path; existence
|
||||
and git identity are validated at bind time by the runtime guard, not here
|
||||
(config validation stays filesystem-independent). Absent means the
|
||||
single-repo default and is allowed.
|
||||
"""
|
||||
if raw is None:
|
||||
return
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
raise ConfigError(
|
||||
f"profile '{name}' canonical_repository_root must be a non-empty "
|
||||
"absolute path string to the target repository working root"
|
||||
)
|
||||
if not os.path.isabs(raw.strip()):
|
||||
raise ConfigError(
|
||||
f"profile '{name}' canonical_repository_root {raw!r} must be an "
|
||||
"absolute path"
|
||||
)
|
||||
|
||||
|
||||
def _reject_inline_secrets(kind, name, obj):
|
||||
for key in _INLINE_SECRET_KEYS:
|
||||
if key in obj:
|
||||
@@ -577,6 +601,9 @@ def _load_v2_contexts(data, path):
|
||||
if not isinstance(allowed, list) or not isinstance(forbidden, list):
|
||||
raise ConfigError(f"profile '{name}' operation fields must be lists")
|
||||
_validate_allowed_repositories(name, raw.get("allowed_repositories"))
|
||||
_validate_canonical_repository_root(
|
||||
name, raw.get("canonical_repository_root")
|
||||
)
|
||||
allowed_n = {_normalize_op("gitea", op, name) for op in allowed}
|
||||
forbidden_n = {_normalize_op("gitea", op, name) for op in forbidden}
|
||||
# Reviewer-identity deadlock rule (#100/#103) applies here unchanged.
|
||||
|
||||
+120
-2
@@ -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.
|
||||
|
||||
@@ -1115,6 +1182,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(
|
||||
@@ -1148,6 +1216,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(
|
||||
@@ -1606,7 +1675,43 @@ def _trusted_session_repository(
|
||||
"repository": None,
|
||||
"reasons": [str(exc)],
|
||||
}
|
||||
slug = _workspace_repository_slug(remote)
|
||||
# #706 F1: when a cross-repository canonical root is configured, the session
|
||||
# repository identity must be derived from that validated *target* repository,
|
||||
# not from the install-checkout git remote. ``_workspace_repository_slug``
|
||||
# reads ``_local_git_remote_url`` in ``PROJECT_ROOT`` (always Gitea-Tools), so
|
||||
# without this a cross-repo namespace pinned Gitea-Tools while #274 filesystem
|
||||
# membership bound the external root, and ``_enforce_canonical_repository_root``
|
||||
# then failed closed on a self-inflicted identity mismatch. The derived
|
||||
# identity is still authorized by the profile allowlist below (never
|
||||
# self-authorizing). Env-over-profile precedence and fail-closed validation
|
||||
# (existence, git toplevel, resolvable remote identity) are handled by
|
||||
# ``crr``; unconfigured single-repo namespaces keep the install-derived slug.
|
||||
configured_value, configured_source = crr.configured_canonical_root(
|
||||
profile, os.environ
|
||||
)
|
||||
if configured_value:
|
||||
assessment = crr.assess_canonical_repository_root(
|
||||
configured_value=configured_value,
|
||||
source=configured_source,
|
||||
expected_slug=None,
|
||||
process_project_root=PROJECT_ROOT,
|
||||
remote=remote,
|
||||
require_binding=True,
|
||||
)
|
||||
slug = assessment.get("resolved_slug")
|
||||
if assessment.get("block") or not slug:
|
||||
return {
|
||||
"org": None,
|
||||
"repository": None,
|
||||
"reasons": assessment.get("reasons")
|
||||
or [
|
||||
"configured canonical repository root has no resolvable "
|
||||
"repository identity; session repository cannot be "
|
||||
"authorized (fail closed)"
|
||||
],
|
||||
}
|
||||
else:
|
||||
slug = _workspace_repository_slug(remote)
|
||||
scope = session_ctx.assess_repository_scope(
|
||||
workspace_slug=slug,
|
||||
allowed=allowed,
|
||||
@@ -1644,6 +1749,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,
|
||||
@@ -1654,6 +1771,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
|
||||
|
||||
@@ -109,8 +109,17 @@ def resolve_namespace_mutation_context(
|
||||
session_lease_worktree: str | None = None,
|
||||
worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
) -> dict:
|
||||
"""Shared workspace resolution for runtime_context and mutation guards."""
|
||||
"""Shared workspace resolution for runtime_context and mutation guards.
|
||||
|
||||
When *configured_canonical_root* is supplied (a cross-repository namespace
|
||||
bound to an external target repository, #706), the canonical repository root
|
||||
is that configured target rather than the MCP install checkout. This keeps
|
||||
the branches-only / worktree-membership guards (#274) evaluating against the
|
||||
repository the namespace actually mutates. Without it the single-repo
|
||||
default is preserved: the canonical root follows the process checkout.
|
||||
"""
|
||||
demotions: list[str] = []
|
||||
workspace, binding_source = resolve_namespace_workspace(
|
||||
role_kind=role_kind,
|
||||
@@ -132,7 +141,11 @@ def resolve_namespace_mutation_context(
|
||||
env=env,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
configured = (configured_canonical_root or "").strip()
|
||||
if configured:
|
||||
canonical_root = os.path.realpath(configured)
|
||||
else:
|
||||
canonical_root = amw.resolve_canonical_repo_root(process_root, process_root)
|
||||
return {
|
||||
"workspace_path": workspace,
|
||||
"workspace_binding_source": binding_source,
|
||||
@@ -258,6 +271,7 @@ def assess_namespace_mutation_workspace(
|
||||
session_lease_worktree: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
current_branch: str | None = None,
|
||||
configured_canonical_root: str | None = None,
|
||||
) -> dict:
|
||||
"""Evaluate namespace workspace binding before preflight/mutation."""
|
||||
ctx = resolve_namespace_mutation_context(
|
||||
@@ -268,6 +282,7 @@ def assess_namespace_mutation_workspace(
|
||||
env=env,
|
||||
session_lease_worktree=session_lease_worktree,
|
||||
profile_name=profile_name,
|
||||
configured_canonical_root=configured_canonical_root,
|
||||
)
|
||||
mutation_workspace = ctx["workspace_path"]
|
||||
binding_source = ctx["workspace_binding_source"]
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Issue #706: immutable startup/profile canonical_repository_root capability.
|
||||
|
||||
Cross-repository MCP namespaces (e.g. ``eagenda-author``) must bind their
|
||||
canonical repository root to the *configured target repository*, not to the
|
||||
Gitea-Tools install checkout the server script lives in. These tests prove:
|
||||
|
||||
* the configured binding is resolved from profile/env with env precedence,
|
||||
* the target repository identity and git common-directory membership are
|
||||
validated (AC3),
|
||||
* the branches-only guard (#274) is enforced *inside the target repo* (AC4),
|
||||
* missing / conflicting / forged bindings fail closed (AC5),
|
||||
* prgs and mdcps namespaces stay simultaneously isolated (AC6),
|
||||
* the session binding pins the canonical root immutably,
|
||||
* no weakening of the install-root single-repo default (AC8).
|
||||
|
||||
Uses two distinct real git repositories/worktrees (AC7).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import canonical_repository_root as crr # noqa: E402
|
||||
import gitea_config # noqa: E402
|
||||
import namespace_workspace_binding as nwb # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
|
||||
def _git(cwd: str, *args: str) -> str:
|
||||
res = subprocess.run(
|
||||
["git", "-C", cwd, *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return res.stdout.strip()
|
||||
|
||||
|
||||
def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
|
||||
"""Create a real git repo with one commit and a remote; return realpath root."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
_git(str(path), "init", "-q")
|
||||
_git(str(path), "config", "user.email", "[email protected]")
|
||||
_git(str(path), "config", "user.name", "Test")
|
||||
_git(str(path), "remote", "add", remote_name, remote_url)
|
||||
(path / "README.md").write_text("seed\n")
|
||||
_git(str(path), "add", "README.md")
|
||||
_git(str(path), "commit", "-q", "-m", "seed")
|
||||
return os.path.realpath(str(path))
|
||||
|
||||
|
||||
def _add_worktree(repo_root: str, worktree_path: Path, branch: str) -> str:
|
||||
_git(repo_root, "worktree", "add", "-q", "-b", branch, str(worktree_path))
|
||||
return os.path.realpath(str(worktree_path))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# configured_canonical_root: resolution + precedence
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConfiguredCanonicalRoot(unittest.TestCase):
|
||||
def test_unconfigured_returns_none(self):
|
||||
value, source = crr.configured_canonical_root({}, {})
|
||||
self.assertIsNone(value)
|
||||
self.assertIsNone(source)
|
||||
|
||||
def test_profile_field_used(self):
|
||||
value, source = crr.configured_canonical_root(
|
||||
{"canonical_repository_root": "/repo/eAgenda"}, {}
|
||||
)
|
||||
self.assertEqual(value, "/repo/eAgenda")
|
||||
self.assertIn("profile", source)
|
||||
|
||||
def test_env_overrides_profile(self):
|
||||
value, source = crr.configured_canonical_root(
|
||||
{"canonical_repository_root": "/repo/eAgenda"},
|
||||
{crr.CANONICAL_ROOT_ENV: "/repo/other"},
|
||||
)
|
||||
self.assertEqual(value, "/repo/other")
|
||||
self.assertIn(crr.CANONICAL_ROOT_ENV, source)
|
||||
|
||||
def test_blank_values_ignored(self):
|
||||
value, source = crr.configured_canonical_root(
|
||||
{"canonical_repository_root": " "},
|
||||
{crr.CANONICAL_ROOT_ENV: ""},
|
||||
)
|
||||
self.assertIsNone(value)
|
||||
self.assertIsNone(source)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assess_canonical_repository_root: default (single-repo) path
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestUnconfiguredFallback(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = self._tmp.name
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_unconfigured_falls_back_to_process_root(self):
|
||||
root = _init_repo(
|
||||
Path(self.tmp) / "install",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||
)
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=None,
|
||||
source=None,
|
||||
expected_slug=None,
|
||||
process_project_root=root,
|
||||
)
|
||||
self.assertTrue(got["proven"])
|
||||
self.assertFalse(got["block"])
|
||||
self.assertFalse(got["configured"])
|
||||
self.assertEqual(got["canonical_repo_root"], root)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# assess_canonical_repository_root: configured cross-repo path
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConfiguredCrossRepo(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self.install = _init_repo(
|
||||
self.tmp / "Gitea-Tools",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||
)
|
||||
self.target = _init_repo(
|
||||
self.tmp / "mcp-control-plane",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_valid_configured_root_binds_to_target(self):
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=self.target,
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||
process_project_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["proven"], got.get("reasons"))
|
||||
self.assertFalse(got["block"])
|
||||
self.assertTrue(got["configured"])
|
||||
self.assertEqual(got["canonical_repo_root"], self.target)
|
||||
self.assertNotEqual(got["canonical_repo_root"], self.install)
|
||||
|
||||
def test_missing_path_fails_closed(self):
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=str(self.tmp / "does-not-exist"),
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug=None,
|
||||
process_project_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
self.assertFalse(got["proven"])
|
||||
self.assertTrue(any("exist" in r for r in got["reasons"]))
|
||||
|
||||
def test_non_git_path_fails_closed(self):
|
||||
plain = self.tmp / "plain"
|
||||
plain.mkdir()
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=str(plain),
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug=None,
|
||||
process_project_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
self.assertTrue(any("git" in r for r in got["reasons"]))
|
||||
|
||||
def test_identity_mismatch_fails_closed(self):
|
||||
# Configured root is the install repo, but session expects the target
|
||||
# repo identity: a forged/conflicting binding.
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=self.install,
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||
process_project_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
self.assertTrue(any("identity" in r for r in got["reasons"]))
|
||||
|
||||
def test_unprovable_identity_blocks_when_required(self):
|
||||
noremote = _init_repo(self.tmp / "noremote-src", "x", remote_name="origin")
|
||||
_git(noremote, "remote", "remove", "origin")
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=noremote,
|
||||
source="profile canonical_repository_root",
|
||||
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||
process_project_root=self.install,
|
||||
require_binding=True,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
|
||||
def test_repository_identity_slug_reads_remote(self):
|
||||
self.assertEqual(
|
||||
crr.repository_identity_slug(self.target),
|
||||
"Scaled-Tech-Consulting/mcp-control-plane",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace membership + branches-only enforced inside the TARGET repo (AC4)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestNamespaceContextUsesConfiguredRoot(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self.install = _init_repo(
|
||||
self.tmp / "Gitea-Tools",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git",
|
||||
)
|
||||
self.target = _init_repo(
|
||||
self.tmp / "mcp-control-plane",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||
)
|
||||
(Path(self.target) / "branches").mkdir()
|
||||
self.target_wt = _add_worktree(
|
||||
self.target,
|
||||
Path(self.target) / "branches" / "author-issue-1",
|
||||
"feat/issue-1",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_context_canonical_root_is_configured_target(self):
|
||||
ctx = nwb.resolve_namespace_mutation_context(
|
||||
role_kind="author",
|
||||
worktree_path=self.target_wt,
|
||||
process_project_root=self.install,
|
||||
env={},
|
||||
configured_canonical_root=self.target,
|
||||
)
|
||||
self.assertEqual(ctx["canonical_repo_root"], self.target)
|
||||
self.assertFalse(ctx["roots_aligned"])
|
||||
|
||||
def test_target_worktree_is_member_of_target_root(self):
|
||||
got = nwb.amw.assess_workspace_repo_membership(
|
||||
workspace_path=self.target_wt,
|
||||
canonical_repo_root=self.target,
|
||||
)
|
||||
self.assertTrue(got["proven"], got.get("reasons"))
|
||||
|
||||
def test_target_worktree_not_member_of_install_root(self):
|
||||
got = nwb.amw.assess_workspace_repo_membership(
|
||||
workspace_path=self.target_wt,
|
||||
canonical_repo_root=self.install,
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
|
||||
def test_branches_guard_passes_inside_target(self):
|
||||
assessment = nwb.assess_namespace_mutation_workspace(
|
||||
role_kind="author",
|
||||
worktree_path=self.target_wt,
|
||||
worktree=None,
|
||||
process_project_root=self.install,
|
||||
env={},
|
||||
current_branch="feat/issue-1",
|
||||
configured_canonical_root=self.target,
|
||||
)
|
||||
self.assertFalse(assessment["block"], assessment.get("reasons"))
|
||||
self.assertEqual(assessment["canonical_repo_root"], self.target)
|
||||
|
||||
def test_target_control_checkout_blocks_branches_guard(self):
|
||||
assessment = nwb.assess_namespace_mutation_workspace(
|
||||
role_kind="author",
|
||||
worktree_path=self.target, # stable target checkout, not branches/
|
||||
worktree=None,
|
||||
process_project_root=self.install,
|
||||
env={},
|
||||
current_branch="master",
|
||||
configured_canonical_root=self.target,
|
||||
)
|
||||
self.assertTrue(assessment["block"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Immutable session pin (AC1/AC5)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSessionCanonicalRootPin(unittest.TestCase):
|
||||
def setUp(self):
|
||||
os.environ["PYTEST_CURRENT_TEST"] = "t"
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def tearDown(self):
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def test_seed_stores_canonical_root(self):
|
||||
ctx = session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="svc",
|
||||
repository="mcp-control-plane",
|
||||
org="Scaled-Tech-Consulting",
|
||||
canonical_repository_root="/repo/mcp-control-plane",
|
||||
)
|
||||
self.assertEqual(ctx["canonical_repository_root"], "/repo/mcp-control-plane")
|
||||
|
||||
def test_canonical_root_drift_fails_closed(self):
|
||||
session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="svc",
|
||||
repository="mcp-control-plane",
|
||||
org="Scaled-Tech-Consulting",
|
||||
canonical_repository_root="/repo/mcp-control-plane",
|
||||
)
|
||||
got = session_ctx.assess_session_context(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
canonical_repository_root="/repo/forged-elsewhere",
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
self.assertTrue(any("canonical" in r for r in got["reasons"]))
|
||||
|
||||
def test_matching_canonical_root_passes(self):
|
||||
session_ctx.seed_session_context_if_unbound(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
host="gitea.prgs.cc",
|
||||
identity="svc",
|
||||
repository="mcp-control-plane",
|
||||
org="Scaled-Tech-Consulting",
|
||||
canonical_repository_root="/repo/mcp-control-plane",
|
||||
)
|
||||
got = session_ctx.assess_session_context(
|
||||
profile_name="mcp-control-plane-author",
|
||||
remote="prgs",
|
||||
canonical_repository_root="/repo/mcp-control-plane",
|
||||
)
|
||||
self.assertFalse(got["block"], got["reasons"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simultaneous prgs / mdcps isolation (AC6)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestSimultaneousIsolation(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.tmp = Path(self._tmp.name)
|
||||
self.prgs = _init_repo(
|
||||
self.tmp / "prgs-repo",
|
||||
"https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git",
|
||||
)
|
||||
self.mdcps = _init_repo(
|
||||
self.tmp / "mdcps-repo",
|
||||
"https://gitea.dadeschools.net/dadeschools/eAgenda.git",
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_each_namespace_binds_its_own_target(self):
|
||||
a = crr.assess_canonical_repository_root(
|
||||
configured_value=self.prgs,
|
||||
source="env",
|
||||
expected_slug="Scaled-Tech-Consulting/mcp-control-plane",
|
||||
process_project_root=self.tmp.as_posix(),
|
||||
)
|
||||
b = crr.assess_canonical_repository_root(
|
||||
configured_value=self.mdcps,
|
||||
source="env",
|
||||
expected_slug="dadeschools/eAgenda",
|
||||
process_project_root=self.tmp.as_posix(),
|
||||
)
|
||||
self.assertEqual(a["canonical_repo_root"], self.prgs)
|
||||
self.assertEqual(b["canonical_repo_root"], self.mdcps)
|
||||
self.assertNotEqual(a["canonical_repo_root"], b["canonical_repo_root"])
|
||||
|
||||
def test_cross_wired_identity_blocks(self):
|
||||
# prgs path claimed under the mdcps identity → forged binding.
|
||||
got = crr.assess_canonical_repository_root(
|
||||
configured_value=self.prgs,
|
||||
source="env",
|
||||
expected_slug="dadeschools/eAgenda",
|
||||
process_project_root=self.tmp.as_posix(),
|
||||
)
|
||||
self.assertTrue(got["block"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config validation of the profile field (AC5: malformed bindings fail closed)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestConfigValidation(unittest.TestCase):
|
||||
def test_absent_is_allowed(self):
|
||||
# single-repo default: no field configured
|
||||
gitea_config._validate_canonical_repository_root("p", None)
|
||||
|
||||
def test_valid_absolute_path_ok(self):
|
||||
gitea_config._validate_canonical_repository_root(
|
||||
"p", "/Users/x/Development/mcp-control-plane"
|
||||
)
|
||||
|
||||
def test_relative_path_rejected(self):
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config._validate_canonical_repository_root(
|
||||
"p", "relative/path"
|
||||
)
|
||||
|
||||
def test_empty_string_rejected(self):
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config._validate_canonical_repository_root("p", " ")
|
||||
|
||||
def test_non_string_rejected(self):
|
||||
with self.assertRaises(gitea_config.ConfigError):
|
||||
gitea_config._validate_canonical_repository_root("p", ["/x"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Issue #706 F1 integration regression (review 457).
|
||||
|
||||
The unit tests in ``test_issue_706_canonical_repository_root.py`` exercise
|
||||
``crr.assess_canonical_repository_root`` / ``session_ctx.seed_session_context_if_unbound``
|
||||
with a *preselected* slug, so they never drive the real end-to-end path that
|
||||
review 457 found broken:
|
||||
|
||||
_seed_session_context -> _trusted_session_repository -> _workspace_repository_slug
|
||||
-> _local_git_remote_url (cwd=PROJECT_ROOT, always Gitea-Tools)
|
||||
|
||||
Before the fix, the session repository identity was pinned from the *install*
|
||||
checkout remote even when a cross-repository ``canonical_repository_root`` was
|
||||
configured to an external repository (e.g. mcp-control-plane). The mutation
|
||||
preflight then derived ``expected_slug`` from that install-derived pin and
|
||||
``_enforce_canonical_repository_root`` failed closed on a self-inflicted
|
||||
identity mismatch, so the stated cross-repo namespaces stayed blocked.
|
||||
|
||||
These tests use two *real* temporary git repositories and drive the live
|
||||
``mcp_server._seed_session_context`` and ``mcp_server._enforce_canonical_repository_root``
|
||||
functions, asserting the session pins the *configured target* identity and that
|
||||
enforcement accepts the target worktree — while every fail-closed property is
|
||||
preserved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import mcp_server # noqa: E402 # loads gitea_mcp_server.py into this namespace
|
||||
import canonical_repository_root as crr # noqa: E402
|
||||
import session_context_binding as session_ctx # noqa: E402
|
||||
|
||||
INSTALL_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
|
||||
TARGET_SLUG = "Scaled-Tech-Consulting/mcp-control-plane"
|
||||
TARGET_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/mcp-control-plane.git"
|
||||
OTHER_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/other-repo.git"
|
||||
|
||||
|
||||
def _git(cwd: str, *args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", cwd, *args], capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _init_repo(path: Path, remote_url: str) -> str:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
_git(str(path), "init", "-q")
|
||||
_git(str(path), "config", "user.email", "[email protected]")
|
||||
_git(str(path), "config", "user.name", "Test")
|
||||
_git(str(path), "remote", "add", "prgs", remote_url)
|
||||
(path / "README.md").write_text("seed\n")
|
||||
_git(str(path), "add", "README.md")
|
||||
_git(str(path), "commit", "-q", "-m", "seed")
|
||||
return os.path.realpath(str(path))
|
||||
|
||||
|
||||
def _add_worktree(repo_root: str, wt: Path, branch: str) -> str:
|
||||
_git(repo_root, "worktree", "add", "-q", "-b", branch, str(wt))
|
||||
return os.path.realpath(str(wt))
|
||||
|
||||
|
||||
def _profile(role: str, *, canonical: str | None = None,
|
||||
allowed=(TARGET_SLUG,)) -> dict:
|
||||
p = {
|
||||
"profile_name": f"mcp-control-plane-{role}",
|
||||
"role": role,
|
||||
"username": "svc",
|
||||
"allowed_operations": ["gitea.read"],
|
||||
"forbidden_operations": [],
|
||||
"allowed_repositories": list(allowed),
|
||||
}
|
||||
if canonical is not None:
|
||||
p["canonical_repository_root"] = canonical
|
||||
return p
|
||||
|
||||
|
||||
class _Base(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
tmp = Path(self._tmp.name)
|
||||
self.install = _init_repo(tmp / "Gitea-Tools", INSTALL_URL)
|
||||
self.target = _init_repo(tmp / "mcp-control-plane", TARGET_URL)
|
||||
(Path(self.target) / "branches").mkdir()
|
||||
self.target_wt = _add_worktree(
|
||||
self.target, Path(self.target) / "branches" / "author-issue-1",
|
||||
"feat/issue-1",
|
||||
)
|
||||
# PROJECT_ROOT and the install git remote are the Gitea-Tools install
|
||||
# checkout — exactly the source that (mis)seeded the session before.
|
||||
self._p_root = mock.patch.object(mcp_server, "PROJECT_ROOT", self.install)
|
||||
self._p_url = mock.patch.object(
|
||||
mcp_server, "_local_git_remote_url", return_value=INSTALL_URL
|
||||
)
|
||||
self._p_root.start()
|
||||
self._p_url.start()
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def tearDown(self):
|
||||
mock.patch.stopall()
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
|
||||
def _seed(self, profile, env=None):
|
||||
session_ctx._reset_session_context_for_testing()
|
||||
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||
return mcp_server._seed_session_context(
|
||||
profile=profile, remote="prgs", host="gitea.prgs.cc",
|
||||
identity="svc",
|
||||
)
|
||||
|
||||
def _enforce(self, profile, env=None):
|
||||
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||
mcp_server._enforce_canonical_repository_root(
|
||||
self.target_wt, remote="prgs"
|
||||
)
|
||||
|
||||
|
||||
class TestF1SeedsConfiguredTargetIdentity(_Base):
|
||||
def test_env_config_seeds_target_not_install(self):
|
||||
ctx = self._seed(
|
||||
_profile("reviewer"),
|
||||
env={crr.CANONICAL_ROOT_ENV: self.target},
|
||||
)
|
||||
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||
# Regression guard: must NOT be the install repo.
|
||||
self.assertNotEqual(ctx["repository"], "Gitea-Tools")
|
||||
|
||||
def test_profile_field_seeds_target_not_install(self):
|
||||
ctx = self._seed(_profile("merger", canonical=self.target))
|
||||
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||
self.assertNotEqual(ctx["repository"], "Gitea-Tools")
|
||||
|
||||
def test_enforce_accepts_target_after_seed_reviewer(self):
|
||||
prof = _profile("reviewer", canonical=self.target)
|
||||
self._seed(prof)
|
||||
# Would raise RuntimeError on the self-inflicted identity mismatch
|
||||
# before the fix.
|
||||
self._enforce(prof)
|
||||
|
||||
def test_enforce_accepts_target_after_seed_merger(self):
|
||||
prof = _profile("merger", canonical=self.target)
|
||||
self._seed(prof)
|
||||
self._enforce(prof)
|
||||
|
||||
def test_env_overrides_profile_for_seed(self):
|
||||
# profile points at install; env points at the real target → env wins.
|
||||
prof = _profile("reviewer", canonical=self.install,
|
||||
allowed=(TARGET_SLUG,))
|
||||
ctx = self._seed(prof, env={crr.CANONICAL_ROOT_ENV: self.target})
|
||||
self.assertEqual(ctx["repository"], "mcp-control-plane")
|
||||
|
||||
|
||||
class TestUnconfiguredDefaultUnchanged(_Base):
|
||||
def test_unconfigured_keeps_install_identity(self):
|
||||
prof = _profile("author", allowed=("Scaled-Tech-Consulting/Gitea-Tools",))
|
||||
ctx = self._seed(prof) # no env, no profile canonical field
|
||||
self.assertEqual(ctx["repository"], "Gitea-Tools")
|
||||
self.assertEqual(ctx["org"], "Scaled-Tech-Consulting")
|
||||
|
||||
|
||||
class TestFailClosed(_Base):
|
||||
def _trusted(self, profile, env=None, *, for_mutation=True):
|
||||
with mock.patch.object(mcp_server, "get_profile", return_value=profile), \
|
||||
mock.patch.dict(os.environ, env or {}, clear=False):
|
||||
return mcp_server._trusted_session_repository(
|
||||
profile, "prgs", for_mutation=for_mutation
|
||||
)
|
||||
|
||||
def test_nonexistent_configured_root_fails_closed(self):
|
||||
res = self._trusted(
|
||||
_profile("reviewer"),
|
||||
env={crr.CANONICAL_ROOT_ENV: self.target + "-missing"},
|
||||
)
|
||||
self.assertIsNone(res["repository"])
|
||||
self.assertTrue(res["reasons"])
|
||||
|
||||
def test_non_git_configured_root_fails_closed(self):
|
||||
plain = Path(self._tmp.name) / "plain"
|
||||
plain.mkdir()
|
||||
res = self._trusted(
|
||||
_profile("reviewer"), env={crr.CANONICAL_ROOT_ENV: str(plain)}
|
||||
)
|
||||
self.assertIsNone(res["repository"])
|
||||
self.assertTrue(any("git" in r for r in res["reasons"]))
|
||||
|
||||
def test_unallowlisted_target_identity_fails_closed(self):
|
||||
# Configured target is valid, but the profile does not authorize it.
|
||||
res = self._trusted(
|
||||
_profile("reviewer", canonical=self.target,
|
||||
allowed=("Scaled-Tech-Consulting/Gitea-Tools",)),
|
||||
)
|
||||
self.assertIsNone(res["repository"])
|
||||
self.assertTrue(any("scope" in r.lower() for r in res["reasons"]))
|
||||
|
||||
def test_forged_identity_conflict_fails_closed_at_enforce(self):
|
||||
# Seed the target, then present a *different* configured root at
|
||||
# enforcement time: the session pin no longer matches → fail closed.
|
||||
other = _init_repo(Path(self._tmp.name) / "other", OTHER_URL)
|
||||
prof_seed = _profile("reviewer", canonical=self.target)
|
||||
self._seed(prof_seed)
|
||||
prof_drift = _profile(
|
||||
"reviewer", canonical=other,
|
||||
allowed=(TARGET_SLUG, "Scaled-Tech-Consulting/other-repo"),
|
||||
)
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._enforce(prof_drift)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user