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
+41
View File
@@ -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.