Adds a gated `system.restart_namespace` action so operators and workers can restart or gracefully reload MCP namespaces through an authorized, audited path instead of manual host process killing (#630). - webui/sanctioned_restart.py: restart/reload operation model, dry-run intent preview, confirmation-string enforcement, audit emission, and post-restart health verification. Fails closed on unknown auth, missing capability, or ambiguous target namespace. - webui/console_authz.py: RBAC entries for the restart capability with secret redaction preserved. - webui/gated_actions.py: registers the restart action in the gated action framework so it cannot be invoked without capability + confirmation. - task_capability_map.py: capability mapping for the restart operation. - docs/sanctioned-restart-controls.md: operator documentation for the sanctioned path and the explicit prohibition on pkill recovery. - docs/webui-authz-audit.md: audit model updated for restart events. - tests/test_webui_sanctioned_restart.py: authorized preview, unauthorized deny, confirmation enforcement, contamination classification, and audit emission coverage. No unrestricted kill path is exposed; manual pkill remains classified as contamination and continues to block clean claims. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
123 lines
5.7 KiB
Markdown
123 lines
5.7 KiB
Markdown
# Sanctioned restart and graceful reload controls (#642)
|
|
|
|
Sessions used to recover MCP connectivity by killing the host daemon
|
|
(`pkill -f mcp_server.py`, #630). That is forbidden and stays forbidden: it
|
|
kills every namespace on the host, contaminates whichever session survives, and
|
|
leaves no audit trail. This document describes the sanctioned replacement,
|
|
implemented in `webui/sanctioned_restart.py`.
|
|
|
|
## What the console will and will not do
|
|
|
|
The console **never** restarts anything. It authorizes an intent, records it,
|
|
and hands off to a host supervisor. There is no code path in which the console
|
|
sends a signal, spawns a process, or renders a kill command — a regression test
|
|
asserts the module contains no `subprocess`, `signal`, `os.kill`, `os.system`,
|
|
or `popen` reference, and that no returned payload contains a kill command.
|
|
|
|
## Operations
|
|
|
|
| Mode | Action | Minimum role | Behaviour |
|
|
|------|--------|--------------|-----------|
|
|
| `reload` | `system.reload_namespace` | controller | Host supervisor reloads the namespace in place, draining in-flight requests. |
|
|
| `restart` | `system.restart_namespace` | admin | Host supervisor restarts the namespace. In-flight requests are lost. |
|
|
|
|
Scope is always exactly one namespace. A fleet-wide restart is an explicit
|
|
non-goal: `all`, `*`, `fleet`, and an empty scope are refused with
|
|
`fleet_scope_not_permitted`, because that is precisely the blast radius the
|
|
forbidden kill already had. An unrecognised namespace is refused rather than
|
|
passed through to the host.
|
|
|
|
## The gate sequence
|
|
|
|
`assess_restart_request()` applies every gate in order and reports the first
|
|
failure with a stable reason code:
|
|
|
|
| Order | Gate | Reason code on failure |
|
|
|-------|------|------------------------|
|
|
| 1 | Mode is `restart` or `reload` | `unknown_mode` |
|
|
| 2 | Scope is a single known namespace | `fleet_scope_not_permitted`, `unknown_namespace` |
|
|
| 3 | Principal holds the required console role | `unauthorized` |
|
|
| 4 | Confirmation phrase supplied | `confirmation_required` |
|
|
| 5 | Confirmation names this namespace and mode | `confirmation_mismatch` |
|
|
| 6 | Out-of-band operator authorization present | `operator_authorization_missing` |
|
|
| 7 | Runtime is not contaminated | `contaminated_runtime` |
|
|
| 8 | Host restart hook configured | `restart_hook_not_configured` |
|
|
|
|
Passing every gate yields `host_action_required`, never "restarted".
|
|
|
|
### Confirmation binds the namespace
|
|
|
|
The required phrase is `"<mode> <namespace>"` — for example
|
|
`restart gitea-author`. Binding the namespace into the phrase is the point: a
|
|
confirmation typed for one namespace cannot be replayed against another.
|
|
|
|
### Operator authorization is not self-assertable
|
|
|
|
Host daemon maintenance is authorized out of band through
|
|
`GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION`, read from the process
|
|
environment and nowhere else (#630; #710 finding F1). A worker session cannot
|
|
set an environment variable for an already-running daemon, so this cannot be
|
|
faked the way a tool argument could.
|
|
|
|
### The host hook
|
|
|
|
`GITEA_SANCTIONED_RESTART_HOOK` holds an opaque reference the *host* resolves —
|
|
a supervisor label such as a launchd job name, never a command line. With no
|
|
hook configured the request is refused; the console does not fall back to a
|
|
process kill. The value is read server-side and never rendered to a client.
|
|
|
|
## Manual kill remains contamination
|
|
|
|
`classify_restart_command()` classifies an operator-proposed recovery command.
|
|
A manual `pkill`/`kill`/`killall` of the MCP daemon is contamination, not a
|
|
restart: it returns `clean_claim_allowed: false` and builds a durable
|
|
contamination marker (redacted command only, never secrets) naming
|
|
`system.restart_namespace` as the sanctioned alternative.
|
|
|
|
A live, uncleared contamination marker also blocks a restart. This is stricter
|
|
than #630's task-scoped gate, which deliberately lets a contaminated worker keep
|
|
commenting and handing off: restarting a contaminated runtime would launder the
|
|
contamination rather than resolve it. Clear the marker through the reconciler
|
|
path first.
|
|
|
|
## Post-restart health verification
|
|
|
|
After the host supervisor acts, `verify_post_restart_health()` decides whether
|
|
the session may claim to be clean:
|
|
|
|
| Status | Meaning | Clean claim |
|
|
|--------|---------|-------------|
|
|
| `clean` | Required tool callable, proven through the live client namespace | Allowed |
|
|
| `unproven` | Reported healthy without live client-namespace evidence | Refused |
|
|
| `unhealthy` | Probe failed | Refused |
|
|
|
|
Only `probe_source=client_namespace` evidence clears a session. Static tool
|
|
registration is not proof, and neither is an offline subprocess probe — an IDE
|
|
client can hold a registered tool list while live calls fail with
|
|
`client is closing: EOF` (see
|
|
[`mcp-namespace-health.md`](mcp-namespace-health.md)).
|
|
|
|
## Audit
|
|
|
|
Every attempt — allowed or denied — is recorded through
|
|
`webui.console_audit` with actor, target namespace, mode, result, and reason
|
|
code, and is redacted before it is persisted. `system.restart_namespace` is
|
|
break-glass, so its records are retained for 730 days. Records carry
|
|
`process_kill_executed: false`, which is a fact about the code path rather than
|
|
a claim: no such path exists.
|
|
|
|
## Environment variables
|
|
|
|
| Variable | Purpose |
|
|
|----------|---------|
|
|
| `GITEA_SANCTIONED_RESTART_HOOK` | Host supervisor reference; absent means restart is refused. |
|
|
| `GITEA_OPERATOR_DAEMON_MAINTENANCE_AUTHORIZATION` | Out-of-band operator authorization reference. |
|
|
| `WEBUI_AUDIT_LOG` | Console audit sink; absent means records are built but not persisted. |
|
|
|
|
## Non-goals
|
|
|
|
* No unrestricted `kill` from the UI, in any role, in any phase.
|
|
* No fleet-wide restart.
|
|
* No silent auto-restart loop: every attempt is confirmed and audited.
|
|
* This does not implement the Phase 1 health API (#634).
|