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:
+119
-1
@@ -839,6 +839,75 @@ def _invalidate_preflight_identity_state() -> None:
|
||||
_clear_preflight_capability_state()
|
||||
|
||||
|
||||
# #690: session-boundary invalidation record for the most recent cross-profile
|
||||
# activation. Surfaced in runtime diagnostics so a formal review run can prove
|
||||
# its state was reset by a profile switch and must be fully re-established.
|
||||
_PROFILE_SWITCH_INVALIDATION: dict | None = None
|
||||
|
||||
|
||||
def _invalidate_review_state_on_profile_switch(
|
||||
before_profile: str,
|
||||
after_profile: str,
|
||||
) -> dict:
|
||||
"""Invalidate review-critical session state on a profile switch (#690).
|
||||
|
||||
Workflow-load proof, reviewer lease binding, the review decision lock,
|
||||
live namespace health, and preflight identity/capability stamps recorded
|
||||
under the prior profile are contaminated for the new role. Durable state
|
||||
keyed by *either* profile identity is cleared so a reviewer → author →
|
||||
reviewer hop cannot resurrect a stale review run: the full reviewer
|
||||
preflight (gitea_whoami, gitea_load_review_workflow,
|
||||
gitea_resolve_task_capability(review_pr), live head re-pin, lease
|
||||
re-acquire/adopt) must be re-established before any formal verdict.
|
||||
"""
|
||||
global _PROFILE_SWITCH_INVALIDATION
|
||||
invalidated: list[str] = []
|
||||
|
||||
_invalidate_preflight_identity_state()
|
||||
invalidated.append("preflight_identity_capability")
|
||||
|
||||
review_workflow_load.clear_review_workflow_load()
|
||||
invalidated.append("review_workflow_load")
|
||||
|
||||
_save_review_decision_lock(None)
|
||||
invalidated.append("review_decision_lock")
|
||||
|
||||
reviewer_pr_lease.clear_session_lease()
|
||||
invalidated.append("reviewer_session_lease")
|
||||
|
||||
if _LIVE_NAMESPACE_HEALTH:
|
||||
_LIVE_NAMESPACE_HEALTH.clear()
|
||||
invalidated.append("live_namespace_health")
|
||||
|
||||
# Durable records keyed by either profile identity must not survive the
|
||||
# switch, or activating author → reviewer → author could revive a stale
|
||||
# review run without re-preflight.
|
||||
for identity in {before_profile, after_profile}:
|
||||
if not identity:
|
||||
continue
|
||||
try:
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_DECISION_LOCK,
|
||||
profile_identity=identity,
|
||||
)
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
|
||||
profile_identity=identity,
|
||||
)
|
||||
except Exception:
|
||||
pass # best-effort durable cleanup; in-memory state already reset
|
||||
invalidated.append("durable_profile_state")
|
||||
|
||||
_PROFILE_SWITCH_INVALIDATION = {
|
||||
"from_profile": before_profile,
|
||||
"to_profile": after_profile,
|
||||
"invalidated": invalidated,
|
||||
"invalidated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"re_preflight_required": True,
|
||||
}
|
||||
return dict(_PROFILE_SWITCH_INVALIDATION)
|
||||
|
||||
|
||||
def record_preflight_check(
|
||||
type_name: str,
|
||||
resolved_role: str | None = None,
|
||||
@@ -17210,6 +17279,11 @@ def gitea_whoami(
|
||||
"session_context_audit": session_ctx.mutation_context_audit_fields(),
|
||||
"identity_match": not id_match.get("block"),
|
||||
"identity_match_reasons": id_match.get("reasons") or [],
|
||||
# #690 AC4: report launcher-declared client namespace alongside the
|
||||
# active execution profile so drift is visible in diagnostics.
|
||||
"namespace_provenance": mcp_namespace_health.namespace_provenance(
|
||||
active_profile=profile["profile_name"]
|
||||
),
|
||||
}
|
||||
if id_match.get("block"):
|
||||
_invalidate_preflight_identity_state()
|
||||
@@ -18108,6 +18182,11 @@ def gitea_get_runtime_context(
|
||||
"shell_health": native_mcp_preference.shell_health_status(),
|
||||
"workflow_load_proof": review_workflow_load.workflow_load_status(
|
||||
PROJECT_ROOT),
|
||||
# #690: namespace provenance + profile-switch invalidation evidence.
|
||||
"namespace_provenance": mcp_namespace_health.namespace_provenance(
|
||||
active_profile=profile["profile_name"]
|
||||
),
|
||||
"profile_switch_invalidation": _PROFILE_SWITCH_INVALIDATION,
|
||||
}
|
||||
|
||||
# #702: read-only visibility into the inherited GITEA_ACTIVE_WORKTREE
|
||||
@@ -18611,6 +18690,17 @@ def gitea_activate_profile(
|
||||
source="gitea_activate_profile",
|
||||
)
|
||||
|
||||
# 4.7 #690: a profile switch is a session-boundary event for review state.
|
||||
# Any workflow-load proof, reviewer lease, decision lock, namespace
|
||||
# health, or preflight stamp recorded under the prior profile is
|
||||
# contaminated for the new role and must be re-established under the new
|
||||
# profile before any formal review verdict.
|
||||
switch_invalidation = None
|
||||
if before_profile != after_profile:
|
||||
switch_invalidation = _invalidate_review_state_on_profile_switch(
|
||||
before_profile, after_profile
|
||||
)
|
||||
|
||||
# 5. Audit the switch if auditing is on
|
||||
_audit(
|
||||
"activate_profile",
|
||||
@@ -18621,11 +18711,12 @@ def gitea_activate_profile(
|
||||
"before": before_profile,
|
||||
"after": after_profile,
|
||||
"session_context": session_ctx.mutation_context_audit_fields(),
|
||||
"review_state_invalidated": bool(switch_invalidation),
|
||||
},
|
||||
username=after_identity,
|
||||
)
|
||||
|
||||
return {
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Successfully activated profile '{profile_name}' (fresh identity verification complete).",
|
||||
"before_profile": before_profile,
|
||||
@@ -18635,6 +18726,18 @@ def gitea_activate_profile(
|
||||
"session_context_audit": session_ctx.mutation_context_audit_fields(),
|
||||
"auto_profile_substitution": False,
|
||||
}
|
||||
if switch_invalidation is not None:
|
||||
result["review_state_invalidation"] = switch_invalidation
|
||||
result["re_preflight_required"] = True
|
||||
result["exact_next_action"] = (
|
||||
"Profile switch invalidated workflow-load proof, reviewer lease, "
|
||||
"decision lock, and preflight stamps (#690). Before any formal "
|
||||
"review verdict, re-run the full reviewer preflight: "
|
||||
"gitea_whoami, gitea_load_review_workflow, "
|
||||
"gitea_resolve_task_capability(review_pr), live head re-pin, and "
|
||||
"lease re-acquire/adopt."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -20879,12 +20982,22 @@ def gitea_resolve_task_capability(
|
||||
f"{required_role} task '{task}' even if nearby permissions are "
|
||||
"present (fail closed)."
|
||||
)
|
||||
# #690 AC4: when the launcher declares a client namespace, a task with a
|
||||
# required namespace must fail closed on mismatch (e.g. review_pr served
|
||||
# from an author namespace).
|
||||
ns_provenance = mcp_namespace_health.namespace_provenance(
|
||||
task=task_key, active_profile=profile.get("profile_name")
|
||||
)
|
||||
ns_mismatch_reason = None
|
||||
if ns_provenance.get("mismatch"):
|
||||
ns_mismatch_reason = "; ".join(ns_provenance.get("reasons") or [])
|
||||
cross_host_block = bool(remote_assess.get("block"))
|
||||
identity_block = bool(id_assess.get("block"))
|
||||
drift_block = bool(ctx_assess.get("block"))
|
||||
allowed_in_current_session = (
|
||||
permission_allowed_in_current_session
|
||||
and role_matches_current_session
|
||||
and not ns_provenance.get("mismatch")
|
||||
and not cross_host_block
|
||||
and not identity_block
|
||||
and not drift_block
|
||||
@@ -20935,6 +21048,8 @@ def gitea_resolve_task_capability(
|
||||
)
|
||||
if role_mismatch_reason:
|
||||
deny_parts.append(role_mismatch_reason)
|
||||
if ns_mismatch_reason:
|
||||
deny_parts.append(ns_mismatch_reason)
|
||||
if deny_parts:
|
||||
reason_msg = "; ".join(deny_parts)
|
||||
elif configured and switching:
|
||||
@@ -21012,6 +21127,8 @@ def gitea_resolve_task_capability(
|
||||
task_role_guidance = []
|
||||
if role_mismatch_reason:
|
||||
task_role_guidance.append(f"STOP: {role_mismatch_reason}")
|
||||
if ns_mismatch_reason:
|
||||
task_role_guidance.append(f"STOP: {ns_mismatch_reason}")
|
||||
if required_role == "reviewer":
|
||||
if allowed_in_current_session:
|
||||
task_role_guidance.append(
|
||||
@@ -21078,6 +21195,7 @@ def gitea_resolve_task_capability(
|
||||
"session_context_audit": session_ctx.mutation_context_audit_fields(),
|
||||
"profile_remote_compatible": not cross_host_block,
|
||||
"identity_match": not identity_block,
|
||||
"namespace_provenance": ns_provenance,
|
||||
"auto_profile_substitution": False,
|
||||
}
|
||||
# #685: report typed reconnect blocker without mutating config or exiting.
|
||||
|
||||
Reference in New Issue
Block a user