fix(mcp): block manual daemon killing as workflow recovery (Closes #630)
A session that ran `pkill -f mcp_server.py`, waited for the IDE to respawn the daemons, and then closed an issue left no trace distinguishing that closure from one performed over a sanctioned runtime. Detection existed only as advisory strings (`native_mcp_preference.classify_command_path`, `review_workflow_boundary`) and never failed closed on the mutations that followed. Adds `runtime_recovery_guard.py`, mirroring the #671 stable-branch contamination model so the two cannot drift apart: classification of kill/pkill/killall commands and known-pid kills, a durable `runtime_recovery_contamination` marker, a fail-closed pre-flight gate over the shared review/merge/close/completion task set, and reconciler-only clearing. `comment_issue` and `lock_issue` stay allowed so a contaminated worker can still post its audit comment and hand off. The marker is recovery-critical, so contamination cannot expire into cleanliness with the session-state TTL. Read-only inspection (`ps aux | grep mcp_server`), sanctioned reconnects, and process management unrelated to the daemons are never flagged; a bare `kill` of an unknown pid is reported as ambiguous rather than contaminating, so ordinary subprocess work is not false-blocked. A pattern broad enough to sweep unrelated namespaces (`pkill -f python`) is contamination even when it never names MCP. Operator-authorized host maintenance stays permitted, but the authorization is read from GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION in the process environment only and never from a tool argument, so a session cannot authorize itself. Final-report rules reject clean-session claims and require the contaminated recovery to be surfaced. Two tools are registered (inventory 112 to 114) and the sanctioned-versus-forbidden contrast is documented in the namespace recovery doc and the workflow skill. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -1410,6 +1410,10 @@ def verify_preflight_purity(
|
||||
# contaminated by a direct stable-branch push attempt (reconciler-exempt).
|
||||
_enforce_stable_branch_contamination_gate(task, remote)
|
||||
|
||||
# #630: block the same mutation classes while the session is
|
||||
# contaminated by manual MCP daemon process killing (reconciler-exempt).
|
||||
_enforce_runtime_recovery_contamination_gate(task, remote)
|
||||
|
||||
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||
workspace = ctx["workspace_path"]
|
||||
canonical_root = ctx["canonical_repo_root"]
|
||||
@@ -1874,6 +1878,74 @@ def _clear_stable_contamination_marker(
|
||||
)
|
||||
|
||||
|
||||
def _runtime_recovery_profile_identity() -> str:
|
||||
return mcp_session_state.current_profile_identity(
|
||||
profile_name=get_profile().get("profile_name"),
|
||||
)
|
||||
|
||||
|
||||
def _load_runtime_recovery_marker(remote: str | None = None) -> dict | None:
|
||||
return mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_RUNTIME_RECOVERY_CONTAMINATION,
|
||||
remote=remote,
|
||||
profile_identity=_runtime_recovery_profile_identity(),
|
||||
)
|
||||
|
||||
|
||||
def _save_runtime_recovery_marker(
|
||||
record: dict,
|
||||
*,
|
||||
remote: str | None = None,
|
||||
) -> dict | None:
|
||||
return mcp_session_state.save_state(
|
||||
kind=mcp_session_state.KIND_RUNTIME_RECOVERY_CONTAMINATION,
|
||||
payload=record,
|
||||
remote=remote,
|
||||
profile_identity=_runtime_recovery_profile_identity(),
|
||||
)
|
||||
|
||||
|
||||
def _clear_runtime_recovery_marker(
|
||||
*,
|
||||
remote: str | None = None,
|
||||
profile_identity: str | None = None,
|
||||
) -> None:
|
||||
mcp_session_state.clear_state(
|
||||
kind=mcp_session_state.KIND_RUNTIME_RECOVERY_CONTAMINATION,
|
||||
remote=remote,
|
||||
profile_identity=profile_identity or _runtime_recovery_profile_identity(),
|
||||
)
|
||||
|
||||
|
||||
def _enforce_runtime_recovery_contamination_gate(
|
||||
task: str | None,
|
||||
remote: str | None = None,
|
||||
) -> None:
|
||||
"""#630 AC3: fail closed on gated mutations after a manual daemon kill.
|
||||
|
||||
Mirrors the #671 stable-branch gate: reconciler role is exempt (the
|
||||
sanctioned audit/clear path), and non-gated tasks (comment_issue,
|
||||
lock_issue) stay allowed so a contaminated worker can still post the
|
||||
durable audit comment and hand off.
|
||||
"""
|
||||
if _preflight_in_test_mode() and not os.environ.get(
|
||||
"GITEA_TEST_FORCE_RUNTIME_CONTAMINATION"
|
||||
):
|
||||
return
|
||||
marker = _load_runtime_recovery_marker(remote)
|
||||
if not marker:
|
||||
return
|
||||
gate = runtime_recovery_guard.assess_contamination_gate(
|
||||
marker,
|
||||
task=task,
|
||||
actual_role=_actual_profile_role(),
|
||||
)
|
||||
if gate["block"]:
|
||||
raise RuntimeError(
|
||||
runtime_recovery_guard.format_contamination_gate_error(gate)
|
||||
)
|
||||
|
||||
|
||||
def _enforce_stable_branch_contamination_gate(
|
||||
task: str | None,
|
||||
remote: str | None = None,
|
||||
@@ -1954,6 +2026,7 @@ import author_mutation_worktree # noqa: E402
|
||||
import root_checkout_guard # noqa: E402
|
||||
import workflow_scope_guard # noqa: E402 # #683 production scope / force-on guards
|
||||
import stable_branch_push_guard # noqa: E402
|
||||
import runtime_recovery_guard # noqa: E402 # #630 manual daemon-kill contamination
|
||||
import remote_repo_guard # noqa: E402
|
||||
import anti_stomp_preflight # noqa: E402
|
||||
import issue_claim_heartbeat # noqa: E402
|
||||
@@ -15871,6 +15944,146 @@ def gitea_audit_stable_branch_contamination(
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_record_daemon_process_kill_attempt(
|
||||
command: str | None = None,
|
||||
remote: str = "dadeschools",
|
||||
mcp_pids: list[str] | None = None,
|
||||
session_id: str | None = None,
|
||||
mark: bool = True,
|
||||
) -> dict:
|
||||
"""Classify a proposed command for manual MCP daemon kill intent (#630).
|
||||
|
||||
Workflow recovery must use sanctioned reconnect/restart paths only. This
|
||||
tool detects ``pkill -f mcp_server.py`` and its equivalents (``killall``,
|
||||
``pkill -f gitea_mcp_server``, broad ``pkill -f python`` sweeps that would
|
||||
take unrelated namespaces as collateral damage, and ``kill <pid>`` of a pid
|
||||
listed in ``mcp_pids``).
|
||||
|
||||
Read-only inspection (``ps aux | grep mcp_server``) and a sanctioned client
|
||||
reconnect are never flagged. A bare ``kill <pid>`` with no MCP linkage is
|
||||
reported as *ambiguous* rather than contaminating, so ordinary subprocess
|
||||
management is not false-blocked.
|
||||
|
||||
Operator authorization for host maintenance is read from the process
|
||||
environment (``GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION``) and never
|
||||
from a tool argument, so it cannot be self-asserted by the session being
|
||||
judged. When authorization is present the attempt is reported as an
|
||||
authorized bypass and no marker is written.
|
||||
|
||||
When ``mark`` is true and contamination is detected without authorization, a
|
||||
durable ``runtime_recovery_contamination`` marker is written for the active
|
||||
profile identity. Subsequent review/merge/close/completion mutations then
|
||||
fail closed until a reconciler audits and clears it. The stored command
|
||||
summary is redacted; secrets never persist.
|
||||
"""
|
||||
assessment = runtime_recovery_guard.assess_recovery_command(
|
||||
command,
|
||||
mcp_pids=mcp_pids,
|
||||
)
|
||||
classification = assessment["classification"]
|
||||
authorization = assessment["authorization"]
|
||||
contaminated = bool(assessment["contaminated"])
|
||||
|
||||
marker = None
|
||||
marked = False
|
||||
if contaminated and mark:
|
||||
record = runtime_recovery_guard.build_contamination_record(
|
||||
reason_class=classification.get("reason_class")
|
||||
or runtime_recovery_guard.REASON_MANUAL_DAEMON_KILL,
|
||||
command_redacted=classification.get("redacted_command"),
|
||||
session_id=session_id,
|
||||
remote=remote,
|
||||
role=_actual_profile_role(),
|
||||
detail="; ".join(classification.get("reasons") or []),
|
||||
authorization_reference=authorization.get("reference"),
|
||||
)
|
||||
marker = _save_runtime_recovery_marker(record, remote=remote)
|
||||
marked = marker is not None
|
||||
|
||||
return {
|
||||
"classification": classification,
|
||||
"authorization": authorization,
|
||||
"contaminated": contaminated,
|
||||
"authorized_bypass": bool(assessment["authorized_bypass"]),
|
||||
"marked": marked,
|
||||
"marker": marker,
|
||||
"profile_identity": _runtime_recovery_profile_identity(),
|
||||
"remediation": assessment["remediation"],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_audit_runtime_recovery_contamination(
|
||||
action: str = "inspect",
|
||||
remote: str = "dadeschools",
|
||||
profile_identity: str | None = None,
|
||||
) -> dict:
|
||||
"""Reconciler audit of a manual daemon-kill contamination marker (#630).
|
||||
|
||||
``action='inspect'`` (default, read-only) loads the durable marker for the
|
||||
given ``profile_identity`` (or the active session's) and reports it.
|
||||
|
||||
``action='clear'`` removes the marker so the contaminated session may
|
||||
resume gated mutations. Clearing is the reconciler audit path and requires
|
||||
an active reconciler profile — a worker session must never self-clear.
|
||||
``profile_identity`` targets the contaminated worker's marker (a reconciler
|
||||
runs under its own identity).
|
||||
"""
|
||||
act = (action or "inspect").strip().lower()
|
||||
target_identity = (
|
||||
(profile_identity or "").strip() or _runtime_recovery_profile_identity()
|
||||
)
|
||||
|
||||
marker = mcp_session_state.load_state(
|
||||
kind=mcp_session_state.KIND_RUNTIME_RECOVERY_CONTAMINATION,
|
||||
remote=remote,
|
||||
profile_identity=target_identity,
|
||||
)
|
||||
|
||||
if act == "inspect":
|
||||
return {
|
||||
"action": "inspect",
|
||||
"profile_identity": target_identity,
|
||||
"contaminated": marker is not None,
|
||||
"marker": marker,
|
||||
"read_only": True,
|
||||
}
|
||||
|
||||
if act == "clear":
|
||||
role = _actual_profile_role()
|
||||
if role != "reconciler":
|
||||
return {
|
||||
"action": "clear",
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"profile_identity": target_identity,
|
||||
"reasons": [
|
||||
"runtime-recovery contamination may only be cleared by a "
|
||||
f"reconciler audit; active role is '{role}' (fail closed). "
|
||||
"The contaminated worker session must not self-clear."
|
||||
],
|
||||
}
|
||||
_clear_runtime_recovery_marker(
|
||||
remote=remote,
|
||||
profile_identity=target_identity,
|
||||
)
|
||||
return {
|
||||
"action": "clear",
|
||||
"success": True,
|
||||
"performed": True,
|
||||
"profile_identity": target_identity,
|
||||
"was_contaminated": marker is not None,
|
||||
}
|
||||
|
||||
return {
|
||||
"action": act,
|
||||
"success": False,
|
||||
"performed": False,
|
||||
"reasons": [f"unknown action '{act}'; use 'inspect' or 'clear'"],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def gitea_validate_review_final_report(
|
||||
report_text: str,
|
||||
|
||||
Reference in New Issue
Block a user