feat(guard): block direct stable-branch pushes from MCP workflow sessions (Closes #671)

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]>
This commit is contained in:
2026-07-11 20:07:40 -04:00
co-authored by Claude Opus 4.8
parent bee24e2b10
commit 5933d87647
6 changed files with 1295 additions and 0 deletions
+233
View File
@@ -653,6 +653,10 @@ def verify_preflight_purity(
f"'{task}' (fail closed)"
)
# #671: block review/merge/close/completion mutations while the session is
# contaminated by a direct stable-branch push attempt (reconciler-exempt).
_enforce_stable_branch_contamination_gate(task, remote)
ctx = _resolve_namespace_mutation_context(worktree_path)
workspace = ctx["workspace_path"]
canonical_root = ctx["canonical_repo_root"]
@@ -809,6 +813,80 @@ def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None:
raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment))
# ── stable-branch push contamination (#671) ──────────────────────────────────
# A worker session that attempts a direct stable-branch push (or a
# root-checkout local commit) is workflow-contaminated. The marker is durable
# (survives daemon process pools like the other session proofs) and keyed per
# profile identity. It fails closed on gated mutations until a reconciler
# audits and clears it.
def _stable_contamination_profile_identity() -> str:
return mcp_session_state.current_profile_identity(
profile_name=get_profile().get("profile_name"),
)
def _load_stable_contamination_marker(remote: str | None = None) -> dict | None:
return mcp_session_state.load_state(
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
remote=remote,
profile_identity=_stable_contamination_profile_identity(),
)
def _save_stable_contamination_marker(
record: dict,
*,
remote: str | None = None,
) -> dict | None:
return mcp_session_state.save_state(
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
payload=record,
remote=remote,
profile_identity=_stable_contamination_profile_identity(),
)
def _clear_stable_contamination_marker(
*,
remote: str | None = None,
profile_identity: str | None = None,
) -> None:
mcp_session_state.clear_state(
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
remote=remote,
profile_identity=profile_identity or _stable_contamination_profile_identity(),
)
def _enforce_stable_branch_contamination_gate(
task: str | None,
remote: str | None = None,
) -> None:
"""#671 AC4: fail closed on gated mutations while contaminated.
Reconciler role is exempt (the sanctioned audit/clear path). Non-gated
tasks (comment_issue, lock_issue) stay allowed so a contaminated worker can
still post the durable audit comment and hand off.
"""
if _preflight_in_test_mode() and not os.environ.get(
"GITEA_TEST_FORCE_STABLE_CONTAMINATION"
):
return
marker = _load_stable_contamination_marker(remote)
if not marker:
return
gate = stable_branch_push_guard.assess_contamination_gate(
marker,
task=task,
actual_role=_actual_profile_role(),
)
if gate["block"]:
raise RuntimeError(
stable_branch_push_guard.format_contamination_gate_error(gate)
)
from mcp.server.fastmcp import FastMCP # noqa: E402
from gitea_auth import ( # noqa: E402
@@ -849,6 +927,7 @@ import merge_approval_gate # noqa: E402
import already_landed_reconcile # noqa: E402
import author_mutation_worktree # noqa: E402
import root_checkout_guard # noqa: E402
import stable_branch_push_guard # noqa: E402
import remote_repo_guard # noqa: E402
import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
@@ -9273,6 +9352,160 @@ def gitea_diagnose_terminal(
}
@mcp.tool()
def gitea_record_stable_branch_push_attempt(
command: str | None = None,
remote: str = "dadeschools",
ref: str | None = None,
session_id: str | None = None,
mark: bool = True,
current_branch: str | None = None,
head_sha: str | None = None,
remote_master_sha: str | None = None,
is_under_branches: bool | None = None,
ahead_count: int | None = None,
) -> dict:
"""Classify a proposed command for direct stable-branch push intent (#671).
Worker sessions must never publish stable branches (``master``/``main``/
``dev``/...) directly. This tool detects ``git push <remote> master``
equivalents (refspecs, ``HEAD:master``, ``--force``, ``--dry-run`` no-op
intent, ``:master`` delete) and separately a root/control-checkout
local commit not carried by an issue feature branch.
When ``mark`` is true and contamination is detected, a durable
``stable_branch_contamination`` marker is written for the active profile
identity. Subsequent review/merge/close/completion mutations then fail
closed (via the pre-flight gate) until a reconciler audits and clears it.
The stored command summary is redacted; secrets never persist.
Read-only when nothing is detected (or ``mark`` is false). Returns the
classification, any root-checkout assessment, and the marker state.
"""
classification = stable_branch_push_guard.classify_push_command(command)
root_checkout = None
if any(v is not None for v in (current_branch, head_sha, remote_master_sha,
is_under_branches, ahead_count)):
root_checkout = stable_branch_push_guard.assess_root_checkout_local_commit(
current_branch=current_branch,
head_sha=head_sha,
remote_master_sha=remote_master_sha,
is_under_branches=bool(is_under_branches),
ahead_count=ahead_count,
)
push_contam = bool(classification.get("contamination"))
root_contam = bool(root_checkout and root_checkout.get("contamination"))
contaminated = push_contam or root_contam
marker = None
marked = False
if contaminated and mark:
if push_contam:
reason_class = "stable_branch_push"
command_redacted = classification.get("redacted_command")
detail = "; ".join(classification.get("reasons") or [])
resolved_ref = ref or (
(classification.get("stable_refs") or [None])[0]
)
else:
reason_class = "root_checkout_commit"
command_redacted = None
detail = "; ".join(root_checkout.get("reasons") or [])
resolved_ref = ref or root_checkout.get("current_branch")
record = stable_branch_push_guard.build_contamination_record(
reason_class=reason_class,
command_redacted=command_redacted,
session_id=session_id,
remote=remote,
ref=resolved_ref,
role=_actual_profile_role(),
detail=detail,
)
marker = _save_stable_contamination_marker(record, remote=remote)
marked = marker is not None
return {
"classification": classification,
"root_checkout": root_checkout,
"contaminated": contaminated,
"marked": marked,
"marker": marker,
"profile_identity": _stable_contamination_profile_identity(),
"remediation": stable_branch_push_guard.REMEDIATION if contaminated else None,
}
@mcp.tool()
def gitea_audit_stable_branch_contamination(
action: str = "inspect",
remote: str = "dadeschools",
profile_identity: str | None = None,
) -> dict:
"""Reconciler audit of a stable-branch contamination marker (#671).
``action='inspect'`` (default, read-only) loads the durable marker for the
given ``profile_identity`` (or the active session's) and reports it.
``action='clear'`` removes the marker so the contaminated session may
resume gated mutations. Clearing is the reconciler audit path and requires
an active reconciler profile a worker session must never self-clear
(#671 security requirement). ``profile_identity`` targets the contaminated
worker's marker (a reconciler runs under its own identity).
"""
act = (action or "inspect").strip().lower()
target_identity = (profile_identity or "").strip() or _stable_contamination_profile_identity()
marker = mcp_session_state.load_state(
kind=mcp_session_state.KIND_STABLE_BRANCH_CONTAMINATION,
remote=remote,
profile_identity=target_identity,
)
if act == "inspect":
return {
"action": "inspect",
"profile_identity": target_identity,
"contaminated": marker is not None,
"marker": marker,
"read_only": True,
}
if act == "clear":
role = _actual_profile_role()
if role != "reconciler":
return {
"action": "clear",
"success": False,
"performed": False,
"profile_identity": target_identity,
"reasons": [
"stable-branch contamination may only be cleared by a "
f"reconciler audit; active role is '{role}' (fail closed). "
"The contaminated worker session must not self-clear."
],
}
_clear_stable_contamination_marker(
remote=remote,
profile_identity=target_identity,
)
return {
"action": "clear",
"success": True,
"performed": True,
"profile_identity": target_identity,
"was_contaminated": marker is not None,
}
return {
"action": act,
"success": False,
"performed": False,
"reasons": [f"unknown action '{act}'; use 'inspect' or 'clear'"],
}
@mcp.tool()
def gitea_validate_review_final_report(
report_text: str,