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]>
169 lines
6.6 KiB
Python
169 lines
6.6 KiB
Python
"""Master-parity staleness gate (#420).
|
|
|
|
The Gitea MCP server loads its capability-gate code and workflow logic into
|
|
memory when the process starts. When ``master`` advances -- for example a newly
|
|
merged security gate such as the branch-delete capability gate (#408/#410) --
|
|
the running process keeps executing the *old* code until it is restarted. A
|
|
stale server can therefore still perform a mutation that the updated codebase
|
|
would forbid.
|
|
|
|
Runtime profile/config data is already read live from disk on every call
|
|
(``gitea_config.load_config`` re-reads the JSON file each time), so profile
|
|
``allowed_operations`` changes take effect immediately without a restart. The
|
|
gap this module closes is *code* parity: it captures the server process's
|
|
source-tree commit at startup and detects, at mutation time, when the on-disk
|
|
``master`` HEAD has advanced past it. Detected staleness fails closed with a
|
|
restart-required recovery report, while read-only operations stay allowed so a
|
|
stale server can still be inspected.
|
|
|
|
The core assessment is pure -- callers inject the observed HEAD SHAs -- so the
|
|
logic is fully unit-testable without a git checkout.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
|
|
# Environment escape hatches (ops + tests):
|
|
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
|
|
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
|
|
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
|
|
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
|
|
|
|
|
|
def read_git_head(root: str) -> str | None:
|
|
"""Return the current ``HEAD`` commit SHA of *root*, or ``None``.
|
|
|
|
``None`` means the SHA could not be determined (not a git checkout, git
|
|
unavailable, or an error). A test override via ``GITEA_TEST_CURRENT_HEAD``
|
|
takes precedence so the gate can be exercised deterministically.
|
|
"""
|
|
forced = os.environ.get(ENV_TEST_CURRENT_HEAD)
|
|
if forced is not None:
|
|
return forced.strip() or None
|
|
if not root:
|
|
return None
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "-C", root, "rev-parse", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
except Exception:
|
|
return None
|
|
if res.returncode != 0:
|
|
return None
|
|
return (res.stdout or "").strip() or None
|
|
|
|
|
|
def capture_startup_parity(root: str, head: str | None = None) -> dict:
|
|
"""Capture the process source-tree baseline once at server startup.
|
|
|
|
*head* may be injected (tests); otherwise it is read from *root*. The result
|
|
is an opaque baseline handed back to :func:`assess_master_parity`.
|
|
"""
|
|
startup_head = head if head is not None else read_git_head(root)
|
|
return {"root": root, "startup_head": startup_head}
|
|
|
|
|
|
def _short(sha: str | None) -> str:
|
|
return sha[:12] if sha else "unknown"
|
|
|
|
|
|
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
|
|
"""Compare the startup baseline against the current on-disk ``HEAD``.
|
|
|
|
Pure: both HEADs are supplied by the caller. Returns a structured result:
|
|
|
|
- ``in_parity`` -- server code matches the on-disk master (or parity
|
|
could not be determined, which is not treated as stale).
|
|
- ``stale`` -- the on-disk master has definitively advanced past the
|
|
running process.
|
|
- ``restart_required`` -- alias of ``stale``; the recovery action.
|
|
- ``determinable`` -- whether both HEADs were known well enough to compare.
|
|
- ``startup_head`` / ``current_head`` / ``reasons``.
|
|
"""
|
|
startup_head = (startup or {}).get("startup_head")
|
|
reasons: list[str] = []
|
|
|
|
if startup_head is None:
|
|
reasons.append(
|
|
"startup commit was not captured; code parity cannot be enforced")
|
|
return _result(True, False, False, startup_head, current_head, reasons)
|
|
|
|
if current_head is None:
|
|
reasons.append(
|
|
"current workspace HEAD could not be read; code parity cannot be "
|
|
"enforced")
|
|
return _result(True, False, False, startup_head, current_head, reasons)
|
|
|
|
if startup_head == current_head:
|
|
return _result(True, False, True, startup_head, current_head, reasons)
|
|
|
|
reasons.append(
|
|
f"MCP server started at commit {_short(startup_head)} but the workspace "
|
|
f"master is now {_short(current_head)}; restart the server to load the "
|
|
f"current capability gates")
|
|
return _result(False, True, True, startup_head, current_head, reasons)
|
|
|
|
|
|
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
|
|
return {
|
|
"in_parity": in_parity,
|
|
"stale": stale,
|
|
"restart_required": stale,
|
|
"determinable": determinable,
|
|
"startup_head": startup_head,
|
|
"current_head": current_head,
|
|
"reasons": list(reasons),
|
|
}
|
|
|
|
|
|
def gate_disabled() -> bool:
|
|
"""Whether the parity gate is disabled by env escape hatch."""
|
|
return bool((os.environ.get(ENV_DISABLE) or "").strip())
|
|
|
|
|
|
def parity_block_reasons(assessment: dict) -> list[str]:
|
|
"""Block reasons for a mutation gate (empty when the mutation may proceed).
|
|
|
|
A disabled gate or an in-parity / non-determinable assessment yields no
|
|
reasons; only a definitively stale server blocks.
|
|
"""
|
|
if gate_disabled():
|
|
return []
|
|
if assessment.get("stale"):
|
|
return list(assessment.get("reasons") or
|
|
["server code is stale relative to master (fail closed)"])
|
|
return []
|
|
|
|
|
|
def parity_report(assessment: dict) -> dict:
|
|
"""Structured stale-server report for permission-block payloads."""
|
|
return {
|
|
"kind": "server_stale",
|
|
"restart_required": True,
|
|
"startup_head": assessment.get("startup_head"),
|
|
"current_head": assessment.get("current_head"),
|
|
"reasons": list(assessment.get("reasons") or []),
|
|
"recovery": [
|
|
"The running MCP server is executing code older than the current "
|
|
"master and may not enforce newly merged capability gates.",
|
|
"Restart the Gitea MCP server so it reloads master's capability "
|
|
"gates and execution profiles before retrying the mutation.",
|
|
],
|
|
}
|
|
|
|
|
|
def format_parity(assessment: dict) -> str:
|
|
"""One-line human summary for logs / runtime context."""
|
|
if assessment.get("stale"):
|
|
return (f"STALE: started {_short(assessment.get('startup_head'))}, "
|
|
f"master now {_short(assessment.get('current_head'))} "
|
|
f"(restart required)")
|
|
if not assessment.get("determinable"):
|
|
return "parity indeterminate (baseline or current HEAD unknown)"
|
|
return f"in parity at {_short(assessment.get('current_head'))}"
|