"""Fail-closed guard against direct pushes to stable branches (#671). MCP workflow sessions must never publish to a stable branch (``master``, ``main``, ``dev``, ...) directly. Stable-branch updates land only through sanctioned Gitea merge tooling or an explicitly authorized reconciler path. Incident origin (#670): a bare direct-to-master commit ``2fa97c26`` landed on ``prgs/master`` without PR/review provenance, and a PR #654 merger run attempted ``git push prgs master`` (audited as a no-op, but the *intent* is the hazard). Partial protections already existed (``author_proofs.PROTECTED_BRANCHES``, ``root_checkout_guard``, branch-push proofs) but did not detect shell push equivalents, mark the session contaminated after such an attempt, or fail closed on subsequent review/merge/close/completion mutations. This module is the detection + contamination-policy core. Like ``author_proofs`` and ``root_checkout_guard`` it is **pure**: callers gather the raw facts (the proposed command line, git state, the durable contamination marker) and pass them in, so the same logic serves prompts, MCP gates, and tests. Nothing here performs git or network calls or reads durable state; the server wires these helpers to ``mcp_session_state`` and ``verify_preflight_purity``. Design rules honoured (from the #671 acceptance criteria): * Detect ``git push master`` shell equivalents, including refspecs (``HEAD:master``, ``+refs/heads/x:refs/heads/master``), ``--force``, ``--dry-run``/no-op intent, and delete refspecs (``:master``). * Never false-block a feature-branch push (``git push prgs fix/issue-671-...``). * Never treat ``git fetch`` / ``git pull --ff-only`` as a push. * Never treat sanctioned ``gitea_merge_pr`` / Gitea API merge as a push. * Fail closed on ambiguous targets that *may* resolve to a stable branch, but surface ambiguity as its own signal rather than silently blocking feature work. * Contamination must not be clearable by the same worker session — only a reconciler (audit) role may clear or bypass the gate. """ from __future__ import annotations import re from typing import Any, Iterable from author_proofs import PROTECTED_BRANCHES # Single source of truth for the stable branch set: the same protected set the # author branch-identity proofs already enforce (#177), so the two guards can # never drift apart. STABLE_BRANCHES = PROTECTED_BRANCHES # Mutations that must fail closed once the session is contaminated by a direct # stable-branch push attempt (#671 AC4: review, merge, close, completion). # ``comment_issue`` / ``lock_issue`` / ``create_issue`` are deliberately NOT # gated so a contaminated worker can still post the durable audit comment and # hand off. Only a reconciler (audit) role bypasses this set. CONTAMINATION_GATED_TASKS = frozenset({ "create_pr", "commit_files", "gitea_commit_files", "close_pr", "close_issue", "review_pr", "approve_pr", "request_changes_pr", "submit_pr_review", "merge_pr", "delete_branch", "complete_issue", }) CONTAMINATION_KIND = "stable_branch_push" REMEDIATION = ( "Direct stable-branch publication is forbidden for worker sessions. Stop, " "leave the stable branch untouched, and route the change through sanctioned " "Gitea merge tooling (gitea_merge_pr) or an explicitly authorized reconciler " "audit. This session is workflow-contaminated until a reconciler audits it." ) # ── command tokenising ──────────────────────────────────────────────────────── # Split a compound command line into individual simple commands on shell # separators so ``a && git push prgs master`` is analysed segment by segment. _SEGMENT_SPLIT_RE = re.compile(r"(?:\|\||&&|\||;|\n)") _GIT_PUSH_RE = re.compile(r"\bgit\b[\w\s\-]*?\bpush\b", re.IGNORECASE) _GIT_FETCH_RE = re.compile(r"\bgit\b[\w\s\-]*?\b(?:fetch|pull)\b", re.IGNORECASE) # Flags that take a value argument in ``git push`` (so the following token is # consumed as the flag's value, not a refspec). _VALUE_FLAGS = frozenset({ "--repo", "-o", "--push-option", "--receive-pack", "--exec", }) _FORCE_FLAGS = frozenset({"-f", "--force"}) _DRY_RUN_FLAGS = frozenset({"-n", "--dry-run"}) _DELETE_FLAGS = frozenset({"-d", "--delete"}) def _clean(value: str | None) -> str: return (value or "").strip() def _strip_ref_prefix(ref: str) -> str: ref = ref.strip() for prefix in ("refs/heads/", "heads/"): if ref.startswith(prefix): return ref[len(prefix):] return ref def is_stable_ref(ref: str | None) -> bool: """True when ``ref`` names a configured stable branch.""" name = _strip_ref_prefix(_clean(ref)) return bool(name) and name in STABLE_BRANCHES # ── redaction ───────────────────────────────────────────────────────────────── _URL_USERINFO_RE = re.compile(r"(https?://)[^/\s:@]+(?::[^/\s@]+)?@", re.IGNORECASE) _SECRET_ASSIGN_RE = re.compile( r"\b((?:GITEA_)?(?:TOKEN|PASSWORD|PASS|PAT|SECRET|API_KEY|AUTH))\s*=\s*\S+", re.IGNORECASE, ) _BEARER_RE = re.compile(r"\b(Bearer|token)\s+[A-Za-z0-9._\-]{8,}", re.IGNORECASE) def redact_command(command: str | None) -> str: """Strip credentials/URLs from a command line before logging it (#671). Redacts URL userinfo (``https://user:tok@host`` → ``https://***@host``), ``TOKEN=...`` style assignments, and bearer/token headers. Leaves the structural parts (``git push master``) intact for audit value. """ text = _clean(command) if not text: return "" text = _URL_USERINFO_RE.sub(r"\1***@", text) text = _SECRET_ASSIGN_RE.sub(lambda m: f"{m.group(1)}=***", text) text = _BEARER_RE.sub(lambda m: f"{m.group(1)} ***", text) return text # ── push classification ─────────────────────────────────────────────────────── def _analyse_push_segment(segment: str) -> dict[str, Any]: """Classify one ``git push ...`` command segment.""" tokens = segment.split() # Drop everything up to and including the ``push`` verb. try: push_idx = next( i for i, tok in enumerate(tokens) if tok.lower() == "push" ) except StopIteration: push_idx = -1 args = tokens[push_idx + 1:] if push_idx >= 0 else [] is_force = False is_dry_run = False is_delete = False positionals: list[str] = [] skip_next = False for tok in args: if skip_next: skip_next = False continue if tok in _FORCE_FLAGS or tok.startswith("--force-with-lease") or tok.startswith("--force-if-includes"): is_force = True continue if tok in _DRY_RUN_FLAGS: is_dry_run = True continue if tok in _DELETE_FLAGS: is_delete = True continue if tok in _VALUE_FLAGS: skip_next = True continue if tok.startswith("--") and "=" in tok: # e.g. --repo=... : self-contained, not a refspec. continue if tok.startswith("-"): # Unknown/combined short flag; ignore for ref detection. continue positionals.append(tok) # First positional after push is the remote (when any positional exists); # the rest are refspecs / branch names. remote = positionals[0] if positionals else None refspecs = positionals[1:] stable_refs: list[str] = [] force_to_stable = False delete_stable = False for spec in refspecs: force_spec = spec.startswith("+") body = spec[1:] if force_spec else spec if ":" in body: src, _, dst = body.partition(":") dest = dst if src == "" and dst: # ``:master`` — delete refspec. is_delete = True else: dest = body if is_stable_ref(dest): stable_refs.append(_strip_ref_prefix(dest)) if force_spec or is_force: force_to_stable = True if is_delete: delete_stable = True targets_stable = bool(stable_refs) # Ambiguous: ``git push `` (or bare ``git push``) with no refspec — # resolves to the current branch's upstream, which we cannot see from the # command alone. Flag it, but do not assert stable (would false-block # feature-branch pushes). ambiguous = not refspecs return { "is_git_push": True, "remote": remote, "refspecs": refspecs, "targets_stable": targets_stable, "stable_refs": stable_refs, "is_force": is_force or force_to_stable, "is_dry_run": is_dry_run, "is_delete": is_delete or delete_stable, "ambiguous_target": ambiguous, } def classify_push_command(command: str | None) -> dict[str, Any]: """Classify a shell command line for direct stable-branch push intent (#671). Returns a dict describing whether the command is a ``git push`` targeting a stable branch. Fetch/pull are never pushes. A sanctioned Gitea merge (which is not a ``git push`` at all) classifies as non-push. Dry-run and no-op pushes still count as *intent* and are reported as contamination (``proves_intent``) so a ``--dry-run`` cannot be used to probe the gate. """ text = _clean(command) result: dict[str, Any] = { "command": text, "redacted_command": redact_command(text), "is_git_push": False, "is_fetch_or_pull": False, "targets_stable": False, "stable_refs": [], "is_force": False, "is_dry_run": False, "is_delete": False, "ambiguous_target": False, "contamination": False, "proves_intent": False, "reasons": [], } if not text: return result stable_refs: list[str] = [] saw_push = False for segment in _SEGMENT_SPLIT_RE.split(text): seg = segment.strip() if not seg: continue if _GIT_FETCH_RE.search(seg) and not _GIT_PUSH_RE.search(seg): result["is_fetch_or_pull"] = True continue if not _GIT_PUSH_RE.search(seg): continue saw_push = True analysis = _analyse_push_segment(seg) result["is_git_push"] = True result["is_force"] = result["is_force"] or analysis["is_force"] result["is_dry_run"] = result["is_dry_run"] or analysis["is_dry_run"] result["is_delete"] = result["is_delete"] or analysis["is_delete"] result["ambiguous_target"] = result["ambiguous_target"] or analysis["ambiguous_target"] stable_refs.extend(analysis["stable_refs"]) result["stable_refs"] = sorted(set(stable_refs)) result["targets_stable"] = bool(stable_refs) reasons: list[str] = [] if result["targets_stable"]: refs = ", ".join(result["stable_refs"]) verb = "delete" if result["is_delete"] else ("force-push" if result["is_force"] else "push") qualifier = " (dry-run/no-op still proves intent)" if result["is_dry_run"] else "" reasons.append( f"direct stable-branch {verb} detected targeting: {refs}{qualifier}; " "worker sessions must never publish stable branches directly" ) result["contamination"] = True result["proves_intent"] = True elif saw_push and result["ambiguous_target"]: # Bare ``git push`` with no refspec: ambiguous. Report, but do not # contaminate on the command alone — the root-checkout / branch-state # detector resolves whether the current branch is stable. reasons.append( "ambiguous 'git push' with no refspec: resolve the current branch " "before pushing; if it is a stable branch this is forbidden" ) result["reasons"] = reasons return result def detect_stable_push(commands: str | Iterable[str] | None) -> dict[str, Any]: """Classify one command or an iterable of commands; contamination if any hit.""" if commands is None: return classify_push_command(None) if isinstance(commands, str): return classify_push_command(commands) worst: dict[str, Any] | None = None for cmd in commands: res = classify_push_command(cmd) if res["contamination"]: return res if worst is None or (res["is_git_push"] and not worst["is_git_push"]): worst = res return worst or classify_push_command(None) # ── root/control checkout local-commit detection ────────────────────────────── def assess_root_checkout_local_commit( *, current_branch: str | None, head_sha: str | None, remote_master_sha: str | None, is_under_branches: bool, ahead_count: int | None = None, ) -> dict[str, Any]: """Detect a local commit on the root/control checkout not on a feature branch. #671 AC2. An isolated ``branches/...`` worktree is exempt (that is where feature work belongs). On the control checkout, a commit is illegitimate when the checkout sits on a stable branch (or detached) and its HEAD has advanced past the tracking ``prgs/master`` — i.e. a commit was made that is not carried by an issue feature branch/PR. Positive evidence only: missing state reports ``unknown`` rather than asserting contamination, but an unreadable *branch* on the control checkout (detached HEAD) with an advanced HEAD is still flagged. """ branch = _clean(current_branch) head = _clean(head_sha).lower() master = _clean(remote_master_sha).lower() if is_under_branches: return { "contamination": False, "unknown": False, "reasons": [], "detail": "isolated branches/ worktree — feature commits are expected here", } reasons: list[str] = [] unknown = False on_stable = (not branch) or (branch in STABLE_BRANCHES) if not on_stable: # Control checkout is on some non-stable branch: out of scope for this # detector (root_checkout_guard #475 handles that contamination class). return { "contamination": False, "unknown": False, "reasons": [], "detail": f"control checkout on non-stable branch '{branch}' — not this detector's class", } advanced = False if ahead_count is not None and ahead_count > 0: advanced = True if head and master and head != master: advanced = True if (not head or not master) and ahead_count is None: unknown = True if advanced: where = f"branch '{branch}'" if branch else "detached HEAD" reasons.append( f"control checkout ({where}) has local commits not on an issue " "feature branch (HEAD advanced past prgs/master); worker sessions " "must commit only on issue branches under branches/" ) return { "contamination": bool(reasons), "unknown": unknown and not reasons, "reasons": reasons, "current_branch": branch or None, "head_sha": head or None, "remote_master_sha": master or None, } # ── contamination record + gate ─────────────────────────────────────────────── def build_contamination_record( *, reason_class: str, command_redacted: str | None = None, session_id: str | None = None, remote: str | None = None, ref: str | None = None, role: str | None = None, detail: str | None = None, ) -> dict[str, Any]: """Build the durable contamination marker payload (redacted, audit-safe). ``reason_class`` is one of ``stable_branch_push`` / ``root_checkout_commit``. The command is stored already-redacted; callers pass the raw line through :func:`redact_command` (or this builder redacts a raw ``command`` for them). """ return { "kind": CONTAMINATION_KIND, "reason_class": _clean(reason_class) or "stable_branch_push", "command_summary": redact_command(command_redacted), "session_id": _clean(session_id) or None, "remote": _clean(remote) or None, "ref": _clean(ref) or None, "role": _clean(role) or None, "detail": _clean(detail) or None, "cleared_by_reconciler": False, } def assess_contamination_gate( marker: dict[str, Any] | None, *, task: str | None, actual_role: str | None, ) -> dict[str, Any]: """Fail closed on gated mutations while a contamination marker is live (#671 AC4). * No marker → allowed. * Reconciler (audit) role → allowed (the sanctioned path to inspect/clear). * Marker present + ``task`` in :data:`CONTAMINATION_GATED_TASKS` → blocked. * Marker present + non-gated task (e.g. ``comment_issue``) → allowed, so the worker can still post the durable audit/handoff comment. """ if not marker or marker.get("cleared_by_reconciler"): return {"block": False, "reasons": [], "task": task} role = _clean(actual_role).lower() if role == "reconciler": return { "block": False, "reasons": [], "task": task, "detail": "reconciler audit path is exempt from the contamination gate", } task_name = _clean(task) if task_name and task_name in CONTAMINATION_GATED_TASKS: summary = marker.get("command_summary") or marker.get("detail") or "(no summary)" reason_class = marker.get("reason_class") or "stable_branch_push" return { "block": True, "reasons": [ f"session is workflow-contaminated ({reason_class}): {summary}. " f"'{task_name}' is blocked until a reconciler audits and clears " "the contamination. " + REMEDIATION ], "task": task_name, } return {"block": False, "reasons": [], "task": task_name or None} def format_contamination_gate_error(gate: dict[str, Any]) -> str: """Single RuntimeError message for MCP mutation gates.""" reasons = "; ".join(gate.get("reasons") or ["session workflow-contaminated"]) return f"Stable-branch contamination gate (#671): {reasons}"