Compare commits

..
Author SHA1 Message Date
jcwalker3 a633db6186 Merge branch 'master' into fix/issue-690-review-profile-switch-guard 2026-07-28 08:38:08 -05:00
sysadmin 2b3f5baaeb 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.
2026-07-25 19:17:19 -04:00
10 changed files with 516 additions and 1175 deletions
+41
View File
@@ -589,6 +589,47 @@ When dynamic profile switching is enabled and a profile is activated via `gitea_
2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity. 2. Call `gitea_whoami` with the target remote to prove and verify the fresh Gitea authenticated identity.
This guarantees the active profile operations align with the actual Gitea authenticated user credential. This guarantees the active profile operations align with the actual Gitea authenticated user credential.
### 4. Review-State Invalidation on Profile Switch (#690)
A cross-profile activation (e.g. reviewer → author → reviewer) is a session
boundary for formal review state. On any switch where the activated profile
differs from the previous one, `gitea_activate_profile` invalidates, in
memory **and** in durable session state (for both the old and new profile
identities):
- preflight identity/capability stamps (`gitea_whoami` / `gitea_resolve_task_capability` proof),
- review workflow-load proof (`gitea_load_review_workflow`),
- the review decision lock (including `final_review_decision_ready` markers),
- the reviewer PR session lease binding,
- live namespace-health assessments.
Before any formal verdict (`gitea_mark_final_review_decision` /
`gitea_submit_pr_review`) the full reviewer preflight must be re-established
under the new profile: `gitea_whoami`, `gitea_load_review_workflow`,
`gitea_resolve_task_capability(review_pr)`, live head re-pin, and lease
re-acquire/adopt. Switching back to the earlier profile does **not**
resurrect the prior run — durable state keyed by either profile identity is
cleared at switch time.
The supported pattern remains **separate session/namespace per role**
(dual-namespace, §2): file author-side follow-ups from an author session,
not by hopping profiles inside a formal review run. Runtime profile
switching is the operator-approved exception and always carries the
re-preflight cost above.
### 5. Namespace Provenance (#690)
The server cannot derive its own client-managed MCP namespace name, so a
launcher may declare it via the `GITEA_MCP_NAMESPACE` environment variable
(e.g. `gitea-reviewer`). `gitea_whoami`, `gitea_get_runtime_context`, and
`gitea_resolve_task_capability` report `namespace_provenance` — the
configured client namespace, the active execution profile, and, for tasks
with a required namespace (`review_pr` → `gitea-reviewer`, `merge_pr` →
`gitea-merger`), a mismatch verdict. A declared namespace that disagrees
with the requested task's required namespace **fails closed**. An
undeclared namespace is reported as `unknown` and is never treated as
proof either way.
## Gitea MCP Runtime Isolation and Worktree Safety ## Gitea MCP Runtime Isolation and Worktree Safety
To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation: To ensure high availability and prevent broken feature worktrees from disabling essential security/identity controls, the Gitea MCP server implements runtime isolation:
+21
View File
@@ -86,3 +86,24 @@ When a namespace returns EOF, follow
When blocked, repair the IDE namespace and re-record a healthy When blocked, repair the IDE namespace and re-record a healthy
`client_namespace` assessment before retrying the mutation. `client_namespace` assessment before retrying the mutation.
## Namespace provenance (#690)
A server process cannot derive the name of the client-managed namespace it is
registered under, so the launcher may declare it with the
`GITEA_MCP_NAMESPACE` environment variable (e.g. `GITEA_MCP_NAMESPACE=gitea-reviewer`).
- `gitea_whoami`, `gitea_get_runtime_context`, and
`gitea_resolve_task_capability` report `namespace_provenance`: the declared
client namespace, the active execution profile, and — for tasks with a
required namespace (`review_pr`/`submit_review``gitea-reviewer`,
`merge_pr``gitea-merger`) — a `mismatch` verdict.
- A declared namespace that disagrees with the requested task's required
namespace **fails closed** (`allowed_in_current_session=false` with a STOP
guidance entry).
- An undeclared namespace is reported as `namespace_source="unknown"` and is
never treated as proof either way.
- A profile switch via `gitea_activate_profile` clears all recorded live
namespace-health assessments; re-probe through the client before further
review/merge mutations.
-14
View File
@@ -144,19 +144,6 @@ tool argument expresses caller intent and cannot be self-asserted by a worker
session. `break_glass_requested` and `break_glass_authorized` are both reported, session. `break_glass_requested` and `break_glass_authorized` are both reported,
so a bypass is never silent. so a bypass is never silent.
### Break-glass Restart Workflow (`gitea_break_glass_restart`, #664)
The dedicated MCP tool `gitea_break_glass_restart` provides the privileged emergency break-glass restart workflow when graceful drain cannot complete:
- **Role Authorization (#664 AC1)**: Ordinary LLM worker roles (`author`, `reviewer`, `merger`, `reconciler`) are denied fail-closed. Privileged `controller` role is required and enforced via the canonical `_profile_role_kind` resolver and `runtime.break_glass_restart` capability. `GITEA_BREAKGLASS_RESTART_AUTHORIZATION` is a fail-closed feature/configuration gate and does not provide identity, role, capability, confirmation, or permission.
- **Required Parameters (#664 AC2)**:
- `reason`: Mandatory non-empty string (min 10 characters).
- `confirmation`: Must equal exactly `"I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION"`.
- `impact_ack`: Must be `True`.
- **Automatic Incident Creation (#664 AC3)**: Creates a Gitea incident issue (`[INCIDENT] [REQUESTED] Break-glass MCP restart invoked by ...`) detailing the reason, timestamp, disrupted sessions, and linking `#652 #653 #655 #630 #658 #662 #664`.
- **Immutable Append-Only Audit Entry**: Records immutable pre-execution (REQUESTED) and post-execution (SUCCEEDED/FAILED) audit log entries with correlation identifiers.
- **Mandatory Reconciliation (#664 AC4)**: Sets `reconciliation_required=True` requiring post-restart reconciliation via `gitea_reconcile_after_restart` (#662).
### Fail closed on apply ### Fail closed on apply
A missing, malformed, expired, unclean, tampered, or fingerprint-stale drain A missing, malformed, expired, unclean, tampered, or fingerprint-stale drain
@@ -173,4 +160,3 @@ profiles are operational metadata only.
A representative dry-run report is in A representative dry-run report is in
[`mcp-restart-impact-sample.json`](./mcp-restart-impact-sample.json). [`mcp-restart-impact-sample.json`](./mcp-restart-impact-sample.json).
+4 -26
View File
@@ -27,32 +27,13 @@ ALLOWED = "allowed"
BLOCKED = "blocked" BLOCKED = "blocked"
FAILED = "failed" FAILED = "failed"
SUCCEEDED = "succeeded" SUCCEEDED = "succeeded"
REQUESTED = "requested"
ACCEPTED = "accepted"
PENDING = "pending"
REDACTED = "[REDACTED]" REDACTED = "[REDACTED]"
# A dict key containing any of these (case-insensitive) has its value redacted. # A dict key containing any of these (case-insensitive) has its value redacted.
_SECRET_KEY_HINTS = ("token", "password", "secret", "authorization", "auth") _SECRET_KEY_HINTS = ("token", "password", "secret", "authorization", "auth")
# A string value starting with one of these has the following run redacted. # A string value starting with one of these has the following run redacted.
_SECRET_VALUE_PREFIXES = ("token ", "Basic ", "Bearer ", "token:", "bearer:", "basic:") _SECRET_VALUE_PREFIXES = ("token ", "Basic ", "Bearer ")
_BARE_SECRET_PATTERN = re.compile(
r'(?i)\b(?:'
r'ghp_[A-Za-z0-9_]{16,}'
r'|gho_[A-Za-z0-9_]{16,}'
r'|ghu_[A-Za-z0-9_]{16,}'
r'|ghs_[A-Za-z0-9_]{16,}'
r'|ghr_[A-Za-z0-9_]{16,}'
r'|sk-live-[A-Za-z0-9_-]{16,}'
r'|sk-proj-[A-Za-z0-9_-]{16,}'
r'|sk-[A-Za-z0-9_-]{20,}'
r'|glpat-[A-Za-z0-9_-]{16,}'
r'|sec-[A-Za-z0-9_-]{16,}'
r')\b'
)
# Known synthetic test-only domains/hostnames to preserve # Known synthetic test-only domains/hostnames to preserve
_SYNTHETIC_HOSTS = { _SYNTHETIC_HOSTS = {
@@ -134,23 +115,20 @@ def redact_urls(text: str) -> str:
def _redact_str(text): def _redact_str(text):
"""Redact anything that looks like an Authorization credential, bare secret token, or raw URL in *text*.""" """Redact anything that looks like an Authorization credential or raw URL in *text*."""
if not isinstance(text, str) or not text: if not isinstance(text, str) or not text:
return text return text
out = _BARE_SECRET_PATTERN.sub(REDACTED, text) out = text
out_lower = out.lower()
for prefix in _SECRET_VALUE_PREFIXES: for prefix in _SECRET_VALUE_PREFIXES:
prefix_lower = prefix.lower()
idx = 0 idx = 0
while True: while True:
i = out_lower.find(prefix_lower, idx) i = out.find(prefix, idx)
if i == -1: if i == -1:
break break
j = i + len(prefix) j = i + len(prefix)
while j < len(out) and not out[j].isspace(): while j < len(out) and not out[j].isspace():
j += 1 j += 1
out = out[:i] + prefix + REDACTED + out[j:] out = out[:i] + prefix + REDACTED + out[j:]
out_lower = out.lower()
idx = i + len(prefix) + len(REDACTED) idx = i + len(prefix) + len(REDACTED)
return redact_urls(out) return redact_urls(out)
+125 -578
View File
@@ -236,15 +236,16 @@ def _effective_workspace_role() -> str:
def _profile_role_kind(profile: dict) -> str: def _profile_role_kind(profile: dict) -> str:
"""Resolve a profile's declared role before inferring from permissions. """Resolve a profile's declared role before inferring from permissions.
Declared ``role`` / ``role_kind`` or profile_name containing 'controller' Declared ``role`` / ``role_kind`` always wins so a controller profile is
always resolves to 'controller' (#840/#664). never reclassified as reconciler from permission inference (#840).
""" """
profile_name = (profile.get("profile_name") or "").strip().lower()
role = (profile.get("role") or profile.get("role_kind") or "").strip().lower() role = (profile.get("role") or profile.get("role_kind") or "").strip().lower()
if "controller" in profile_name or "control" in role or role == "controller":
return "controller"
if role: if role:
# Normalize aliases / case.
if "control" in role:
return "controller"
return role return role
profile_name = (profile.get("profile_name") or "").strip().lower()
for candidate in ( for candidate in (
"controller", "controller",
"reconciler", "reconciler",
@@ -838,6 +839,75 @@ def _invalidate_preflight_identity_state() -> None:
_clear_preflight_capability_state() _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( def record_preflight_check(
type_name: str, type_name: str,
resolved_role: str | None = None, resolved_role: str | None = None,
@@ -18107,6 +18177,11 @@ def gitea_whoami(
"session_context_audit": session_ctx.mutation_context_audit_fields(), "session_context_audit": session_ctx.mutation_context_audit_fields(),
"identity_match": not id_match.get("block"), "identity_match": not id_match.get("block"),
"identity_match_reasons": id_match.get("reasons") or [], "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"): if id_match.get("block"):
_invalidate_preflight_identity_state() _invalidate_preflight_identity_state()
@@ -19011,6 +19086,11 @@ def gitea_get_runtime_context(
"shell_health": native_mcp_preference.shell_health_status(), "shell_health": native_mcp_preference.shell_health_status(),
"workflow_load_proof": review_workflow_load.workflow_load_status( "workflow_load_proof": review_workflow_load.workflow_load_status(
PROJECT_ROOT), 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,
} }
if not is_client_managed: if not is_client_managed:
@@ -19521,6 +19601,17 @@ def gitea_activate_profile(
source="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 # 5. Audit the switch if auditing is on
_audit( _audit(
"activate_profile", "activate_profile",
@@ -19531,11 +19622,12 @@ def gitea_activate_profile(
"before": before_profile, "before": before_profile,
"after": after_profile, "after": after_profile,
"session_context": session_ctx.mutation_context_audit_fields(), "session_context": session_ctx.mutation_context_audit_fields(),
"review_state_invalidated": bool(switch_invalidation),
}, },
username=after_identity, username=after_identity,
) )
return { result = {
"success": True, "success": True,
"message": f"Successfully activated profile '{profile_name}' (fresh identity verification complete).", "message": f"Successfully activated profile '{profile_name}' (fresh identity verification complete).",
"before_profile": before_profile, "before_profile": before_profile,
@@ -19545,6 +19637,18 @@ def gitea_activate_profile(
"session_context_audit": session_ctx.mutation_context_audit_fields(), "session_context_audit": session_ctx.mutation_context_audit_fields(),
"auto_profile_substitution": False, "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() @mcp.tool()
@@ -21834,12 +21938,22 @@ def gitea_resolve_task_capability(
f"{required_role} task '{task}' even if nearby permissions are " f"{required_role} task '{task}' even if nearby permissions are "
"present (fail closed)." "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")) cross_host_block = bool(remote_assess.get("block"))
identity_block = bool(id_assess.get("block")) identity_block = bool(id_assess.get("block"))
drift_block = bool(ctx_assess.get("block")) drift_block = bool(ctx_assess.get("block"))
allowed_in_current_session = ( allowed_in_current_session = (
permission_allowed_in_current_session permission_allowed_in_current_session
and role_matches_current_session and role_matches_current_session
and not ns_provenance.get("mismatch")
and not cross_host_block and not cross_host_block
and not identity_block and not identity_block
and not drift_block and not drift_block
@@ -21890,6 +22004,8 @@ def gitea_resolve_task_capability(
) )
if role_mismatch_reason: if role_mismatch_reason:
deny_parts.append(role_mismatch_reason) deny_parts.append(role_mismatch_reason)
if ns_mismatch_reason:
deny_parts.append(ns_mismatch_reason)
if deny_parts: if deny_parts:
reason_msg = "; ".join(deny_parts) reason_msg = "; ".join(deny_parts)
elif configured and switching: elif configured and switching:
@@ -21972,6 +22088,8 @@ def gitea_resolve_task_capability(
task_role_guidance = [] task_role_guidance = []
if role_mismatch_reason: if role_mismatch_reason:
task_role_guidance.append(f"STOP: {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 required_role == "reviewer":
if allowed_in_current_session: if allowed_in_current_session:
task_role_guidance.append( task_role_guidance.append(
@@ -22038,6 +22156,7 @@ def gitea_resolve_task_capability(
"session_context_audit": session_ctx.mutation_context_audit_fields(), "session_context_audit": session_ctx.mutation_context_audit_fields(),
"profile_remote_compatible": not cross_host_block, "profile_remote_compatible": not cross_host_block,
"identity_match": not identity_block, "identity_match": not identity_block,
"namespace_provenance": ns_provenance,
"auto_profile_substitution": False, "auto_profile_substitution": False,
} }
# #685: report typed reconnect blocker without mutating config or exiting. # #685: report typed reconnect blocker without mutating config or exiting.
@@ -23921,578 +24040,6 @@ def gitea_request_mcp_restart(
return payload return payload
BREAK_GLASS_CONFIRMATION_PHRASE = "I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION"
PRIVILEGED_BREAK_GLASS_ROLES = frozenset({"controller"})
@mcp.tool()
def gitea_break_glass_restart(
reason: str,
confirmation: str,
impact_ack: bool = False,
restart_class: str = "full_mcp_restart",
create_incident_issue: bool = True,
dry_run: bool = False,
remote: str = "dadeschools",
host: str | None = None,
org: str | None = None,
repo: str | None = None,
worktree_path: str | None = None,
) -> dict:
"""Privileged emergency break-glass MCP restart workflow (#664).
Break-glass restart permits emergency recovery when graceful drain cannot
complete. It requires:
1. Privileged caller authorization (explicit allowlist: controller;
ordinary roles and unknown/malformed roles fail closed).
2. Explicit non-empty reason (minimum 10 characters).
3. Exact confirmation string matching 'I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION'.
4. Mandatory impact acknowledgement (impact_ack=True).
5. Immutable append-only audit entry recorded prior to execution and after terminal completion.
6. Automatic incident record created on Gitea prior to execution.
7. Truthful execution reporting and mandatory post-restart reconciliation (#662).
"""
read_block = _profile_operation_gate("runtime.break_glass_restart")
if read_block:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": read_block,
"permission_report": _permission_block_report("runtime.break_glass_restart"),
"blocker_kind": "permission_denied",
})
h, o, r = _resolve(remote, host, org, repo)
profile = get_profile()
active_role = _profile_role_kind(profile)
break_glass_env_auth = bool(
(os.environ.get("GITEA_BREAKGLASS_RESTART_AUTHORIZATION") or "").strip()
)
# 1. Privileged role authorization (explicit allowlist: controller only - B1)
if active_role not in PRIVILEGED_BREAK_GLASS_ROLES:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"active_role": active_role,
"reasons": [
f"role '{active_role}' is not in privileged break-glass allowlist "
f"({sorted(PRIVILEGED_BREAK_GLASS_ROLES)}); break-glass restart requires a privileged controller role (#664 AC1)"
],
"blocker_kind": "role_authorization",
})
# 2. Required fields and redaction (B8)
raw_reason = (reason or "").strip()
clean_reason = _redact(raw_reason)
if not raw_reason or len(raw_reason) < 10:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"reason is required and must be at least 10 characters long (#664 AC2)"
],
"blocker_kind": "missing_required_fields",
})
clean_confirmation = (confirmation or "").strip()
if clean_confirmation != BREAK_GLASS_CONFIRMATION_PHRASE:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
f"confirmation string mismatch; must equal exactly '{BREAK_GLASS_CONFIRMATION_PHRASE}' (#664 AC2)"
],
"blocker_kind": "confirmation_mismatch",
})
if not impact_ack:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"impact_ack must be True to acknowledge disruption of in-flight sessions (#664 AC2)"
],
"blocker_kind": "impact_ack_required",
})
# Gate incident issue creation on gitea.issue.create permission (B3)
if create_incident_issue:
create_block = _profile_operation_gate("gitea.issue.create")
if create_block:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": create_block,
"permission_report": _permission_block_report("gitea.issue.create"),
"blocker_kind": "permission_denied",
})
# Evaluate impact / disrupted sessions
impact_result = gitea_request_mcp_restart(
remote=remote,
host=host,
org=org,
repo=repo,
dry_run=True,
restart_class=restart_class,
request_break_glass=True,
)
disrupted_sessions = list(impact_result.get("affected_sessions") or [])
disrupted_count = len(disrupted_sessions)
identity = _authenticated_username(h) or profile.get("username") or "unknown"
now_iso = datetime.now(timezone.utc).isoformat()
ns_ctx = _resolve_namespace_mutation_context(worktree_path)
mcp_namespace = ns_ctx.get("mcp_namespace") or profile.get("profile_name") or "gitea-controller"
correlation_id = f"bg-{uuid.uuid4().hex[:12]}"
audit_payload = gitea_audit.redact({
"event": "break_glass_mcp_restart",
"correlation_id": correlation_id,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"timestamp": now_iso,
"reason": clean_reason,
"confirmation": clean_confirmation,
"restart_class": restart_class,
"disrupted_sessions_count": disrupted_count,
"disrupted_sessions": [
s.get("session_id") if isinstance(s, dict) else str(s)
for s in disrupted_sessions
],
"dry_run": dry_run,
"remote": remote,
"org": o,
"repo": r,
"env_auth_present": break_glass_env_auth,
})
# Dry-run handling (B7: no durable mutation)
if dry_run:
return gitea_audit.redact({
"success": True,
"performed": False,
"dry_run": True,
"break_glass_executed": False,
"would_execute": True,
"correlation_id": correlation_id,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reason": clean_reason,
"confirmation": clean_confirmation,
"disrupted_sessions_count": disrupted_count,
"disrupted_sessions": disrupted_sessions,
"audit_record": audit_payload,
"saved_audit": None,
"incident_issue": None,
"reconciliation_required": True,
"reconciliation_tool": "gitea_reconcile_after_restart",
"follow_up_issue_required": True,
"cross_references": ["#652", "#653", "#655", "#630", "#658", "#662", "#664"],
"reasons": ["break-glass restart dry-run evaluated successfully"],
})
# Fail closed if create_incident_issue is False on real execution (B5)
if not create_incident_issue:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"create_incident_issue=False is forbidden on real break-glass execution; "
"pre-execution incident creation is mandatory (#664 AC3)"
],
"blocker_kind": "incident_creation_required",
})
# B9: Fail closed if audit backend is disabled
if not gitea_audit.audit_enabled():
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"audit recording is disabled or unavailable; break-glass restart requires an enabled audit backend (#664 AC3)"
],
"blocker_kind": "audit_recording_failed",
})
# Pre-execution recording: Audit record in REQUESTED state (B4, B10)
pre_audit_event = gitea_audit.build_event(
action="break_glass_mcp_restart_requested",
result=gitea_audit.REQUESTED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason=clean_reason,
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=now_iso,
request_metadata={
"correlation_id": correlation_id,
"confirmation": clean_confirmation,
"restart_class": restart_class,
"disrupted_sessions_count": disrupted_count,
"disrupted_sessions": [
s.get("session_id") if isinstance(s, dict) else str(s)
for s in disrupted_sessions
],
},
)
audit_write_success = gitea_audit.write_event(pre_audit_event)
if not audit_write_success:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
"failed to persist required pre-execution audit event (#664 AC3)"
],
"blocker_kind": "audit_recording_failed",
})
# Pre-execution recording: Gitea Incident Issue (B5, B8)
issue_title = _redact(f"[INCIDENT] [REQUESTED] Break-glass MCP restart invoked by {identity} ({correlation_id})")
issue_body = _redact(
f"## Break-glass MCP restart incident report (#664)\n\n"
f"- **Correlation ID**: `{correlation_id}`\n"
f"- **Invoked by**: `{identity}` (role: `{active_role}`, namespace: `{mcp_namespace}`)\n"
f"- **Timestamp**: `{now_iso}`\n"
f"- **Reason**: {clean_reason}\n"
f"- **Confirmation**: `{clean_confirmation}`\n"
f"- **Disrupted Sessions Count**: `{disrupted_count}`\n\n"
f"### Mandatory Post-Restart Reconciliation (#662)\n"
f"Post-restart reconciliation must be executed via `gitea_reconcile_after_restart` "
f"to clean up orphaned leases, inspect worktree integrity, and handle disrupted work.\n\n"
f"### Cross-references\n"
f"Ref #652 #653 #655 #630 #658 #662 #664\n"
)
incident_issue_result = None
try:
incident_issue_result = api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues",
_auth(h),
{
"title": issue_title,
"body": issue_body,
"labels": ["incident", "mcp-health", "break-glass"],
},
)
if not isinstance(incident_issue_result, dict) or "number" not in incident_issue_result:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
f"incident issue creation failed (#664 AC3): {_redact(str(incident_issue_result))}"
],
"blocker_kind": "incident_creation_failed",
"incident_issue": incident_issue_result,
})
except Exception as exc:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": [
f"incident issue creation failed with exception (#664 AC3): {_redact(str(exc))}"
],
"blocker_kind": "incident_creation_failed",
"incident_issue": {"error": _redact(str(exc))},
})
incident_number = incident_issue_result.get("number")
# Execute or delegate canonical non-dry-run restart (B6/B11)
restart_exec_result = gitea_request_mcp_restart(
remote=remote,
host=host,
org=org,
repo=repo,
dry_run=False,
restart_class=restart_class,
request_break_glass=True,
)
apply_supported = bool(restart_exec_result.get("apply_supported", False))
apply_authorized = bool(restart_exec_result.get("apply_authorized", False))
exec_success = bool(restart_exec_result.get("success", False))
restart_performed = bool(
restart_exec_result.get("restart_performed", False)
or restart_exec_result.get("break_glass_executed", False)
)
if not apply_supported:
term_audit = gitea_audit.build_event(
action="break_glass_mcp_restart_terminal",
result=gitea_audit.FAILED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason="apply_unsupported",
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=datetime.now(timezone.utc).isoformat(),
request_metadata={
"correlation_id": correlation_id,
"incident_number": incident_number,
"break_glass_executed": False,
"blocker_kind": "apply_unsupported",
},
)
gitea_audit.write_event(term_audit)
if incident_number:
try:
api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments",
_auth(h),
{"body": _redact(f"**Terminal Status**: `apply_unsupported` - Restart coordinator does not support apply execution. No restart was performed. ({correlation_id})")},
)
except Exception:
pass
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reasons": [
"break-glass apply is unsupported by restart coordinator (apply_supported=False) (#664)",
*(restart_exec_result.get("reasons") or []),
],
"blocker_kind": "apply_unsupported",
"restart_result": restart_exec_result,
"incident_issue": incident_issue_result,
"audit_record": pre_audit_event,
})
if not apply_authorized or not exec_success or not restart_performed:
term_audit = gitea_audit.build_event(
action="break_glass_mcp_restart_terminal",
result=gitea_audit.FAILED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason="restart_delegation_failed",
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=datetime.now(timezone.utc).isoformat(),
request_metadata={
"correlation_id": correlation_id,
"incident_number": incident_number,
"break_glass_executed": False,
"blocker_kind": "restart_delegation_failed",
},
)
gitea_audit.write_event(term_audit)
if incident_number:
try:
api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments",
_auth(h),
{"body": _redact(f"**Terminal Status**: `restart_delegation_failed` - Restart execution failed or was denied. No restart was performed. ({correlation_id})")},
)
except Exception:
pass
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reasons": [
"delegated restart execution failed or was denied by coordinator (#664)",
*(restart_exec_result.get("reasons") or []),
],
"blocker_kind": "restart_delegation_failed",
"restart_result": restart_exec_result,
"incident_issue": incident_issue_result,
"audit_record": pre_audit_event,
})
# Restart occurred! Perform mandatory post-restart reconciliation (B10)
recon_result = None
try:
recon_result = gitea_reconcile_after_restart(
remote=remote,
host=host,
org=org,
repo=repo,
)
except Exception as exc:
recon_result = {"success": False, "error": _redact(str(exc))}
recon_success = bool(recon_result and isinstance(recon_result, dict) and recon_result.get("success", False))
if not recon_success:
term_audit = gitea_audit.build_event(
action="break_glass_mcp_restart_terminal",
result=gitea_audit.FAILED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason="reconciliation_failed",
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=datetime.now(timezone.utc).isoformat(),
request_metadata={
"correlation_id": correlation_id,
"incident_number": incident_number,
"break_glass_executed": True,
"reconciliation_success": False,
"blocker_kind": "reconciliation_failed",
},
)
gitea_audit.write_event(term_audit)
if incident_number:
try:
api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments",
_auth(h),
{"body": _redact(f"**Terminal Status**: `reconciliation_failed` - Restart was executed but post-restart reconciliation failed. ({correlation_id})")},
)
except Exception:
pass
return gitea_audit.redact({
"success": False,
"performed": True,
"break_glass_executed": True,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reasons": [
"break-glass restart executed but post-restart reconciliation failed (#664/#662)"
],
"blocker_kind": "reconciliation_failed",
"restart_result": restart_exec_result,
"reconciliation_result": recon_result,
"incident_issue": incident_issue_result,
"audit_record": pre_audit_event,
})
# Terminal audit append for successful execution + reconciliation
term_audit = gitea_audit.build_event(
action="break_glass_mcp_restart_terminal",
result=gitea_audit.SUCCEEDED,
remote=remote,
server=(gitea_url(h, "").rstrip("/") if h else None),
repository=r,
profile_name=profile.get("profile_name", "unknown"),
audit_label=profile.get("audit_label", "unknown"),
authenticated_username=identity,
reason=clean_reason,
mcp_namespace=mcp_namespace,
task_role=active_role,
operation="break_glass_mcp_restart",
now=datetime.now(timezone.utc).isoformat(),
request_metadata={
"correlation_id": correlation_id,
"incident_number": incident_number,
"break_glass_executed": True,
"reconciliation_success": True,
},
)
term_write_success = gitea_audit.write_event(term_audit)
if incident_number:
try:
api_request(
"POST",
f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments",
_auth(h),
{"body": _redact(f"**Terminal Status**: `succeeded` - Break-glass restart executed and reconciled successfully ({correlation_id}).")},
)
except Exception:
pass
if not term_write_success:
return gitea_audit.redact({
"success": False,
"performed": True,
"break_glass_executed": True,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reasons": [
"break-glass restart executed and reconciled but terminal audit recording failed (#664)"
],
"blocker_kind": "terminal_audit_failed",
"restart_result": restart_exec_result,
"reconciliation_result": recon_result,
"incident_issue": incident_issue_result,
"audit_record": pre_audit_event,
})
return gitea_audit.redact({
"success": True,
"performed": True,
"dry_run": False,
"break_glass_executed": True,
"would_execute": True,
"correlation_id": correlation_id,
"actor": identity,
"role": active_role,
"mcp_namespace": mcp_namespace,
"restart_class": restart_class,
"reason": clean_reason,
"confirmation": clean_confirmation,
"disrupted_sessions_count": disrupted_count,
"disrupted_sessions": disrupted_sessions,
"audit_record": term_audit,
"saved_audit": term_audit,
"incident_issue": incident_issue_result,
"reconciliation_result": recon_result,
"reconciliation_required": True,
"reconciliation_tool": "gitea_reconcile_after_restart",
"follow_up_issue_required": True,
"cross_references": ["#652", "#653", "#655", "#630", "#658", "#662", "#664"],
"reasons": [
"break-glass restart executed with incident creation and mandatory reconciliation"
],
})
# --- #662 post-restart reconciliation --------------------------------------- # --- #662 post-restart reconciliation ---------------------------------------
_POST_RESTART_LAST_PROOF: dict | None = None _POST_RESTART_LAST_PROOF: dict | None = None
+49
View File
@@ -16,6 +16,7 @@ Probe sources
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
@@ -57,8 +58,56 @@ SAFE_ENV_KEYS = (
"GITEA_SERVICE", "GITEA_SERVICE",
"GITEA_EXECUTION_ROLE", "GITEA_EXECUTION_ROLE",
"GITEA_MCP_CONFIG", "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: def _as_list(value: Any) -> list[str] | None:
if value is None: if value is None:
-9
View File
@@ -576,15 +576,6 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "runtime.record_analytics_usage", "permission": "runtime.record_analytics_usage",
"role": "author", "role": "author",
}, },
# #664: emergency break-glass MCP restart workflow (privileged controller role).
"break_glass_restart": {
"permission": "runtime.break_glass_restart",
"role": "controller",
},
"gitea_break_glass_restart": {
"permission": "runtime.break_glass_restart",
"role": "controller",
},
} }
+2
View File
@@ -41,6 +41,7 @@ def _reset_mutation_authority(monkeypatch):
"GITEA_REVIEWER_WORKTREE", "GITEA_REVIEWER_WORKTREE",
"GITEA_MERGER_WORKTREE", "GITEA_MERGER_WORKTREE",
"GITEA_RECONCILER_WORKTREE", "GITEA_RECONCILER_WORKTREE",
"GITEA_MCP_NAMESPACE",
]: ]:
monkeypatch.delenv(env_key, raising=False) monkeypatch.delenv(env_key, raising=False)
@@ -117,6 +118,7 @@ def _reset_mutation_authority(monkeypatch):
monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_ACTOR_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None)
monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {}) monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {})
monkeypatch.setattr(mcp_server, "_PROFILE_SWITCH_INVALIDATION", None)
monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False) monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False)
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False) monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None) monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
-548
View File
@@ -1,548 +0,0 @@
"""Tests for emergency break-glass MCP restart workflow (#664)."""
from __future__ import annotations
import os
import unittest
from unittest.mock import MagicMock, patch
import gitea_audit
import gitea_mcp_server
class TestBreakGlassRestart(unittest.TestCase):
"""Test suite for gitea_break_glass_restart tool and guardrails (#664)."""
def setUp(self) -> None:
self.env_patcher = patch.dict(os.environ, {}, clear=False)
self.env_patcher.start()
def tearDown(self) -> None:
self.env_patcher.stop()
def test_role_authorization_matrix_with_real_resolver(self) -> None:
"""AC1: Explicit allowlist enforcement using production _profile_role_kind resolver.
Privileged controller role passes role check.
Synthetic roles (operator, admin, sysadmin), ordinary LLM roles (author, reviewer, merger, reconciler), and unknown/malformed roles fail closed.
"""
allowed_profiles = [
{"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]},
{"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]},
]
for prof in allowed_profiles:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", return_value={"affected_sessions": []}
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart required due to deadlock in worker pool",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertTrue(
res["success"],
f"Profile {prof} should pass role check",
)
denied_profiles = [
{"role": "operator", "allowed_operations": ["gitea.read", "gitea.issue.create"]},
{"role": "admin", "allowed_operations": ["gitea.read", "gitea.issue.create"]},
{"role": "sysadmin", "allowed_operations": ["gitea.read", "gitea.issue.create"]},
{"role": "author", "allowed_operations": ["gitea.read", "gitea.issue.create"]},
{"role": "reviewer", "allowed_operations": ["gitea.read"]},
{"role": "merger", "allowed_operations": ["gitea.read"]},
{"role": "reconciler", "allowed_operations": ["gitea.read"]},
{"role": "guest", "allowed_operations": ["gitea.read"]},
{"role": "unknown", "allowed_operations": ["gitea.read"]},
{"role": "mixed", "allowed_operations": ["gitea.read"]},
{"role": "limited", "allowed_operations": ["gitea.read"]},
{"role": "", "allowed_operations": ["gitea.read"]},
{"allowed_operations": ["gitea.read"]}, # no declared role
]
for prof in denied_profiles:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart required due to deadlock in worker pool",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertFalse(
res["success"],
f"Profile {prof} should fail role check",
)
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "role_authorization")
self.assertIn("not in privileged break-glass allowlist", res["reasons"][0])
def test_env_var_cannot_grant_authorization_or_bypass_denial(self) -> None:
"""Requirement 5: GITEA_BREAKGLASS_RESTART_AUTHORIZATION cannot grant authorization or bypass role denial."""
os.environ["GITEA_BREAKGLASS_RESTART_AUTHORIZATION"] = "secret-bypass-token"
unprivileged_prof = {"role": "author", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
with patch.object(gitea_mcp_server, "get_profile", return_value=unprivileged_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart attempting env var bypass",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "role_authorization")
def test_reason_validation(self) -> None:
"""AC2: Reason is required and must be at least 10 characters long."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
for invalid_reason in ["", " ", "too short", "123456789"]:
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason=invalid_reason,
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "missing_required_fields")
def test_confirmation_validation(self) -> None:
"""AC2: Confirmation phrase must match exact required string."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart needed due to stuck daemon processes",
confirmation="wrong_confirmation_phrase",
impact_ack=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "confirmation_mismatch")
def test_impact_ack_validation(self) -> None:
"""AC2: impact_ack=True is mandatory."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart needed due to stuck daemon processes",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=False,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "impact_ack_required")
def test_incident_permission_gate(self) -> None:
"""Requirement 3 / B3: Gate incident creation on gitea.issue.create permission."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read"]}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", side_effect=lambda op: ["missing gitea.issue.create"] if op == "gitea.issue.create" else None
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart needed due to hung worker process cohort",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "permission_denied")
def test_redaction_across_surfaces(self) -> None:
"""Requirement 3: Redact operator-controlled reason before incident body and audit surfaces."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
raw_reason = "Emergency restart: token ghp_secretToken12345 and url https://user:[email protected]/api"
mock_api_request = MagicMock(return_value={"number": 101, "title": "[INCIDENT]"})
mock_restart_exec = {"success": True, "apply_supported": True, "apply_authorized": True, "restart_performed": True}
mock_recon = {"success": True}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", return_value=mock_restart_exec
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart", return_value=mock_recon
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason=raw_reason,
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertTrue(res["success"])
self.assertNotIn("ghp_secretToken12345", res["reason"])
self.assertNotIn("user:[email protected]", res["reason"])
self.assertIn("[REDACTED]", res["reason"])
# Verify incident body redaction
posted_body = mock_api_request.call_args[0][3]["body"]
self.assertNotIn("ghp_secretToken12345", posted_body)
self.assertNotIn("user:[email protected]", posted_body)
def test_audit_failure_before_execution_fails_closed(self) -> None:
"""Requirement 2: Audit recording failure stops execution fail-closed."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
mock_api_request = MagicMock()
mock_restart_exec = MagicMock()
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", side_effect=[{"affected_sessions": []}, mock_restart_exec]
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=False # Audit write fails!
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart with failing audit sink",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "audit_recording_failed")
mock_api_request.assert_not_called()
def test_incident_creation_failure_before_execution_fails_closed(self) -> None:
"""Requirement 2 / B5: Incident creation failure stops execution fail-closed."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
mock_restart_exec = MagicMock()
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", side_effect=[{"affected_sessions": []}, mock_restart_exec]
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request", side_effect=RuntimeError("Gitea 500 API Error")
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart with failing incident POST",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "incident_creation_failed")
def test_incident_opt_out_on_real_execution_fails_closed(self) -> None:
"""Requirement 2 / B5: create_incident_issue=False fails closed on real execution."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", return_value={"affected_sessions": []}
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart trying to skip incident creation",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=False,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "incident_creation_required")
def test_dry_run_truthfulness_and_no_durable_mutation(self) -> None:
"""Requirement 4 / B7: Dry-run returns preview without executing or creating durable records."""
controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}
mock_audit_write = MagicMock()
mock_api_request = MagicMock()
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", return_value={"affected_sessions": [{"session_id": "s1"}]}
), patch.object(
gitea_audit, "write_event", mock_audit_write
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart preview in dry-run mode",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=True,
remote="prgs",
)
self.assertTrue(res["success"])
self.assertTrue(res["dry_run"])
self.assertFalse(res["break_glass_executed"])
self.assertTrue(res["would_execute"])
self.assertIsNone(res["incident_issue"])
self.assertIsNone(res["saved_audit"])
mock_audit_write.assert_not_called()
mock_api_request.assert_not_called()
def test_successful_real_execution(self) -> None:
"""Requirement 4: Real execution calls canonical restart path and reports execution truthfully."""
controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]}
mock_api_request = MagicMock(return_value={"number": 555, "title": "[INCIDENT]"})
mock_restart_exec = {"success": True, "apply_supported": True, "apply_authorized": True, "restart_performed": True, "affected_sessions": []}
mock_recon = {"success": True, "reconciled": True}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", return_value=mock_restart_exec
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart", return_value=mock_recon
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged emergency break-glass restart execution",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertTrue(res["success"])
self.assertFalse(res["dry_run"])
self.assertTrue(res["performed"])
self.assertTrue(res["break_glass_executed"])
self.assertEqual(res["incident_issue"]["number"], 555)
def test_unsupported_apply_returns_truthful_unsupported_result(self) -> None:
"""B6/B11: apply_supported=False returns apply_unsupported blocker and break_glass_executed=False."""
controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]}
mock_api_request = MagicMock(return_value={"number": 555, "title": "[INCIDENT]"})
mock_restart_unsupported = {"success": True, "apply_supported": False, "apply_authorized": True, "restart_performed": False}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", side_effect=[{"affected_sessions": []}, mock_restart_unsupported]
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart request with unsupported coordinator apply",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "apply_unsupported")
def test_reconciliation_failure_after_execution(self) -> None:
"""B10: Post-restart reconciliation failure reports break_glass_executed=True but success=False."""
controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]}
mock_api_request = MagicMock(return_value={"number": 555, "title": "[INCIDENT]"})
mock_restart_exec = {"success": True, "apply_supported": True, "apply_authorized": True, "restart_performed": True}
mock_recon = {"success": False, "error": "lease cleanup failed"}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", return_value=mock_restart_exec
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart", return_value=mock_recon
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart request with failing reconciliation",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertTrue(res["performed"])
self.assertTrue(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "reconciliation_failed")
def test_bare_secret_redaction(self) -> None:
"""B8: Bare token shapes like ghp_... and sk-live-... are redacted on all surfaces."""
controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]}
raw_reason = "Emergency restart reason containing bare tokens ghp_1234567890abcdef12345678 and sk-live-abcdef1234567890abcdef"
mock_api_request = MagicMock(return_value={"number": 101, "title": "[INCIDENT]"})
mock_restart_exec = {"success": True, "apply_supported": True, "apply_authorized": True, "restart_performed": True}
mock_recon = {"success": True}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", return_value=mock_restart_exec
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart", return_value=mock_recon
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason=raw_reason,
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertTrue(res["success"])
self.assertNotIn("ghp_1234567890abcdef12345678", res["reason"])
self.assertNotIn("sk-live-abcdef1234567890abcdef", res["reason"])
self.assertIn("[REDACTED]", res["reason"])
def test_audit_disabled_fails_closed(self) -> None:
"""B9: Disabling audit recording blocks execution fail-closed."""
controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_audit, "audit_enabled", return_value=False
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Emergency restart with disabled audit logging",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "audit_recording_failed")
def test_capability_map_registration(self) -> None:
"""B12: Ensure task_capability_map maps gitea_break_glass_restart to runtime.break_glass_restart."""
from task_capability_map import TASK_CAPABILITY_MAP
entry = TASK_CAPABILITY_MAP.get("gitea_break_glass_restart")
self.assertIsNotNone(entry)
self.assertEqual(entry["permission"], "runtime.break_glass_restart")
self.assertEqual(entry["role"], "controller")
def test_delegated_execution_failure(self) -> None:
"""Requirement 4: Delegated restart execution failure produces distinct terminal state."""
controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]}
mock_api_request = MagicMock(return_value={"number": 555, "title": "[INCIDENT]"})
mock_impact_eval = {"affected_sessions": []}
mock_restart_denied = {"success": False, "apply_supported": True, "apply_authorized": False, "reasons": ["restart class denied"]}
with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object(
gitea_mcp_server, "_profile_operation_gate", return_value=None
), patch.object(
gitea_mcp_server, "_auth", return_value={"Authorization": "token test"}
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart", side_effect=[mock_impact_eval, mock_restart_denied]
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=True
), patch.object(
gitea_mcp_server, "api_request", mock_api_request
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged break-glass restart with denied delegation",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "restart_delegation_failed")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,274 @@
"""Regression coverage for #690: cross-role profile activation invalidation.
A mid-run profile switch (e.g. reviewer → author → reviewer) must invalidate
workflow-load proof, reviewer lease binding, review decision lock, live
namespace health, and preflight identity/capability stamps, and must require
a full reviewer preflight before any formal verdict. Namespace provenance
must be reported and fail closed on task/namespace mismatch.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_config
import mcp_namespace_health
import mcp_server
import mcp_session_state
import review_workflow_load
import reviewer_pr_lease
from tests.test_runtime_clarity import CONFIG_SWITCHING_ENABLED
class TestProfileSwitchReviewGuard(unittest.TestCase):
def setUp(self):
self._remotes_patch = patch.dict(mcp_server.REMOTES, {
"dadeschools": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"},
"prgs": {"host": "gitea.example.com", "org": "Example-Org", "repo": "Example-Repo"},
})
self._remotes_patch.start()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG_SWITCHING_ENABLED))
def tearDown(self):
self._remotes_patch.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
self._dir.cleanup()
def _env(self, profile="reviewer-profile"):
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
"GITEA_TOKEN_MERGER": "merger-pass",
}
def _seed_contaminated_review_state(self):
"""Simulate an in-flight reviewer run under reviewer-profile."""
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "reviewer"
mcp_server._preflight_resolved_task = "review_pr"
review_workflow_load._REVIEW_WORKFLOW_LOAD = {"loaded": True}
mcp_server._REVIEW_DECISION_LOCK = {
"session_profile": "reviewer-profile",
"final_review_decision_ready": True,
"ready_pr_number": 688,
}
reviewer_pr_lease.record_session_lease(
{"session_id": "lease-session-1", "pr_number": 688}
)
mcp_server._LIVE_NAMESPACE_HEALTH["gitea-reviewer"] = {
"namespace": "gitea-reviewer",
"healthy": True,
"ide_namespace_proven": True,
}
# Durable records keyed by the reviewer identity must also be cleared.
mcp_session_state.save_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
payload={"loaded": True},
profile_identity="reviewer-profile",
)
mcp_session_state.save_state(
kind=mcp_session_state.KIND_DECISION_LOCK,
payload={"final_review_decision_ready": True, "ready_pr_number": 688},
profile_identity="reviewer-profile",
)
def _activate(self, target, logins):
with patch.object(
mcp_server, "get_auth_header", side_effect=[f"token p" for _ in logins]
), patch.object(
mcp_server, "api_request", side_effect=[{"login": l} for l in logins]
), patch.object(
mcp_server,
"_workspace_repository_slug",
return_value="Example-Org/Example-Repo",
), patch.object(
mcp_server, "_canonical_repository_slug", return_value=(None, [])
):
return mcp_server.gitea_activate_profile(profile_name=target)
# -----------------------------------------------------------------
# AC1/AC2/AC3: switch invalidates review state; re-preflight required
# -----------------------------------------------------------------
def test_switch_invalidates_review_state_and_blocks_verdict(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
res = self._activate("author-profile", ["reviewer-user", "author-user"])
self.assertTrue(res["success"])
self.assertTrue(res["re_preflight_required"])
inv = res["review_state_invalidation"]
self.assertEqual(inv["from_profile"], "reviewer-profile")
self.assertEqual(inv["to_profile"], "author-profile")
for item in (
"preflight_identity_capability",
"review_workflow_load",
"review_decision_lock",
"reviewer_session_lease",
"live_namespace_health",
):
self.assertIn(item, inv["invalidated"])
# In-memory state cleared.
self.assertFalse(mcp_server._preflight_whoami_called)
self.assertFalse(mcp_server._preflight_capability_called)
self.assertIsNone(mcp_server._preflight_resolved_task)
self.assertIsNone(review_workflow_load._REVIEW_WORKFLOW_LOAD)
self.assertIsNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertIsNone(reviewer_pr_lease.get_session_lease())
self.assertEqual(mcp_server._LIVE_NAMESPACE_HEALTH, {})
self.assertIsNotNone(mcp_server._PROFILE_SWITCH_INVALIDATION)
# Durable records keyed by the reviewer identity are gone.
self.assertIsNone(
mcp_session_state.load_state(
kind=mcp_session_state.KIND_WORKFLOW_LOAD,
profile_identity="reviewer-profile",
)
)
self.assertIsNone(
mcp_session_state.load_state(
kind=mcp_session_state.KIND_DECISION_LOCK,
profile_identity="reviewer-profile",
)
)
# A formal verdict without re-preflight fails closed.
reasons = mcp_server.check_review_decision_gate(
688, "APPROVE", final_review_decision_ready=True
)
self.assertTrue(reasons)
def test_switch_back_cannot_resurrect_stale_review_run(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
self._activate("author-profile", ["reviewer-user", "author-user"])
res = self._activate("reviewer-profile", ["author-user", "reviewer-user"])
self.assertTrue(res["success"])
# The pre-switch review run must not reappear.
self.assertIsNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertIsNone(review_workflow_load._REVIEW_WORKFLOW_LOAD)
self.assertIsNone(reviewer_pr_lease.get_session_lease())
status = review_workflow_load.workflow_load_status()
self.assertFalse(status["workflow_load_valid"])
reasons = mcp_server.check_review_decision_gate(
688, "APPROVE", final_review_decision_ready=True
)
self.assertTrue(reasons)
def test_same_profile_reactivation_keeps_state(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
res = self._activate("reviewer-profile", ["reviewer-user", "reviewer-user"])
self.assertTrue(res["success"], res)
self.assertNotIn("review_state_invalidation", res)
self.assertIsNotNone(mcp_server._REVIEW_DECISION_LOCK)
self.assertTrue(mcp_server._preflight_whoami_called)
def test_clean_repreflight_after_switch_allows_gate(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
self._seed_contaminated_review_state()
self._activate("author-profile", ["reviewer-user", "author-user"])
self._activate("reviewer-profile", ["author-user", "reviewer-user"])
# Re-establish the full reviewer preflight under the new profile.
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reviewer", resolved_task="review_pr"
)
mcp_server.init_review_decision_lock("dadeschools", "review_pr")
lock = mcp_server._load_review_decision_lock()
self.assertIsNotNone(lock)
lock.update(
{
"final_review_decision_ready": True,
"ready_pr_number": 688,
"ready_action": "APPROVE",
"ready_remote": "dadeschools",
"ready_org": "Example-Org",
"ready_repo": "Example-Repo",
}
)
mcp_server._save_review_decision_lock(lock)
with patch.object(
mcp_server, "_review_workflow_load_gate_reasons", return_value=[]
):
reasons = mcp_server.check_review_decision_gate(
688,
"APPROVE",
final_review_decision_ready=True,
remote="dadeschools",
)
self.assertEqual(reasons, [])
# -----------------------------------------------------------------
# AC4: namespace provenance reporting + fail-closed mismatch
# -----------------------------------------------------------------
def test_namespace_provenance_mismatch_detection(self):
prov = mcp_namespace_health.namespace_provenance(
task="review_pr",
active_profile="reviewer-profile",
env={"GITEA_MCP_NAMESPACE": "gitea-author"},
)
self.assertTrue(prov["mismatch"])
self.assertEqual(prov["required_namespace"], "gitea-reviewer")
prov_ok = mcp_namespace_health.namespace_provenance(
task="review_pr",
active_profile="reviewer-profile",
env={"GITEA_MCP_NAMESPACE": "gitea-reviewer"},
)
self.assertFalse(prov_ok["mismatch"])
prov_unknown = mcp_namespace_health.namespace_provenance(
task="review_pr", active_profile="reviewer-profile", env={}
)
self.assertIsNone(prov_unknown["configured_namespace"])
self.assertFalse(prov_unknown["mismatch"])
self.assertEqual(prov_unknown["namespace_source"], "unknown")
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_whoami_reports_namespace_provenance(self, _auth, _api):
env = self._env("reviewer-profile")
env["GITEA_MCP_NAMESPACE"] = "gitea-reviewer"
with patch.dict(os.environ, env, clear=True):
res = mcp_server.gitea_whoami(remote="dadeschools")
prov = res["namespace_provenance"]
self.assertEqual(prov["configured_namespace"], "gitea-reviewer")
self.assertEqual(prov["active_profile"], "reviewer-profile")
self.assertFalse(prov["mismatch"])
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_resolve_fails_closed_on_namespace_mismatch(self, _auth, _api):
env = self._env("reviewer-profile")
env["GITEA_MCP_NAMESPACE"] = "gitea-author"
with patch.dict(os.environ, env, clear=True):
res = mcp_server.gitea_resolve_task_capability(
task="review_pr", kwargs="{}", remote="dadeschools"
)
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["namespace_provenance"]["mismatch"])
self.assertTrue(
any("namespace" in g for g in res["task_role_guidance"])
)
if __name__ == "__main__":
unittest.main()