diff --git a/docs/mcp-restart-coordinator.md b/docs/mcp-restart-coordinator.md index c737da0..dabb7de 100644 --- a/docs/mcp-restart-coordinator.md +++ b/docs/mcp-restart-coordinator.md @@ -148,13 +148,13 @@ so a bypass is never silent. The dedicated MCP tool `gitea_break_glass_restart` provides the privileged emergency break-glass restart workflow when graceful drain cannot complete: -- **Role Authorization (#664 AC1)**: Ordinary LLM worker roles (`author`, `reviewer`, `merger`, `reconciler`) are denied fail-closed. Privileged `controller` role or explicit `GITEA_BREAKGLASS_RESTART_AUTHORIZATION` is required. +- **Role Authorization (#664 AC1)**: Ordinary LLM worker roles (`author`, `reviewer`, `merger`, `reconciler`) are denied fail-closed. Privileged `controller` role is required and enforced via the canonical `_profile_role_kind` resolver and `runtime.break_glass_restart` capability. `GITEA_BREAKGLASS_RESTART_AUTHORIZATION` is a fail-closed feature/configuration gate and does not provide identity, role, capability, confirmation, or permission. - **Required Parameters (#664 AC2)**: - `reason`: Mandatory non-empty string (min 10 characters). - `confirmation`: Must equal exactly `"I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION"`. - `impact_ack`: Must be `True`. -- **Automatic Incident Creation (#664 AC3)**: Creates a Gitea incident issue (`[INCIDENT] Break-glass MCP restart invoked by ...`) detailing the reason, timestamp, disrupted sessions, and linking `#652 #653 #655 #630 #658 #662 #664`. -- **Immutable Audit Entry**: Records an immutable audit log entry under `event="break_glass_mcp_restart"`. +- **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 diff --git a/gitea_audit.py b/gitea_audit.py index ceb73e9..f9f714b 100644 --- a/gitea_audit.py +++ b/gitea_audit.py @@ -27,13 +27,32 @@ ALLOWED = "allowed" BLOCKED = "blocked" FAILED = "failed" SUCCEEDED = "succeeded" +REQUESTED = "requested" +ACCEPTED = "accepted" +PENDING = "pending" REDACTED = "[REDACTED]" # A dict key containing any of these (case-insensitive) has its value redacted. _SECRET_KEY_HINTS = ("token", "password", "secret", "authorization", "auth") # A string value starting with one of these has the following run redacted. -_SECRET_VALUE_PREFIXES = ("token ", "Basic ", "Bearer ") +_SECRET_VALUE_PREFIXES = ("token ", "Basic ", "Bearer ", "token:", "bearer:", "basic:") + +_BARE_SECRET_PATTERN = re.compile( + r'(?i)\b(?:' + r'ghp_[A-Za-z0-9_]{16,}' + r'|gho_[A-Za-z0-9_]{16,}' + r'|ghu_[A-Za-z0-9_]{16,}' + r'|ghs_[A-Za-z0-9_]{16,}' + r'|ghr_[A-Za-z0-9_]{16,}' + r'|sk-live-[A-Za-z0-9_-]{16,}' + r'|sk-proj-[A-Za-z0-9_-]{16,}' + r'|sk-[A-Za-z0-9_-]{20,}' + r'|glpat-[A-Za-z0-9_-]{16,}' + r'|sec-[A-Za-z0-9_-]{16,}' + r')\b' +) + # Known synthetic test-only domains/hostnames to preserve _SYNTHETIC_HOSTS = { @@ -115,20 +134,23 @@ def redact_urls(text: str) -> str: def _redact_str(text): - """Redact anything that looks like an Authorization credential or raw URL in *text*.""" + """Redact anything that looks like an Authorization credential, bare secret token, or raw URL in *text*.""" if not isinstance(text, str) or not text: return text - out = text + out = _BARE_SECRET_PATTERN.sub(REDACTED, text) + out_lower = out.lower() for prefix in _SECRET_VALUE_PREFIXES: + prefix_lower = prefix.lower() idx = 0 while True: - i = out.find(prefix, idx) + i = out_lower.find(prefix_lower, 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:] + out_lower = out.lower() idx = i + len(prefix) + len(REDACTED) return redact_urls(out) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 10f27bf..088f841 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -236,16 +236,15 @@ def _effective_workspace_role() -> str: def _profile_role_kind(profile: dict) -> str: """Resolve a profile's declared role before inferring from permissions. - Declared ``role`` / ``role_kind`` always wins so a controller profile is - never reclassified as reconciler from permission inference (#840). + Declared ``role`` / ``role_kind`` or profile_name containing 'controller' + always resolves to 'controller' (#840/#664). """ - role = (profile.get("role") or profile.get("role_kind") or "").strip().lower() - if role: - # Normalize aliases / case. - if "control" in role: - return "controller" - return role profile_name = (profile.get("profile_name") or "").strip().lower() + role = (profile.get("role") or profile.get("role_kind") or "").strip().lower() + if "controller" in profile_name or "control" in role or role == "controller": + return "controller" + if role: + return role for candidate in ( "controller", "reconciler", @@ -23923,7 +23922,7 @@ def gitea_request_mcp_restart( BREAK_GLASS_CONFIRMATION_PHRASE = "I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION" -PRIVILEGED_BREAK_GLASS_ROLES = frozenset({"controller", "operator", "admin", "sysadmin"}) +PRIVILEGED_BREAK_GLASS_ROLES = frozenset({"controller"}) @mcp.tool() @@ -23944,25 +23943,25 @@ def gitea_break_glass_restart( Break-glass restart permits emergency recovery when graceful drain cannot complete. It requires: - 1. Privileged caller authorization (explicit allowlist: controller, operator, - admin, sysadmin; ordinary roles and unknown/malformed roles fail closed). + 1. Privileged caller authorization (explicit allowlist: controller; + ordinary roles and unknown/malformed roles fail closed). 2. Explicit non-empty reason (minimum 10 characters). 3. Exact confirmation string matching 'I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION'. 4. Mandatory impact acknowledgement (impact_ack=True). - 5. Immutable append-only audit entry recorded prior to execution. + 5. Immutable append-only audit entry recorded prior to execution and after terminal completion. 6. Automatic incident record created on Gitea prior to execution. 7. Truthful execution reporting and mandatory post-restart reconciliation (#662). """ - read_block = _profile_operation_gate("gitea.read") + read_block = _profile_operation_gate("runtime.break_glass_restart") if read_block: - return { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, "reasons": read_block, - "permission_report": _permission_block_report("gitea.read"), + "permission_report": _permission_block_report("runtime.break_glass_restart"), "blocker_kind": "permission_denied", - } + }) h, o, r = _resolve(remote, host, org, repo) profile = get_profile() @@ -23971,25 +23970,25 @@ def gitea_break_glass_restart( (os.environ.get("GITEA_BREAKGLASS_RESTART_AUTHORIZATION") or "").strip() ) - # 1. Privileged role authorization (explicit allowlist - Finding 1 & 5) + # 1. Privileged role authorization (explicit allowlist: controller only - B1) if active_role not in PRIVILEGED_BREAK_GLASS_ROLES: - return { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, "active_role": active_role, "reasons": [ f"role '{active_role}' is not in privileged break-glass allowlist " - f"({sorted(PRIVILEGED_BREAK_GLASS_ROLES)}); break-glass restart requires a privileged role (#664 AC1)" + f"({sorted(PRIVILEGED_BREAK_GLASS_ROLES)}); break-glass restart requires a privileged controller role (#664 AC1)" ], "blocker_kind": "role_authorization", - } + }) - # 2. Required fields and redaction (Finding 3) + # 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 { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, @@ -23997,11 +23996,11 @@ def gitea_break_glass_restart( "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 { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, @@ -24009,10 +24008,10 @@ def gitea_break_glass_restart( f"confirmation string mismatch; must equal exactly '{BREAK_GLASS_CONFIRMATION_PHRASE}' (#664 AC2)" ], "blocker_kind": "confirmation_mismatch", - } + }) if not impact_ack: - return { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, @@ -24020,20 +24019,20 @@ def gitea_break_glass_restart( "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 (Finding 3, B3) + # 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 { + 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( @@ -24050,11 +24049,16 @@ def gitea_break_glass_restart( 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 = { + 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, @@ -24069,18 +24073,20 @@ def gitea_break_glass_restart( "org": o, "repo": r, "env_auth_present": break_glass_env_auth, - } + }) - # 4. Dry-run handling (Finding 4, B7) + # Dry-run handling (B7: no durable mutation) if dry_run: - return { + 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, @@ -24094,11 +24100,11 @@ def gitea_break_glass_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 (Finding 2, B5) + # Fail closed if create_incident_issue is False on real execution (B5) if not create_incident_issue: - return { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, @@ -24107,12 +24113,24 @@ def gitea_break_glass_restart( "pre-execution incident creation is mandatory (#664 AC3)" ], "blocker_kind": "incident_creation_required", - } + }) - # Pre-execution recording: Audit record (Finding 2, B4) - audit_event = gitea_audit.build_event( - action="break_glass_mcp_restart", - result=gitea_audit.SUCCEEDED, + # 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, @@ -24120,11 +24138,12 @@ def gitea_break_glass_restart( audit_label=profile.get("audit_label", "unknown"), authenticated_username=identity, reason=clean_reason, - mcp_namespace="gitea-author", + 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, @@ -24134,12 +24153,10 @@ def gitea_break_glass_restart( ], }, ) - audit_write_success = True - if gitea_audit.audit_enabled(): - audit_write_success = gitea_audit.write_event(audit_event) + audit_write_success = gitea_audit.write_event(pre_audit_event) if not audit_write_success: - return { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, @@ -24147,13 +24164,14 @@ def gitea_break_glass_restart( "failed to persist required pre-execution audit event (#664 AC3)" ], "blocker_kind": "audit_recording_failed", - } + }) - # Pre-execution recording: Gitea Incident Issue (Finding 2 & 3, B5) - issue_title = f"[INCIDENT] Break-glass MCP restart invoked by {identity}" - issue_body = ( + # 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"- **Invoked by**: `{identity}` (role: `{active_role}`)\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" @@ -24177,18 +24195,18 @@ def gitea_break_glass_restart( }, ) if not isinstance(incident_issue_result, dict) or "number" not in incident_issue_result: - return { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, "reasons": [ - f"incident issue creation failed (#664 AC3): {incident_issue_result}" + 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 { + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, @@ -24197,9 +24215,11 @@ def gitea_break_glass_restart( ], "blocker_kind": "incident_creation_failed", "incident_issue": {"error": _redact(str(exc))}, - } + }) - # Execute or delegate canonical non-dry-run restart (Finding 4) + incident_number = incident_issue_result.get("number") + + # Execute or delegate canonical non-dry-run restart (B6/B11) restart_exec_result = gitea_request_mcp_restart( remote=remote, host=host, @@ -24210,13 +24230,107 @@ def gitea_break_glass_restart( request_break_glass=True, ) - if not restart_exec_result.get("apply_authorized") or not restart_exec_result.get("success"): - return { + apply_supported = bool(restart_exec_result.get("apply_supported", False)) + apply_authorized = bool(restart_exec_result.get("apply_authorized", False)) + exec_success = bool(restart_exec_result.get("success", False)) + restart_performed = bool( + restart_exec_result.get("restart_performed", False) + or restart_exec_result.get("break_glass_executed", False) + ) + + if not apply_supported: + term_audit = gitea_audit.build_event( + action="break_glass_mcp_restart_terminal", + result=gitea_audit.FAILED, + remote=remote, + server=(gitea_url(h, "").rstrip("/") if h else None), + repository=r, + profile_name=profile.get("profile_name", "unknown"), + audit_label=profile.get("audit_label", "unknown"), + authenticated_username=identity, + reason="apply_unsupported", + mcp_namespace=mcp_namespace, + task_role=active_role, + operation="break_glass_mcp_restart", + now=datetime.now(timezone.utc).isoformat(), + request_metadata={ + "correlation_id": correlation_id, + "incident_number": incident_number, + "break_glass_executed": False, + "blocker_kind": "apply_unsupported", + }, + ) + gitea_audit.write_event(term_audit) + if incident_number: + try: + api_request( + "POST", + f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments", + _auth(h), + {"body": _redact(f"**Terminal Status**: `apply_unsupported` - Restart coordinator does not support apply execution. No restart was performed. ({correlation_id})")}, + ) + except Exception: + pass + + return gitea_audit.redact({ "success": False, "performed": False, "break_glass_executed": False, "actor": identity, "role": active_role, + "mcp_namespace": mcp_namespace, + "restart_class": restart_class, + "reasons": [ + "break-glass apply is unsupported by restart coordinator (apply_supported=False) (#664)", + *(restart_exec_result.get("reasons") or []), + ], + "blocker_kind": "apply_unsupported", + "restart_result": restart_exec_result, + "incident_issue": incident_issue_result, + "audit_record": pre_audit_event, + }) + + if not apply_authorized or not exec_success or not restart_performed: + term_audit = gitea_audit.build_event( + action="break_glass_mcp_restart_terminal", + result=gitea_audit.FAILED, + remote=remote, + server=(gitea_url(h, "").rstrip("/") if h else None), + repository=r, + profile_name=profile.get("profile_name", "unknown"), + audit_label=profile.get("audit_label", "unknown"), + authenticated_username=identity, + reason="restart_delegation_failed", + mcp_namespace=mcp_namespace, + task_role=active_role, + operation="break_glass_mcp_restart", + now=datetime.now(timezone.utc).isoformat(), + request_metadata={ + "correlation_id": correlation_id, + "incident_number": incident_number, + "break_glass_executed": False, + "blocker_kind": "restart_delegation_failed", + }, + ) + gitea_audit.write_event(term_audit) + if incident_number: + try: + api_request( + "POST", + f"{repo_api_url(h, o, r)}/issues/{incident_number}/comments", + _auth(h), + {"body": _redact(f"**Terminal Status**: `restart_delegation_failed` - Restart execution failed or was denied. No restart was performed. ({correlation_id})")}, + ) + except Exception: + pass + + return gitea_audit.redact({ + "success": False, + "performed": False, + "break_glass_executed": False, + "actor": identity, + "role": active_role, + "mcp_namespace": mcp_namespace, "restart_class": restart_class, "reasons": [ "delegated restart execution failed or was denied by coordinator (#664)", @@ -24225,25 +24339,149 @@ def gitea_break_glass_restart( "blocker_kind": "restart_delegation_failed", "restart_result": restart_exec_result, "incident_issue": incident_issue_result, - "audit_record": audit_event, - } + "audit_record": pre_audit_event, + }) - return { + # 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": audit_event, - "saved_audit": audit_event, + "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, @@ -24251,7 +24489,7 @@ def gitea_break_glass_restart( "reasons": [ "break-glass restart executed with incident creation and mandatory reconciliation" ], - } + }) diff --git a/task_capability_map.py b/task_capability_map.py index 1253f53..51a6830 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -578,11 +578,11 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { }, # #664: emergency break-glass MCP restart workflow (privileged controller role). "break_glass_restart": { - "permission": "gitea.read", + "permission": "runtime.break_glass_restart", "role": "controller", }, "gitea_break_glass_restart": { - "permission": "gitea.read", + "permission": "runtime.break_glass_restart", "role": "controller", }, } diff --git a/tests/test_issue_664_break_glass_restart.py b/tests/test_issue_664_break_glass_restart.py index 14b125e..3889298 100644 --- a/tests/test_issue_664_break_glass_restart.py +++ b/tests/test_issue_664_break_glass_restart.py @@ -23,14 +23,12 @@ class TestBreakGlassRestart(unittest.TestCase): def test_role_authorization_matrix_with_real_resolver(self) -> None: """AC1: Explicit allowlist enforcement using production _profile_role_kind resolver. - Privileged roles (controller, operator, admin, sysadmin) pass role check. - Ordinary LLM roles (author, reviewer, merger, reconciler) and unknown/malformed roles fail closed. + Privileged controller role passes role check. + Synthetic roles (operator, admin, sysadmin), ordinary LLM roles (author, reviewer, merger, reconciler), and unknown/malformed roles fail closed. """ allowed_profiles = [ - {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]}, - {"role": "operator", "allowed_operations": ["gitea.read", "gitea.issue.create"]}, - {"role": "admin", "allowed_operations": ["gitea.read", "gitea.issue.create"]}, - {"role": "sysadmin", "allowed_operations": ["gitea.read", "gitea.issue.create"]}, + {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]}, + {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]}, ] for prof in allowed_profiles: @@ -48,10 +46,13 @@ class TestBreakGlassRestart(unittest.TestCase): ) self.assertTrue( res["success"], - f"Profile with role '{prof.get('role')}' should pass role check", + f"Profile {prof} should pass role check", ) denied_profiles = [ + {"role": "operator", "allowed_operations": ["gitea.read", "gitea.issue.create"]}, + {"role": "admin", "allowed_operations": ["gitea.read", "gitea.issue.create"]}, + {"role": "sysadmin", "allowed_operations": ["gitea.read", "gitea.issue.create"]}, {"role": "author", "allowed_operations": ["gitea.read", "gitea.issue.create"]}, {"role": "reviewer", "allowed_operations": ["gitea.read"]}, {"role": "merger", "allowed_operations": ["gitea.read"]}, @@ -174,7 +175,8 @@ class TestBreakGlassRestart(unittest.TestCase): raw_reason = "Emergency restart: token ghp_secretToken12345 and url https://user:pass@gitea.prgs.cc/api" mock_api_request = MagicMock(return_value={"number": 101, "title": "[INCIDENT]"}) - mock_restart_exec = {"success": True, "apply_authorized": True} + mock_restart_exec = {"success": True, "apply_supported": True, "apply_authorized": True, "restart_performed": True} + mock_recon = {"success": True} with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object( gitea_mcp_server, "_profile_operation_gate", return_value=None @@ -186,6 +188,10 @@ class TestBreakGlassRestart(unittest.TestCase): gitea_mcp_server, "api_request", mock_api_request ), patch.object( gitea_mcp_server, "gitea_request_mcp_restart", return_value=mock_restart_exec + ), patch.object( + gitea_mcp_server, "gitea_reconcile_after_restart", return_value=mock_recon + ), patch.object( + gitea_audit, "audit_enabled", return_value=True ), patch.object( gitea_audit, "write_event", return_value=True ): @@ -253,6 +259,8 @@ class TestBreakGlassRestart(unittest.TestCase): gitea_mcp_server, "_authenticated_username", return_value="sysadmin" ), patch.object( gitea_mcp_server, "gitea_request_mcp_restart", side_effect=[{"affected_sessions": []}, mock_restart_exec] + ), patch.object( + gitea_audit, "audit_enabled", return_value=True ), patch.object( gitea_audit, "write_event", return_value=True ), patch.object( @@ -326,9 +334,10 @@ class TestBreakGlassRestart(unittest.TestCase): def test_successful_real_execution(self) -> None: """Requirement 4: Real execution calls canonical restart path and reports execution truthfully.""" - controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]} + controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]} mock_api_request = MagicMock(return_value={"number": 555, "title": "[INCIDENT]"}) - mock_restart_exec = {"success": True, "apply_authorized": True, "affected_sessions": []} + mock_restart_exec = {"success": True, "apply_supported": True, "apply_authorized": True, "restart_performed": True, "affected_sessions": []} + mock_recon = {"success": True, "reconciled": True} with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object( gitea_mcp_server, "_profile_operation_gate", return_value=None @@ -338,6 +347,10 @@ class TestBreakGlassRestart(unittest.TestCase): gitea_mcp_server, "_authenticated_username", return_value="sysadmin" ), patch.object( gitea_mcp_server, "gitea_request_mcp_restart", return_value=mock_restart_exec + ), patch.object( + gitea_mcp_server, "gitea_reconcile_after_restart", return_value=mock_recon + ), patch.object( + gitea_audit, "audit_enabled", return_value=True ), patch.object( gitea_audit, "write_event", return_value=True ), patch.object( @@ -357,12 +370,150 @@ class TestBreakGlassRestart(unittest.TestCase): self.assertTrue(res["break_glass_executed"]) self.assertEqual(res["incident_issue"]["number"], 555) + def test_unsupported_apply_returns_truthful_unsupported_result(self) -> None: + """B6/B11: apply_supported=False returns apply_unsupported blocker and break_glass_executed=False.""" + controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]} + mock_api_request = MagicMock(return_value={"number": 555, "title": "[INCIDENT]"}) + mock_restart_unsupported = {"success": True, "apply_supported": False, "apply_authorized": True, "restart_performed": False} + + with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object( + gitea_mcp_server, "_profile_operation_gate", return_value=None + ), patch.object( + gitea_mcp_server, "_auth", return_value={"Authorization": "token test"} + ), patch.object( + gitea_mcp_server, "_authenticated_username", return_value="sysadmin" + ), patch.object( + gitea_mcp_server, "gitea_request_mcp_restart", side_effect=[{"affected_sessions": []}, mock_restart_unsupported] + ), patch.object( + gitea_audit, "audit_enabled", return_value=True + ), patch.object( + gitea_audit, "write_event", return_value=True + ), patch.object( + gitea_mcp_server, "api_request", mock_api_request + ): + res = gitea_mcp_server.gitea_break_glass_restart( + reason="Privileged restart request with unsupported coordinator apply", + confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION", + impact_ack=True, + dry_run=False, + create_incident_issue=True, + remote="prgs", + ) + self.assertFalse(res["success"]) + self.assertFalse(res["performed"]) + self.assertFalse(res["break_glass_executed"]) + self.assertEqual(res["blocker_kind"], "apply_unsupported") + + def test_reconciliation_failure_after_execution(self) -> None: + """B10: Post-restart reconciliation failure reports break_glass_executed=True but success=False.""" + controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]} + mock_api_request = MagicMock(return_value={"number": 555, "title": "[INCIDENT]"}) + mock_restart_exec = {"success": True, "apply_supported": True, "apply_authorized": True, "restart_performed": True} + mock_recon = {"success": False, "error": "lease cleanup failed"} + + with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object( + gitea_mcp_server, "_profile_operation_gate", return_value=None + ), patch.object( + gitea_mcp_server, "_auth", return_value={"Authorization": "token test"} + ), patch.object( + gitea_mcp_server, "_authenticated_username", return_value="sysadmin" + ), patch.object( + gitea_mcp_server, "gitea_request_mcp_restart", return_value=mock_restart_exec + ), patch.object( + gitea_mcp_server, "gitea_reconcile_after_restart", return_value=mock_recon + ), patch.object( + gitea_audit, "audit_enabled", return_value=True + ), patch.object( + gitea_audit, "write_event", return_value=True + ), patch.object( + gitea_mcp_server, "api_request", mock_api_request + ): + res = gitea_mcp_server.gitea_break_glass_restart( + reason="Privileged restart request with failing reconciliation", + confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION", + impact_ack=True, + dry_run=False, + create_incident_issue=True, + remote="prgs", + ) + self.assertFalse(res["success"]) + self.assertTrue(res["performed"]) + self.assertTrue(res["break_glass_executed"]) + self.assertEqual(res["blocker_kind"], "reconciliation_failed") + + def test_bare_secret_redaction(self) -> None: + """B8: Bare token shapes like ghp_... and sk-live-... are redacted on all surfaces.""" + controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]} + raw_reason = "Emergency restart reason containing bare tokens ghp_1234567890abcdef12345678 and sk-live-abcdef1234567890abcdef" + mock_api_request = MagicMock(return_value={"number": 101, "title": "[INCIDENT]"}) + mock_restart_exec = {"success": True, "apply_supported": True, "apply_authorized": True, "restart_performed": True} + mock_recon = {"success": True} + + with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object( + gitea_mcp_server, "_profile_operation_gate", return_value=None + ), patch.object( + gitea_mcp_server, "_auth", return_value={"Authorization": "token test"} + ), patch.object( + gitea_mcp_server, "_authenticated_username", return_value="sysadmin" + ), patch.object( + gitea_mcp_server, "api_request", mock_api_request + ), patch.object( + gitea_mcp_server, "gitea_request_mcp_restart", return_value=mock_restart_exec + ), patch.object( + gitea_mcp_server, "gitea_reconcile_after_restart", return_value=mock_recon + ), patch.object( + gitea_audit, "audit_enabled", return_value=True + ), patch.object( + gitea_audit, "write_event", return_value=True + ): + res = gitea_mcp_server.gitea_break_glass_restart( + reason=raw_reason, + confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION", + impact_ack=True, + dry_run=False, + create_incident_issue=True, + remote="prgs", + ) + self.assertTrue(res["success"]) + self.assertNotIn("ghp_1234567890abcdef12345678", res["reason"]) + self.assertNotIn("sk-live-abcdef1234567890abcdef", res["reason"]) + self.assertIn("[REDACTED]", res["reason"]) + + def test_audit_disabled_fails_closed(self) -> None: + """B9: Disabling audit recording blocks execution fail-closed.""" + controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]} + + with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object( + gitea_mcp_server, "_profile_operation_gate", return_value=None + ), patch.object( + gitea_audit, "audit_enabled", return_value=False + ): + res = gitea_mcp_server.gitea_break_glass_restart( + reason="Emergency restart with disabled audit logging", + confirmation="I_ACKNOWLEDGE_BREAK_GLASS_MCP_RESTART_DISRUPTION", + impact_ack=True, + dry_run=False, + create_incident_issue=True, + remote="prgs", + ) + self.assertFalse(res["success"]) + self.assertFalse(res["break_glass_executed"]) + self.assertEqual(res["blocker_kind"], "audit_recording_failed") + + def test_capability_map_registration(self) -> None: + """B12: Ensure task_capability_map maps gitea_break_glass_restart to runtime.break_glass_restart.""" + from task_capability_map import TASK_CAPABILITY_MAP + entry = TASK_CAPABILITY_MAP.get("gitea_break_glass_restart") + self.assertIsNotNone(entry) + self.assertEqual(entry["permission"], "runtime.break_glass_restart") + self.assertEqual(entry["role"], "controller") + def test_delegated_execution_failure(self) -> None: """Requirement 4: Delegated restart execution failure produces distinct terminal state.""" - controller_prof = {"role": "controller", "allowed_operations": ["gitea.read", "gitea.issue.create"]} + controller_prof = {"profile_name": "prgs-controller", "allowed_operations": ["gitea.read", "gitea.issue.create", "runtime.break_glass_restart"]} mock_api_request = MagicMock(return_value={"number": 555, "title": "[INCIDENT]"}) mock_impact_eval = {"affected_sessions": []} - mock_restart_denied = {"success": False, "apply_authorized": False, "reasons": ["restart class denied"]} + mock_restart_denied = {"success": False, "apply_supported": True, "apply_authorized": False, "reasons": ["restart class denied"]} with patch.object(gitea_mcp_server, "get_profile", return_value=controller_prof), patch.object( gitea_mcp_server, "_profile_operation_gate", return_value=None @@ -372,6 +523,8 @@ class TestBreakGlassRestart(unittest.TestCase): gitea_mcp_server, "_authenticated_username", return_value="sysadmin" ), patch.object( gitea_mcp_server, "gitea_request_mcp_restart", side_effect=[mock_impact_eval, mock_restart_denied] + ), patch.object( + gitea_audit, "audit_enabled", return_value=True ), patch.object( gitea_audit, "write_event", return_value=True ), patch.object(