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