Addresses the two blockers raised in the PR #886 review (comment 16559) for
issue #663.
B1 — apply_authorized ignored restart-class authorization.
The #663 restart-class matrix and the #661 drain-proof hard gate are
independent authorizations that first coexisted when PR #882 landed on
master and this branch merged it. The union preserved both, but the apply
decision consulted only the drain gate:
payload["apply_authorized"] = gate.allow
so a clean drain proof — or an authorized break-glass, which needs no proof
at all — reported apply_authorized: True for a class the least-privilege
matrix had just denied, in the same payload carrying allow_restart: False
and "role 'author' may not request full_mcp_restart". One environment
variable therefore collapsed the whole nine-class matrix for the apply
decision, including host_restart.
The apply decision is now the conjunction of both authorizations, and
apply_gate carries drain_gate_allow and restart_class_authorized so a denial
is attributable to the authorization that produced it. Break-glass keeps its
purpose — bypassing the drain proof — and never bypasses the class matrix.
No existing fail-closed behaviour is weakened: allow_restart, drain-proof
verification, fingerprint binding, and requester authorization are untouched.
B2 — docs/mcp-restart-coordinator.md described pre-#661 behaviour.
The document still called the drain proof "a separate child" and omitted
drain_proof_json and request_break_glass from the published signature, so a
safety document asserted there was no gate where a gate now exists. It now
documents both parameters, states that the gate executes inside this tool,
and records dry-run versus apply behaviour, authorization ordering, the
break-glass scope, and fail-closed conditions as implemented.
Regression coverage.
tests/test_issue_886_apply_authorization_conjunction.py exercises the MCP
tool itself, which previously had no test at all — that absence is why the
defect shipped. It pins both conjunction directions, proves a clean proof
cannot override a role, approval, unknown-class, or missing-target denial,
proves break-glass does not collapse the matrix for any worker role or
restricted class, and proves the existing scoped and unscoped paths and the
#661 denials still hold. Against the pre-fix tree 24 of these fail; against
this commit all 19 pass with 45 subtests.
tests/test_mcp_restart_governance_docs.py now binds the published signature
to inspect.signature() of the real tool and forbids the stale pre-#661
phrasing, so the drift that produced B2 cannot return unnoticed.
Verification: targeted restart/drain/governance/webui suites 194 passed,
113 subtests. Full suite 23 failed, 5230 passed, 6 skipped, 912 subtests —
the failure set is identical to the reviewed baseline at 9bc021e
(23 failed, 5201 passed), with +29 passing from the added tests and no new
or changed failure. Zero conflict markers; py_compile passes; the #882
union remains intact in both directions.
Refs #663, PR #886
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01V6xFqovhbArPv61j9KCGkL
225 lines
8.2 KiB
Python
225 lines
8.2 KiB
Python
"""Documentation acceptance for the MCP restart governance ADR (#656).
|
|
|
|
Enforces issue #656 acceptance criteria:
|
|
|
|
* AC1 — policy document exists with an authorization matrix and the recorded
|
|
v1 decision (controller approval + automated safety gates).
|
|
* AC2 — restart is stated as a last resort with enumerated narrower recoveries.
|
|
* AC3 — a unilateral LLM full restart with affected sessions is forbidden.
|
|
* AC4 — break-glass conditions are listed.
|
|
* AC5 — the ADR is linked to #655, #652, #653, #630, #642, and is cross-linked
|
|
from the safety model and the web-console deployment boundary docs.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
ADR = REPO_ROOT / "docs" / "architecture" / "mcp-restart-governance.md"
|
|
ADR_BASENAME = "mcp-restart-governance.md"
|
|
|
|
CROSS_LINK_DOCS = (
|
|
REPO_ROOT / "docs" / "safety-model.md",
|
|
REPO_ROOT / "docs" / "webui-deployment.md",
|
|
)
|
|
|
|
LINKED_ISSUES = ("#655", "#652", "#653", "#630", "#642")
|
|
POLICY_IDS = ("RG-01", "RG-02", "RG-03", "RG-04", "RG-05", "RG-06", "RG-07", "RG-08")
|
|
|
|
|
|
def _read(path: Path) -> str:
|
|
assert path.is_file(), f"missing {path.relative_to(REPO_ROOT)}"
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_ac1_adr_exists_with_matrix_and_v1_decision():
|
|
text = _read(ADR)
|
|
lower = text.lower()
|
|
assert text.lstrip().startswith("#"), "ADR lacks a title"
|
|
assert "#656" in text
|
|
assert "authorization matrix" in lower
|
|
# The matrix is a real table with the worker and privileged roles.
|
|
for role in ("author", "reviewer", "merger", "reconciler", "controller",
|
|
"operator", "admin"):
|
|
assert role in lower, f"authorization matrix missing role {role!r}"
|
|
# Recorded v1 decision.
|
|
assert "restart-governance/v1" in text
|
|
assert "controller approval" in lower and "automated safety gates" in lower
|
|
|
|
|
|
def test_ac2_restart_is_last_resort_with_narrower_recoveries():
|
|
text = _read(ADR)
|
|
lower = text.lower()
|
|
assert "last resort" in lower
|
|
# Enumerated narrower recoveries precede full restart on the ladder.
|
|
for rung in ("reconnect", "rebind", "scoped restart", "full restart",
|
|
"host"):
|
|
assert rung in lower, f"recovery ladder missing rung {rung!r}"
|
|
|
|
|
|
def test_ac3_forbids_unilateral_llm_full_restart_with_affected_sessions():
|
|
text = _read(ADR)
|
|
lower = text.lower()
|
|
assert "forbidden" in lower
|
|
assert "llm" in lower and "restart" in lower
|
|
assert "unilateral" in lower
|
|
# A worker role must not perform or authorize full/host restart.
|
|
assert "must not" in lower
|
|
|
|
|
|
def test_ac4_break_glass_conditions_listed():
|
|
text = _read(ADR)
|
|
lower = text.lower()
|
|
assert "break-glass" in lower
|
|
assert "incident" in lower
|
|
assert "audit" in lower
|
|
|
|
|
|
def test_ac5_adr_links_issue_lineage():
|
|
text = _read(ADR)
|
|
for issue in LINKED_ISSUES:
|
|
assert issue in text, f"ADR must link issue {issue}"
|
|
|
|
|
|
def test_ac5_safety_model_and_deployment_cross_link_adr():
|
|
for path in CROSS_LINK_DOCS:
|
|
text = _read(path)
|
|
assert ADR_BASENAME in text, (
|
|
f"{path.relative_to(REPO_ROOT)} must cross-link {ADR_BASENAME} "
|
|
f"(issue #656 acceptance criterion 5)"
|
|
)
|
|
|
|
|
|
def test_policy_ids_present_for_enforcement_code():
|
|
text = _read(ADR)
|
|
for pid in POLICY_IDS:
|
|
assert pid in text, f"policy id {pid} missing from ADR"
|
|
|
|
|
|
def test_failure_behavior_denies_on_ambiguity():
|
|
text = _read(ADR)
|
|
lower = text.lower()
|
|
assert "ambiguous" in lower and "deny" in lower
|
|
|
|
|
|
def test_cross_links_do_not_embed_secrets():
|
|
for path in (ADR,) + CROSS_LINK_DOCS:
|
|
text = _read(path)
|
|
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
|
assert marker not in text, f"{path} contains {marker!r}"
|
|
|
|
|
|
# --- Coordinator doc stays in lock-step with the tool (#886 review blocker B2) --
|
|
#
|
|
# PR #882 moved the #661 drain-proof hard gate *into* gitea_request_mcp_restart,
|
|
# but the coordinator document still described the proof as "a separate child"
|
|
# and omitted both new parameters. Nothing referenced that document, so nothing
|
|
# caught the drift. These tests bind the prose to the real signature.
|
|
|
|
COORDINATOR_DOC = REPO_ROOT / "docs" / "mcp-restart-coordinator.md"
|
|
|
|
# Affirmative claims that were accurate before #661 landed and are now false.
|
|
# Matched against whitespace-normalized text so re-wrapping cannot hide them.
|
|
# Deliberately not the bare phrase "a separate child": the corrected prose uses
|
|
# it in a negation ("no longer a separate child operation"), and a guard that
|
|
# forbids naming the old behaviour would block explaining that it changed.
|
|
STALE_PRE_661_PHRASES = (
|
|
"gated by a drain proof (a separate child)",
|
|
"is a later child gated by a drain proof",
|
|
"mutative apply path is explicitly out of scope",
|
|
"apply is gated by a drain proof (a separate child)",
|
|
)
|
|
|
|
|
|
def _documented_signature_block() -> str:
|
|
"""The fenced signature block for the tool, as published in the doc."""
|
|
text = _read(COORDINATOR_DOC)
|
|
marker = "gitea_request_mcp_restart("
|
|
start = text.index(marker)
|
|
end = text.index("```", start)
|
|
return text[start:end]
|
|
|
|
|
|
def test_documented_signature_matches_the_real_tool_signature():
|
|
import inspect
|
|
|
|
import gitea_mcp_server
|
|
|
|
block = _documented_signature_block()
|
|
real = inspect.signature(gitea_mcp_server.gitea_request_mcp_restart)
|
|
for name in real.parameters:
|
|
assert name in block, (
|
|
f"docs/mcp-restart-coordinator.md documents no {name!r} parameter; "
|
|
"the published signature has drifted from the tool"
|
|
)
|
|
|
|
|
|
def test_drain_proof_and_break_glass_parameters_are_documented():
|
|
block = _documented_signature_block()
|
|
for name in ("drain_proof_json", "request_break_glass"):
|
|
assert name in block, f"signature block missing {name}"
|
|
|
|
|
|
def test_restart_class_and_target_scoping_parameters_survive():
|
|
block = _documented_signature_block()
|
|
for name in ("restart_class", "target_session_id", "target_role",
|
|
"target_connector"):
|
|
assert name in block, f"signature block lost #663 parameter {name}"
|
|
|
|
|
|
def test_gate_is_documented_as_executing_inside_this_tool():
|
|
lower = _read(COORDINATOR_DOC).lower()
|
|
assert "inside this tool" in lower, (
|
|
"the coordinator doc must state that the drain-proof gate executes in "
|
|
"gitea_request_mcp_restart, not in a later child"
|
|
)
|
|
assert "no longer a separate child operation" in lower
|
|
|
|
|
|
def test_stale_pre_661_wording_cannot_return():
|
|
normalized = " ".join(_read(COORDINATOR_DOC).split()).lower()
|
|
for phrase in STALE_PRE_661_PHRASES:
|
|
assert phrase not in normalized, (
|
|
f"stale pre-#661 wording returned to the coordinator doc: {phrase!r}"
|
|
)
|
|
|
|
|
|
def test_dry_run_versus_apply_behavior_is_documented():
|
|
lower = _read(COORDINATOR_DOC).lower()
|
|
assert "dry_run=true" in lower and "dry_run=false" in lower
|
|
assert "apply_supported" in lower and "restart_performed" in lower
|
|
assert "never restarts anything" in lower
|
|
|
|
|
|
def test_authorization_ordering_and_conjunction_are_documented():
|
|
text = _read(COORDINATOR_DOC)
|
|
lower = text.lower()
|
|
assert "authorization ordering" in lower
|
|
assert "allow_restart" in text
|
|
assert "apply_authorized" in text
|
|
# The conjunction itself, and the attribution fields behind it.
|
|
assert "gate.allow and allow_restart" in text
|
|
for field in ("drain_gate_allow", "restart_class_authorized"):
|
|
assert field in text, f"doc omits apply_gate.{field}"
|
|
|
|
|
|
def test_break_glass_scope_is_documented_as_drain_proof_only():
|
|
text = _read(COORDINATOR_DOC)
|
|
lower = text.lower()
|
|
assert "break-glass" in lower
|
|
assert "drain proof only" in lower, (
|
|
"doc must state break-glass never bypasses the restart-class matrix"
|
|
)
|
|
assert "GITEA_BREAKGLASS_RESTART_AUTHORIZATION" in text
|
|
|
|
|
|
def test_fail_closed_on_apply_is_documented():
|
|
lower = _read(COORDINATOR_DOC).lower()
|
|
assert "fail closed" in lower
|
|
for condition in ("expired", "unclean", "tampered", "stale"):
|
|
assert condition in lower, f"fail-closed list omits {condition!r}"
|
|
|
|
|
|
def test_coordinator_doc_embeds_no_secrets():
|
|
text = _read(COORDINATOR_DOC)
|
|
for marker in ("ghp_", "BEGIN PRIVATE KEY", "Authorization: Bearer"):
|
|
assert marker not in text, f"{COORDINATOR_DOC} contains {marker!r}"
|