Prevention hardening for the #670 incident (bare direct-to-master commit
2fa97c26 and the PR #654 merger stable-branch push attempt). Worker sessions
must never publish stable branches directly; stable updates land only through
sanctioned Gitea merge tooling or an authorized reconciler path.
New pure module stable_branch_push_guard.py:
- classify_push_command: detects git push <remote> <stable-ref> equivalents
including refspecs (HEAD:<ref>, +refs/heads/x:refs/heads/<ref>), --force,
--delete/:<ref>, and --dry-run/-n no-op probes (dry-run still proves intent).
Fetch/pull and feature-branch pushes are never flagged; sanctioned
gitea_merge_pr / API merge is not a push.
- assess_root_checkout_local_commit: detects control-checkout commits not on an
issue feature branch (branches/ worktrees exempt).
- redact_command: strips URL userinfo/token assignments before logging.
- build_contamination_record + assess_contamination_gate: durable marker shape
and fail-closed gate (reconciler-exempt; comment/lock stay allowed for handoff).
Server wiring (gitea_mcp_server.py, mcp_session_state.py):
- KIND_STABLE_BRANCH_CONTAMINATION durable session marker (per profile identity).
- _enforce_stable_branch_contamination_gate wired into verify_preflight_purity
so review/merge/close/completion mutations fail closed while contaminated.
- gitea_record_stable_branch_push_attempt: classify + mark on detection.
- gitea_audit_stable_branch_contamination: reconciler-only inspect/clear; a
worker session can never self-clear.
Tests (AC5): no-op dry-run push, real direct push, sanctioned Gitea merge,
fetch-only, root-checkout local commit, feature-branch push allowed, gate
block/reconciler-exempt, redaction. 51 new tests; full suite 2596 passed.
Docs: llm-project-workflow SKILL.md — universal rule, blocker class, and a
dedicated "Stable Branch Push Protection" section (worker sessions must never
push stable branches directly).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
189 lines
6.6 KiB
Python
189 lines
6.6 KiB
Python
"""Server wiring for stable-branch push contamination (#671).
|
|
|
|
Exercises the MCP tools and the pre-flight enforcement gate against the durable
|
|
contamination marker (isolated per-test state dir from conftest).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
import gitea_mcp_server as srv
|
|
|
|
|
|
def _clear_marker(remote="prgs"):
|
|
srv._clear_stable_contamination_marker(remote=remote)
|
|
|
|
|
|
def teardown_function():
|
|
_clear_marker()
|
|
|
|
|
|
# ── record tool marks a real direct push ─────────────────────────────────────
|
|
|
|
def test_record_tool_marks_direct_master_push():
|
|
_clear_marker()
|
|
res = srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs master", remote="prgs"
|
|
)
|
|
assert res["contaminated"] is True
|
|
assert res["marked"] is True
|
|
assert res["marker"] is not None
|
|
assert res["marker"]["reason_class"] == "stable_branch_push"
|
|
# loaded back through the durable store
|
|
loaded = srv._load_stable_contamination_marker("prgs")
|
|
assert loaded is not None
|
|
assert loaded["ref"] == "master"
|
|
|
|
|
|
def test_record_tool_dry_run_still_marks():
|
|
_clear_marker()
|
|
res = srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push --dry-run prgs master", remote="prgs"
|
|
)
|
|
assert res["contaminated"] is True
|
|
assert res["marked"] is True
|
|
|
|
|
|
def test_record_tool_feature_branch_does_not_mark():
|
|
_clear_marker()
|
|
res = srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs fix/issue-671-block-stable-branch-push",
|
|
remote="prgs",
|
|
)
|
|
assert res["contaminated"] is False
|
|
assert res["marked"] is False
|
|
assert srv._load_stable_contamination_marker("prgs") is None
|
|
|
|
|
|
def test_record_tool_fetch_only_does_not_mark():
|
|
_clear_marker()
|
|
res = srv.gitea_record_stable_branch_push_attempt(
|
|
command="git fetch prgs", remote="prgs"
|
|
)
|
|
assert res["contaminated"] is False
|
|
assert res["marked"] is False
|
|
|
|
|
|
def test_record_tool_mark_false_is_readonly():
|
|
_clear_marker()
|
|
res = srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs master", remote="prgs", mark=False
|
|
)
|
|
assert res["contaminated"] is True
|
|
assert res["marked"] is False
|
|
assert srv._load_stable_contamination_marker("prgs") is None
|
|
|
|
|
|
def test_record_tool_redacts_secret_in_marker():
|
|
_clear_marker()
|
|
res = srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push https://u:supersecret@host/o/r.git master",
|
|
remote="prgs",
|
|
)
|
|
assert res["marked"] is True
|
|
assert "supersecret" not in res["marker"]["command_summary"]
|
|
|
|
|
|
def test_record_tool_root_checkout_local_commit_marks():
|
|
_clear_marker()
|
|
res = srv.gitea_record_stable_branch_push_attempt(
|
|
command=None,
|
|
remote="prgs",
|
|
current_branch="master",
|
|
head_sha="a" * 40,
|
|
remote_master_sha="b" * 40,
|
|
is_under_branches=False,
|
|
)
|
|
assert res["contaminated"] is True
|
|
assert res["marked"] is True
|
|
assert res["marker"]["reason_class"] == "root_checkout_commit"
|
|
|
|
|
|
# ── audit tool: inspect + reconciler-only clear ──────────────────────────────
|
|
|
|
def test_audit_inspect_reports_marker():
|
|
_clear_marker()
|
|
srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs master", remote="prgs"
|
|
)
|
|
out = srv.gitea_audit_stable_branch_contamination(action="inspect", remote="prgs")
|
|
assert out["contaminated"] is True
|
|
assert out["read_only"] is True
|
|
|
|
|
|
def test_audit_clear_refused_for_non_reconciler():
|
|
_clear_marker()
|
|
srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs master", remote="prgs"
|
|
)
|
|
with patch.object(srv, "_actual_profile_role", return_value="author"):
|
|
out = srv.gitea_audit_stable_branch_contamination(action="clear", remote="prgs")
|
|
assert out["success"] is False
|
|
assert out["reasons"]
|
|
# marker survives a refused clear
|
|
assert srv._load_stable_contamination_marker("prgs") is not None
|
|
|
|
|
|
def test_audit_clear_allowed_for_reconciler():
|
|
_clear_marker()
|
|
srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs master", remote="prgs"
|
|
)
|
|
identity = srv._stable_contamination_profile_identity()
|
|
with patch.object(srv, "_actual_profile_role", return_value="reconciler"):
|
|
out = srv.gitea_audit_stable_branch_contamination(
|
|
action="clear", remote="prgs", profile_identity=identity
|
|
)
|
|
assert out["success"] is True
|
|
assert srv._load_stable_contamination_marker("prgs") is None
|
|
|
|
|
|
# ── pre-flight enforcement gate ──────────────────────────────────────────────
|
|
|
|
def _force_gate_env():
|
|
return patch.dict(os.environ, {"GITEA_TEST_FORCE_STABLE_CONTAMINATION": "1"})
|
|
|
|
|
|
def test_gate_blocks_gated_mutation_when_contaminated():
|
|
_clear_marker()
|
|
srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs master", remote="prgs"
|
|
)
|
|
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
|
|
for task in ("merge_pr", "review_pr", "close_issue", "create_pr"):
|
|
try:
|
|
srv._enforce_stable_branch_contamination_gate(task, "prgs")
|
|
raised = False
|
|
except RuntimeError as exc:
|
|
raised = True
|
|
assert "#671" in str(exc)
|
|
assert raised, task
|
|
|
|
|
|
def test_gate_allows_comment_for_handoff_when_contaminated():
|
|
_clear_marker()
|
|
srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs master", remote="prgs"
|
|
)
|
|
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
|
|
# must not raise — worker can still post the durable audit comment
|
|
srv._enforce_stable_branch_contamination_gate("comment_issue", "prgs")
|
|
srv._enforce_stable_branch_contamination_gate("lock_issue", "prgs")
|
|
|
|
|
|
def test_gate_exempts_reconciler_when_contaminated():
|
|
_clear_marker()
|
|
srv.gitea_record_stable_branch_push_attempt(
|
|
command="git push prgs master", remote="prgs"
|
|
)
|
|
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="reconciler"):
|
|
srv._enforce_stable_branch_contamination_gate("merge_pr", "prgs")
|
|
|
|
|
|
def test_gate_noop_when_not_contaminated():
|
|
_clear_marker()
|
|
with _force_gate_env(), patch.object(srv, "_actual_profile_role", return_value="author"):
|
|
srv._enforce_stable_branch_contamination_gate("merge_pr", "prgs")
|