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
+24
View File
@@ -16,6 +16,7 @@ import issue_acceptance_gate
import issue_lock_provenance
import merger_lease_adoption
import reviewer_handoff_consistency
import runtime_recovery_guard
import thread_state_ledger_validator
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
@@ -1870,6 +1871,7 @@ def assess_final_report_validator(
validation_session: dict | None = None,
reconciler_close_lock: dict | None = None,
mutation_attempt_ledger: list[dict] | None = None,
runtime_recovery_marker: dict | None = None,
) -> dict[str, Any]:
"""Validate final-report text against task-specific proof rules (#327).
@@ -1908,6 +1910,28 @@ def assess_final_report_validator(
action_log = sanitized_action_log
findings.extend(action_log_findings)
# #630 scope item 4: while a manual daemon-kill contamination marker is
# live, the report must surface it and must not claim a clean session.
if runtime_recovery_marker:
runtime_recovery = runtime_recovery_guard.assess_final_report_claim(
report_text,
runtime_recovery_marker,
)
checks["runtime_recovery_contamination"] = runtime_recovery
if runtime_recovery.get("block"):
findings.extend(
_findings_from_reasons(
"shared.runtime_recovery_contamination",
runtime_recovery.get("reasons") or [],
field="Runtime recovery",
severity="block",
safe_next_action=(
"state the manual daemon kill and the pending reconciler "
"audit in the report; remove any clean-session claim"
),
)
)
if normalized_kind == "issue_filing" and issue_filing_lock is not None:
checks["issue_filing"] = assess_issue_filing_final_report(
report_text,