Compare commits

..
Author SHA1 Message Date
sysadmin e423dd5870 fix(mcp): remediate B8, B6/B11, and B15 break-glass restart blockers (#664)
- B8: Correct redaction boundary for GITEA_TOKEN= and URI userinfo without destroying adjacent audit evidence or benign sec- text
- B6/B11: Remove false restart execution claims from default executor when GITEA_SANCTIONED_RESTART_HOOK is non-empty
- B15: Document deployable production grant set (runtime.break_glass_restart and gitea.issue.create) for prgs-controller
- Preserve B13, B1, B14 and previously accepted corrections
2026-07-29 01:23:10 -04:00
sysadminandClaude Opus 4.8 c67f39b40e fix(#664): remediate PR #908 review #641 blockers B13, B1, B14, B6/B11, and B8
Register runtime.break_glass_restart in the multi-service operation normalizer
and enforce it through the real profile gate. Authorize only the exact trusted
prgs-controller profile plus that capability; remove substring controller
authority so declared reconciler roles and cleanup_merged_pr_branch semantics
are preserved. Route non-dry-run apply through a canonical injectable executor
delegate with truthful execution flags. Correct under/over-redaction for
credentials while preserving benign sec- text.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-28 23:33:25 -04:00
sysadmin 4463a300ba fix(#664): remediate break-glass restart workflow blockers B1, B6/B11, B9, B10, B8, and B12 2026-07-28 22:44:43 -04:00
sysadmin 75794609d1 fix(mcp): remediate break-glass restart authorization and audit findings (#664) 2026-07-28 21:45:31 -04:00
jcwalker3 da3294fbe5 Merge branch 'master' into feat/issue-664-break-glass-restart 2026-07-28 08:39:10 -05:00
sysadmin c1ecadce8e feat(mcp): implement emergency break-glass MCP restart workflow (#664) 2026-07-25 17:06:38 -04:00
12 changed files with 1835 additions and 557 deletions
-41
View File
@@ -589,47 +589,6 @@ 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,24 +86,3 @@ 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,6 +144,19 @@ 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:
- **Authorization (#664 AC1 / B1 / B13 / B15)**: Requires the exact trusted profile `prgs-controller` **and** explicit `runtime.break_glass_restart` and `gitea.issue.create` grants enforced by the real production operation gate (no `gitea.read` fallback). Incident creation is mandatory prior to execution (`gitea.issue.create`), so the deployable production policy for `prgs-controller` includes `allowed_operations`: `["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.issue.comment", "gitea.issue.create", "runtime.break_glass_restart", "gitea.branch.delete", "gitea.decision_lock.irrecoverable_recovery"]`. Ordinary roles, non-controller reconcilers, lookalike profile names (`fake-controller`, …), and env vars cannot authorize. A narrow break-glass capability does **not** redefine the profile's declared global role. Updating a live running `prgs-controller` profile in production requires an operator configuration update and daemon reload post-merge.
- **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
@@ -160,3 +173,4 @@ 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).
+99 -10
View File
@@ -27,13 +27,73 @@ 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",
"passwd",
"pwd",
"secret",
"authorization",
"auth",
"api_key",
"apikey",
"access_key",
"private_key",
"client_secret",
"credential",
)
# 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 ") # Space-terminated scheme prefixes only. Colon forms (``token:`` / ``api_key:``)
# and ``Authorization: Bearer …`` are handled by ``_ASSIGNMENT_SECRET_PATTERN``
# so policy/docs text that merely *names* a scheme is not itself flagged as a
# live secret by console detectors.
_SECRET_VALUE_PREFIXES = (
"token ",
"Basic ",
"Bearer ",
)
# Bare token-shaped values only — never a broad ``sec-`` prefix that erases
# ordinary words (#664 B8 over-redaction).
_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')\b'
)
# Key/value credentials embedded in free text (password=..., api_key: ..., GITEA_TOKEN=..., etc.).
# Group 1 captures the key name (e.g. GITEA_TOKEN, password, api_key).
# Group 2 captures delimiter/whitespace (=, : ).
# Group 3 captures the secret value, stopping at whitespace or non-secret delimiters (&, ;, ,, quotes, closing brackets).
_ASSIGNMENT_SECRET_PATTERN = re.compile(
r'(?i)\b([A-Za-z0-9_]*?(?:token|password|passwd|pwd|secret|api[_-]?key|access[_-]?key|'
r'client[_-]?secret|private[_-]?key|authorization|credential))\b(\s*[:=]\s*)('
r'"[^"]*"|\'[^\']*\'|'
r'(?:Bearer|Basic|Token)\s+[^\s;&,"\'\)\}\]\>]+|'
r'[^\s;&,"\'\)\}\]\>]+'
r')'
)
# Connection-string style credentials: Password=...; User ID=...; etc.
_CONN_STRING_SECRET_PATTERN = re.compile(
r'(?i)\b((?:password|pwd|user\s*id|uid|username|account)\s*=\s*)([^\s;\'"]+)'
)
# Known synthetic test-only domains/hostnames to preserve # Known synthetic test-only domains/hostnames to preserve
_SYNTHETIC_HOSTS = { _SYNTHETIC_HOSTS = {
@@ -59,7 +119,8 @@ def redact_urls(text: str) -> str:
if not isinstance(text, str) or not text: if not isinstance(text, str) or not text:
return text return text
url_pattern = re.compile(r'(https?://[^\s)>\]}]+)', re.IGNORECASE) # Match any URI scheme (http, https, postgres, mysql, mongodb, redis, etc.)
url_pattern = re.compile(r'([a-z0-9\+\.\-]+://[^\s)>\]}]+)', re.IGNORECASE)
def replace_url(match): def replace_url(match):
url_str = match.group(1) url_str = match.group(1)
@@ -73,11 +134,11 @@ def redact_urls(text: str) -> str:
is_synthetic = True is_synthetic = True
break break
if is_synthetic: if is_synthetic or (parsed.username or parsed.password) or parsed.scheme.lower() not in ("http", "https"):
# Rebuild synthetic URL to redact any credentials or query secrets # Rebuild URL to redact any credentials or query secrets
new_netloc = parsed.netloc new_netloc = parsed.netloc
if parsed.username or parsed.password: if parsed.username or parsed.password:
netloc_clean = parsed.hostname netloc_clean = parsed.hostname or ""
if parsed.port: if parsed.port:
netloc_clean = f"{netloc_clean}:{parsed.port}" netloc_clean = f"{netloc_clean}:{parsed.port}"
new_netloc = f"[REDACTED_USER]:[REDACTED_PASS]@{netloc_clean}" new_netloc = f"[REDACTED_USER]:[REDACTED_PASS]@{netloc_clean}"
@@ -114,23 +175,51 @@ def redact_urls(text: str) -> str:
return out return out
def _mask_assignment(match: re.Match) -> str:
"""Keep the key and separator; replace only the secret value."""
val = match.group(3)
if val.startswith(REDACTED) or val.startswith("%5BREDACTED") or val.startswith("[REDACTED"):
return f"{match.group(1)}{match.group(2)}{val}"
return f"{match.group(1)}{match.group(2)}{REDACTED}"
def _mask_conn_secret(match: re.Match) -> str:
"""Keep the connection-string key; replace only the credential value."""
val = match.group(2)
if val.startswith(REDACTED) or val.startswith("%5BREDACTED") or val.startswith("[REDACTED"):
return f"{match.group(1)}{val}"
return f"{match.group(1)}{REDACTED}"
def _redact_str(text): def _redact_str(text):
"""Redact anything that looks like an Authorization credential or raw URL in *text*.""" """Redact credentials, bare token shapes, and raw URLs in *text* (#664 B8).
Covers key/value credentials, authorization/bearer material, bare
token-shaped values, connection-string credentials, and secrets embedded
in larger sentences. Deliberately does **not** erase ordinary words that
merely begin with a broad ``sec-`` prefix.
"""
if not isinstance(text, str) or not text: if not isinstance(text, str) or not text:
return text return text
out = text out = redact_urls(text)
out = _BARE_SECRET_PATTERN.sub(REDACTED, out)
out = _ASSIGNMENT_SECRET_PATTERN.sub(_mask_assignment, out)
out = _CONN_STRING_SECRET_PATTERN.sub(_mask_conn_secret, out)
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.find(prefix, idx) i = out_lower.find(prefix_lower, 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 out
def redact(value): def redact(value):
+32 -3
View File
@@ -97,6 +97,26 @@ GITEA_OPERATION_ALIASES = {
_REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"}) _REVIEW_MERGE_OPS = frozenset({"gitea.pr.approve", "gitea.pr.merge"})
_AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"}) _AUTHOR_ONLY_OPS = frozenset({"gitea.pr.create", "gitea.branch.push"})
# First-class operation services that may appear in multi-service profile
# allowlists. ``runtime.*`` is the control-plane capability namespace used by
# non-Gitea MCP tools such as ``runtime.break_glass_restart`` (#664 B13).
# Unknown foreign prefixes (e.g. ``jenkins.*`` under service=gitea) still fail
# closed — they are not registered here.
KNOWN_OPERATION_SERVICES = frozenset({"gitea", "runtime"})
def service_for_operation(op, default="gitea"):
"""Return the registered service prefix for a fully-qualified *op*.
Unqualified names and unknown prefixes fall back to *default* so callers
keep the historical Gitea-centric gate behaviour.
"""
if isinstance(op, str) and "." in op:
prefix = op.split(".", 1)[0]
if prefix in KNOWN_OPERATION_SERVICES:
return prefix
return default
def normalize_operation(op, service="gitea"): def normalize_operation(op, service="gitea"):
"""Return the canonical namespaced name for *op*, or fail closed (#106). """Return the canonical namespaced name for *op*, or fail closed (#106).
@@ -133,6 +153,12 @@ def check_operation(op, allowed, forbidden=(), service="gitea"):
Reasons: ``allowed``, ``invalid-operation``, ``invalid-forbidden-entry``, Reasons: ``allowed``, ``invalid-operation``, ``invalid-forbidden-entry``,
``forbidden``, ``no-allowed-operations``, ``not-allowed``. ``forbidden``, ``no-allowed-operations``, ``not-allowed``.
Multi-service profile allowlists (#664 B13): each allow/forbid entry is
normalized with its own registered service prefix (``gitea.*`` or
``runtime.*``) so a gate defaulting to service=gitea can still enforce an
exact ``runtime.break_glass_restart`` grant. Unknown / misspelled
operations and foreign service prefixes remain fail-closed.
Fail-closed rules: Fail-closed rules:
- an *op* that cannot be normalized is denied (``invalid-operation``) - an *op* that cannot be normalized is denied (``invalid-operation``)
- a forbidden entry that cannot be normalized denies the request - a forbidden entry that cannot be normalized denies the request
@@ -143,14 +169,16 @@ def check_operation(op, allowed, forbidden=(), service="gitea"):
- ``forbidden`` always overrides ``allowed`` - ``forbidden`` always overrides ``allowed``
- an empty or missing allowed list denies everything - an empty or missing allowed list denies everything
""" """
op_service = service_for_operation(op, default=service)
try: try:
op_n = normalize_operation(op, service) op_n = normalize_operation(op, op_service)
except ConfigError: except ConfigError:
return (False, "invalid-operation") return (False, "invalid-operation")
forbidden_n = set() forbidden_n = set()
for entry in (forbidden or ()): for entry in (forbidden or ()):
try: try:
forbidden_n.add(normalize_operation(entry, service)) entry_service = service_for_operation(entry, default=service)
forbidden_n.add(normalize_operation(entry, entry_service))
except ConfigError: except ConfigError:
return (False, "invalid-forbidden-entry") return (False, "invalid-forbidden-entry")
if op_n in forbidden_n: if op_n in forbidden_n:
@@ -160,7 +188,8 @@ def check_operation(op, allowed, forbidden=(), service="gitea"):
allowed_n = set() allowed_n = set()
for entry in allowed: for entry in allowed:
try: try:
allowed_n.add(normalize_operation(entry, service)) entry_service = service_for_operation(entry, default=service)
allowed_n.add(normalize_operation(entry, entry_service))
except ConfigError: except ConfigError:
continue continue
if op_n in allowed_n: if op_n in allowed_n:
+691 -154
View File
@@ -233,28 +233,50 @@ def _effective_workspace_role() -> str:
) )
# Exact production profile → role mapping used only when no declared role is
# present. Substring lookalikes (fake-controller, not-controller, …) never
# match (#664 B1/B14).
_EXACT_PROFILE_ROLE_NAMES = {
"prgs-controller": "controller",
"prgs-reconciler": "reconciler",
"prgs-author": "author",
"prgs-reviewer": "reviewer",
"prgs-merger": "merger",
"mdcps-author": "author",
"mdcps-reviewer": "reviewer",
"mdcps-merger": "merger",
"controller": "controller",
"reconciler": "reconciler",
"author": "author",
"reviewer": "reviewer",
"merger": "merger",
}
# Exact trusted profile that may hold break-glass (#664 B1). Capability
# ``runtime.break_glass_restart`` is still required and enforced by the real
# operation gate; this set only rejects lookalike profile names.
TRUSTED_BREAK_GLASS_PROFILES = frozenset({"prgs-controller"})
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`` always wins so a controller profile is Declared ``role`` / ``role_kind`` always wins so a controller profile is
never reclassified as reconciler from permission inference (#840). never reclassified as reconciler from permission inference (#840). Exact
match only profile-name / role *substrings* never grant controller (or
any) authority (#664 B1/B14). Lookalikes such as ``fake-controller``,
``not-controller``, or ``xcontrollerx`` do not become controller.
""" """
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 role: if role:
# Normalize aliases / case. # Exact aliases only; never ``"control" in role`` (matches
if "control" in role: # "not-controller" / "control-plane").
if role in ("controller", "control"):
return "controller" return "controller"
return role return role
profile_name = (profile.get("profile_name") or "").strip().lower() profile_name = (profile.get("profile_name") or "").strip().lower()
for candidate in ( if profile_name in _EXACT_PROFILE_ROLE_NAMES:
"controller", return _EXACT_PROFILE_ROLE_NAMES[profile_name]
"reconciler",
"merger",
"reviewer",
"author",
):
if candidate in profile_name:
return candidate
return _role_kind( return _role_kind(
profile.get("allowed_operations") or [], profile.get("allowed_operations") or [],
profile.get("forbidden_operations") or [], profile.get("forbidden_operations") or [],
@@ -839,75 +861,6 @@ 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,
@@ -7190,35 +7143,17 @@ def terminal_review_hard_stop_reasons(
] ]
# Patterns scrubbed from any surfaced error text so a credential can never leak.
_SECRET_PREFIXES = ("token ", "Basic ")
def _redact(text: str) -> str: def _redact(text: str) -> str:
"""Strip anything that looks like an Authorization credential or raw URL from *text*. """Strip credentials, bare token shapes, and raw URLs from *text* (#664 B8).
Errors raised by ``api_request`` echo the server response body, not the Defers to the canonical :mod:`gitea_audit` redactor so MCP tool results,
request headers, so a token should never appear this is defence in depth incidents, audits, and delegated error surfaces share one boundary.
so a future change can't leak ``token …`` / ``Basic …`` material into a
tool result or log line.
""" """
if not text: if not text:
return text return text
out = text
for prefix in _SECRET_PREFIXES:
idx = 0
while True:
i = out.find(prefix, idx)
if i == -1:
break
j = i + len(prefix)
while j < len(out) and not out[j].isspace():
j += 1
out = out[:i] + prefix + "[REDACTED]" + out[j:]
idx = i + len(prefix) + len("[REDACTED]")
# Redact raw URLs, query secrets, hostnames, etc.
import gitea_audit import gitea_audit
return gitea_audit.redact_urls(out) redacted = gitea_audit._redact_str(str(text))
return redacted if isinstance(redacted, str) else str(redacted)
# Review states that carry a submitted verdict. Gitea also emits PENDING # Review states that carry a submitted verdict. Gitea also emits PENDING
@@ -18177,11 +18112,6 @@ 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()
@@ -19086,11 +19016,6 @@ 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:
@@ -19601,17 +19526,6 @@ 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",
@@ -19622,12 +19536,11 @@ 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,
) )
result = { return {
"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,
@@ -19637,18 +19550,6 @@ 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()
@@ -21938,22 +21839,12 @@ 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
@@ -22004,8 +21895,6 @@ 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:
@@ -22088,8 +21977,6 @@ 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(
@@ -22156,7 +22043,6 @@ 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.
@@ -24040,6 +23926,657 @@ def gitea_request_mcp_restart(
return payload return payload
BREAK_GLASS_CONFIRMATION_PHRASE = "I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION"
# Test-injectable canonical break-glass executor/delegate (#664 B6/B11).
# Production default never signals the live MCP cohort.
_break_glass_restart_executor = None
def _default_break_glass_restart_executor(request: dict) -> dict:
"""Canonical non-dry-run break-glass executor/delegate (#664 B6/B11).
Never kills, signals, or restarts the live MCP cohort in-process.
An environment string alone (``GITEA_SANCTIONED_RESTART_HOOK``) does not
prove restart execution without an active confirmed delegate handoff.
"""
hook = (os.environ.get("GITEA_SANCTIONED_RESTART_HOOK") or "").strip()
if hook:
return {
"success": False,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": False,
"break_glass_executed": False,
"execution_mode": "accepted_not_executed",
"host_hook_configured": True,
"reasons": [
"sanctioned host restart hook reference is configured, but environment text "
"cannot prove execution without a confirmed delegate handoff (fail closed) (#664 B6/B11)"
],
}
return {
"success": False,
"apply_supported": False,
"apply_authorized": False,
"restart_performed": False,
"break_glass_executed": False,
"execution_mode": "unsupported",
"reasons": [
"break-glass apply is unsupported: no sanctioned host restart "
"executor is configured (#664)"
],
}
def _run_break_glass_restart_executor(request: dict) -> dict:
"""Invoke the installed or default break-glass executor (test-injectable)."""
executor = _break_glass_restart_executor or _default_break_glass_restart_executor
result = executor(request)
if not isinstance(result, dict):
return {
"success": False,
"apply_supported": True,
"apply_authorized": False,
"restart_performed": False,
"break_glass_executed": False,
"reasons": ["break-glass executor returned a non-dict result (fail closed)"],
}
return result
@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. Exact ``runtime.break_glass_restart`` capability on the trusted
``prgs-controller`` profile (ordinary roles, lookalike names, env vars,
and non-controller reconcilers 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 via the canonical executor/delegate and
mandatory post-restart reconciliation (#662).
"""
# B13: real production gate for the exact canonical operation — never
# fall back to gitea.read and never stub this gate in production.
capability_block = _profile_operation_gate("runtime.break_glass_restart")
if capability_block:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"reasons": capability_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)
profile_name = (
(profile.get("profile_name") or profile.get("execution_profile") or "")
.strip()
)
break_glass_env_auth = bool(
(os.environ.get("GITEA_BREAKGLASS_RESTART_AUTHORIZATION") or "").strip()
)
# Env is never an authorization channel for this endpoint (B2/B1).
_ = break_glass_env_auth
# B1: exact trusted profile only — not role substring, not lookalike names.
if profile_name not in TRUSTED_BREAK_GLASS_PROFILES:
return gitea_audit.redact({
"success": False,
"performed": False,
"break_glass_executed": False,
"active_role": active_role,
"profile_name": profile_name or None,
"reasons": [
f"profile '{profile_name or '(unset)'}' is not the trusted "
f"break-glass profile {sorted(TRUSTED_BREAK_GLASS_PROFILES)}; "
"lookalike names, ordinary roles, env vars, and non-controller "
"reconcilers cannot authorize break-glass (#664 AC1/B1)"
],
"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")
# B6/B11: reach the canonical non-dry-run executor/delegate.
# Authorization and apply_authorized do not mean execution occurred.
# Dry-run never reaches this path (returned earlier).
restart_exec_result = _run_break_glass_restart_executor({
"remote": remote,
"host": host,
"org": o,
"repo": r,
"restart_class": restart_class,
"correlation_id": correlation_id,
"reason": clean_reason,
"confirmation": clean_confirmation,
"profile_name": profile_name,
"active_role": active_role,
"incident_number": incident_number,
"disrupted_sessions_count": disrupted_count,
"request_break_glass": True,
"dry_run": False,
})
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))
# break_glass_executed is true only when the executor contract proves
# execution (or accepted host delegation) occurred.
restart_performed = bool(
restart_exec_result.get("restart_performed", False)
and 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,7 +16,6 @@ Probe sources
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
@@ -58,56 +57,8 @@ 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:
+8 -3
View File
@@ -37,12 +37,17 @@ def normalize_role_kind(
*, *,
profile_name: str | None = None, profile_name: str | None = None,
) -> str: ) -> str:
"""Map profile/task role to a workspace namespace key.""" """Map profile/task role to a workspace namespace key.
Exact profile-name matches only for controller routing (#840 / #664 B1):
substring lookalikes such as ``fake-controller`` must not become
controller.
"""
role = (role_kind or "author").strip().lower() role = (role_kind or "author").strip().lower()
profile = (profile_name or "").strip().lower() profile = (profile_name or "").strip().lower()
if role == "reviewer" and "merger" in profile: if role == "reviewer" and profile in ("prgs-merger", "mdcps-merger", "merger"):
return "merger" return "merger"
if "controller" in profile or role == "controller": if role == "controller" or profile in ("prgs-controller", "controller"):
return "controller" return "controller"
if role in ROLE_WORKTREE_ENVS: if role in ROLE_WORKTREE_ENVS:
return role return role
+9
View File
@@ -576,6 +576,15 @@ 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,7 +41,6 @@ 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)
@@ -118,7 +117,6 @@ 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)
+982
View File
@@ -0,0 +1,982 @@
"""Tests for emergency break-glass MCP restart workflow (#664).
Regression suite for review #641 remediation: B13, B1, B14, B6/B11, B8, and
preservation of previously accepted B2/B3/B5/B7/B9/B10 corrections.
"""
from __future__ import annotations
import os
import unittest
from unittest.mock import MagicMock, patch
import gitea_audit
import gitea_config
import gitea_mcp_server
def _controller_profile(**extra) -> dict:
base = {
"profile_name": "prgs-controller",
"execution_profile": "prgs-controller",
"role": "reconciler", # declared role must not be redefined by capability
"allowed_operations": [
"gitea.read",
"gitea.issue.create",
"gitea.branch.delete",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
"runtime.break_glass_restart",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
],
}
base.update(extra)
return base
def _gate_open_patches():
"""Keep master-parity / runtime-mode blocks out of unit tests."""
return (
patch.object(gitea_mcp_server, "_master_parity_block", return_value=[]),
patch.object(gitea_mcp_server, "_runtime_mode_block", return_value=[]),
patch.object(gitea_mcp_server, "_try_auto_switch_for_operation", return_value=False),
)
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()
os.environ.pop("GITEA_BREAKGLASS_RESTART_AUTHORIZATION", None)
os.environ.pop("GITEA_SANCTIONED_RESTART_HOOK", None)
os.environ.pop("GITEA_AUDIT_LOG", None)
# Reset injectable executor between tests.
gitea_mcp_server._break_glass_restart_executor = None
def tearDown(self) -> None:
gitea_mcp_server._break_glass_restart_executor = None
self.env_patcher.stop()
# ── B13: operation registration / real gate ───────────────────────────
def test_normalize_operation_accepts_canonical_break_glass_op(self) -> None:
"""B13: production normalizer accepts exact runtime.break_glass_restart."""
self.assertEqual(
gitea_config.normalize_operation(
"runtime.break_glass_restart", service="runtime"
),
"runtime.break_glass_restart",
)
ok, reason = gitea_config.check_operation(
"runtime.break_glass_restart",
["gitea.read", "runtime.break_glass_restart"],
)
self.assertTrue(ok, reason)
self.assertEqual(reason, "allowed")
def test_normalize_unknown_and_misspelled_ops_fail_closed(self) -> None:
"""B13: unknown / misspelled operations fail closed (no gitea.read fallback)."""
# Well-formed but misspelled runtime op normalizes, then is not allowed.
ok, reason = gitea_config.check_operation(
"runtime.break_glass_restar", # misspelled
["gitea.read", "runtime.break_glass_restart"],
)
self.assertFalse(ok)
self.assertEqual(reason, "not-allowed")
ok2, reason2 = gitea_config.check_operation(
"runtime.break_glass_restart",
["gitea.read"], # capability not granted
)
self.assertFalse(ok2)
self.assertEqual(reason2, "not-allowed")
ok3, reason3 = gitea_config.check_operation(
"frobnicate",
["gitea.read", "runtime.break_glass_restart"],
)
self.assertFalse(ok3)
self.assertEqual(reason3, "invalid-operation")
# gitea.read grant alone never authorizes break-glass.
ok4, reason4 = gitea_config.check_operation(
"runtime.break_glass_restart",
["gitea.read"],
)
self.assertFalse(ok4)
self.assertNotEqual(reason4, "allowed")
def test_real_profile_operation_gate_without_stubbing(self) -> None:
"""B13: exercise real _profile_operation_gate (not stubbed)."""
allowed = _controller_profile()
denied = _controller_profile(
allowed_operations=["gitea.read", "gitea.issue.create"]
)
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=allowed):
self.assertEqual(
gitea_mcp_server._profile_operation_gate(
"runtime.break_glass_restart"
),
[],
)
with patch.object(gitea_mcp_server, "get_profile", return_value=denied):
reasons = gitea_mcp_server._profile_operation_gate(
"runtime.break_glass_restart"
)
self.assertTrue(reasons)
self.assertTrue(
any("runtime.break_glass_restart" in r or "not allowed" in r
for r in reasons)
)
def test_entry_point_uses_same_operation_as_gate(self) -> None:
"""B13: entry point enforces runtime.break_glass_restart, not gitea.read."""
# Profile has gitea.read but not the break-glass capability.
prof = _controller_profile(
allowed_operations=["gitea.read", "gitea.issue.create"]
)
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
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"])
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "permission_denied")
self.assertNotIn("gitea.read", " ".join(res.get("reasons") or []))
# ── B1 / B14: exact profile auth, no substring, role preservation ─────
def test_trusted_prgs_controller_authorized(self) -> None:
"""B1: exact trusted prgs-controller with capability is authorized."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
):
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"], res)
self.assertFalse(res["break_glass_executed"])
def test_fabricated_controller_like_profile_names_denied(self) -> None:
"""B1: lookalike profile names never become authorized."""
p_parity, p_runtime, p_switch = _gate_open_patches()
for name in (
"fake-controller",
"controller-copy",
"not-controller",
"xcontrollerx",
"CONTROLLER",
"prgs-controller-copy",
):
prof = _controller_profile(profile_name=name, execution_profile=name)
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
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"], name)
self.assertEqual(res["blocker_kind"], "role_authorization", name)
self.assertFalse(res["break_glass_executed"])
def test_ordinary_and_non_controller_reconciler_denied(self) -> None:
"""B1: ordinary roles and non-controller reconcilers are denied."""
p_parity, p_runtime, p_switch = _gate_open_patches()
denied = [
{"profile_name": "prgs-author", "role": "author",
"allowed_operations": ["gitea.read", "runtime.break_glass_restart"],
"forbidden_operations": []},
{"profile_name": "prgs-reviewer", "role": "reviewer",
"allowed_operations": ["gitea.read", "runtime.break_glass_restart"],
"forbidden_operations": []},
{"profile_name": "prgs-merger", "role": "merger",
"allowed_operations": ["gitea.read", "runtime.break_glass_restart"],
"forbidden_operations": []},
{"profile_name": "prgs-reconciler", "role": "reconciler",
"allowed_operations": [
"gitea.read", "gitea.branch.delete", "runtime.break_glass_restart"
],
"forbidden_operations": []},
{"profile_name": "prgs-controller", "role": "reconciler",
"allowed_operations": ["gitea.read"],
"forbidden_operations": []}, # no capability
]
for prof in denied:
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
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"], prof)
self.assertFalse(res["break_glass_executed"], prof)
self.assertIn(
res["blocker_kind"],
("role_authorization", "permission_denied"),
prof,
)
def test_env_var_cannot_grant_authorization_or_bypass_denial(self) -> None:
"""B2 preserved: env var cannot grant break-glass authorization."""
os.environ["GITEA_BREAKGLASS_RESTART_AUTHORIZATION"] = "secret-bypass-token"
prof = {
"profile_name": "prgs-author",
"role": "author",
"allowed_operations": [
"gitea.read", "gitea.issue.create", "runtime.break_glass_restart"
],
"forbidden_operations": [],
}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
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_profile_role_kind_no_substring_authority(self) -> None:
"""B1/B14: substring lookalikes do not become controller."""
for name in (
"fake-controller",
"controller-copy",
"not-controller",
"xcontrollerx",
"myCONTROLLER",
):
prof = {
"profile_name": name,
"role": "author",
"allowed_operations": ["gitea.read"],
}
self.assertEqual(
gitea_mcp_server._profile_role_kind(prof),
"author",
name,
)
# Role substrings must not promote.
for role in ("not-controller", "control-plane", "xcontrollerx"):
prof = {"profile_name": "other", "role": role, "allowed_operations": ["gitea.read"]}
self.assertEqual(
gitea_mcp_server._profile_role_kind(prof),
role,
role,
)
def test_prgs_controller_retains_declared_reconciler_role(self) -> None:
"""B14: break-glass capability does not redefine global role."""
prof = _controller_profile(role="reconciler")
self.assertEqual(gitea_mcp_server._profile_role_kind(prof), "reconciler")
# Declared controller still wins when declared.
prof2 = _controller_profile(role="controller")
self.assertEqual(gitea_mcp_server._profile_role_kind(prof2), "controller")
def test_cleanup_merged_pr_branch_role_resolution_unchanged(self) -> None:
"""B14: prgs-controller with reconciler role still resolves for cleanup."""
# When declared reconciler, cleanup gate's role check should see reconciler.
prof = _controller_profile(role="reconciler")
self.assertEqual(gitea_mcp_server._profile_role_kind(prof), "reconciler")
# Fabricated controller-like names with reconciler ops do not become controller.
fake = {
"profile_name": "fake-controller",
"role": "reconciler",
"allowed_operations": [
"gitea.read", "gitea.branch.delete", "gitea.pr.close"
],
}
self.assertEqual(gitea_mcp_server._profile_role_kind(fake), "reconciler")
def test_narrow_break_glass_capability_does_not_redefine_role(self) -> None:
"""B14: granting runtime.break_glass_restart does not invent controller role."""
prof = {
"profile_name": "prgs-reconciler",
"role": "reconciler",
"allowed_operations": [
"gitea.read",
"gitea.branch.delete",
"runtime.break_glass_restart",
],
}
self.assertEqual(gitea_mcp_server._profile_role_kind(prof), "reconciler")
# ── B2-style input validation (preserved) ─────────────────────────────
def test_reason_validation(self) -> None:
"""AC2: Reason is required and must be at least 10 characters long."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
for invalid_reason in ["", " ", "too short", "123456789"]:
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
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."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
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."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
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:
"""B3 preserved: Gate incident creation on gitea.issue.create permission."""
prof = _controller_profile(
allowed_operations=["gitea.read", "runtime.break_glass_restart"]
)
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof):
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,
dry_run=False,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertEqual(res["blocker_kind"], "permission_denied")
# ── B8: redaction ─────────────────────────────────────────────────────
def test_redaction_key_value_and_connection_strings(self) -> None:
"""B8: key/value, bearer, connection-string, and embedded secrets."""
cases = [
("password=hunter2supersecret", ["hunter2supersecret"], "password"),
("api_key: sk-live-abcdef1234567890ab", ["sk-live-abcdef1234567890ab"], "api_key"),
("Server=db;Password=s3cretValue;Uid=sa", ["s3cretValue"], "password"),
(
"Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc.def",
["eyJhbGciOiJIUzI1NiJ9.abc.def", "Bearer eyJ"],
"authorization",
),
(
"token ghp_1234567890abcdef12345678 embedded",
["ghp_1234567890abcdef12345678"],
"token",
),
("nested note password=letmein12345 end", ["letmein12345"], "password"),
]
for raw, secrets, key in cases:
out = gitea_audit._redact_str(raw)
self.assertIn("[REDACTED]", out, raw)
for secret in secrets:
self.assertNotIn(secret, out, raw)
self.assertIn(key, out.lower(), raw)
nested = gitea_audit.redact({
"reason": "password=supersecret99",
"items": [{"api_key": "abc123xyz"}, "token ghp_abcdefghijklmnop1234"],
"error": RuntimeError("pwd=nestedSecret99"),
})
# Exception objects pass through redact as non-str/non-container; ensure
# string forms are covered via str conversion in _redact_str usage.
self.assertEqual(nested["items"][0]["api_key"], gitea_audit.REDACTED)
self.assertNotIn("supersecret99", nested["reason"])
self.assertNotIn("ghp_abcdefghijklmnop1234", nested["items"][1])
def test_redaction_preserves_benign_sec_prefix_text(self) -> None:
"""B8: ordinary text beginning with sec- must not be erased."""
benign = (
"Emergency restart in sec-primary-region for sector-planning "
"and secondary-health checks"
)
out = gitea_audit._redact_str(benign)
self.assertIn("sec-primary-region", out)
self.assertIn("sector-planning", out)
self.assertIn("secondary-health", out)
self.assertNotIn("[REDACTED]", out)
def test_redaction_across_break_glass_surfaces(self) -> None:
"""B8: operator reason is redacted on result, incident, and audit surfaces."""
prof = _controller_profile()
raw_reason = (
"Emergency restart: password=supersecret99 api_key: "
"sk-live-abcdef1234567890ab and url https://user:[email protected]/api"
)
mock_api_request = MagicMock(return_value={"number": 101, "title": "[INCIDENT]"})
fake_exec = {
"success": True,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": True,
"break_glass_executed": True,
}
gitea_mcp_server._break_glass_restart_executor = lambda req: fake_exec
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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={"affected_sessions": []},
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart",
return_value={"success": True},
), 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"], res)
self.assertNotIn("supersecret99", res["reason"])
self.assertNotIn("sk-live-abcdef1234567890ab", res["reason"])
self.assertNotIn("user:[email protected]", res["reason"])
posted_body = mock_api_request.call_args[0][3]["body"]
self.assertNotIn("supersecret99", posted_body)
self.assertNotIn("sk-live-abcdef1234567890ab", posted_body)
# ── B6/B11: reachable executor / truthful flags ───────────────────────
def test_dry_run_never_executes_and_reports_false(self) -> None:
"""B7/B6: dry-run never executes; break_glass_executed always false."""
prof = _controller_profile()
called = {"n": 0}
def _should_not_run(_req):
called["n"] += 1
return {"restart_performed": True, "break_glass_executed": True}
gitea_mcp_server._break_glass_restart_executor = _should_not_run
mock_audit = MagicMock()
mock_api = MagicMock()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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
), patch.object(
gitea_mcp_server, "api_request", mock_api
):
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.assertEqual(called["n"], 0)
mock_audit.assert_not_called()
mock_api.assert_not_called()
def test_unsupported_apply_truthful(self) -> None:
"""B6/B11: unsupported apply returns blocked result, execution false."""
prof = _controller_profile()
gitea_mcp_server._break_glass_restart_executor = lambda req: {
"success": False,
"apply_supported": False,
"apply_authorized": False,
"restart_performed": False,
"break_glass_executed": False,
"reasons": ["no hook"],
}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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={"affected_sessions": []},
), 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",
return_value={"number": 555, "title": "[INCIDENT]"},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart request with unsupported 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_delegation_success_rejection_and_failure(self) -> None:
"""B6/B11: distinct terminal states for success / rejection / failure."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
def _run(exec_result, recon=None):
gitea_mcp_server._break_glass_restart_executor = lambda req: exec_result
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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={"affected_sessions": []},
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart",
return_value=recon or {"success": True},
), 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",
return_value={"number": 555, "title": "[INCIDENT]"},
):
return gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged break-glass restart delegation path",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
ok = _run({
"success": True,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": True,
"break_glass_executed": True,
})
self.assertTrue(ok["success"], ok)
self.assertTrue(ok["break_glass_executed"])
self.assertTrue(ok["performed"])
rejected = _run({
"success": False,
"apply_supported": True,
"apply_authorized": False,
"restart_performed": False,
"break_glass_executed": False,
"reasons": ["class denied"],
})
self.assertFalse(rejected["success"])
self.assertFalse(rejected["break_glass_executed"])
self.assertEqual(rejected["blocker_kind"], "restart_delegation_failed")
failed = _run({
"success": False,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": False,
"break_glass_executed": False,
"reasons": ["executor error"],
})
self.assertFalse(failed["success"])
self.assertFalse(failed["break_glass_executed"])
self.assertEqual(failed["blocker_kind"], "restart_delegation_failed")
def test_reconciliation_success_and_failure_truthful_flags(self) -> None:
"""B10 preserved: recon failure keeps break_glass_executed=true."""
prof = _controller_profile()
gitea_mcp_server._break_glass_restart_executor = lambda req: {
"success": True,
"apply_supported": True,
"apply_authorized": True,
"restart_performed": True,
"break_glass_executed": True,
}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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={"affected_sessions": []},
), patch.object(
gitea_mcp_server, "gitea_reconcile_after_restart",
return_value={"success": False, "error": "lease cleanup failed"},
), 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",
return_value={"number": 555, "title": "[INCIDENT]"},
):
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_authorization_is_not_execution(self) -> None:
"""B6: apply_authorized alone never sets break_glass_executed."""
# Default executor without hook → unsupported, not executed.
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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={"affected_sessions": []},
), 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",
return_value={"number": 555, "title": "[INCIDENT]"},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart without host hook configured",
confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION",
impact_ack=True,
dry_run=False,
create_incident_issue=True,
remote="prgs",
)
self.assertFalse(res["break_glass_executed"])
self.assertEqual(res["blocker_kind"], "apply_unsupported")
# ── preserved fail-closed pre-exec (B5/B9) ────────────────────────────
def test_audit_failure_before_execution_fails_closed(self) -> None:
"""B9 preserved: Audit recording failure stops execution fail-closed."""
prof = _controller_profile()
called = {"n": 0}
gitea_mcp_server._break_glass_restart_executor = lambda req: called.__setitem__("n", called["n"] + 1) or {}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_mcp_server, "_authenticated_username", return_value="sysadmin"
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
), patch.object(
gitea_audit, "audit_enabled", return_value=True
), patch.object(
gitea_audit, "write_event", return_value=False
), patch.object(
gitea_mcp_server, "api_request", MagicMock()
):
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")
self.assertEqual(called["n"], 0)
def test_incident_creation_failure_before_execution_fails_closed(self) -> None:
"""B5 preserved: Incident creation failure stops execution fail-closed."""
prof = _controller_profile()
called = {"n": 0}
gitea_mcp_server._break_glass_restart_executor = lambda req: called.__setitem__("n", called["n"] + 1) or {}
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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={"affected_sessions": []},
), 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")
self.assertEqual(called["n"], 0)
def test_incident_opt_out_on_real_execution_fails_closed(self) -> None:
"""B5 preserved: create_incident_issue=False fails closed on real execution."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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_audit_disabled_fails_closed(self) -> None:
"""B9 preserved: Disabling audit recording blocks execution fail-closed."""
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), patch.object(
gitea_audit, "audit_enabled", return_value=False
), patch.object(
gitea_mcp_server, "gitea_request_mcp_restart",
return_value={"affected_sessions": []},
):
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/B13: capability map registers exact 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")
entry2 = TASK_CAPABILITY_MAP.get("break_glass_restart")
self.assertEqual(entry2["permission"], "runtime.break_glass_restart")
# ── B8 / B6 / B15 remediation tests ─────────────────────────────────────
def test_b8_redaction_gitea_token_and_uri_credentials(self) -> None:
"""B8: GITEA_TOKEN= and URI userinfo credentials redacted without erasing neighbours."""
# GITEA_TOKEN= with underscore key
out1 = gitea_audit._redact_str("failed with GITEA_TOKEN=synthetic_tok_123456789")
self.assertIn("GITEA_TOKEN=[REDACTED]", out1)
self.assertNotIn("synthetic_tok_123456789", out1)
# Connection string with URI userinfo
out2 = gitea_audit._redact_str("conn postgres://user:[email protected]:5432/app")
self.assertIn("postgres://[REDACTED_USER]:[REDACTED_PASS]@db.internal:5432/app", out2)
self.assertNotIn("s3cr3tpw", out2)
# Value boundary preserving adjacent audit evidence (correlation_id, incident_number)
raw_audit = "password=secret123;correlation_id=bg-7f2a1c;incident_number=4242"
out3 = gitea_audit._redact_str(raw_audit)
self.assertIn("password=[REDACTED]", out3)
self.assertIn("correlation_id=bg-7f2a1c", out3)
self.assertIn("incident_number=4242", out3)
self.assertNotIn("secret123", out3)
# Query param boundary in URL preserving adjacent parameters
raw_url = "token=abc-123&pr=908&issue=664&head=c67f39b4"
out4 = gitea_audit._redact_str(raw_url)
self.assertIn("token=[REDACTED]", out4)
self.assertIn("pr=908", out4)
self.assertIn("issue=664", out4)
self.assertIn("head=c67f39b4", out4)
def test_b6_default_executor_environment_text_cannot_imply_execution(self) -> None:
"""B6/B11: GITEA_SANCTIONED_RESTART_HOOK string alone returns break_glass_executed=False."""
os.environ["GITEA_SANCTIONED_RESTART_HOOK"] = "this-string-is-never-invoked"
prof = _controller_profile()
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prof), 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={"affected_sessions": []},
), 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",
return_value={"number": 555, "title": "[INCIDENT]"},
):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Privileged restart request with non-empty hook env var",
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")
def test_b15_production_prgs_controller_grant_set_and_gates(self) -> None:
"""B15: Genuine prgs-controller carrying runtime.break_glass_restart and gitea.issue.create passes real gates."""
# Full production-shaped prgs-controller profile
prod_profile = {
"profile_name": "prgs-controller",
"execution_profile": "prgs-controller",
"role": "reconciler",
"allowed_operations": [
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.create",
"runtime.break_glass_restart",
"gitea.branch.delete",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
],
}
# 1. Real _profile_operation_gate checks
p_parity, p_runtime, p_switch = _gate_open_patches()
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=prod_profile):
# Both required operations pass the real operation gate (no stubs)
self.assertEqual(
gitea_mcp_server._profile_operation_gate("runtime.break_glass_restart"),
[],
)
self.assertEqual(
gitea_mcp_server._profile_operation_gate("gitea.issue.create"),
[],
)
# 2. Missing gitea.issue.create fails incident creation gate
no_issue_create = dict(prod_profile)
no_issue_create["allowed_operations"] = [
"gitea.read", "gitea.pr.close", "runtime.break_glass_restart"
]
with p_parity, p_runtime, p_switch:
with patch.object(gitea_mcp_server, "get_profile", return_value=no_issue_create):
res = gitea_mcp_server.gitea_break_glass_restart(
reason="Restart testing missing gitea.issue.create permission",
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.assertEqual(res["blocker_kind"], "permission_denied")
self.assertIn("gitea.issue.create", " ".join(res.get("reasons") or []))
if __name__ == "__main__":
unittest.main()
@@ -1,274 +0,0 @@
"""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()