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:
@@ -110,8 +110,49 @@ healthy. See `docs/mcp-namespace-health.md`.
|
||||
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
|
||||
reconnect.
|
||||
|
||||
## Sanctioned recovery vs forbidden process manipulation (#630)
|
||||
|
||||
Both restore a working namespace. Only one leaves the session trustworthy.
|
||||
|
||||
**Sanctioned — the runtime is repaired by whoever owns it:**
|
||||
|
||||
- IDE/host auto-reconnect, or an explicit client reconnect (`/mcp reconnect`).
|
||||
- Relaunching the IDE/client so it respawns the daemons it started.
|
||||
- An operator-owned restart performed outside the workflow session.
|
||||
|
||||
**Forbidden — the session manipulates the processes its own proof depends on:**
|
||||
|
||||
- `pkill -f mcp_server.py`, `pkill -f gitea_mcp_server`, broad `pkill -f mcp`.
|
||||
- `killall` of a daemon, or `kill <pid>` of an MCP daemon pid.
|
||||
- Any pattern broad enough to take unrelated namespaces with it
|
||||
(`pkill -f python`), even when it never names MCP.
|
||||
|
||||
Read-only inspection (`ps aux | grep mcp_server`) is neither: it proves nothing
|
||||
and breaks nothing. A `kill` of some unrelated pid is reported as *ambiguous*
|
||||
rather than contaminating, so ordinary subprocess work is never false-blocked.
|
||||
|
||||
**What happens on a detected attempt.** `gitea_record_daemon_process_kill_attempt`
|
||||
classifies a proposed command and, when it is a manual daemon kill, writes a
|
||||
durable contamination marker for the active profile identity. While that marker
|
||||
is live every review / merge / close / completion mutation fails closed;
|
||||
`comment_issue` and `lock_issue` stay allowed so the contaminated worker can
|
||||
still post its audit comment and hand off. The final report must surface the
|
||||
contaminated recovery and must not claim a clean session.
|
||||
|
||||
Contamination is **not self-clearable**. Only
|
||||
`gitea_audit_runtime_recovery_contamination` with `action=clear`, run under a
|
||||
reconciler profile, removes it. The marker is recovery-critical, so it does not
|
||||
expire into cleanliness when the session-state TTL lapses.
|
||||
|
||||
**Operator-authorized host maintenance stays permitted.** Authorization is read
|
||||
from the `GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION` environment variable
|
||||
and from nowhere else — set outside the session by the operator who owns the
|
||||
host, and recorded as an audit reference on the assessment. It is deliberately
|
||||
not a tool argument: a session must never be able to authorize itself.
|
||||
|
||||
## Related
|
||||
|
||||
- #630 — manual daemon killing as contaminated recovery (this contrast, enforced).
|
||||
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
|
||||
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair.
|
||||
- `docs/mcp-client-registration.md` — per-server registration contract.
|
||||
|
||||
@@ -64,6 +64,7 @@ that gates each call, not which tools exist.
|
||||
- `gitea_assess_work_issue_duplicate`
|
||||
- `gitea_assess_worktree_cleanup_integrity`
|
||||
- `gitea_audit_config`
|
||||
- `gitea_audit_runtime_recovery_contamination`
|
||||
- `gitea_audit_stable_branch_contamination`
|
||||
- `gitea_audit_worktree_cleanup`
|
||||
- `gitea_authorize_reconciliation_cleanup_phase`
|
||||
@@ -125,6 +126,7 @@ that gates each call, not which tools exist.
|
||||
- `gitea_reconcile_issue_claims`
|
||||
- `gitea_reconcile_merged_cleanups`
|
||||
- `gitea_reconcile_superseded_by_merged_pr`
|
||||
- `gitea_record_daemon_process_kill_attempt`
|
||||
- `gitea_record_irrecoverable_decision_lock_provenance`
|
||||
- `gitea_record_pre_review_command`
|
||||
- `gitea_record_shell_spawn_outcome`
|
||||
|
||||
Reference in New Issue
Block a user