feat: add master-parity staleness gate for MCP mutations (Closes #420)

The MCP server loads capability-gate code into memory at startup, so a
newly merged gate (e.g. the branch-delete capability gate #408/#410) does
not take effect until the process restarts. A long-running server could
therefore still perform a mutation the updated codebase forbids.

Runtime profile/config data is already read live from disk each call
(gitea_config.load_config re-reads the JSON), so allowed_operations changes
apply without a restart. This closes the remaining gap: *code* parity.

- master_parity_gate.py: capture the process's startup commit and, at
  mutation time, compare it against the on-disk master HEAD; pure,
  injectable assessment plus block-reasons and a restart-required report.
- gitea_mcp_server.py: capture _STARTUP_PARITY at import; _profile_operation_gate
  fails closed for mutating ops when stale while leaving gitea.read allowed;
  gitea_get_runtime_context surfaces master_parity; new read-only
  gitea_assess_master_parity tool reports parity/restart-required.
- Escape hatches: GITEA_MCP_DISABLE_PARITY_GATE (ops), GITEA_TEST_CURRENT_HEAD (tests).
- tests/test_master_parity_gate.py: 18 cases (pure logic, block/report,
  read override, and server wiring: reads pass, mutations blocked when stale).

Validation: venv/bin/python -m pytest -q -> 1618 passed, 6 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-07 13:11:36 -04:00
co-authored by Claude Opus 4.8
parent 1a1e679246
commit 934688a71d
3 changed files with 412 additions and 1 deletions
+92 -1
View File
@@ -508,6 +508,13 @@ import reconciler_profile # noqa: E402
import reconciliation_workflow # noqa: E402
import review_merge_state_machine # noqa: E402
import native_mcp_preference # noqa: E402
import master_parity_gate # noqa: E402
# Master-parity baseline (#420): the commit this server process started at.
# Captured once so that, at mutation time, we can detect when the on-disk
# master has advanced past the running code and fail closed until restart.
# Read-only operations are never blocked by staleness.
_STARTUP_PARITY = master_parity_gate.capture_startup_parity(PROJECT_ROOT)
# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue,
@@ -4252,15 +4259,41 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
return False
def _current_master_parity() -> dict:
"""Assess this process's code against the on-disk master HEAD (#420)."""
current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
def _master_parity_block(op: str) -> list[str]:
"""Fail-closed staleness reasons for a mutation *op* (#420).
Read-only operations (``gitea.read``) are never blocked: a stale server can
still be inspected. Any other (mutating) operation is refused when the
on-disk master has definitively advanced past the running code, since newly
merged capability gates would not yet be loaded in memory.
"""
if op == "gitea.read":
return []
return master_parity_gate.parity_block_reasons(_current_master_parity())
def _profile_operation_gate(op: str) -> list[str]:
"""Profile permission check for a single gated operation (#126, #216).
"""Profile permission check for a single gated operation (#126, #216, #420).
Issue discussion comments are gated separately from the gitea.pr.*
review/merge family: listing requires ``gitea.read``, creating requires
``gitea.issue.comment``. Closing a PR requires the distinct
``gitea.pr.close`` (#216). Returns a list of block reasons (empty =
allowed); an unreadable profile fails closed.
A mutating operation is additionally refused when the server code is stale
relative to master (#420), so a long-running process cannot bypass a
capability gate that has since been merged.
"""
stale_reasons = _master_parity_block(op)
if stale_reasons:
return stale_reasons
try:
profile = get_profile()
except Exception as exc:
@@ -5673,12 +5706,70 @@ def gitea_get_runtime_context(
"shell_health": native_mcp_preference.shell_health_status(),
}
parity = _current_master_parity()
result["master_parity"] = {
"in_parity": parity["in_parity"],
"stale": parity["stale"],
"restart_required": parity["restart_required"],
"startup_head": parity["startup_head"],
"current_head": parity["current_head"],
"summary": master_parity_gate.format_parity(parity),
"mutation_gate_enforced": not master_parity_gate.gate_disabled(),
}
if parity["stale"] and not master_parity_gate.gate_disabled():
safe_next_action = (
"Server code is stale relative to master; restart the Gitea MCP "
"server to load current capability gates before mutating. "
f"({master_parity_gate.format_parity(parity)})"
)
result["safe_next_action"] = safe_next_action
if reveal and h:
result["server"] = gitea_url(h, "").rstrip("/")
return result
@mcp.tool()
def gitea_assess_master_parity(
remote: str = "dadeschools",
host: str | None = None,
) -> dict:
"""Read-only: is the running server code in parity with the on-disk master?
The MCP server loads its capability-gate code into memory at startup;
``master`` advancing (e.g. a newly merged security gate) does not take
effect until the process restarts. This tool compares the commit the
process started at against the current workspace ``HEAD`` and reports
whether a restart is required before mutations are safe again (#420).
Never mutates and makes no network calls. Read-only operations are never
blocked by staleness; only mutating operations fail closed while stale.
Returns:
dict with 'in_parity', 'stale', 'restart_required', 'startup_head',
'current_head', 'mutation_gate_enforced', 'summary', 'reasons', and a
'report' recovery payload when stale.
"""
parity = _current_master_parity()
enforced = not master_parity_gate.gate_disabled()
out = {
"in_parity": parity["in_parity"],
"stale": parity["stale"],
"restart_required": parity["restart_required"],
"determinable": parity["determinable"],
"startup_head": parity["startup_head"],
"current_head": parity["current_head"],
"mutation_gate_enforced": enforced,
"summary": master_parity_gate.format_parity(parity),
"reasons": parity["reasons"],
"process_root": PROJECT_ROOT,
}
if parity["stale"] and enforced:
out["report"] = master_parity_gate.parity_report(parity)
return out
@mcp.tool()
def gitea_list_profiles() -> dict:
"""Read-only: list all Gitea MCP profiles with redacted metadata.