feat: enforce stable-control vs dev runtime modes for Gitea MCP (Closes #615)
The stable-control runtime ADR (docs/architecture/mcp-stable-control-runtime-policy-adr.md)
established the policy but had no runtime enforcement: a daemon relaunched from a
feature worktree still holds production credentials and will happily mutate real
issues. This adds the enforcement layer (acceptance criteria 6-11).
stable_control_runtime.py:
- classify_runtime_mode(): stable-control | dev-test | unknown, inferred from the
process root and checkout branch, with an explicit GITEA_MCP_RUNTIME_MODE
declaration for packaged layouts that have no git checkout.
- build_runtime_report(): runtime mode, git SHA, branch, checkout path, process
root, active workspace, repo binding, profile, identity, dirty files,
alignment, and real_mutations_allowed.
- assess_runtime_mutation_gate(): fails closed on dev-test targeting production,
unknown runtime, dirty stable checkout, dev-worktree launch, and unsafe
process-root/workspace alignment.
- Post-transport-flap re-proving tracked per namespace, so proving the author
namespace never implies reviewer, merger, or reconciler (#584).
- assess_promotion_record(): promotion must record previous and promoted SHAs
plus health, identity, profile, workspace, capability, and rollback proof.
Server wiring:
- _profile_operation_gate() consults the runtime gate alongside the #420 parity
gate. gitea.read is never blocked, so an operator can still diagnose a sick
runtime.
- The gate reads a startup snapshot rather than shelling out per mutation, for
the same reason parity uses a startup baseline: the runtime a process serves
from is fixed when it loads its code. Enforcement is decided from how the
process was loaded, so per-test production simulation cannot switch it on.
- gitea_get_runtime_context() reports the live runtime under
stable_control_runtime and points at the promotion runbook when blocked.
Docs and tooling:
- docs/stable-runtime-promotion-runbook.md: operator promotion procedure,
required record fields, per-namespace re-proving, rollback.
- scripts/promote-stable-runtime: read-only helper that emits and validates a
promotion record; it never restarts anything.
- Five canonical [THREAD STATE LEDGER] examples: runtime healthy, transport flap
recovered, namespace not yet re-proven, promotion completed, rollback required.
- ADR section 5 follow-ups marked landed; runbooks cross-link the new runbook.
Validation: 47 new tests in tests/test_stable_control_runtime.py. Full suite
3827 passed / 6 skipped / 2 failed; both failures are pre-existing on master
059ee77 (verified in a clean baseline worktree: identical 2 failures,
3780 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+124
-1
@@ -2002,12 +2002,21 @@ import native_mcp_preference # noqa: E402
|
||||
import branch_cleanup_guard # noqa: E402
|
||||
import thread_state_ledger_validator # noqa: E402
|
||||
import master_parity_gate # noqa: E402
|
||||
import stable_control_runtime # 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)
|
||||
|
||||
# Stable-control runtime snapshot (#615): which runtime this process serves
|
||||
# from. Captured lazily on first use and then reused, so the mutation gate never
|
||||
# shells out per call. Whether the gate enforces at all is decided from how the
|
||||
# process was loaded -- a suite running from a branches/ worktree is dev-test by
|
||||
# design, and per-test production simulation must not switch this gate on.
|
||||
_STARTUP_RUNTIME_MODE: dict | None = None
|
||||
_RUNTIME_MODE_GATE_UNDER_TEST = "pytest" in sys.modules or "unittest" in sys.modules
|
||||
import worktree_cleanup_audit # noqa: E402
|
||||
import canonical_comment_validator as ccv # noqa: E402
|
||||
|
||||
@@ -11356,6 +11365,92 @@ def _current_master_parity() -> dict:
|
||||
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
|
||||
|
||||
|
||||
def _current_runtime_mode_report(refresh: bool = False) -> dict:
|
||||
"""Describe the runtime this process is serving from (#615).
|
||||
|
||||
Combines the observed checkout facts at ``PROJECT_ROOT`` with the workspace
|
||||
binding this session resolved, so the report answers both "what code is the
|
||||
control plane running" and "does it agree with the task workspace".
|
||||
|
||||
The mutation gate uses the **startup snapshot** (``refresh=False``), for the
|
||||
same reason the #420 parity gate compares against a startup baseline: the
|
||||
runtime a process serves from is fixed when it loads its code, and editing
|
||||
files afterwards does not change what is already in memory. That also keeps
|
||||
the mutation path free of per-call subprocess reads. ``refresh=True`` re-reads
|
||||
live state for the read-only reporting path.
|
||||
"""
|
||||
global _STARTUP_RUNTIME_MODE
|
||||
|
||||
if not refresh and _STARTUP_RUNTIME_MODE is not None:
|
||||
return _STARTUP_RUNTIME_MODE
|
||||
observed = stable_control_runtime.observe_runtime(PROJECT_ROOT)
|
||||
workspace_root = None
|
||||
aligned = None
|
||||
canonical_root = None
|
||||
try:
|
||||
ctx = _resolve_namespace_mutation_context(None)
|
||||
workspace_root = ctx.get("workspace_path")
|
||||
canonical_root = ctx.get("canonical_repo_root")
|
||||
aligned = os.path.realpath(workspace_root or "") == (
|
||||
ctx.get("process_project_root") or ""
|
||||
)
|
||||
except Exception:
|
||||
# An unresolvable binding is reported as unknown alignment, never as
|
||||
# proof of alignment.
|
||||
aligned = None
|
||||
try:
|
||||
profile_name = get_profile()["profile_name"]
|
||||
except Exception:
|
||||
profile_name = None
|
||||
report = stable_control_runtime.build_runtime_report(
|
||||
process_root=PROJECT_ROOT,
|
||||
checkout_branch=observed["checkout_branch"],
|
||||
runtime_head=observed["runtime_head"],
|
||||
is_git_checkout=observed["is_git_checkout"],
|
||||
dirty_files=observed["dirty_files"],
|
||||
active_task_workspace=workspace_root,
|
||||
canonical_repository_root=canonical_root,
|
||||
workspace_roots_aligned=aligned,
|
||||
profile=profile_name,
|
||||
declared_mode=stable_control_runtime.declared_runtime_mode(),
|
||||
)
|
||||
if _STARTUP_RUNTIME_MODE is None:
|
||||
_STARTUP_RUNTIME_MODE = report
|
||||
return report
|
||||
|
||||
|
||||
def _runtime_mode_block(op: str) -> list[str]:
|
||||
"""Fail-closed runtime-mode reasons for a mutation *op* (#615).
|
||||
|
||||
Real Gitea mutations may only run on the promoted stable control runtime.
|
||||
Read-only operations are never blocked: a dev/test or unknown runtime can
|
||||
still be inspected, which is exactly how an operator diagnoses it.
|
||||
|
||||
Under unit-test isolation the gate is skipped unless the explicit #683
|
||||
force-on flag requests production behaviour: the suite legitimately runs
|
||||
from ``branches/`` worktrees, which classify as ``dev-test`` by design. That
|
||||
decision is taken from how the *process* was loaded, not from a per-call
|
||||
check, so a test that simulates production for some other guard does not
|
||||
silently switch this one on. The legacy dirty/porcelain force flags are
|
||||
deliberately not honoured here -- they request dirtiness paths, not a claim
|
||||
that the runtime is promoted.
|
||||
"""
|
||||
if op == "gitea.read":
|
||||
return []
|
||||
if _RUNTIME_MODE_GATE_UNDER_TEST and not (
|
||||
workflow_scope_guard.production_guards_forced()
|
||||
):
|
||||
return []
|
||||
try:
|
||||
report = _current_runtime_mode_report()
|
||||
except Exception as exc:
|
||||
return [
|
||||
"runtime mode could not be assessed (fail closed): "
|
||||
f"{_redact(str(exc))}"
|
||||
]
|
||||
return stable_control_runtime.runtime_block_reasons(report)
|
||||
|
||||
|
||||
def _master_parity_block(op: str) -> list[str]:
|
||||
"""Fail-closed staleness reasons for a mutation *op* (#420).
|
||||
|
||||
@@ -11380,11 +11475,16 @@ def _profile_operation_gate(op: str) -> list[str]:
|
||||
|
||||
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.
|
||||
capability gate that has since been merged, and when the runtime itself is
|
||||
not the promoted stable control runtime (#615) -- a dev/test or unknown
|
||||
runtime holds production credentials but has not been promoted.
|
||||
"""
|
||||
stale_reasons = _master_parity_block(op)
|
||||
if stale_reasons:
|
||||
return stale_reasons
|
||||
runtime_reasons = _runtime_mode_block(op)
|
||||
if runtime_reasons:
|
||||
return runtime_reasons
|
||||
try:
|
||||
profile = get_profile()
|
||||
except Exception as exc:
|
||||
@@ -15027,6 +15127,29 @@ def gitea_get_runtime_context(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# #615: runtime mode + SHA reporting, so a session can tell whether it is
|
||||
# talking to the promoted stable control runtime before it mutates.
|
||||
try:
|
||||
runtime_mode_report = _current_runtime_mode_report(refresh=True)
|
||||
result["stable_control_runtime"] = runtime_mode_report
|
||||
result["stable_control_runtime"]["summary"] = (
|
||||
stable_control_runtime.format_runtime_mode(runtime_mode_report)
|
||||
)
|
||||
# The advisory only replaces safe_next_action when the gate is live for
|
||||
# this process; under test isolation the report stays informational.
|
||||
gate_live = not _RUNTIME_MODE_GATE_UNDER_TEST or (
|
||||
workflow_scope_guard.production_guards_forced()
|
||||
)
|
||||
if gate_live and not runtime_mode_report["real_mutations_allowed"]:
|
||||
result["safe_next_action"] = (
|
||||
"Runtime is not the promoted stable control runtime "
|
||||
f"({stable_control_runtime.format_runtime_mode(runtime_mode_report)}); "
|
||||
"real mutations are blocked. Operator promotion is required — "
|
||||
"see docs/stable-runtime-promotion-runbook.md."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parity = _current_master_parity()
|
||||
result["master_parity"] = {
|
||||
"in_parity": parity["in_parity"],
|
||||
|
||||
Reference in New Issue
Block a user