fix(mcp): invalidate review session state on cross-profile activation (Closes #690)
A mid-run profile switch (reviewer -> author -> reviewer) left workflow-load proof, reviewer lease binding, the review decision lock, live namespace health, and preflight identity/capability stamps intact, so a formal verdict could be recorded under contaminated session state. - gitea_activate_profile now invalidates all review-critical session state on a cross-profile switch, in memory and in durable state keyed by either profile identity, and reports the invalidation + re-preflight requirement. - Full reviewer preflight (whoami, load_review_workflow, resolve_task_capability(review_pr), head re-pin, lease re-acquire) is required before any formal verdict after a switch; switching back cannot resurrect the stale run. - Namespace provenance: optional launcher-declared GITEA_MCP_NAMESPACE is reported by whoami/runtime context/capability resolution, and a declared namespace that disagrees with a task's required namespace fails closed. - Docs: supported pattern is separate session/namespace per role, not in-process profile hopping mid-review.
This commit is contained in:
@@ -16,6 +16,7 @@ Probe sources
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -57,8 +58,56 @@ SAFE_ENV_KEYS = (
|
||||
"GITEA_SERVICE",
|
||||
"GITEA_EXECUTION_ROLE",
|
||||
"GITEA_MCP_CONFIG",
|
||||
"GITEA_MCP_NAMESPACE",
|
||||
)
|
||||
|
||||
# Optional launcher-provided env declaring the client-managed MCP namespace
|
||||
# this process is registered under (e.g. ``gitea-reviewer``). The server
|
||||
# cannot derive its own IDE namespace name, so the launcher declares it; when
|
||||
# declared, reviewers/mergers can fail closed on a namespace/task mismatch
|
||||
# (#690 AC4). Absence means "unknown" — reported, never guessed.
|
||||
NAMESPACE_ENV = "GITEA_MCP_NAMESPACE"
|
||||
|
||||
|
||||
def configured_client_namespace(env: dict[str, str] | None = None) -> str | None:
|
||||
"""Return the launcher-declared client namespace, or None when unknown."""
|
||||
source = os.environ if env is None else env
|
||||
value = (source.get(NAMESPACE_ENV) or "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def namespace_provenance(
|
||||
task: str | None = None,
|
||||
*,
|
||||
active_profile: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Report configured client namespace vs active execution profile (#690).
|
||||
|
||||
When *task* carries a required namespace (``TASK_REQUIRED_NAMESPACES``)
|
||||
and the launcher declared a different one, ``mismatch`` is True and the
|
||||
caller must fail closed for that task. An undeclared namespace is
|
||||
reported as unknown — never treated as proof either way.
|
||||
"""
|
||||
configured = configured_client_namespace(env)
|
||||
required = TASK_REQUIRED_NAMESPACES.get(task or "")
|
||||
mismatch = bool(configured and required and configured != required)
|
||||
reasons: list[str] = []
|
||||
if mismatch:
|
||||
reasons.append(
|
||||
f"configured client namespace '{configured}' does not match "
|
||||
f"required namespace '{required}' for task '{task}' (fail closed)"
|
||||
)
|
||||
return {
|
||||
"configured_namespace": configured,
|
||||
"namespace_source": NAMESPACE_ENV if configured else "unknown",
|
||||
"active_profile": active_profile,
|
||||
"requested_task": task,
|
||||
"required_namespace": required,
|
||||
"mismatch": mismatch,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
|
||||
Reference in New Issue
Block a user