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:
2026-07-21 17:54:19 -05:00
co-authored by Claude Opus 4.8
parent 7ecf7bf2d6
commit 1ec4672fad
8 changed files with 1315 additions and 0 deletions
+213
View File
@@ -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,