"""Fail-closed guard against manual MCP daemon process killing (#630). Workflow recovery must use sanctioned reconnect/restart paths only: host auto-reconnect, an operator-owned restart, or the documented client relaunch. A session that instead runs ``pkill -f mcp_server.py`` has manipulated the very host processes its own proof depends on. Incident origin: a session ran ``ps aux | grep mcp_server``, then ``pkill -f mcp_server.py``, waited for the IDE to respawn the daemons, called MCP tools, and closed issue #601. Nothing distinguished that closure from one performed over a sanctioned runtime, and unrelated namespaces may have been killed as collateral damage. Partial detection already existed — ``native_mcp_preference.classify_command_path`` flags ``kill``/``pkill`` near ``mcp_server`` as an MCP-server touch, and ``review_workflow_boundary`` classifies a pre-review ``pkill`` as MCP repair activity — but neither wrote a durable marker nor failed closed on the review / merge / close mutations that followed. This module mirrors ``stable_branch_push_guard`` (#671) deliberately: same contamination-marker shape, same gated-task set, same reconciler-only clear. Like that guard it is **pure** — callers gather the raw facts (the proposed command line, known MCP pids, the durable marker, the process environment) and pass them in, so one implementation serves prompts, MCP gates and tests. Nothing here kills, spawns or inspects a process, performs I/O, or reads durable state. Design rules honoured (from the #630 acceptance criteria): * Detect ``pkill -f mcp_server.py``, ``pkill -f gitea_mcp_server``, broad ``pkill -f mcp``, ``killall`` equivalents, and ``kill `` of a known MCP daemon pid. * Detect a pattern broad enough to take unrelated namespaces as collateral damage (``pkill -f python``) even when it never names MCP. * Never flag read-only inspection (``ps aux | grep mcp_server``), a sanctioned client reconnect, or process management unrelated to the daemons. A bare ``kill `` with no MCP linkage is reported as *ambiguous*, never as contamination, so ordinary subprocess work is not false-blocked. * Never accept operator authorization from a tool argument. Authorization is read from the process environment only — which an in-session worker cannot set for an already-running daemon. A self-assertable ``operator_authorized`` argument was rejected in the PR #710 review (finding F1) and is not reintroduced here. * Contamination is never clearable by the same worker session; only a reconciler (audit) role may clear it or bypass the gate. """ from __future__ import annotations import os import re from typing import Any, Iterable, Iterator # Single source of truth for both the redactor and the gated-mutation set: the # #671 guard already owns them, so the two contamination models can never drift # apart on which mutations a contaminated session may still perform. from stable_branch_push_guard import ( # noqa: F401 (CONTAMINATION_GATED_TASKS re-exported) CONTAMINATION_GATED_TASKS, redact_command, ) CONTAMINATION_KIND = "manual_daemon_kill" #: The session killed (or pattern-matched) an MCP daemon process directly. REASON_MANUAL_DAEMON_KILL = "manual_daemon_kill" #: The pattern was broad enough to sweep unrelated MCP namespaces. REASON_BROAD_PROCESS_KILL = "broad_process_kill" #: Operator authorization is read from this environment variable ONLY. It is #: set outside the workflow session by the operator who owns host maintenance; #: an in-session worker cannot set it for an already-running daemon. The value #: is an audit reference (ticket, change id, or operator note) and is recorded #: on the marker. Never accept this from a tool argument (#710 finding F1). OPERATOR_AUTHORIZATION_ENV = "GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION" REMEDIATION = ( "Manual MCP daemon process killing is not a sanctioned workflow recovery. " "Stop, leave the host processes alone, and recover through the client " "reconnect / relaunch path (see docs/mcp-namespace-eof-recovery.md) or an " "operator-owned restart. This session is workflow-contaminated until a " "reconciler audits it; review, merge, close and completion mutations fail " "closed until then." ) # ── command tokenising ──────────────────────────────────────────────────────── # Split a compound command line into simple commands on shell separators so # ``ps aux | grep mcp_server`` is analysed segment by segment and its harmless # inspection half never reaches the kill classifier. The background separator # ``&`` is a separator too: without it ``sleep 1 & pkill -f mcp_server.py`` was # a single segment whose command position held ``sleep``, so the kill was never # classified (#787). # # Splitting is *quote-aware*, and a regex alternation cannot express that, so # the scan below replaces the earlier ``_SEGMENT_SPLIT_RE`` pattern. A separator # only separates where it is syntactically active: outside single and double # quotes, and not backslash-escaped. Without that, adding ``&`` made every # benign mention of the canonical kill string classify as a real kill — a commit # message quoting ``sleep 1 & pkill -f mcp_server.py``, an ``echo`` of the same # sentence, a ``grep`` for it — and a false contamination marker fails review, # merge, close and completion mutations closed until a reconciler clears it (PR # #789 review finding F1). Quote-awareness is not specific to ``&``: it also # retires the same false-positive class that ``;`` and ``|`` carried before #787. _SEPARATOR_CHARS = frozenset("|&;\n") #: Two-character logical separators, consumed whole so ``&&`` and ``||`` are #: never split into single characters leaving a stray operator behind. _LOGICAL_SEPARATORS = ("&&", "||") _KILL_VERBS = frozenset({"kill", "pkill", "killall"}) # Tokens that may legitimately precede the kill verb in command position. _COMMAND_PREFIXES = frozenset({ "sudo", "command", "exec", "time", "nohup", "env", "builtin", }) # Matches the daemon process names: ``mcp_server``/``mcp-server`` (optionally # ``gitea_``-prefixed, optionally ``.py``) or a standalone ``mcp`` token. # ``mcpfoo`` deliberately does not match. _MCP_TARGET_RE = re.compile( r"(?:gitea[_-])?mcp[_-]?server|(? str: return (value or "").strip() def _iter_active(text: str) -> Iterator[tuple[int, str]]: """Yield ``(index, char)`` for every *syntactically active* character. Active means outside single and double quotes and not backslash-escaped — the positions where a shell metacharacter actually carries its meaning. Quoted runs, the quote characters themselves, and escaped characters are skipped, so a separator written inside a commit message or a ``grep`` pattern is literal text rather than syntax. A backslash escapes nothing inside single quotes, matching POSIX. An unterminated quote swallows the rest of the line, exactly as it does for the shell — which would reject such a command as a syntax error rather than run its tail, so nothing executable hides behind it. """ quote: str | None = None index = 0 end = len(text) while index < end: char = text[index] if quote == "'": if char == "'": quote = None index += 1 elif quote == '"': if char == "\\" and index + 1 < end: index += 2 else: if char == '"': quote = None index += 1 elif char == "\\" and index + 1 < end: index += 2 elif char in ("'", '"'): quote = char index += 1 else: yield index, char index += 1 def _is_redirection(command: str, index: int, active: frozenset[int]) -> bool: """Is the ``&``/``|`` at *index* part of a redirection, not a separator? ``2>&1`` and ``>&2`` put the character immediately after a redirection operator, and ``&>log`` immediately before one; in neither position does it separate commands. Without this, ``a 2>&1`` split into ``['a 2>', '1']`` (PR #789 review finding F3). """ previous = command[index - 1] if index else "" if previous in ("<", ">") and (index - 1) in active: return True return ( command[index] == "&" and command[index + 1:index + 2] == ">" and (index + 1) in active ) def _closes_leading_paren(body: str) -> bool: """Does *body* end with the active ``)`` matching a stripped leading ``(``?""" if not body.endswith(")"): return False depth = 0 for index, char in _iter_active(body): if char == "(": depth += 1 elif char == ")": if depth == 0: return index == len(body) - 1 depth -= 1 return False def _strip_subshell(segment: str) -> str: """Remove subshell wrappers so ``(pkill -f mcp_server.py)`` is classified. The parentheses are shell syntax, not part of the simple command, so a wrapped kill otherwise put ``(pkill`` in command position and never reached the kill classifier (#787). Nested wrappers are unwrapped too. A trailing ``)`` is removed only when it closes a leading ``(`` this call stripped. Removing one unconditionally mangled balanced command substitution — ``kill $(pgrep -f myapp)`` became ``kill $(pgrep -f myapp`` (PR #789 review finding F3). An unmatched leading ``(`` is still dropped on its own, because splitting a wrapped compound orphans the opening half. """ stripped = segment.strip() while stripped.startswith("("): body = stripped[1:].strip() if _closes_leading_paren(body): body = body[:-1].strip() stripped = body return stripped def _split_segments(command: str) -> list[str]: """Split *command* into simple commands on syntactically active separators.""" active = frozenset(index for index, _ in _iter_active(command)) segments: list[str] = [] start = 0 index = 0 end = len(command) while index < end: char = command[index] if ( char not in _SEPARATOR_CHARS or index not in active or (char in "&|" and _is_redirection(command, index, active)) ): index += 1 continue width = ( 2 if command[index:index + 2] in _LOGICAL_SEPARATORS and (index + 1) in active else 1 ) segments.append(command[start:index]) index += width start = index segments.append(command[start:]) return [seg for seg in (_strip_subshell(seg) for seg in segments) if seg] def is_sanctioned_recovery(text: str | None) -> bool: """True when *text* describes a sanctioned reconnect/restart path. Informational only: this never downgrades a detected process kill. """ return bool(_SANCTIONED_RECOVERY_RE.search(_clean(text))) # ── kill classification ─────────────────────────────────────────────────────── def _analyse_kill_segment( segment: str, *, mcp_pids: frozenset[str], ) -> dict[str, Any] | None: """Classify one command segment, or return None when it is not a kill.""" tokens = segment.split() idx = 0 # Skip env assignments and harmless command prefixes (``sudo pkill ...``). while idx < len(tokens) and (tokens[idx] in _COMMAND_PREFIXES or "=" in tokens[idx]): idx += 1 if idx >= len(tokens): return None verb = os.path.basename(tokens[idx]).lower() if verb not in _KILL_VERBS: return None operands: list[str] = [] skip_next = False for token in tokens[idx + 1:]: if skip_next: skip_next = False continue if token.startswith("-"): if token in _VALUE_FLAGS: skip_next = True continue operands.append(token) names_mcp = bool(_MCP_TARGET_RE.search(segment)) def _result( *, reason_class: str | None, contamination: bool, ambiguous: bool, reason: str, ) -> dict[str, Any]: return { "verb": verb, "operands": operands, "reason_class": reason_class, "contamination": contamination, "ambiguous": ambiguous, "reason": reason, } if verb in {"pkill", "killall"}: if names_mcp: return _result( reason_class=REASON_MANUAL_DAEMON_KILL, contamination=True, ambiguous=False, reason=( f"'{verb}' targets the MCP daemon process pattern; this is " "manual daemon killing, not a sanctioned recovery" ), ) broad = [op for op in operands if _BROAD_PATTERN_RE.match(op)] if broad: return _result( reason_class=REASON_BROAD_PROCESS_KILL, contamination=True, ambiguous=False, reason=( f"'{verb}' pattern {broad[0]!r} is broad enough to kill " "unrelated MCP namespaces as collateral damage" ), ) if not operands: return _result( reason_class=None, contamination=False, ambiguous=True, reason=f"'{verb}' with no resolvable pattern; target unknown", ) return _result( reason_class=None, contamination=False, ambiguous=False, reason=( f"'{verb}' targets {operands!r}, which does not name an MCP " "daemon or a broad pattern" ), ) # ``kill`` — pid-addressed. pids = [op for op in operands if op.isdigit()] hits = sorted(set(pids) & mcp_pids, key=int) if hits: return _result( reason_class=REASON_MANUAL_DAEMON_KILL, contamination=True, ambiguous=False, reason=( "'kill' targets known MCP daemon pid(s) " f"{', '.join(hits)}; this is manual daemon killing" ), ) if names_mcp: return _result( reason_class=REASON_MANUAL_DAEMON_KILL, contamination=True, ambiguous=False, reason="'kill' resolves its target from an MCP daemon process lookup", ) if not pids: return _result( reason_class=None, contamination=False, ambiguous=True, reason="'kill' with no resolvable numeric pid; target unknown", ) return _result( reason_class=None, contamination=False, ambiguous=True, reason=( f"'kill' targets pid(s) {', '.join(pids)}, which are not known MCP " "daemon pids; pass mcp_pids to resolve the ambiguity" ), ) def classify_recovery_command( command: str | None = None, *, mcp_pids: Iterable[Any] | None = None, ) -> dict[str, Any]: """Classify a proposed command for manual MCP daemon kill intent (#630). Pure classification; operator authorization is applied separately by :func:`assess_recovery_command`. """ text = _clean(command) pid_set = frozenset( str(pid).strip() for pid in (mcp_pids or []) if str(pid).strip() ) segments: list[dict[str, Any]] = [] for raw_segment in _split_segments(text): analysed = _analyse_kill_segment(raw_segment, mcp_pids=pid_set) if analysed is not None: analysed["segment"] = redact_command(raw_segment) segments.append(analysed) contaminating = [seg for seg in segments if seg["contamination"]] return { "command_present": bool(text), "redacted_command": redact_command(text), "process_kill": bool(segments), "contamination": bool(contaminating), "reason_class": contaminating[0]["reason_class"] if contaminating else None, "ambiguous": bool( not contaminating and any(seg["ambiguous"] for seg in segments) ), "sanctioned_recovery": is_sanctioned_recovery(text), "segments": segments, "reasons": [seg["reason"] for seg in segments], "known_mcp_pids": sorted(pid_set, key=lambda p: int(p) if p.isdigit() else 0), } # ── operator authorization ──────────────────────────────────────────────────── def operator_authorization(env: dict[str, str] | None = None) -> dict[str, Any]: """Read operator authorization for host daemon maintenance (#630 non-goal 1). Authorization comes from :data:`OPERATOR_AUTHORIZATION_ENV` in the process environment and from nowhere else. A worker session cannot set an environment variable for an already-running daemon, so this cannot be self-asserted the way a tool argument could be (#710 finding F1). """ source = env if env is not None else os.environ reference = _clean(source.get(OPERATOR_AUTHORIZATION_ENV)) return { "authorized": bool(reference), "reference": reference or None, "source": OPERATOR_AUTHORIZATION_ENV if reference else None, "self_assertable": False, } def assess_recovery_command( command: str | None = None, *, mcp_pids: Iterable[Any] | None = None, env: dict[str, str] | None = None, ) -> dict[str, Any]: """Classify *command* and apply operator authorization (#630 AC1/AC2).""" classification = classify_recovery_command(command, mcp_pids=mcp_pids) authorization = operator_authorization(env) detected = classification["contamination"] contaminated = detected and not authorization["authorized"] return { "classification": classification, "authorization": authorization, "contaminated": contaminated, "authorized_bypass": bool(detected and authorization["authorized"]), "remediation": REMEDIATION if contaminated else None, } # ── contamination record + gate ─────────────────────────────────────────────── def build_contamination_record( *, reason_class: str, command_redacted: str | None = None, session_id: str | None = None, remote: str | None = None, role: str | None = None, detail: str | None = None, authorization_reference: str | None = None, ) -> dict[str, Any]: """Build the durable contamination marker payload (redacted, audit-safe). ``reason_class`` is :data:`REASON_MANUAL_DAEMON_KILL` or :data:`REASON_BROAD_PROCESS_KILL`. The command is stored already redacted; secrets never persist on the marker. """ return { "kind": CONTAMINATION_KIND, "reason_class": _clean(reason_class) or REASON_MANUAL_DAEMON_KILL, "command_summary": redact_command(command_redacted), "session_id": _clean(session_id) or None, "remote": _clean(remote) or None, "role": _clean(role) or None, "detail": _clean(detail) or None, "authorization_reference": _clean(authorization_reference) or None, "cleared_by_reconciler": False, } def assess_contamination_gate( marker: dict[str, Any] | None, *, task: str | None, actual_role: str | None, ) -> dict[str, Any]: """Fail closed on gated mutations while a contamination marker is live (#630 AC3). * No marker → allowed. * Reconciler (audit) role → allowed (the sanctioned inspect/clear path). * Marker present + ``task`` in :data:`CONTAMINATION_GATED_TASKS` → blocked. * Marker present + non-gated task (``comment_issue``, ``lock_issue``) → allowed, so the contaminated worker can still post the durable audit comment and hand off. """ if not marker or marker.get("cleared_by_reconciler"): return {"block": False, "reasons": [], "task": task} role = _clean(actual_role).lower() if role == "reconciler": return { "block": False, "reasons": [], "task": task, "detail": "reconciler audit path is exempt from the contamination gate", } task_name = _clean(task) if task_name and task_name in CONTAMINATION_GATED_TASKS: summary = marker.get("command_summary") or marker.get("detail") or "(no summary)" reason_class = marker.get("reason_class") or REASON_MANUAL_DAEMON_KILL return { "block": True, "reasons": [ f"session is workflow-contaminated ({reason_class}): {summary}. " f"'{task_name}' is blocked until a reconciler audits and clears " "the contamination. " + REMEDIATION ], "task": task_name, } return {"block": False, "reasons": [], "task": task_name or None} def format_contamination_gate_error(gate: dict[str, Any]) -> str: """Single RuntimeError message for MCP mutation gates.""" reasons = "; ".join(gate.get("reasons") or ["session workflow-contaminated"]) return f"Runtime-recovery contamination gate (#630): {reasons}" # ── final-report rules ──────────────────────────────────────────────────────── # Claims that assert a clean session. While a marker is live these are false and # must be rejected rather than merely downgraded. _CLEAN_CLAIM_RE = re.compile( r"\bclean\s+session\b|\bsession\s+(?:is|was|remains)\s+clean\b|" r"\bno\s+contamination\b|\buncontaminated\b|\bcontamination\s*[:=]\s*none\b|" r"\bworkflow[- ]clean\b|\bno\s+workflow\s+contamination\b", re.IGNORECASE, ) # Language that actually surfaces the contamination to a reader. _SURFACED_RE = re.compile( r"manual[_ ]daemon[_ ]kill|broad[_ ]process[_ ]kill|daemon\s+process\s+kill|" r"contaminated\s+recovery|runtime[- ]recovery\s+contamination|" r"workflow[- ]contaminated", re.IGNORECASE, ) def assess_final_report_claim( report_text: str | None, marker: dict[str, Any] | None, ) -> dict[str, Any]: """Reject clean-session claims while contaminated (#630 scope item 4). A live marker imposes two obligations on the final report: it must surface the contaminated recovery explicitly, and it must not claim the session is clean. Either failure blocks. """ if not marker or marker.get("cleared_by_reconciler"): return { "block": False, "reasons": [], "contaminated": False, "surfaced": None, "clean_claim": False, } text = _clean(report_text) surfaced = bool(_SURFACED_RE.search(text)) clean_claim = bool(_CLEAN_CLAIM_RE.search(text)) reasons: list[str] = [] if clean_claim: reasons.append( "final report claims a clean session while a live " f"{marker.get('reason_class') or CONTAMINATION_KIND} contamination " "marker exists; the claim is false and must be removed" ) if not surfaced: reasons.append( "final report does not surface the contaminated runtime recovery; " "the report must state that MCP daemon processes were manually " "killed and that the session awaits a reconciler audit" ) return { "block": bool(reasons), "reasons": reasons, "contaminated": True, "surfaced": surfaced, "clean_claim": clean_claim, "reason_class": marker.get("reason_class"), }