#706 routed the #274 filesystem guards and the session repository slug through
the configured canonical_repository_root. Three consumption paths were left
deriving from the Gitea-Tools installation checkout.
F1 — gitea_get_runtime_context did not normalize its `remote` argument, while
gitea_whoami did. On a prgs-hosted namespace whose first native call was the
runtime-context path, the 'dadeschools' argument default was pinned: identity
resolved against the wrong host, and because first-bind is first-write-wins a
later correct gitea_whoami(remote="prgs") could not repair the binding. It now
calls _effective_remote before any host lookup, identity resolution, or session
seeding. Explicit remotes pass through untouched and a dadeschools-hosted
profile still resolves to dadeschools.
F2 — _delete_branch_repository_binding_block derived its expected slug from
_workspace_repository_slug, which reads _local_git_remote_url in PROJECT_ROOT
(always Gitea-Tools). For a cross-repository namespace this inverted the guard:
the genuinely bound target was rejected and Gitea-Tools was accepted. The
canonical-root branch already present in _trusted_session_repository is
extracted as _canonical_repository_slug and shared by both call sites. A
configured-but-unresolvable root now fails closed instead of falling back to
the installation identity; unconfigured namespaces keep the install-derived
default unchanged.
F3 — gitea_assess_master_parity measures Gitea-Tools server implementation
parity only. That is intentional and is preserved: startup_head, current_head,
in_parity, stale, and restart_required keep their existing meaning and values,
and only the server dimension gates mutations. Two separately labelled
dimensions are added — server_implementation (installation root and commit) and
target_repository (canonical root, repository slug, checkout head, last-known
remote master head, staleness) — so cross-repository commissioning evidence can
distinguish them. The target assessment makes no network call: the remote side
is read from the existing remote-tracking ref, and an unfetched target is
reported indeterminate rather than guessed at.
Verified intentional and left unchanged: the #274 workspace-membership,
author-mutation-worktree, and branches-only guards already validate against the
configured canonical root; explicit org/repo may confirm but never authorize a
binding; and the four-role repository-specific configuration surface already
passes audit and bind-time validation with a wrong root failing closed.
Tests: 31 new cases in tests/test_cross_repo_canonical_consumption.py using
real git repositories and no network. Full suite 3298 passed / 6 skipped
against a clean-baseline 3267; the 2 failures
(test_issue_702_review_findings_f1_f6, test_reconciler_supersession_close)
reproduce identically on unmodified master a8d2087 and are pre-existing.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013jUxaLVLkPHuTQwAzFzztW
294 lines
11 KiB
Python
294 lines
11 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'))}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Target-repository parity (#739 F3)
|
|
#
|
|
# Everything above measures ONE dimension: the Gitea-Tools server's own
|
|
# implementation commit, comparing the SHA this process was loaded from against
|
|
# the SHA now on disk at PROJECT_ROOT. That is deliberate and is left untouched
|
|
# — it is what proves the in-memory capability gates are current.
|
|
#
|
|
# It is not, however, a statement about the repository a cross-repository
|
|
# namespace actually mutates. The assessment below is a separate, separately
|
|
# labelled dimension for the configured canonical target repository. It never
|
|
# feeds the mutation gate and never changes startup_head/current_head.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DEFAULT_TARGET_TRACKING_REF = "refs/remotes/origin/master"
|
|
|
|
|
|
def _git_capture(root: str, *args: str) -> str | None:
|
|
"""Run a read-only git command in *root*; ``None`` on any failure.
|
|
|
|
Deliberately does not honour ``GITEA_TEST_CURRENT_HEAD``: that override
|
|
exists to pin the *server's* HEAD, and applying it here would make a target
|
|
repository silently report the server's forced SHA.
|
|
"""
|
|
if not root:
|
|
return None
|
|
try:
|
|
res = subprocess.run(
|
|
["git", "-C", root, *args],
|
|
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 assess_target_repository_parity(
|
|
*,
|
|
canonical_root: str | None,
|
|
source: str | None,
|
|
tracking_ref: str = DEFAULT_TARGET_TRACKING_REF,
|
|
) -> dict:
|
|
"""Assess the configured cross-repository target checkout.
|
|
|
|
Reports the target's canonical root, repository identity, checked-out
|
|
commit, and last-known remote master commit, plus whether the checkout is
|
|
behind that ref. No network call is made: the remote side is read from the
|
|
existing remote-tracking ref, so a target that has never been fetched is
|
|
reported as indeterminate rather than guessed at.
|
|
|
|
An unconfigured namespace is ``configured=False`` and never ``stale`` — the
|
|
single-repository default has no second dimension to be stale about. A
|
|
configured root that cannot be read is ``determinable=False`` with reasons.
|
|
"""
|
|
result = {
|
|
"configured": bool(canonical_root),
|
|
"canonical_repository_root": None,
|
|
"source": source,
|
|
"repository_slug": None,
|
|
"checkout_head": None,
|
|
"tracking_ref": tracking_ref,
|
|
"remote_tracking_head": None,
|
|
"determinable": False,
|
|
"stale": False,
|
|
"reasons": [],
|
|
}
|
|
if not canonical_root:
|
|
result["determinable"] = True
|
|
return result
|
|
|
|
result["canonical_repository_root"] = canonical_root
|
|
if not os.path.isdir(canonical_root):
|
|
result["reasons"].append(
|
|
f"configured canonical repository root '{canonical_root}' "
|
|
f"does not exist or is not a directory"
|
|
)
|
|
return result
|
|
|
|
toplevel = _git_capture(canonical_root, "rev-parse", "--show-toplevel")
|
|
if not toplevel:
|
|
result["reasons"].append(
|
|
f"configured canonical repository root '{canonical_root}' "
|
|
f"is not a git checkout"
|
|
)
|
|
return result
|
|
|
|
head = _git_capture(canonical_root, "rev-parse", "HEAD")
|
|
if not head:
|
|
result["reasons"].append(
|
|
f"target repository HEAD could not be read at '{canonical_root}'"
|
|
)
|
|
return result
|
|
result["checkout_head"] = head
|
|
result["determinable"] = True
|
|
|
|
remote_url = _git_capture(canonical_root, "remote", "get-url", "origin")
|
|
if remote_url:
|
|
# Local import keeps this module dependency-light for its startup role.
|
|
import remote_repo_guard
|
|
|
|
parsed = remote_repo_guard.parse_org_repo_from_remote_url(remote_url)
|
|
if parsed:
|
|
result["repository_slug"] = f"{parsed[0]}/{parsed[1]}"
|
|
if not result["repository_slug"]:
|
|
result["reasons"].append(
|
|
"target repository identity could not be derived from its git remote"
|
|
)
|
|
|
|
tracking_head = _git_capture(canonical_root, "rev-parse", tracking_ref)
|
|
if not tracking_head:
|
|
result["reasons"].append(
|
|
f"remote-tracking ref '{tracking_ref}' is unknown in the target "
|
|
f"checkout; target staleness is indeterminate (no fetch is "
|
|
f"performed by this assessment)"
|
|
)
|
|
return result
|
|
result["remote_tracking_head"] = tracking_head
|
|
result["stale"] = tracking_head != head
|
|
return result
|