feat: add server code parity gate for mutations (Closes #420) #444
+89
-1
@@ -828,6 +828,13 @@ import audit_reconciliation_mode # noqa: E402
|
||||
import review_merge_state_machine # noqa: E402
|
||||
import pr_work_lease # 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)
|
||||
import worktree_cleanup_audit # noqa: E402
|
||||
|
||||
|
||||
@@ -5585,15 +5592,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:
|
||||
@@ -7697,6 +7730,24 @@ def gitea_get_runtime_context(
|
||||
PROJECT_ROOT),
|
||||
}
|
||||
|
||||
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("/")
|
||||
|
||||
@@ -7704,6 +7755,43 @@ def gitea_get_runtime_context(
|
||||
|
||||
|
||||
@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
|
||||
def gitea_record_pre_review_command(
|
||||
command: str,
|
||||
cwd: str | None = None,
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""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'))}"
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for the master-parity staleness gate (#420).
|
||||
|
||||
Covers the pure assessment logic, the mutation-block helper, the env escape
|
||||
hatches, and the server-side wiring: reads are never blocked by staleness, a
|
||||
mutation is refused when the on-disk master has advanced, and the read-only
|
||||
gitea_assess_master_parity tool reports the stale state.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
||||
|
||||
import master_parity_gate as mp # noqa: E402
|
||||
|
||||
|
||||
SHA_A = "a" * 40
|
||||
SHA_B = "b" * 40
|
||||
|
||||
|
||||
class TestAssessMasterParity(unittest.TestCase):
|
||||
def test_in_parity_when_heads_match(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
self.assertTrue(res["in_parity"])
|
||||
self.assertFalse(res["stale"])
|
||||
self.assertFalse(res["restart_required"])
|
||||
self.assertTrue(res["determinable"])
|
||||
|
||||
def test_stale_when_master_advanced(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
||||
self.assertFalse(res["in_parity"])
|
||||
self.assertTrue(res["stale"])
|
||||
self.assertTrue(res["restart_required"])
|
||||
self.assertTrue(res["determinable"])
|
||||
self.assertTrue(any("restart" in r for r in res["reasons"]))
|
||||
|
||||
def test_missing_startup_head_not_determinable(self):
|
||||
res = mp.assess_master_parity({"startup_head": None}, SHA_B)
|
||||
self.assertFalse(res["determinable"])
|
||||
self.assertFalse(res["stale"])
|
||||
self.assertTrue(res["in_parity"])
|
||||
|
||||
def test_missing_current_head_not_determinable(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, None)
|
||||
self.assertFalse(res["determinable"])
|
||||
self.assertFalse(res["stale"])
|
||||
self.assertTrue(res["in_parity"])
|
||||
|
||||
def test_none_startup_baseline(self):
|
||||
res = mp.assess_master_parity(None, SHA_A)
|
||||
self.assertFalse(res["determinable"])
|
||||
self.assertFalse(res["stale"])
|
||||
|
||||
|
||||
class TestBlockReasonsAndReport(unittest.TestCase):
|
||||
def test_stale_produces_block_reasons(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
||||
self.assertTrue(mp.parity_block_reasons(res))
|
||||
|
||||
def test_in_parity_no_block_reasons(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
|
||||
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||
|
||||
def test_disable_env_suppresses_block(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
||||
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
|
||||
self.assertTrue(mp.gate_disabled())
|
||||
self.assertEqual(mp.parity_block_reasons(res), [])
|
||||
|
||||
def test_report_shape_when_stale(self):
|
||||
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
|
||||
report = mp.parity_report(res)
|
||||
self.assertEqual(report["kind"], "server_stale")
|
||||
self.assertTrue(report["restart_required"])
|
||||
self.assertEqual(report["startup_head"], SHA_A)
|
||||
self.assertEqual(report["current_head"], SHA_B)
|
||||
self.assertTrue(report["recovery"])
|
||||
|
||||
|
||||
class TestReadGitHead(unittest.TestCase):
|
||||
def test_test_override_takes_precedence(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||
self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B)
|
||||
|
||||
def test_blank_override_is_none(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: " "}):
|
||||
self.assertIsNone(mp.read_git_head("/nonexistent"))
|
||||
|
||||
def test_empty_root_is_none(self):
|
||||
# No override set; empty root cannot resolve a HEAD.
|
||||
env = {k: v for k, v in os.environ.items()
|
||||
if k != mp.ENV_TEST_CURRENT_HEAD}
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
self.assertIsNone(mp.read_git_head(""))
|
||||
|
||||
|
||||
class TestServerWiring(unittest.TestCase):
|
||||
"""Integration with the gate choke point in the server namespace."""
|
||||
|
||||
def setUp(self):
|
||||
import mcp_server
|
||||
self.srv = mcp_server
|
||||
# Pin a known startup baseline so the current-HEAD override can diverge.
|
||||
self._saved = self.srv._STARTUP_PARITY
|
||||
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
|
||||
"startup_head": SHA_A}
|
||||
|
||||
def tearDown(self):
|
||||
self.srv._STARTUP_PARITY = self._saved
|
||||
|
||||
def test_reads_not_blocked_when_stale(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
|
||||
|
||||
def test_mutation_blocked_when_stale(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||
reasons = self.srv._master_parity_block("gitea.pr.create")
|
||||
self.assertTrue(reasons)
|
||||
# The profile-operation choke point surfaces the same block.
|
||||
self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create"))
|
||||
|
||||
def test_mutation_allowed_when_in_parity(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
|
||||
self.assertEqual(
|
||||
self.srv._master_parity_block("gitea.pr.create"), [])
|
||||
|
||||
def test_disable_env_lets_mutation_through(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B,
|
||||
mp.ENV_DISABLE: "1"}):
|
||||
self.assertEqual(
|
||||
self.srv._master_parity_block("gitea.pr.create"), [])
|
||||
|
||||
def test_assess_tool_reports_stale(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
|
||||
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertTrue(out["stale"])
|
||||
self.assertTrue(out["restart_required"])
|
||||
self.assertEqual(out["startup_head"], SHA_A)
|
||||
self.assertEqual(out["current_head"], SHA_B)
|
||||
self.assertIn("report", out)
|
||||
|
||||
def test_assess_tool_reports_in_parity(self):
|
||||
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
|
||||
out = self.srv.gitea_assess_master_parity(remote="prgs")
|
||||
self.assertFalse(out["stale"])
|
||||
self.assertTrue(out["in_parity"])
|
||||
self.assertNotIn("report", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user