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:
+92
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user