"""Native MCP preference gate and shell health circuit breaker (#270). Gitea mutations must use native MCP tools first. Shell scripts, direct API calls, browser helpers, and improvised encoders are blocked when MCP is available unless explicit recovery-mode proof is supplied. """ from __future__ import annotations import re import os import shutil import subprocess from typing import Any from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES # Tasks that must prefer native MCP over shell/API/helper fallbacks. GITEA_MUTATION_TASKS = frozenset({ "comment_issue", "mark_issue", "lock_issue", "set_issue_labels", "create_issue", "close_issue", "create_branch", "push_branch", "create_pr", "close_pr", "comment_pr", "review_pr", "merge_pr", "approve_pr", "request_changes_pr", "commit_files", "gitea_commit_files", "delete_branch", "address_pr_change_requests", }) ALLOWED_PATH_KINDS = frozenset({ "mcp_native", "recovery_fallback", }) BLOCKED_PATH_KINDS = frozenset({ "shell_script", "direct_api", "webfetch", "playwright", "helper_script", "unsafe_helper", "mcp_server_touch", }) SHELL_SPAWN_FAILURE_THRESHOLD = 2 TERMINAL_REPORT_HEADING = ( "MCP transport unavailable or shell circuit breaker tripped. " "No unsafe Gitea fallback performed." ) _RECOVERY_MODE_RE = re.compile( r"\b(?:recovery mode|explicit recovery|mcp unavailable|mcp not available|" r"mcp tools unavailable|no mcp path)\b", re.IGNORECASE, ) _LOCAL_SCRIPT_RE = re.compile( r"(?:^|[\s\"'`/])(?:python3?|bash|sh)?\s*(?:" + "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES) + r")", re.IGNORECASE, ) _WEBFETCH_RE = re.compile(r"\b(?:webfetch|mcp_web_fetch|fetch\s+url)\b", re.IGNORECASE) _PLAYWRIGHT_RE = re.compile(r"\b(?:playwright|browser_navigate|browser_click)\b", re.IGNORECASE) _HELPER_SCRIPT_RE = re.compile( r"(?:^|[\s\"'`/])(?:_encode_|_emit_|_inline_)[\w-]+\.py", re.IGNORECASE, ) _MANUAL_BASE64_RE = re.compile( r"\b(?:manual\s+base64|llm-generated\s+base64|base64-encode\s+in\s+chat)\b", re.IGNORECASE, ) _DIRECT_API_RE = re.compile( r"\b(?:api_request|urllib\.request|requests\.(?:post|patch|put)|curl\s+-X\s+POST)\b", re.IGNORECASE, ) _MCP_SERVER_TOUCH_RE = re.compile( r"\b(?:kill|pkill|restart|reload|touch|edit|modify|write)\b.{0,40}\b(?:mcp_server|mcp-server|gitea_mcp_server)\b", re.IGNORECASE, ) _CLI_AUTH_DIVERGENCE_RE = re.compile( r"GITEA_MCP_PROFILE\s*=\s*['\"]?([^\s'\";]+)", re.IGNORECASE, ) _session_shell: dict[str, Any] = { "consecutive_spawn_failures": 0, "shell_unavailable": False, "hard_stopped": False, "last_exit_code": None, } def _clean(value: str | None) -> str: return (value or "").strip() def is_spawn_failure( *, exit_code: int | None = None, stdout: str | None = None, stderr: str | None = None, spawn_failure: bool | None = None, ) -> bool: """True for executor spawn failures (exit_code -1, empty output).""" if spawn_failure is True: return True if spawn_failure is False: return False if exit_code != -1: return False return not (_clean(stdout) or _clean(stderr)) def record_shell_spawn_outcome( *, exit_code: int | None = None, stdout: str | None = None, stderr: str | None = None, spawn_failure: bool | None = None, probe_attempted: bool = False, probe_succeeded: bool | None = None, ) -> dict[str, Any]: """Track shell spawn outcomes and trip the session circuit breaker (#270 AC4).""" global _session_shell failed = is_spawn_failure( exit_code=exit_code, stdout=stdout, stderr=stderr, spawn_failure=spawn_failure, ) if failed: _session_shell["consecutive_spawn_failures"] = ( int(_session_shell.get("consecutive_spawn_failures") or 0) + 1 ) _session_shell["last_exit_code"] = exit_code if probe_attempted and probe_succeeded is False: _session_shell["shell_unavailable"] = True else: _session_shell["consecutive_spawn_failures"] = 0 _session_shell["shell_unavailable"] = False _session_shell["hard_stopped"] = False _session_shell["last_exit_code"] = exit_code failures = int(_session_shell["consecutive_spawn_failures"]) if failures >= SHELL_SPAWN_FAILURE_THRESHOLD: _session_shell["shell_unavailable"] = True _session_shell["hard_stopped"] = True return shell_health_status() def shell_health_status() -> dict[str, Any]: """Return the current shell health / circuit-breaker state.""" failures = int(_session_shell.get("consecutive_spawn_failures") or 0) hard_stopped = bool(_session_shell.get("hard_stopped")) shell_unavailable = bool(_session_shell.get("shell_unavailable")) return { "consecutive_spawn_failures": failures, "shell_unavailable": shell_unavailable, "hard_stopped": hard_stopped, "threshold": SHELL_SPAWN_FAILURE_THRESHOLD, "shell_use_allowed": not hard_stopped, "last_exit_code": _session_shell.get("last_exit_code"), "safe_next_action": ( "emit terminal recovery report; prefer native MCP for remaining Gitea mutations" if hard_stopped else ( "probe shell once with echo/pwd; if probe fails mark shell unavailable" if failures == 1 else "shell healthy" ) ), } def clear_shell_health_for_tests() -> None: """Reset session shell state (tests only).""" global _session_shell _session_shell = { "consecutive_spawn_failures": 0, "shell_unavailable": False, "hard_stopped": False, "last_exit_code": None, } def classify_command_path(command_or_detail: str | None) -> str: """Classify a proposed command/detail into a path kind.""" text = command_or_detail or "" if _MCP_SERVER_TOUCH_RE.search(text): return "mcp_server_touch" if _WEBFETCH_RE.search(text): return "webfetch" if _PLAYWRIGHT_RE.search(text): return "playwright" if _HELPER_SCRIPT_RE.search(text) or _MANUAL_BASE64_RE.search(text): return "unsafe_helper" if _LOCAL_SCRIPT_RE.search(text): return "shell_script" if _DIRECT_API_RE.search(text): return "direct_api" return "mcp_native" def detect_cli_auth_divergence( command_or_detail: str | None, *, active_profile: str | None, ) -> list[str]: """Flag shell commands that override GITEA_MCP_PROFILE away from the session.""" reasons: list[str] = [] text = command_or_detail or "" active = _clean(active_profile) match = _CLI_AUTH_DIVERGENCE_RE.search(text) if not match or not active: return reasons requested = _clean(match.group(1)) if requested and requested != active: reasons.append( f"CLI auth divergence: command sets GITEA_MCP_PROFILE='{requested}' " f"but active session profile is '{active}' (fail closed)" ) return reasons def format_mcp_unavailable_terminal_report( *, task: str | None = None, reasons: list[str] | None = None, ) -> str: """Terminal report when MCP is broken and unsafe recovery is forbidden (#270 AC3).""" lines = [TERMINAL_REPORT_HEADING, ""] if task: lines.append(f"Blocked task: {task}") if reasons: lines.extend(["", "Reasons:"]) lines.extend(f"- {reason}" for reason in reasons) lines.extend([ "", "Required recovery (no improvised fallback):", "- Restart the MCP session", "- Kill hung background terminals holding the shell executor", "- Retry the native MCP tool once after reconnect", "- If MCP remains unavailable, stop and hand off to the operator", "- Do not run local Gitea scripts, WebFetch, Playwright, or manual base64 encoders", ]) return "\n".join(lines) def assess_gitea_operation_path( *, task: str, path_kind: str | None = None, command_or_detail: str | None = None, mcp_available: bool = True, mcp_tool_visible: bool = True, recovery_mode: bool = False, recovery_proof_complete: bool = False, role: str | None = None, active_profile: str | None = None, ) -> dict[str, Any]: """Fail closed when a Gitea mutation would bypass native MCP (#270).""" task_name = _clean(task) resolved_kind = _clean(path_kind) or classify_command_path(command_or_detail) reasons: list[str] = [] if task_name and task_name not in GITEA_MUTATION_TASKS: reasons.append( f"unknown or non-mutation Gitea task '{task_name}'; " "native MCP preference gate applies only to Gitea mutations" ) shell = shell_health_status() if shell["hard_stopped"] and resolved_kind != "mcp_native": reasons.append( "shell circuit breaker is hard-stopped after consecutive spawn failures; " "use native MCP or emit terminal recovery report" ) recovery_declared = recovery_mode or bool( _RECOVERY_MODE_RE.search(command_or_detail or "") ) recovery_allowed = ( recovery_declared and recovery_proof_complete and not mcp_available and resolved_kind in BLOCKED_PATH_KINDS - {"mcp_server_touch"} ) if resolved_kind in BLOCKED_PATH_KINDS and not recovery_allowed: reasons.append(f"path kind '{resolved_kind}' is not an approved Gitea mutation path") if resolved_kind == "mcp_server_touch": if _clean(role) == "reviewer" or ( active_profile and "reviewer" in active_profile.lower() ): reasons.append( "reviewer workflows must not touch, restart, or kill MCP server files/processes" ) else: reasons.append( "MCP server files/processes must not be modified during normal Gitea workflows" ) reasons.extend( detect_cli_auth_divergence(command_or_detail, active_profile=active_profile) ) if ( mcp_available and mcp_tool_visible and resolved_kind != "mcp_native" and not recovery_allowed ): if not recovery_declared: reasons.append( "native MCP tools are available; shell/API/helper fallback is forbidden" ) elif not recovery_proof_complete: reasons.append( "recovery-mode fallback requires complete recovery proof before proceeding" ) if not mcp_available and resolved_kind != "mcp_native" and not recovery_allowed: if not recovery_declared: reasons.append( "MCP transport unavailable; produce terminal recovery report instead of " "improvising unsafe fallback" ) block = bool(reasons) terminal_report = None if block and (not mcp_available or shell["hard_stopped"]): terminal_report = format_mcp_unavailable_terminal_report( task=task_name or None, reasons=reasons, ) return { "task": task_name or None, "path_kind": resolved_kind, "mcp_available": mcp_available, "mcp_tool_visible": mcp_tool_visible, "recovery_mode": recovery_declared, "shell_health": shell, "block": block, "allowed": not block, "reasons": reasons, "terminal_report": terminal_report, "safe_next_action": ( "use the native MCP tool for this task" if mcp_available and mcp_tool_visible and block else ( terminal_report or "proceed with native MCP" if block else "proceed with native MCP" ) ), } def default_terminal_probe_command() -> list[str]: return ["git", "--version"] if shutil.which("git") else ["echo", "ok"] def _resolve_executable(cwd: str | None, executable: str | None) -> str | None: if not executable: return None if os.path.isabs(executable): if os.path.exists(executable) and os.access(executable, os.X_OK): return executable return None if "/" in executable or "\\" in executable: target_cwd = cwd or os.getcwd() full_path = os.path.abspath(os.path.join(target_cwd, executable)) if os.path.exists(full_path) and os.access(full_path, os.X_OK): return full_path return None return shutil.which(executable) def diagnose_terminal_failure(cwd: str | None, command: list[str]) -> dict[str, Any]: """Inspect and categorize command spawn failures (#556).""" diagnostics = { "cwd": cwd, "command": command, "cwd_exists": True, "cwd_is_dir": True, "shell_exists": True, "executable_exists": True, "resolved_executable": None, "error_type": "session launcher failure", "error_msg": ( "Subprocess spawn failed despite valid CWD and executable. This may be due " "to sandboxing, permission restrictions, or system resource limits." ), } if cwd: if not os.path.exists(cwd): diagnostics["cwd_exists"] = False diagnostics["error_type"] = "missing cwd" diagnostics["error_msg"] = f"Current working directory does not exist: {cwd}" return diagnostics if not os.path.isdir(cwd): diagnostics["cwd_is_dir"] = False diagnostics["error_type"] = "cwd is not a directory" diagnostics["error_msg"] = f"Current working directory is not a directory: {cwd}" return diagnostics exe = command[0] if command else None if exe: resolved_exe = _resolve_executable(cwd, exe) diagnostics["resolved_executable"] = resolved_exe if not resolved_exe: diagnostics["executable_exists"] = False if "venv" in exe or "pytest" in exe: diagnostics["error_type"] = "missing runtime wrapper" diagnostics["error_msg"] = ( "Virtual environment runtime wrapper/executable not found or not " f"executable: {exe}" ) else: diagnostics["error_type"] = "missing executable" diagnostics["error_msg"] = f"Executable not found in PATH or CWD: {exe}" return diagnostics # Check common shell availability has_any_shell = False for shell_path in ("/bin/sh", "/bin/zsh", "/bin/bash"): if os.path.exists(shell_path) and os.access(shell_path, os.X_OK): has_any_shell = True break if not has_any_shell: diagnostics["shell_exists"] = False diagnostics["error_type"] = "missing shell" diagnostics["error_msg"] = ( "No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is " "available or executable." ) return diagnostics return diagnostics def probe_terminal_spawn( cwd: str | None = None, command: list[str] | None = None, ) -> dict[str, Any]: """Proactively probe command spawning to detect launcher health (#556).""" probe_cmd = command or default_terminal_probe_command() diag = diagnose_terminal_failure(cwd, probe_cmd) if diag["error_type"] != "session launcher failure": return { "healthy": False, "error_type": diag["error_type"], "error_msg": diag["error_msg"], "cwd": cwd, "command": probe_cmd, "diagnostics": diag, } try: proc = subprocess.run( probe_cmd, capture_output=True, text=True, cwd=cwd, timeout=5, ) if proc.returncode == 0: return { "healthy": True, "error_type": None, "error_msg": "", "cwd": cwd, "command": probe_cmd, "diagnostics": diag, } # Non-zero status still proves the launcher spawned the process. return { "healthy": True, "error_type": None, "error_msg": "", "cwd": cwd, "command": probe_cmd, "exit_code": proc.returncode, "diagnostics": diag, } except FileNotFoundError: return { "healthy": False, "error_type": diag["error_type"], "error_msg": diag["error_msg"], "cwd": cwd, "command": probe_cmd, "diagnostics": diag, } except subprocess.TimeoutExpired as exc: return { "healthy": False, "error_type": "probe timeout", "error_msg": f"Subprocess probe timed out after {exc.timeout} seconds.", "cwd": cwd, "command": probe_cmd, "diagnostics": diag, } except Exception as exc: return { "healthy": False, "error_type": diag["error_type"], "error_msg": f"Subprocess spawn failed with exception: {exc}", "cwd": cwd, "command": probe_cmd, "diagnostics": diag, }