Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fd02a3c63 | ||
|
|
ea8e186549 | ||
|
|
22698c1b5f | ||
|
|
de27053f74 | ||
|
|
5933d87647 | ||
|
|
72b18143f8 | ||
|
|
8886ce201f | ||
|
|
bee24e2b10 | ||
|
|
a7aac0df1d | ||
|
|
ec903b0d61 |
@@ -0,0 +1,807 @@
|
|||||||
|
"""Common anti-stomp preflight for every MCP mutation tool (#604).
|
||||||
|
|
||||||
|
Even with leases, mutation tools need a **shared** preflight that prevents
|
||||||
|
stale sessions, wrong worktrees, wrong repos, old prompts, terminal locks,
|
||||||
|
foreign leases, contaminated approvals, and root-checkout mutations from
|
||||||
|
slipping through.
|
||||||
|
|
||||||
|
This module is the pure assessment core. Callers (MCP mutation entrypoints)
|
||||||
|
gather live facts and pass them in — nothing here performs git, network, or
|
||||||
|
durable-state I/O. Existing happy-path guards remain authoritative; this
|
||||||
|
module composes them into one typed, fail-closed result.
|
||||||
|
|
||||||
|
Required checks (issue #604):
|
||||||
|
|
||||||
|
* repo/org verification
|
||||||
|
* profile/role verification
|
||||||
|
* root checkout clean and not used for mutation (when role requires branches/)
|
||||||
|
* worktree under branches when required
|
||||||
|
* active lease ownership (when lease is required for the mutation)
|
||||||
|
* terminal lock status (when applicable)
|
||||||
|
* expected head SHA (when pinned)
|
||||||
|
* stale runtime status
|
||||||
|
* workflow hash (when a workflow load is required)
|
||||||
|
* source contamination status
|
||||||
|
* no manual state/mtime/source-file bypass
|
||||||
|
|
||||||
|
Failure returns a typed blocker and an exact next action. There is **no**
|
||||||
|
agent-facing bypass flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import author_mutation_worktree
|
||||||
|
import master_parity_gate
|
||||||
|
import remote_repo_guard
|
||||||
|
import root_checkout_guard
|
||||||
|
import stable_branch_push_guard
|
||||||
|
|
||||||
|
# ── public constants ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Typed blocker kinds returned in the structured result.
|
||||||
|
BLOCKER_WRONG_REPO = "wrong_repo"
|
||||||
|
BLOCKER_WRONG_ROLE = "wrong_role"
|
||||||
|
BLOCKER_ROOT_CHECKOUT = "root_checkout_mutation"
|
||||||
|
BLOCKER_WRONG_WORKTREE = "wrong_worktree"
|
||||||
|
BLOCKER_FOREIGN_LEASE = "foreign_lease"
|
||||||
|
BLOCKER_TERMINAL_LOCK = "terminal_lock"
|
||||||
|
BLOCKER_HEAD_SHA = "head_sha_mismatch"
|
||||||
|
BLOCKER_STALE_RUNTIME = "stale_runtime"
|
||||||
|
BLOCKER_WORKFLOW_HASH = "workflow_hash"
|
||||||
|
BLOCKER_SOURCE_CONTAMINATION = "source_contamination"
|
||||||
|
BLOCKER_MANUAL_BYPASS = "manual_bypass"
|
||||||
|
|
||||||
|
BLOCKER_KINDS = frozenset({
|
||||||
|
BLOCKER_WRONG_REPO,
|
||||||
|
BLOCKER_WRONG_ROLE,
|
||||||
|
BLOCKER_ROOT_CHECKOUT,
|
||||||
|
BLOCKER_WRONG_WORKTREE,
|
||||||
|
BLOCKER_FOREIGN_LEASE,
|
||||||
|
BLOCKER_TERMINAL_LOCK,
|
||||||
|
BLOCKER_HEAD_SHA,
|
||||||
|
BLOCKER_STALE_RUNTIME,
|
||||||
|
BLOCKER_WORKFLOW_HASH,
|
||||||
|
BLOCKER_SOURCE_CONTAMINATION,
|
||||||
|
BLOCKER_MANUAL_BYPASS,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mutation tasks that must invoke the shared anti-stomp preflight before
|
||||||
|
# acting (issue #604 AC1). Every member must appear as a live task= kwarg
|
||||||
|
# to verify_preflight_purity / _run_anti_stomp_preflight (or the review
|
||||||
|
# anti_task map) in gitea_mcp_server.py. Inventory↔wiring tests fail if
|
||||||
|
# a declared task is not wired.
|
||||||
|
MUTATION_TASKS = frozenset({
|
||||||
|
"create_issue",
|
||||||
|
"comment_issue",
|
||||||
|
"close_issue",
|
||||||
|
"mark_issue",
|
||||||
|
"lock_issue",
|
||||||
|
"set_issue_labels",
|
||||||
|
"create_label",
|
||||||
|
"create_pr",
|
||||||
|
"close_pr",
|
||||||
|
"edit_pr",
|
||||||
|
"commit_files",
|
||||||
|
"gitea_commit_files",
|
||||||
|
"delete_branch",
|
||||||
|
"cleanup_merged_pr_branch",
|
||||||
|
"cleanup_stale_claims",
|
||||||
|
"reconcile_merged_cleanups",
|
||||||
|
"reconcile_already_landed_pr",
|
||||||
|
"reconcile_close_superseded_pr",
|
||||||
|
"post_heartbeat",
|
||||||
|
"acquire_reviewer_pr_lease",
|
||||||
|
"gitea_acquire_reviewer_pr_lease",
|
||||||
|
"adopt_merger_pr_lease",
|
||||||
|
"review_pr",
|
||||||
|
"submit_pr_review",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"merge_pr",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Intentionally excluded from MUTATION_TASKS. Each has a dedicated
|
||||||
|
# fail-closed gate that preserves decision-lock ownership, workflow-hash,
|
||||||
|
# head-SHA, and repository consistency. Do not re-add without either
|
||||||
|
# wiring shared preflight or updating this rationale (#604 Blocker B).
|
||||||
|
DEDICATED_GATE_MUTATIONS: dict[str, str] = {
|
||||||
|
"mark_final_review_decision": (
|
||||||
|
"Local review-decision lock only. Enforced by session lock ownership, "
|
||||||
|
"workflow-hash, expected head SHA, PR work lease, eligibility, and "
|
||||||
|
"terminal-lock gates. No Gitea review POST; shared anti-stomp would "
|
||||||
|
"duplicate without side-effect-ordering value."
|
||||||
|
),
|
||||||
|
"save_review_draft": (
|
||||||
|
"Local session-state draft save with live PR head/base consistency "
|
||||||
|
"checks. Not a Gitea review/merge mutation; dedicated head-SHA and "
|
||||||
|
"worktree resolution gates apply."
|
||||||
|
),
|
||||||
|
"resume_review_draft": (
|
||||||
|
"Live-state resume with terminal lock, lease ownership, head/base SHA, "
|
||||||
|
"and parity checks. When submit=True, delegates to gitea_submit_pr_review "
|
||||||
|
"which runs shared anti-stomp before the Gitea review POST."
|
||||||
|
),
|
||||||
|
"cleanup_stale_review_decision_lock": (
|
||||||
|
"Moot-lock cleanup with identity match, live PR merged/closed proof, "
|
||||||
|
"and reviewer capability gate (#594). Distinct from shared anti-stomp."
|
||||||
|
),
|
||||||
|
"gitea_cleanup_stale_review_decision_lock": (
|
||||||
|
"Alias of cleanup_stale_review_decision_lock; dedicated #594 path."
|
||||||
|
),
|
||||||
|
"heartbeat_reviewer_pr_lease": (
|
||||||
|
"Lease heartbeat posts via verify_preflight_purity(task='review_pr') "
|
||||||
|
"plus in-session lease ownership checks; not a separate mutation class."
|
||||||
|
),
|
||||||
|
"release_reviewer_pr_lease": (
|
||||||
|
"Lease release uses workspace binding + ownership checks; posts only "
|
||||||
|
"when the session owns the active lease."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Roles that must mutate from a branches/ worktree (not the control checkout).
|
||||||
|
_BRANCHES_REQUIRED_ROLES = frozenset({"author"})
|
||||||
|
|
||||||
|
# Reviewer/merger mutations that require an owned lease + head pinning when
|
||||||
|
# the caller supplies those facts.
|
||||||
|
_LEASE_AWARE_TASKS = frozenset({
|
||||||
|
"review_pr",
|
||||||
|
"submit_pr_review",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"merge_pr",
|
||||||
|
"acquire_reviewer_pr_lease",
|
||||||
|
"gitea_acquire_reviewer_pr_lease",
|
||||||
|
"adopt_merger_pr_lease",
|
||||||
|
})
|
||||||
|
|
||||||
|
_NEXT_ACTIONS: dict[str, str] = {
|
||||||
|
BLOCKER_WRONG_REPO: (
|
||||||
|
"Pass explicit org= and repo= matching the local git remote "
|
||||||
|
"(e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools) and retry."
|
||||||
|
),
|
||||||
|
BLOCKER_WRONG_ROLE: (
|
||||||
|
"Call gitea_resolve_task_capability, switch to the required "
|
||||||
|
"namespace/profile, re-verify with gitea_whoami, then retry."
|
||||||
|
),
|
||||||
|
BLOCKER_ROOT_CHECKOUT: (
|
||||||
|
"Leave the root control checkout on clean master; create or switch "
|
||||||
|
"to a session-owned worktree under branches/ and retry the mutation "
|
||||||
|
"with worktree_path set."
|
||||||
|
),
|
||||||
|
BLOCKER_WRONG_WORKTREE: (
|
||||||
|
"Create or bind a session-owned worktree under branches/, set "
|
||||||
|
"worktree_path (or GITEA_ACTIVE_WORKTREE), and retry."
|
||||||
|
),
|
||||||
|
BLOCKER_FOREIGN_LEASE: (
|
||||||
|
"Stop. Do not stomp a foreign lease. Wait for expiry, request "
|
||||||
|
"takeover through the sanctioned path, or hand off to the owner."
|
||||||
|
),
|
||||||
|
BLOCKER_TERMINAL_LOCK: (
|
||||||
|
"Stop. A terminal review-decision lock is active for this head. "
|
||||||
|
"Do not re-approve/merge; follow the #332/#620 recovery path."
|
||||||
|
),
|
||||||
|
BLOCKER_HEAD_SHA: (
|
||||||
|
"Re-fetch the live PR head with gitea_view_pr, re-pin "
|
||||||
|
"expected_head_sha to the current head, re-validate, then retry."
|
||||||
|
),
|
||||||
|
BLOCKER_STALE_RUNTIME: (
|
||||||
|
"Restart the Gitea MCP server so it reloads master's capability "
|
||||||
|
"gates, re-run gitea_whoami + gitea_resolve_task_capability, then retry."
|
||||||
|
),
|
||||||
|
BLOCKER_WORKFLOW_HASH: (
|
||||||
|
"Reload the canonical workflow via gitea_load_review_workflow, then "
|
||||||
|
"rerun the full review-merge workflow from inventory (no approve/merge replay)."
|
||||||
|
),
|
||||||
|
BLOCKER_SOURCE_CONTAMINATION: (
|
||||||
|
"Stop. Session is source-contaminated. Hand off to a reconciler for "
|
||||||
|
"audit/clear; do not clear markers by deleting session-state files."
|
||||||
|
),
|
||||||
|
BLOCKER_MANUAL_BYPASS: (
|
||||||
|
"Stop. Manual state/mtime/source-file bypass is forbidden. Use "
|
||||||
|
"sanctioned MCP tools only; never delete or rewrite session-state "
|
||||||
|
"or lock files by hand."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_mutation_task(task: str | None) -> bool:
|
||||||
|
"""True when *task* is in the #604 mutation set (normalized)."""
|
||||||
|
name = (task or "").strip().lower().removeprefix("gitea_")
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
if name in MUTATION_TASKS:
|
||||||
|
return True
|
||||||
|
# Accept gitea_ prefixed membership for aliases already in the set.
|
||||||
|
return f"gitea_{name}" in MUTATION_TASKS
|
||||||
|
|
||||||
|
|
||||||
|
def roles_compatible(active_role: str | None, required_role: str | None) -> bool:
|
||||||
|
"""Whether *active_role* may perform a task that maps to *required_role*.
|
||||||
|
|
||||||
|
Exact match always passes. Reconcilers may run author-class mutations
|
||||||
|
(comment/close/cleanup) that the capability map stamps as ``author`` —
|
||||||
|
matching the long-standing reconciler exemption for control-checkout
|
||||||
|
work. No other cross-role substitutions are allowed.
|
||||||
|
|
||||||
|
Capability-authorized multi-role tasks (e.g. reviewer holding
|
||||||
|
``gitea.issue.comment`` for ``comment_issue``) are handled by
|
||||||
|
:func:`authorization_compatible`, not by expanding this role matrix.
|
||||||
|
"""
|
||||||
|
active = (active_role or "").strip().lower()
|
||||||
|
required = (required_role or "").strip().lower()
|
||||||
|
if not active or not required:
|
||||||
|
return True
|
||||||
|
if active == required:
|
||||||
|
return True
|
||||||
|
if active == "reconciler" and required in {"author", "reconciler"}:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def authorization_compatible(
|
||||||
|
active_role: str | None,
|
||||||
|
required_role: str | None,
|
||||||
|
*,
|
||||||
|
required_permission: str | None = None,
|
||||||
|
allowed_operations: Any = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Whether the active session may run a task given role *and* capability.
|
||||||
|
|
||||||
|
Order:
|
||||||
|
|
||||||
|
1. :func:`roles_compatible` (exact role or narrow reconciler→author).
|
||||||
|
2. Capability possession: when *required_permission* is non-empty and
|
||||||
|
present in *allowed_operations*, allow even if the nominal task role
|
||||||
|
differs (e.g. reviewer/merger with ``gitea.issue.comment`` on
|
||||||
|
``comment_issue`` / ``mark_issue`` / ``set_issue_labels`` /
|
||||||
|
``lock_issue``).
|
||||||
|
|
||||||
|
Fail closed otherwise. This is **not** a broad reviewer→author or
|
||||||
|
merger→author role rewrite: without the specific permission the check
|
||||||
|
still denies. Missing *allowed_operations* when a permission is required
|
||||||
|
also fails closed (callers must supply the live profile op list).
|
||||||
|
"""
|
||||||
|
if roles_compatible(active_role, required_role):
|
||||||
|
return True
|
||||||
|
perm = (required_permission or "").strip()
|
||||||
|
if not perm:
|
||||||
|
return False
|
||||||
|
if allowed_operations is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ops = {str(o).strip() for o in allowed_operations if o is not None}
|
||||||
|
except TypeError:
|
||||||
|
return False
|
||||||
|
return perm in ops
|
||||||
|
|
||||||
|
|
||||||
|
def _blocker(
|
||||||
|
kind: str,
|
||||||
|
reasons: list[str],
|
||||||
|
*,
|
||||||
|
exact_next_action: str | None = None,
|
||||||
|
detail: dict | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if kind not in BLOCKER_KINDS:
|
||||||
|
raise ValueError(f"unknown anti-stomp blocker kind: {kind!r}")
|
||||||
|
return {
|
||||||
|
"kind": kind,
|
||||||
|
"reasons": list(reasons),
|
||||||
|
"exact_next_action": exact_next_action or _NEXT_ACTIONS[kind],
|
||||||
|
"detail": dict(detail or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_result(checks: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"allowed": True,
|
||||||
|
"block": False,
|
||||||
|
"blockers": [],
|
||||||
|
"reasons": [],
|
||||||
|
"exact_next_action": "proceed",
|
||||||
|
"blocker_kind": None,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_result(
|
||||||
|
blockers: list[dict[str, Any]],
|
||||||
|
checks: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
primary = blockers[0]
|
||||||
|
all_reasons: list[str] = []
|
||||||
|
for b in blockers:
|
||||||
|
for r in b.get("reasons") or []:
|
||||||
|
if r not in all_reasons:
|
||||||
|
all_reasons.append(r)
|
||||||
|
return {
|
||||||
|
"allowed": False,
|
||||||
|
"block": True,
|
||||||
|
"blockers": blockers,
|
||||||
|
"reasons": all_reasons,
|
||||||
|
"exact_next_action": primary["exact_next_action"],
|
||||||
|
"blocker_kind": primary["kind"],
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assess_anti_stomp_preflight(
|
||||||
|
*,
|
||||||
|
task: str | None = None,
|
||||||
|
# repo/org
|
||||||
|
remote: str | None = None,
|
||||||
|
resolved_org: str | None = None,
|
||||||
|
resolved_repo: str | None = None,
|
||||||
|
local_remote_url: str | None = None,
|
||||||
|
org_explicit: bool = False,
|
||||||
|
repo_explicit: bool = False,
|
||||||
|
check_repo: bool = True,
|
||||||
|
# profile/role + capability
|
||||||
|
profile_name: str | None = None,
|
||||||
|
profile_role: str | None = None,
|
||||||
|
required_role: str | None = None,
|
||||||
|
required_permission: str | None = None,
|
||||||
|
allowed_operations: Any = None,
|
||||||
|
check_role: bool = True,
|
||||||
|
# root checkout + worktree
|
||||||
|
workspace_path: str | None = None,
|
||||||
|
project_root: str | None = None,
|
||||||
|
current_branch: str | None = None,
|
||||||
|
root_head_sha: str | None = None,
|
||||||
|
root_porcelain: str | None = None,
|
||||||
|
remote_master_sha: str | None = None,
|
||||||
|
check_root_checkout: bool = True,
|
||||||
|
check_worktree: bool = True,
|
||||||
|
# stale runtime (master parity)
|
||||||
|
startup_head: str | None = None,
|
||||||
|
current_code_head: str | None = None,
|
||||||
|
check_stale_runtime: bool = True,
|
||||||
|
# lease ownership
|
||||||
|
lease_required: bool = False,
|
||||||
|
foreign_lease: bool | None = None,
|
||||||
|
lease_owner_session: str | None = None,
|
||||||
|
active_session_id: str | None = None,
|
||||||
|
lease_owner_identity: str | None = None,
|
||||||
|
active_identity: str | None = None,
|
||||||
|
lease_reasons: list[str] | None = None,
|
||||||
|
# terminal lock
|
||||||
|
terminal_lock_blocks: bool | None = None,
|
||||||
|
terminal_lock_reasons: list[str] | None = None,
|
||||||
|
# expected head SHA
|
||||||
|
require_head_sha: bool = False,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
live_head_sha: str | None = None,
|
||||||
|
# workflow hash
|
||||||
|
workflow_hash_valid: bool | None = None,
|
||||||
|
workflow_hash_reasons: list[str] | None = None,
|
||||||
|
# source contamination (stable-branch push / approval contamination)
|
||||||
|
source_contaminated: bool | None = None,
|
||||||
|
contamination_reasons: list[str] | None = None,
|
||||||
|
# manual bypass
|
||||||
|
manual_bypass_attempted: bool = False,
|
||||||
|
manual_bypass_reasons: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fail-closed common anti-stomp assessment (pure).
|
||||||
|
|
||||||
|
Only checks for which facts are supplied (or which are required by the
|
||||||
|
mutation category) are evaluated. Unsupplied optional facts are recorded
|
||||||
|
as ``skipped`` so callers can see coverage without inventing defaults.
|
||||||
|
|
||||||
|
Returns a structured dict with ``allowed``/``block``, typed ``blockers``,
|
||||||
|
aggregated ``reasons``, ``exact_next_action``, and per-check ``checks``.
|
||||||
|
"""
|
||||||
|
task_name = (task or "").strip()
|
||||||
|
role = (profile_role or "").strip().lower() or None
|
||||||
|
req_role = (required_role or "").strip().lower() or None
|
||||||
|
req_perm = (required_permission or "").strip() or None
|
||||||
|
checks: dict[str, Any] = {
|
||||||
|
"task": task_name or None,
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"profile_role": role,
|
||||||
|
"required_role": req_role,
|
||||||
|
"required_permission": req_perm,
|
||||||
|
}
|
||||||
|
blockers: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
# ── manual bypass (always first; never skippable when attempted) ─────────
|
||||||
|
if manual_bypass_attempted:
|
||||||
|
reasons = list(manual_bypass_reasons or [
|
||||||
|
"manual state/mtime/source-file bypass attempt detected (fail closed)"
|
||||||
|
])
|
||||||
|
blockers.append(_blocker(BLOCKER_MANUAL_BYPASS, reasons))
|
||||||
|
checks["manual_bypass"] = {"block": True, "reasons": reasons}
|
||||||
|
else:
|
||||||
|
checks["manual_bypass"] = {"block": False, "skipped": False}
|
||||||
|
|
||||||
|
# ── repo/org ─────────────────────────────────────────────────────────────
|
||||||
|
if check_repo and resolved_org is not None and resolved_repo is not None:
|
||||||
|
repo_assessment = remote_repo_guard.assess_remote_repo_match(
|
||||||
|
remote=remote or "",
|
||||||
|
resolved_org=resolved_org,
|
||||||
|
resolved_repo=resolved_repo,
|
||||||
|
local_remote_url=local_remote_url,
|
||||||
|
org_explicit=org_explicit,
|
||||||
|
repo_explicit=repo_explicit,
|
||||||
|
)
|
||||||
|
checks["repo"] = {
|
||||||
|
"block": bool(repo_assessment.get("block")),
|
||||||
|
"reasons": list(repo_assessment.get("reasons") or []),
|
||||||
|
"resolved_org": resolved_org,
|
||||||
|
"resolved_repo": resolved_repo,
|
||||||
|
}
|
||||||
|
if repo_assessment.get("block"):
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_WRONG_REPO,
|
||||||
|
list(repo_assessment.get("reasons") or ["repo/org mismatch"]),
|
||||||
|
detail={
|
||||||
|
"resolved_org": resolved_org,
|
||||||
|
"resolved_repo": resolved_repo,
|
||||||
|
"local_remote_url": local_remote_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["repo"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── profile/role + capability ────────────────────────────────────────────
|
||||||
|
# Role match OR possession of the task's required permission (Blocker A).
|
||||||
|
# Reviewer/merger may run issue-comment-class tasks when they hold
|
||||||
|
# gitea.issue.comment; unauthorized escalation without the permission
|
||||||
|
# still fails closed.
|
||||||
|
if check_role and req_role and role:
|
||||||
|
authorized = authorization_compatible(
|
||||||
|
role,
|
||||||
|
req_role,
|
||||||
|
required_permission=req_perm,
|
||||||
|
allowed_operations=allowed_operations,
|
||||||
|
)
|
||||||
|
if not authorized:
|
||||||
|
perm_clause = (
|
||||||
|
f", required_permission='{req_perm}'" if req_perm else ""
|
||||||
|
)
|
||||||
|
reasons = [
|
||||||
|
f"active profile role '{role}' is not authorized for task "
|
||||||
|
f"'{task_name or '(unknown)'}' (required_role='{req_role}'"
|
||||||
|
f"{perm_clause}; profile={profile_name or '(unknown)'})"
|
||||||
|
]
|
||||||
|
checks["role"] = {
|
||||||
|
"block": True,
|
||||||
|
"reasons": reasons,
|
||||||
|
"required_permission": req_perm,
|
||||||
|
"capability_authorized": False,
|
||||||
|
}
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_WRONG_ROLE,
|
||||||
|
reasons,
|
||||||
|
detail={
|
||||||
|
"profile_name": profile_name,
|
||||||
|
"profile_role": role,
|
||||||
|
"required_role": req_role,
|
||||||
|
"required_permission": req_perm,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["role"] = {
|
||||||
|
"block": False,
|
||||||
|
"reasons": [],
|
||||||
|
"required_permission": req_perm,
|
||||||
|
"capability_authorized": bool(
|
||||||
|
req_perm
|
||||||
|
and allowed_operations is not None
|
||||||
|
and req_perm in {
|
||||||
|
str(o).strip() for o in (allowed_operations or [])
|
||||||
|
if o is not None
|
||||||
|
}
|
||||||
|
and not roles_compatible(role, req_role)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
checks["role"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── stale runtime (master parity) ────────────────────────────────────────
|
||||||
|
if check_stale_runtime and (
|
||||||
|
startup_head is not None or current_code_head is not None
|
||||||
|
):
|
||||||
|
parity = master_parity_gate.assess_master_parity(
|
||||||
|
{"startup_head": startup_head},
|
||||||
|
current_code_head,
|
||||||
|
)
|
||||||
|
stale_reasons = master_parity_gate.parity_block_reasons(parity)
|
||||||
|
checks["stale_runtime"] = {
|
||||||
|
"block": bool(stale_reasons),
|
||||||
|
"reasons": list(stale_reasons),
|
||||||
|
"startup_head": startup_head,
|
||||||
|
"current_code_head": current_code_head,
|
||||||
|
"stale": bool(parity.get("stale")),
|
||||||
|
}
|
||||||
|
if stale_reasons:
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_STALE_RUNTIME,
|
||||||
|
list(stale_reasons),
|
||||||
|
detail={
|
||||||
|
"startup_head": startup_head,
|
||||||
|
"current_code_head": current_code_head,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["stale_runtime"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── root checkout ────────────────────────────────────────────────────────
|
||||||
|
if (
|
||||||
|
check_root_checkout
|
||||||
|
and workspace_path is not None
|
||||||
|
and project_root is not None
|
||||||
|
and root_porcelain is not None
|
||||||
|
):
|
||||||
|
root_assessment = root_checkout_guard.assess_root_checkout_guard(
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
canonical_repo_root=project_root,
|
||||||
|
current_branch=current_branch,
|
||||||
|
head_sha=root_head_sha,
|
||||||
|
porcelain_status=root_porcelain,
|
||||||
|
remote_master_sha=remote_master_sha,
|
||||||
|
resolved_role=req_role or role,
|
||||||
|
actual_role=role,
|
||||||
|
)
|
||||||
|
checks["root_checkout"] = {
|
||||||
|
"block": bool(root_assessment.get("block")),
|
||||||
|
"reasons": list(root_assessment.get("reasons") or []),
|
||||||
|
}
|
||||||
|
if root_assessment.get("block"):
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_ROOT_CHECKOUT,
|
||||||
|
list(root_assessment.get("reasons") or [
|
||||||
|
"control checkout is not clean master"
|
||||||
|
]),
|
||||||
|
detail={
|
||||||
|
"workspace_path": workspace_path,
|
||||||
|
"project_root": project_root,
|
||||||
|
"current_branch": current_branch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["root_checkout"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── worktree under branches (author) ─────────────────────────────────────
|
||||||
|
worktree_role = role or req_role
|
||||||
|
if (
|
||||||
|
check_worktree
|
||||||
|
and workspace_path is not None
|
||||||
|
and project_root is not None
|
||||||
|
and worktree_role in _BRANCHES_REQUIRED_ROLES
|
||||||
|
):
|
||||||
|
wt = author_mutation_worktree.assess_author_mutation_worktree(
|
||||||
|
workspace_path=workspace_path,
|
||||||
|
project_root=project_root,
|
||||||
|
current_branch=current_branch,
|
||||||
|
)
|
||||||
|
checks["worktree"] = {
|
||||||
|
"block": bool(wt.get("block")),
|
||||||
|
"reasons": list(wt.get("reasons") or []),
|
||||||
|
"under_branches": wt.get("under_branches"),
|
||||||
|
}
|
||||||
|
if wt.get("block"):
|
||||||
|
blockers.append(
|
||||||
|
_blocker(
|
||||||
|
BLOCKER_WRONG_WORKTREE,
|
||||||
|
list(wt.get("reasons") or [
|
||||||
|
"mutation worktree is not under branches/"
|
||||||
|
]),
|
||||||
|
detail={
|
||||||
|
"workspace_path": workspace_path,
|
||||||
|
"project_root": project_root,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["worktree"] = {
|
||||||
|
"block": False,
|
||||||
|
"skipped": worktree_role not in _BRANCHES_REQUIRED_ROLES
|
||||||
|
or workspace_path is None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── foreign lease ────────────────────────────────────────────────────────
|
||||||
|
lease_needed = lease_required or (
|
||||||
|
task_name.removeprefix("gitea_") in {
|
||||||
|
t.removeprefix("gitea_") for t in _LEASE_AWARE_TASKS
|
||||||
|
}
|
||||||
|
and foreign_lease is not None
|
||||||
|
)
|
||||||
|
if lease_needed or foreign_lease is True:
|
||||||
|
is_foreign = bool(foreign_lease)
|
||||||
|
if foreign_lease is None and lease_required:
|
||||||
|
# Ownership must be proven when lease_required; missing ownership
|
||||||
|
# facts fail closed.
|
||||||
|
owner_ok = (
|
||||||
|
(not lease_owner_session and not active_session_id)
|
||||||
|
or (
|
||||||
|
lease_owner_session
|
||||||
|
and active_session_id
|
||||||
|
and lease_owner_session == active_session_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
identity_ok = (
|
||||||
|
(not lease_owner_identity and not active_identity)
|
||||||
|
or (
|
||||||
|
lease_owner_identity
|
||||||
|
and active_identity
|
||||||
|
and lease_owner_identity == active_identity
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not owner_ok or not identity_ok:
|
||||||
|
is_foreign = True
|
||||||
|
reasons = list(lease_reasons or [])
|
||||||
|
if is_foreign and not reasons:
|
||||||
|
reasons = [
|
||||||
|
"active lease is owned by a foreign session or identity "
|
||||||
|
"(anti-stomp fail closed)"
|
||||||
|
]
|
||||||
|
checks["lease"] = {
|
||||||
|
"block": is_foreign,
|
||||||
|
"reasons": reasons if is_foreign else [],
|
||||||
|
"lease_owner_session": lease_owner_session,
|
||||||
|
"active_session_id": active_session_id,
|
||||||
|
}
|
||||||
|
if is_foreign:
|
||||||
|
blockers.append(
|
||||||
|
_blocker(BLOCKER_FOREIGN_LEASE, reasons)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["lease"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── terminal lock ────────────────────────────────────────────────────────
|
||||||
|
if terminal_lock_blocks is not None:
|
||||||
|
reasons = list(terminal_lock_reasons or [])
|
||||||
|
if terminal_lock_blocks and not reasons:
|
||||||
|
reasons = [
|
||||||
|
"terminal review-decision lock is active for this head "
|
||||||
|
"(anti-stomp fail closed)"
|
||||||
|
]
|
||||||
|
checks["terminal_lock"] = {
|
||||||
|
"block": bool(terminal_lock_blocks),
|
||||||
|
"reasons": reasons if terminal_lock_blocks else [],
|
||||||
|
}
|
||||||
|
if terminal_lock_blocks:
|
||||||
|
blockers.append(_blocker(BLOCKER_TERMINAL_LOCK, reasons))
|
||||||
|
else:
|
||||||
|
checks["terminal_lock"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── expected head SHA ────────────────────────────────────────────────────
|
||||||
|
if require_head_sha or (
|
||||||
|
expected_head_sha is not None and live_head_sha is not None
|
||||||
|
):
|
||||||
|
exp = (expected_head_sha or "").strip().lower()
|
||||||
|
live = (live_head_sha or "").strip().lower()
|
||||||
|
mismatch = False
|
||||||
|
reasons: list[str] = []
|
||||||
|
if require_head_sha and not exp:
|
||||||
|
mismatch = True
|
||||||
|
reasons.append(
|
||||||
|
"expected_head_sha is required before this mutation "
|
||||||
|
"(anti-stomp fail closed)"
|
||||||
|
)
|
||||||
|
elif exp and live and exp != live:
|
||||||
|
mismatch = True
|
||||||
|
reasons.append(
|
||||||
|
f"expected_head_sha '{exp}' does not match live PR head "
|
||||||
|
f"'{live}' (anti-stomp fail closed; stale prompt data)"
|
||||||
|
)
|
||||||
|
elif require_head_sha and exp and not live:
|
||||||
|
mismatch = True
|
||||||
|
reasons.append(
|
||||||
|
"live PR head SHA could not be determined to corroborate "
|
||||||
|
"expected_head_sha (anti-stomp fail closed)"
|
||||||
|
)
|
||||||
|
checks["head_sha"] = {
|
||||||
|
"block": mismatch,
|
||||||
|
"reasons": reasons,
|
||||||
|
"expected_head_sha": expected_head_sha,
|
||||||
|
"live_head_sha": live_head_sha,
|
||||||
|
}
|
||||||
|
if mismatch:
|
||||||
|
blockers.append(_blocker(BLOCKER_HEAD_SHA, reasons))
|
||||||
|
else:
|
||||||
|
checks["head_sha"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── workflow hash ────────────────────────────────────────────────────────
|
||||||
|
if workflow_hash_valid is not None:
|
||||||
|
reasons = list(workflow_hash_reasons or [])
|
||||||
|
if not workflow_hash_valid and not reasons:
|
||||||
|
reasons = [
|
||||||
|
"workflow load proof missing or hash stale "
|
||||||
|
"(anti-stomp fail closed)"
|
||||||
|
]
|
||||||
|
checks["workflow_hash"] = {
|
||||||
|
"block": not bool(workflow_hash_valid),
|
||||||
|
"reasons": reasons if not workflow_hash_valid else [],
|
||||||
|
}
|
||||||
|
if not workflow_hash_valid:
|
||||||
|
blockers.append(_blocker(BLOCKER_WORKFLOW_HASH, reasons))
|
||||||
|
else:
|
||||||
|
checks["workflow_hash"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
# ── source contamination ─────────────────────────────────────────────────
|
||||||
|
if source_contaminated is not None:
|
||||||
|
reasons = list(contamination_reasons or [])
|
||||||
|
if source_contaminated and not reasons:
|
||||||
|
reasons = [
|
||||||
|
"session is source-contaminated (stable-branch push or "
|
||||||
|
"contaminated approval path); anti-stomp fail closed"
|
||||||
|
]
|
||||||
|
checks["source_contamination"] = {
|
||||||
|
"block": bool(source_contaminated),
|
||||||
|
"reasons": reasons if source_contaminated else [],
|
||||||
|
}
|
||||||
|
if source_contaminated:
|
||||||
|
blockers.append(
|
||||||
|
_blocker(BLOCKER_SOURCE_CONTAMINATION, reasons)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
checks["source_contamination"] = {"block": False, "skipped": True}
|
||||||
|
|
||||||
|
if blockers:
|
||||||
|
return _blocked_result(blockers, checks)
|
||||||
|
return _allowed_result(checks)
|
||||||
|
|
||||||
|
|
||||||
|
def format_anti_stomp_error(assessment: dict[str, Any]) -> str:
|
||||||
|
"""Single RuntimeError message for MCP mutation gates."""
|
||||||
|
kind = assessment.get("blocker_kind") or "unknown"
|
||||||
|
reasons = "; ".join(
|
||||||
|
assessment.get("reasons")
|
||||||
|
or ["anti-stomp preflight blocked the mutation"]
|
||||||
|
)
|
||||||
|
next_action = assessment.get("exact_next_action") or "stop and diagnose"
|
||||||
|
return (
|
||||||
|
f"Anti-stomp preflight (#604) blocked mutation "
|
||||||
|
f"[{kind}]: {reasons}. "
|
||||||
|
f"exact_next_action: {next_action}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def block_response(
|
||||||
|
assessment: dict[str, Any],
|
||||||
|
**extra_fields: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Structured tool-return payload for a blocked mutation."""
|
||||||
|
payload = {
|
||||||
|
"success": False,
|
||||||
|
"performed": False,
|
||||||
|
"blocked": True,
|
||||||
|
"anti_stomp": True,
|
||||||
|
"blocker_kind": assessment.get("blocker_kind"),
|
||||||
|
"blockers": list(assessment.get("blockers") or []),
|
||||||
|
"reasons": list(assessment.get("reasons") or []),
|
||||||
|
"exact_next_action": assessment.get("exact_next_action"),
|
||||||
|
"checks": assessment.get("checks") or {},
|
||||||
|
}
|
||||||
|
payload.update(extra_fields)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def contamination_from_stable_marker(
|
||||||
|
marker: dict | None,
|
||||||
|
*,
|
||||||
|
task: str | None,
|
||||||
|
actual_role: str | None,
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Derive source-contamination facts from a #671 durable marker."""
|
||||||
|
if not marker:
|
||||||
|
return False, []
|
||||||
|
gate = stable_branch_push_guard.assess_contamination_gate(
|
||||||
|
marker,
|
||||||
|
task=task,
|
||||||
|
actual_role=actual_role,
|
||||||
|
)
|
||||||
|
if gate.get("block"):
|
||||||
|
return True, list(gate.get("reasons") or [])
|
||||||
|
return False, []
|
||||||
+516
-3
@@ -619,18 +619,219 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _anti_stomp_in_test_mode() -> bool:
|
||||||
|
"""Whether the #604 anti-stomp gate is skipped under pytest.
|
||||||
|
|
||||||
|
Mirrors remote-repo / stable-contamination test bypasses so the existing
|
||||||
|
suite (bare remotes, mocked APIs) stays green. Set
|
||||||
|
``GITEA_TEST_FORCE_ANTI_STOMP=1`` to exercise the live gate under tests.
|
||||||
|
"""
|
||||||
|
if not _preflight_in_test_mode():
|
||||||
|
return False
|
||||||
|
return not bool(os.environ.get("GITEA_TEST_FORCE_ANTI_STOMP"))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_anti_stomp_preflight(
|
||||||
|
task: str | None,
|
||||||
|
*,
|
||||||
|
remote: str | None = None,
|
||||||
|
worktree_path: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
live_head_sha: str | None = None,
|
||||||
|
require_head_sha: bool = False,
|
||||||
|
lease_required: bool = False,
|
||||||
|
foreign_lease: bool | None = None,
|
||||||
|
lease_reasons: list[str] | None = None,
|
||||||
|
terminal_lock_blocks: bool | None = None,
|
||||||
|
terminal_lock_reasons: list[str] | None = None,
|
||||||
|
workflow_hash_valid: bool | None = None,
|
||||||
|
workflow_hash_reasons: list[str] | None = None,
|
||||||
|
raise_on_block: bool = True,
|
||||||
|
) -> dict | None:
|
||||||
|
"""Shared #604 anti-stomp preflight for MCP mutation entrypoints.
|
||||||
|
|
||||||
|
Gathers live workspace/parity/role/repo facts and invokes the pure
|
||||||
|
:func:`anti_stomp_preflight.assess_anti_stomp_preflight` assessor. On
|
||||||
|
block, raises ``RuntimeError`` (default) or returns a structured
|
||||||
|
:func:`anti_stomp_preflight.block_response` when *raise_on_block* is
|
||||||
|
False. Returns ``None`` when the mutation may proceed.
|
||||||
|
"""
|
||||||
|
if _anti_stomp_in_test_mode():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Only enforce when the caller names a known mutation task. Read-only
|
||||||
|
# paths and legacy verify_preflight_purity calls without a task stay
|
||||||
|
# on the pre-#604 gate chain (whoami/capability/root/worktree).
|
||||||
|
if not task or not anti_stomp_preflight.is_mutation_task(task):
|
||||||
|
return None
|
||||||
|
|
||||||
|
profile = get_profile()
|
||||||
|
profile_name = profile.get("profile_name")
|
||||||
|
profile_role = _actual_profile_role()
|
||||||
|
allowed_operations = list(profile.get("allowed_operations") or [])
|
||||||
|
req_role = None
|
||||||
|
req_perm = None
|
||||||
|
if task:
|
||||||
|
try:
|
||||||
|
req_role = task_capability_map.required_role(task)
|
||||||
|
except KeyError:
|
||||||
|
req_role = _preflight_resolved_role
|
||||||
|
try:
|
||||||
|
req_perm = task_capability_map.required_permission(task)
|
||||||
|
except KeyError:
|
||||||
|
req_perm = None
|
||||||
|
|
||||||
|
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||||
|
workspace = ctx["workspace_path"]
|
||||||
|
canonical_root = ctx["canonical_repo_root"]
|
||||||
|
git_state = issue_lock_worktree.read_worktree_git_state(canonical_root)
|
||||||
|
remote_master_sha = root_checkout_guard.resolve_remote_master_sha(canonical_root)
|
||||||
|
|
||||||
|
# Repo/org facts (best-effort; explicit org/repo when provided).
|
||||||
|
resolved_org = org
|
||||||
|
resolved_repo = repo
|
||||||
|
local_remote_url = None
|
||||||
|
org_explicit = org is not None
|
||||||
|
repo_explicit = repo is not None
|
||||||
|
if remote:
|
||||||
|
try:
|
||||||
|
local_remote_url = _local_git_remote_url(remote)
|
||||||
|
except Exception:
|
||||||
|
local_remote_url = None
|
||||||
|
if resolved_org is None or resolved_repo is None:
|
||||||
|
try:
|
||||||
|
rem = _effective_remote(remote)
|
||||||
|
if rem in REMOTES:
|
||||||
|
if resolved_org is None:
|
||||||
|
resolved_org = REMOTES[rem]["org"]
|
||||||
|
if resolved_repo is None:
|
||||||
|
resolved_repo = REMOTES[rem]["repo"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parity = _current_master_parity()
|
||||||
|
startup_head = parity.get("startup_head")
|
||||||
|
current_code_head = parity.get("current_head")
|
||||||
|
|
||||||
|
# Source contamination from durable #671 marker (role-aware).
|
||||||
|
source_contaminated = None
|
||||||
|
contamination_reasons = None
|
||||||
|
try:
|
||||||
|
marker = _load_stable_contamination_marker(remote)
|
||||||
|
contaminated, cont_reasons = anti_stomp_preflight.contamination_from_stable_marker(
|
||||||
|
marker,
|
||||||
|
task=task,
|
||||||
|
actual_role=profile_role,
|
||||||
|
)
|
||||||
|
if marker is not None:
|
||||||
|
source_contaminated = contaminated
|
||||||
|
contamination_reasons = cont_reasons
|
||||||
|
except Exception:
|
||||||
|
# Fail soft on marker I/O: existing #671 enforcer still runs separately.
|
||||||
|
source_contaminated = None
|
||||||
|
|
||||||
|
# Workflow-hash facts when the task is review/merge oriented.
|
||||||
|
if workflow_hash_valid is None and task in {
|
||||||
|
"review_pr",
|
||||||
|
"submit_pr_review",
|
||||||
|
"approve_pr",
|
||||||
|
"request_changes_pr",
|
||||||
|
"merge_pr",
|
||||||
|
}:
|
||||||
|
try:
|
||||||
|
wf_reasons = _review_workflow_load_gate_reasons()
|
||||||
|
workflow_hash_valid = not bool(wf_reasons)
|
||||||
|
workflow_hash_reasons = list(wf_reasons) if wf_reasons else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Mirror remote_repo_guard's pytest bypass: under the unit suite bare
|
||||||
|
# remote=prgs still defaults to Timesheet, and re-checking here would
|
||||||
|
# break happy-path reconciler/author tests that already rely on the
|
||||||
|
# #530 gate being opt-in via GITEA_FORCE_REMOTE_REPO_CHECK.
|
||||||
|
check_repo = True
|
||||||
|
if "pytest" in sys.modules and not os.environ.get(
|
||||||
|
"GITEA_FORCE_REMOTE_REPO_CHECK"
|
||||||
|
):
|
||||||
|
check_repo = False
|
||||||
|
|
||||||
|
assessment = anti_stomp_preflight.assess_anti_stomp_preflight(
|
||||||
|
task=task,
|
||||||
|
remote=remote,
|
||||||
|
resolved_org=resolved_org,
|
||||||
|
resolved_repo=resolved_repo,
|
||||||
|
local_remote_url=local_remote_url,
|
||||||
|
org_explicit=org_explicit,
|
||||||
|
repo_explicit=repo_explicit,
|
||||||
|
check_repo=check_repo,
|
||||||
|
profile_name=profile_name,
|
||||||
|
profile_role=profile_role,
|
||||||
|
required_role=req_role or _preflight_resolved_role,
|
||||||
|
required_permission=req_perm,
|
||||||
|
allowed_operations=allowed_operations,
|
||||||
|
workspace_path=workspace,
|
||||||
|
project_root=canonical_root,
|
||||||
|
current_branch=git_state.get("current_branch"),
|
||||||
|
root_head_sha=git_state.get("head_sha"),
|
||||||
|
root_porcelain=git_state.get("porcelain_status") or "",
|
||||||
|
remote_master_sha=remote_master_sha,
|
||||||
|
startup_head=startup_head,
|
||||||
|
current_code_head=current_code_head,
|
||||||
|
lease_required=lease_required,
|
||||||
|
foreign_lease=foreign_lease,
|
||||||
|
lease_reasons=lease_reasons,
|
||||||
|
terminal_lock_blocks=terminal_lock_blocks,
|
||||||
|
terminal_lock_reasons=terminal_lock_reasons,
|
||||||
|
require_head_sha=require_head_sha,
|
||||||
|
expected_head_sha=expected_head_sha,
|
||||||
|
live_head_sha=live_head_sha,
|
||||||
|
workflow_hash_valid=workflow_hash_valid,
|
||||||
|
workflow_hash_reasons=workflow_hash_reasons,
|
||||||
|
source_contaminated=source_contaminated,
|
||||||
|
contamination_reasons=contamination_reasons,
|
||||||
|
manual_bypass_attempted=False,
|
||||||
|
)
|
||||||
|
if not assessment.get("block"):
|
||||||
|
return None
|
||||||
|
if raise_on_block:
|
||||||
|
raise RuntimeError(anti_stomp_preflight.format_anti_stomp_error(assessment))
|
||||||
|
return anti_stomp_preflight.block_response(assessment)
|
||||||
|
|
||||||
|
|
||||||
def verify_preflight_purity(
|
def verify_preflight_purity(
|
||||||
remote: str | None = None,
|
remote: str | None = None,
|
||||||
worktree_path: str | None = None,
|
worktree_path: str | None = None,
|
||||||
task: str | None = None,
|
task: str | None = None,
|
||||||
|
*,
|
||||||
|
expected_head_sha: str | None = None,
|
||||||
|
live_head_sha: str | None = None,
|
||||||
|
require_head_sha: bool = False,
|
||||||
|
lease_required: bool = False,
|
||||||
|
foreign_lease: bool | None = None,
|
||||||
|
lease_reasons: list[str] | None = None,
|
||||||
|
terminal_lock_blocks: bool | None = None,
|
||||||
|
terminal_lock_reasons: list[str] | None = None,
|
||||||
|
workflow_hash_valid: bool | None = None,
|
||||||
|
workflow_hash_reasons: list[str] | None = None,
|
||||||
|
org: str | None = None,
|
||||||
|
repo: str | None = None,
|
||||||
):
|
):
|
||||||
"""Verify that identity and capability were verified prior to session edits."""
|
"""Verify that identity and capability were verified prior to session edits.
|
||||||
|
|
||||||
|
Also runs the shared #604 anti-stomp preflight for mutation tasks (typed
|
||||||
|
blocker + exact next action). Extended lease/head/terminal facts may be
|
||||||
|
supplied by review/merge callers.
|
||||||
|
"""
|
||||||
global _preflight_reviewer_violation_files
|
global _preflight_reviewer_violation_files
|
||||||
|
|
||||||
in_test = _preflight_in_test_mode()
|
in_test = _preflight_in_test_mode()
|
||||||
if in_test and not (
|
if in_test and not (
|
||||||
os.environ.get("GITEA_TEST_FORCE_DIRTY")
|
os.environ.get("GITEA_TEST_FORCE_DIRTY")
|
||||||
or os.environ.get("GITEA_TEST_PORCELAIN") is not None
|
or os.environ.get("GITEA_TEST_PORCELAIN") is not None
|
||||||
|
or os.environ.get("GITEA_TEST_FORCE_ANTI_STOMP")
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -653,6 +854,10 @@ def verify_preflight_purity(
|
|||||||
f"'{task}' (fail closed)"
|
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)
|
ctx = _resolve_namespace_mutation_context(worktree_path)
|
||||||
workspace = ctx["workspace_path"]
|
workspace = ctx["workspace_path"]
|
||||||
canonical_root = ctx["canonical_repo_root"]
|
canonical_root = ctx["canonical_repo_root"]
|
||||||
@@ -713,6 +918,30 @@ def verify_preflight_purity(
|
|||||||
|
|
||||||
_enforce_root_checkout_guard(worktree_path)
|
_enforce_root_checkout_guard(worktree_path)
|
||||||
_enforce_branches_only_author_mutation(worktree_path)
|
_enforce_branches_only_author_mutation(worktree_path)
|
||||||
|
|
||||||
|
# #604: common anti-stomp preflight (typed blocker + exact next action).
|
||||||
|
# Runs after the legacy enforcers so existing happy paths stay green and
|
||||||
|
# the shared module remains the single fail-closed composition point for
|
||||||
|
# repo/role/worktree/lease/terminal-lock/head/stale/workflow/contamination.
|
||||||
|
_run_anti_stomp_preflight(
|
||||||
|
task,
|
||||||
|
remote=remote,
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
expected_head_sha=expected_head_sha,
|
||||||
|
live_head_sha=live_head_sha,
|
||||||
|
require_head_sha=require_head_sha,
|
||||||
|
lease_required=lease_required,
|
||||||
|
foreign_lease=foreign_lease,
|
||||||
|
lease_reasons=lease_reasons,
|
||||||
|
terminal_lock_blocks=terminal_lock_blocks,
|
||||||
|
terminal_lock_reasons=terminal_lock_reasons,
|
||||||
|
workflow_hash_valid=workflow_hash_valid,
|
||||||
|
workflow_hash_reasons=workflow_hash_reasons,
|
||||||
|
raise_on_block=True,
|
||||||
|
)
|
||||||
|
|
||||||
_clear_preflight_capability_state()
|
_clear_preflight_capability_state()
|
||||||
|
|
||||||
|
|
||||||
@@ -724,6 +953,7 @@ def _verify_role_mutation_workspace(
|
|||||||
task: str | None = None,
|
task: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
|
"""Bind reviewer/merger mutations to the active namespace workspace (#510)."""
|
||||||
|
|
||||||
# Check running runtimes to prevent stale mutations
|
# Check running runtimes to prevent stale mutations
|
||||||
try:
|
try:
|
||||||
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ:
|
||||||
@@ -809,6 +1039,80 @@ def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None:
|
|||||||
raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment))
|
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 mcp.server.fastmcp import FastMCP # noqa: E402
|
||||||
|
|
||||||
from gitea_auth import ( # noqa: E402
|
from gitea_auth import ( # noqa: E402
|
||||||
@@ -849,7 +1153,9 @@ import merge_approval_gate # noqa: E402
|
|||||||
import already_landed_reconcile # noqa: E402
|
import already_landed_reconcile # noqa: E402
|
||||||
import author_mutation_worktree # noqa: E402
|
import author_mutation_worktree # noqa: E402
|
||||||
import root_checkout_guard # noqa: E402
|
import root_checkout_guard # noqa: E402
|
||||||
|
import stable_branch_push_guard # noqa: E402
|
||||||
import remote_repo_guard # noqa: E402
|
import remote_repo_guard # noqa: E402
|
||||||
|
import anti_stomp_preflight # noqa: E402
|
||||||
import issue_claim_heartbeat # noqa: E402
|
import issue_claim_heartbeat # noqa: E402
|
||||||
import issue_work_duplicate_gate # noqa: E402
|
import issue_work_duplicate_gate # noqa: E402
|
||||||
import issue_workflow_labels # noqa: E402
|
import issue_workflow_labels # noqa: E402
|
||||||
@@ -3753,6 +4059,30 @@ def _evaluate_pr_review_submission(
|
|||||||
result["pr_work_lease"] = lease_block
|
result["pr_work_lease"] = lease_block
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
if live:
|
||||||
|
# #604: common anti-stomp with live head + lease proof (typed blocker).
|
||||||
|
anti_task = {
|
||||||
|
"approve": "approve_pr",
|
||||||
|
"request_changes": "request_changes_pr",
|
||||||
|
"comment": "submit_pr_review",
|
||||||
|
}.get(action, "submit_pr_review")
|
||||||
|
try:
|
||||||
|
_run_anti_stomp_preflight(
|
||||||
|
anti_task,
|
||||||
|
remote=remote,
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
expected_head_sha=pinned_sha,
|
||||||
|
live_head_sha=actual_sha,
|
||||||
|
require_head_sha=True,
|
||||||
|
foreign_lease=False,
|
||||||
|
terminal_lock_blocks=False,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
reasons.append(str(exc))
|
||||||
|
return result
|
||||||
|
|
||||||
if (body or "").strip():
|
if (body or "").strip():
|
||||||
gate = _canonical_comment_gate(body, context="pr_review")
|
gate = _canonical_comment_gate(body, context="pr_review")
|
||||||
if gate["blocked"]:
|
if gate["blocked"]:
|
||||||
@@ -4707,7 +5037,12 @@ def gitea_edit_pr(
|
|||||||
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
|
raise ValueError("At least one field to edit (title, body, state, base) must be provided.")
|
||||||
|
|
||||||
closing = payload.get("state") == "closed"
|
closing = payload.get("state") == "closed"
|
||||||
verify_preflight_purity(remote, task="close_pr" if closing else None)
|
# Closing uses close_pr; non-closing title/body/base edits use edit_pr so
|
||||||
|
# the shared #604 anti-stomp inventory stays consistent with wiring.
|
||||||
|
if closing:
|
||||||
|
verify_preflight_purity(remote, task="close_pr")
|
||||||
|
else:
|
||||||
|
verify_preflight_purity(remote, task="edit_pr")
|
||||||
|
|
||||||
# PR closure is a first-class capability, distinct from retitling or
|
# PR closure is a first-class capability, distinct from retitling or
|
||||||
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
|
# rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close
|
||||||
@@ -5189,6 +5524,25 @@ def gitea_merge_pr(
|
|||||||
reasons.extend(lease_block.get("reasons") or [])
|
reasons.extend(lease_block.get("reasons") or [])
|
||||||
result["pr_work_lease"] = lease_block
|
result["pr_work_lease"] = lease_block
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# #604: common anti-stomp with live head + lease proof (typed blocker).
|
||||||
|
try:
|
||||||
|
_run_anti_stomp_preflight(
|
||||||
|
"merge_pr",
|
||||||
|
remote=remote,
|
||||||
|
worktree_path=worktree_path,
|
||||||
|
org=org,
|
||||||
|
repo=repo,
|
||||||
|
expected_head_sha=expected_head_sha,
|
||||||
|
live_head_sha=actual_sha,
|
||||||
|
require_head_sha=True,
|
||||||
|
foreign_lease=False,
|
||||||
|
terminal_lock_blocks=False,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
reasons.append(str(exc))
|
||||||
|
return result
|
||||||
|
|
||||||
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
|
if expected_head_sha and actual_sha and expected_head_sha != actual_sha:
|
||||||
reasons.append(
|
reasons.append(
|
||||||
"expected head SHA does not match current PR head (fail closed)"
|
"expected head SHA does not match current PR head (fail closed)"
|
||||||
@@ -7308,7 +7662,11 @@ def gitea_acquire_reviewer_pr_lease(
|
|||||||
"permission_report": _permission_block_report("gitea.pr.comment"),
|
"permission_report": _permission_block_report("gitea.pr.comment"),
|
||||||
}
|
}
|
||||||
|
|
||||||
_verify_role_mutation_workspace(remote, worktree=worktree, task="review_pr")
|
# task=acquire_reviewer_pr_lease so verify_preflight_purity runs shared #604
|
||||||
|
# anti-stomp for the declared lease-acquire mutation inventory entry.
|
||||||
|
_verify_role_mutation_workspace(
|
||||||
|
remote, worktree=worktree, task="acquire_reviewer_pr_lease"
|
||||||
|
)
|
||||||
h, o, r = _resolve(remote, host, org, repo)
|
h, o, r = _resolve(remote, host, org, repo)
|
||||||
auth = _auth(h)
|
auth = _auth(h)
|
||||||
profile = get_profile()
|
profile = get_profile()
|
||||||
@@ -7449,6 +7807,7 @@ def gitea_adopt_merger_pr_lease(
|
|||||||
"permission_report": _permission_block_report("gitea.pr.merge"),
|
"permission_report": _permission_block_report("gitea.pr.merge"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# task=adopt_merger_pr_lease so verify_preflight_purity runs shared #604 anti-stomp.
|
||||||
_verify_role_mutation_workspace(
|
_verify_role_mutation_workspace(
|
||||||
remote, worktree=worktree, task="adopt_merger_pr_lease"
|
remote, worktree=worktree, task="adopt_merger_pr_lease"
|
||||||
)
|
)
|
||||||
@@ -9273,6 +9632,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()
|
@mcp.tool()
|
||||||
def gitea_validate_review_final_report(
|
def gitea_validate_review_final_report(
|
||||||
report_text: str,
|
report_text: str,
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class UnsanctionedRuntimeError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
def is_pytest_runtime() -> bool:
|
def is_pytest_runtime() -> bool:
|
||||||
|
if os.environ.get("GITEA_TEST_FORCE_UNSANCTIONED") == "1":
|
||||||
|
return False
|
||||||
|
import sys
|
||||||
|
if "pytest" in sys.modules:
|
||||||
|
return True
|
||||||
return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip())
|
return bool((os.environ.get("PYTEST_CURRENT_TEST") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Runs over stdio. All tools authenticate via macOS keychain (git credential fill)
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
if "PYTEST_CURRENT_TEST" not in os.environ:
|
||||||
sys.stderr = open("/tmp/mcp_server_stderr.log", "a", buffering=1)
|
sys.stderr = open("/tmp/mcp_server_stderr.log", "a", buffering=1)
|
||||||
sys.stderr.write(f"\n--- MCP SERVER STARTUP (PID {os.getpid()}) ---\n")
|
sys.stderr.write(f"\n--- MCP SERVER STARTUP (PID {os.getpid()}) ---\n")
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ DEFAULT_TTL_HOURS = 4.0
|
|||||||
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
KIND_WORKFLOW_LOAD = "review_workflow_load"
|
||||||
KIND_DECISION_LOCK = "review_decision_lock"
|
KIND_DECISION_LOCK = "review_decision_lock"
|
||||||
KIND_REVIEW_DRAFT = "review_draft"
|
KIND_REVIEW_DRAFT = "review_draft"
|
||||||
|
# Durable marker set when a worker session attempts a direct stable-branch push
|
||||||
|
# or a root-checkout local commit (#671). Keyed per profile identity like the
|
||||||
|
# other session proofs; a contaminated session fails closed on gated mutations
|
||||||
|
# until a reconciler audits and clears it.
|
||||||
|
KIND_STABLE_BRANCH_CONTAMINATION = "stable_branch_contamination"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ _READONLY_REVIEWER_GIT = re.compile(
|
|||||||
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
|
_GIT_INVOCATION = re.compile(r"\bgit\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
# #673: Canonical pattern for identifying review worktrees under branches/.
|
||||||
|
REVIEW_WORKTREE_RE = re.compile(
|
||||||
|
r"branches/(?:review-pr\d+[\w/-]*|merge-simulation-pr\d+|review-[\w-]+)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_review_worktree_path(path: str) -> bool:
|
||||||
|
"""True when the path belongs to a reviewer or simulation worktree."""
|
||||||
|
normalized = (path or "").replace("\\", "/")
|
||||||
|
return bool(REVIEW_WORKTREE_RE.search(normalized))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
|
def parse_dirty_tracked_files(porcelain: str) -> list[str]:
|
||||||
"""Return tracked paths with local modifications from ``git status --porcelain``.
|
"""Return tracked paths with local modifications from ``git status --porcelain``.
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SESSION_OWNED_RE = re.compile(
|
from reviewer_worktree import REVIEW_WORKTREE_RE
|
||||||
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
|
|
||||||
re.IGNORECASE,
|
_SESSION_OWNED_RE = REVIEW_WORKTREE_RE
|
||||||
)
|
|
||||||
_WORKTREE_PATH_RE = re.compile(
|
_WORKTREE_PATH_RE = re.compile(
|
||||||
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ workflow file.
|
|||||||
mutation.
|
mutation.
|
||||||
- A nearby capability does not count.
|
- A nearby capability does not count.
|
||||||
- Do not self-review or self-merge.
|
- Do not self-review or self-merge.
|
||||||
|
- **Never push a stable branch directly.** Worker sessions (author/reviewer/merger)
|
||||||
|
must never run `git push <remote> master` — or `main`/`dev`/other stable refs,
|
||||||
|
including refspecs (`HEAD:master`), `--force`, `--delete`, or `--dry-run` no-op
|
||||||
|
probes — and must never commit on the root/control checkout outside an issue
|
||||||
|
feature branch. Stable-branch updates land ONLY through sanctioned Gitea merge
|
||||||
|
tooling (`gitea_merge_pr`) or an explicitly authorized reconciler path. A
|
||||||
|
detected attempt marks the session workflow-contaminated (#671) and fails
|
||||||
|
closed on review/merge/close/completion until a reconciler audits and clears
|
||||||
|
it. `git fetch` / `git pull --ff-only` and feature-branch pushes stay allowed.
|
||||||
- Do not mix modes in one run.
|
- Do not mix modes in one run.
|
||||||
- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless.
|
- **BLOCKED + DIAGNOSE default rule (required):** If any required workflow step, skill, tool, capability, preflight, instruction, profile, worktree binding, or terminal/MCP operation cannot be performed or loaded (including the canonical ones listed in this skill and its loaded workflow), immediately enter `BLOCKED + DIAGNOSE`. Stop before any git or Gitea mutation. Diagnose using the standard template in [`templates/blocked-diagnose-report.md`](templates/blocked-diagnose-report.md). Attempt *only* safe non-mutating recovery. Report using the template. Do not continue, use fallbacks, or treat the missing requirement as harmless.
|
||||||
- If the required workflow cannot be loaded, stop and produce a recovery handoff
|
- If the required workflow cannot be loaded, stop and produce a recovery handoff
|
||||||
@@ -48,6 +57,11 @@ workflow file.
|
|||||||
- wrong profile or role for the requested operation
|
- wrong profile or role for the requested operation
|
||||||
- dirty or misbound worktree (root checkout or non-branches/ path)
|
- dirty or misbound worktree (root checkout or non-branches/ path)
|
||||||
- root checkout mutation risk
|
- root checkout mutation risk
|
||||||
|
- stable-branch push contamination (#671): a direct `git push <remote> master`
|
||||||
|
(or `main`/`dev`) equivalent, or a root/control-checkout commit not on an issue
|
||||||
|
feature branch, marks the session workflow-contaminated; review/merge/close/
|
||||||
|
completion mutations then fail closed until a reconciler audits and clears it
|
||||||
|
(`gitea_audit_stable_branch_contamination action=clear`, reconciler-only)
|
||||||
- mutation guard failure (e.g. branches-only guard)
|
- mutation guard failure (e.g. branches-only guard)
|
||||||
- missing required MCP tool/schema or operation
|
- missing required MCP tool/schema or operation
|
||||||
- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement)
|
- stale or inconsistent runtime state (e.g. lease vs actual, dirty state disagreement)
|
||||||
@@ -118,6 +132,42 @@ The main project checkout is a stable control checkout on `master`, `main`, or
|
|||||||
If `cwd` is not inside `branches/`, stop before any file edit, test write,
|
If `cwd` is not inside `branches/`, stop before any file edit, test write,
|
||||||
commit, merge, rebase, or cleanup. The main checkout is orchestration-only.
|
commit, merge, rebase, or cleanup. The main checkout is orchestration-only.
|
||||||
|
|
||||||
|
## Stable Branch Push Protection (#671)
|
||||||
|
|
||||||
|
Worker sessions must **never** publish a stable branch directly. This is the
|
||||||
|
prevention hardening for the #670 incident (a bare direct-to-master commit
|
||||||
|
`2fa97c26` and a PR #654 merger `git push prgs master` attempt).
|
||||||
|
|
||||||
|
**Forbidden for author/reviewer/merger sessions:**
|
||||||
|
|
||||||
|
- `git push <remote> master` (and `main`, `dev`, `develop`, `development`),
|
||||||
|
including refspecs (`HEAD:master`, `+refs/heads/x:refs/heads/master`),
|
||||||
|
`--force`, `--delete` / `:master`, and `--dry-run`/`-n` no-op probes (a
|
||||||
|
dry-run still proves intent and contaminates the session).
|
||||||
|
- Local commits on the root/control checkout that are not carried by an issue
|
||||||
|
feature branch under `branches/`.
|
||||||
|
|
||||||
|
**Allowed (never blocked):**
|
||||||
|
|
||||||
|
- `git fetch` and `git pull --ff-only` of master into the control checkout when
|
||||||
|
authorized for sync.
|
||||||
|
- Feature-branch pushes to non-stable refs (`git push <remote> fix/issue-N-...`).
|
||||||
|
- Sanctioned merges via `gitea_merge_pr` / the Gitea API merge endpoint — the
|
||||||
|
**only** way stable branches advance.
|
||||||
|
|
||||||
|
**What happens on a detected attempt:** the session is marked
|
||||||
|
workflow-contaminated (durable `stable_branch_contamination` marker, redacted
|
||||||
|
command summary + session id + remote + ref). While contaminated, all
|
||||||
|
review / merge / close / issue-completion mutations fail closed. `comment_issue`
|
||||||
|
and `lock_issue` remain allowed so the contaminated worker can post the durable
|
||||||
|
audit comment and hand off. Contamination **cannot be self-cleared** — only a
|
||||||
|
reconciler audit (`gitea_audit_stable_branch_contamination action=clear`) may
|
||||||
|
clear it.
|
||||||
|
|
||||||
|
Tooling: call `gitea_record_stable_branch_push_attempt` to classify/record a
|
||||||
|
proposed push before running it; `gitea_audit_stable_branch_contamination` to
|
||||||
|
inspect or (reconciler-only) clear the marker.
|
||||||
|
|
||||||
## Shell Spawn Hard-Stop Rule
|
## Shell Spawn Hard-Stop Rule
|
||||||
|
|
||||||
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""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 <remote> 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 <remote> 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 <remote>`` (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}"
|
||||||
@@ -60,6 +60,15 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.close",
|
"permission": "gitea.pr.close",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
},
|
},
|
||||||
|
# Non-closing PR metadata edits (title/body/base). Closing uses close_pr.
|
||||||
|
"edit_pr": {
|
||||||
|
"permission": "gitea.pr.create",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
|
"gitea_edit_pr": {
|
||||||
|
"permission": "gitea.pr.create",
|
||||||
|
"role": "author",
|
||||||
|
},
|
||||||
"address_pr_change_requests": {
|
"address_pr_change_requests": {
|
||||||
"permission": "gitea.branch.push",
|
"permission": "gitea.branch.push",
|
||||||
"role": "author",
|
"role": "author",
|
||||||
@@ -68,10 +77,22 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
|
|||||||
"permission": "gitea.pr.review",
|
"permission": "gitea.pr.review",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
},
|
},
|
||||||
|
"submit_pr_review": {
|
||||||
|
"permission": "gitea.pr.review",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
"merge_pr": {
|
"merge_pr": {
|
||||||
"permission": "gitea.pr.merge",
|
"permission": "gitea.pr.merge",
|
||||||
"role": "merger",
|
"role": "merger",
|
||||||
},
|
},
|
||||||
|
"acquire_reviewer_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
|
"gitea_acquire_reviewer_pr_lease": {
|
||||||
|
"permission": "gitea.pr.comment",
|
||||||
|
"role": "reviewer",
|
||||||
|
},
|
||||||
"adopt_merger_pr_lease": {
|
"adopt_merger_pr_lease": {
|
||||||
"permission": "gitea.pr.comment",
|
"permission": "gitea.pr.comment",
|
||||||
"role": "reviewer",
|
"role": "reviewer",
|
||||||
|
|||||||
+10
-1
@@ -26,7 +26,16 @@ def _reset_mutation_authority(monkeypatch):
|
|||||||
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
Pin ``default_state_dir`` / ``DEFAULT_STATE_DIR`` to a per-test temp dir
|
||||||
so durable load/save never touches host state even after env clears.
|
so durable load/save never touches host state even after env clears.
|
||||||
"""
|
"""
|
||||||
monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False)
|
for env_key in [
|
||||||
|
"GITEA_SESSION_PROFILE_LOCK",
|
||||||
|
"GITEA_ACTIVE_WORKTREE",
|
||||||
|
"GITEA_AUTHOR_WORKTREE",
|
||||||
|
"GITEA_REVIEWER_WORKTREE",
|
||||||
|
"GITEA_MERGER_WORKTREE",
|
||||||
|
"GITEA_RECONCILER_WORKTREE",
|
||||||
|
]:
|
||||||
|
monkeypatch.delenv(env_key, raising=False)
|
||||||
|
|
||||||
# Isolate durable session-state files so tests never share host cache (#559).
|
# Isolate durable session-state files so tests never share host cache (#559).
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -82,6 +82,7 @@ class TestPreflightIntegration(unittest.TestCase):
|
|||||||
control_root = "/repo/Gitea-Tools"
|
control_root = "/repo/Gitea-Tools"
|
||||||
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
|
||||||
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
with mock.patch.object(mcp_server, "_enforce_root_checkout_guard"):
|
||||||
|
with mock.patch("gitea_auth.get_profile", return_value={"profile_name": "gitea-author"}):
|
||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
"os.environ",
|
"os.environ",
|
||||||
{"GITEA_TEST_PORCELAIN": ""},
|
{"GITEA_TEST_PORCELAIN": ""},
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import gitea_mcp_server as srv
|
|||||||
|
|
||||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||||
# Stable control checkout (parent of branches/), not the MCP server worktree root.
|
# Stable control checkout (parent of branches/), not the MCP server worktree root.
|
||||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
current_file_path = Path(__file__).resolve()
|
||||||
|
if "branches" in current_file_path.parts:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||||
|
else:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||||
PROJECT_ROOT = srv.PROJECT_ROOT
|
PROJECT_ROOT = srv.PROJECT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import gitea_mcp_server as srv # noqa: E402
|
|||||||
import root_checkout_guard as rcg # noqa: E402
|
import root_checkout_guard as rcg # noqa: E402
|
||||||
|
|
||||||
FAKE_AUTH = "token test"
|
FAKE_AUTH = "token test"
|
||||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
current_file_path = Path(__file__).resolve()
|
||||||
|
if "branches" in current_file_path.parts:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||||
|
else:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||||
MASTER_SHA = "a" * 40
|
MASTER_SHA = "a" * 40
|
||||||
OTHER_SHA = "b" * 40
|
OTHER_SHA = "b" * 40
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import gitea_mcp_server as srv
|
|||||||
|
|
||||||
|
|
||||||
FAKE_AUTH = {"Authorization": "token test-token"}
|
FAKE_AUTH = {"Authorization": "token test-token"}
|
||||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
current_file_path = Path(__file__).resolve()
|
||||||
|
if "branches" in current_file_path.parts:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||||
|
else:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||||
|
|
||||||
|
|
||||||
class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
class TestIssueCommentWorkspaceGuard(unittest.TestCase):
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class TestMcpDaemonGuard(unittest.TestCase):
|
|||||||
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
|
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
|
||||||
"PYTEST_CURRENT_TEST",
|
"PYTEST_CURRENT_TEST",
|
||||||
}}
|
}}
|
||||||
|
env["GITEA_TEST_FORCE_UNSANCTIONED"] = "1"
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
mcp_daemon_guard.assert_sanctioned_mutation_runtime("test")
|
mcp_daemon_guard.assert_sanctioned_mutation_runtime("test")
|
||||||
@@ -43,6 +44,7 @@ class TestMcpDaemonGuard(unittest.TestCase):
|
|||||||
"PYTEST_CURRENT_TEST",
|
"PYTEST_CURRENT_TEST",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
env["GITEA_TEST_FORCE_UNSANCTIONED"] = "1"
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
mcp_daemon_guard.assert_keychain_access_allowed()
|
mcp_daemon_guard.assert_keychain_access_allowed()
|
||||||
@@ -58,6 +60,7 @@ class TestMcpDaemonGuard(unittest.TestCase):
|
|||||||
"PYTEST_CURRENT_TEST",
|
"PYTEST_CURRENT_TEST",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
env["GITEA_TEST_FORCE_UNSANCTIONED"] = "1"
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
|
||||||
gitea_auth.get_auth_header("gitea.prgs.cc")
|
gitea_auth.get_auth_header("gitea.prgs.cc")
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ import gitea_config # noqa: E402
|
|||||||
|
|
||||||
import mcp_server
|
import mcp_server
|
||||||
import issue_lock_store
|
import issue_lock_store
|
||||||
|
import gitea_auth
|
||||||
|
|
||||||
|
_orig_api_request = gitea_auth.api_request
|
||||||
|
def mockable_api_request(*args, **kwargs):
|
||||||
|
import mcp_server
|
||||||
|
return mcp_server.api_request(*args, **kwargs)
|
||||||
|
|
||||||
|
def setUpModule():
|
||||||
|
gitea_auth.api_request = mockable_api_request
|
||||||
|
patch("mcp_server._enforce_root_checkout_guard").start()
|
||||||
|
patch("mcp_server._enforce_branches_only_author_mutation").start()
|
||||||
|
|
||||||
|
def tearDownModule():
|
||||||
|
gitea_auth.api_request = _orig_api_request
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
|
||||||
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
|
||||||
FULL_HEAD_SHA = "a" * 40
|
FULL_HEAD_SHA = "a" * 40
|
||||||
@@ -1364,11 +1380,11 @@ class TestReviewPR(unittest.TestCase):
|
|||||||
self.assertIn("Review/Merge Blocked", result["message"])
|
self.assertIn("Review/Merge Blocked", result["message"])
|
||||||
self.assertIn("Author profile", result["message"])
|
self.assertIn("Author profile", result["message"])
|
||||||
|
|
||||||
@patch("mcp_server.api_get_all")
|
@patch("mcp_server.api_fetch_page")
|
||||||
@patch("mcp_server.api_request")
|
@patch("mcp_server.api_request")
|
||||||
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
|
||||||
@patch("mcp_server.get_profile")
|
@patch("mcp_server.get_profile")
|
||||||
def test_legacy_review_pr_self_approval_blocked(self, mock_get_profile, _auth, mock_api, mock_get_all):
|
def test_legacy_review_pr_self_approval_blocked(self, mock_get_profile, _auth, mock_api, mock_fetch):
|
||||||
mock_get_profile.return_value = {
|
mock_get_profile.return_value = {
|
||||||
"profile_name": "gitea-reviewer",
|
"profile_name": "gitea-reviewer",
|
||||||
"allowed_operations": ["read", "approve"],
|
"allowed_operations": ["read", "approve"],
|
||||||
@@ -1376,7 +1392,10 @@ class TestReviewPR(unittest.TestCase):
|
|||||||
"base_url": None,
|
"base_url": None,
|
||||||
}
|
}
|
||||||
head_sha = FULL_HEAD_SHA
|
head_sha = FULL_HEAD_SHA
|
||||||
mock_get_all.return_value = [{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
|
mock_fetch.return_value = (
|
||||||
|
[{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}],
|
||||||
|
{"page": 1, "per_page": 50, "returned_count": 1, "has_more": False, "next_page": None, "is_final_page": True}
|
||||||
|
)
|
||||||
# mock_api responses: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility)
|
# mock_api responses: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility)
|
||||||
mock_api.side_effect = [
|
mock_api.side_effect = [
|
||||||
{"login": "jcwalker3"}, # /api/v1/user (inventory)
|
{"login": "jcwalker3"}, # /api/v1/user (inventory)
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||||||
import gitea_mcp_server as srv
|
import gitea_mcp_server as srv
|
||||||
|
|
||||||
FAKE_AUTH = "token test"
|
FAKE_AUTH = "token test"
|
||||||
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
|
current_file_path = Path(__file__).resolve()
|
||||||
|
if "branches" in current_file_path.parts:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[3])
|
||||||
|
else:
|
||||||
|
CONTROL_CHECKOUT_ROOT = str(current_file_path.parents[1])
|
||||||
RECONCILER_PROFILE = {
|
RECONCILER_PROFILE = {
|
||||||
"profile_name": "prgs-reconciler",
|
"profile_name": "prgs-reconciler",
|
||||||
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"],
|
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"],
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||||||
import gitea_mcp_server as srv # noqa: E402
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
import root_checkout_guard as rcg # noqa: E402
|
import root_checkout_guard as rcg # noqa: E402
|
||||||
|
|
||||||
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
|
current_file_path = Path(__file__).resolve()
|
||||||
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
|
if "branches" in current_file_path.parts:
|
||||||
|
CONTROL_ROOT = str(current_file_path.parents[3])
|
||||||
|
BRANCHES_WORKTREE = str(current_file_path.parents[1])
|
||||||
|
else:
|
||||||
|
CONTROL_ROOT = str(current_file_path.parents[1])
|
||||||
|
BRANCHES_WORKTREE = str(current_file_path.parents[1] / "branches" / "mock-worktree")
|
||||||
MASTER_SHA = "a" * 40
|
MASTER_SHA = "a" * 40
|
||||||
OTHER_SHA = "b" * 40
|
OTHER_SHA = "b" * 40
|
||||||
|
|
||||||
@@ -136,13 +141,21 @@ class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
|
|||||||
self.assertIn("Root checkout guard (#475)", str(ctx.exception))
|
self.assertIn("Root checkout guard (#475)", str(ctx.exception))
|
||||||
self.assertIn(rcg.REMEDIATION, str(ctx.exception))
|
self.assertIn(rcg.REMEDIATION, str(ctx.exception))
|
||||||
|
|
||||||
|
@patch("os.path.isdir", return_value=True)
|
||||||
|
@patch("os.path.exists", return_value=True)
|
||||||
|
@patch("subprocess.run")
|
||||||
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
@patch("gitea_mcp_server._get_workspace_porcelain", return_value="")
|
||||||
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
|
@patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA)
|
||||||
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
|
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state")
|
||||||
@patch("gitea_mcp_server._resolve_author_mutation_context")
|
@patch("gitea_mcp_server._resolve_author_mutation_context")
|
||||||
def test_reviewer_from_branches_worktree_allowed(
|
def test_reviewer_from_branches_worktree_allowed(
|
||||||
self, mock_ctx, mock_git, _remote_sha, _porcelain,
|
self, mock_ctx, mock_git, _remote_sha, _porcelain, mock_run, _exists, _isdir,
|
||||||
):
|
):
|
||||||
|
import unittest.mock
|
||||||
|
mock_run.return_value = unittest.mock.MagicMock(
|
||||||
|
returncode=0,
|
||||||
|
stdout=f"{CONTROL_ROOT}/.git\n",
|
||||||
|
)
|
||||||
srv._preflight_capability_baseline_porcelain = ""
|
srv._preflight_capability_baseline_porcelain = ""
|
||||||
mock_ctx.return_value = {
|
mock_ctx.return_value = {
|
||||||
"workspace_path": BRANCHES_WORKTREE,
|
"workspace_path": BRANCHES_WORKTREE,
|
||||||
@@ -156,6 +169,5 @@ class TestVerifyPreflightRootGuardIntegration(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE)
|
srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""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")
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""Tests for the direct stable-branch push guard (#671).
|
||||||
|
|
||||||
|
Covers the acceptance criteria:
|
||||||
|
1. detect shell ``git push <remote> master`` equivalents
|
||||||
|
2. detect root/control-checkout local commits not on an issue branch
|
||||||
|
3. contamination record shape
|
||||||
|
4. fail-closed gate on review/merge/close/completion (reconciler-exempt)
|
||||||
|
5. AC5 case matrix: no-op dry-run push, real direct push, sanctioned Gitea
|
||||||
|
merge, fetch-only, root-checkout local commit; plus feature-branch push
|
||||||
|
still allowed and sanctioned merge still allowed
|
||||||
|
6. redaction of credentials in logged command summaries
|
||||||
|
"""
|
||||||
|
|
||||||
|
import stable_branch_push_guard as guard
|
||||||
|
|
||||||
|
|
||||||
|
# ── AC1: detect direct stable-branch push equivalents ────────────────────────
|
||||||
|
|
||||||
|
def test_plain_git_push_remote_master_is_contamination():
|
||||||
|
res = guard.classify_push_command("git push prgs master")
|
||||||
|
assert res["is_git_push"] is True
|
||||||
|
assert res["targets_stable"] is True
|
||||||
|
assert res["stable_refs"] == ["master"]
|
||||||
|
assert res["contamination"] is True
|
||||||
|
assert res["reasons"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_main_and_dev_detected():
|
||||||
|
for ref in ("main", "dev", "develop", "development"):
|
||||||
|
res = guard.classify_push_command(f"git push origin {ref}")
|
||||||
|
assert res["contamination"] is True, ref
|
||||||
|
assert res["stable_refs"] == [ref]
|
||||||
|
|
||||||
|
|
||||||
|
def test_head_colon_master_refspec_detected():
|
||||||
|
res = guard.classify_push_command("git push prgs HEAD:master")
|
||||||
|
assert res["targets_stable"] is True
|
||||||
|
assert res["stable_refs"] == ["master"]
|
||||||
|
assert res["contamination"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_refspec_force_plus_detected():
|
||||||
|
res = guard.classify_push_command(
|
||||||
|
"git push prgs +refs/heads/tmp:refs/heads/master"
|
||||||
|
)
|
||||||
|
assert res["contamination"] is True
|
||||||
|
assert res["is_force"] is True
|
||||||
|
assert res["stable_refs"] == ["master"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_force_flag_to_master_detected():
|
||||||
|
res = guard.classify_push_command("git push --force prgs master")
|
||||||
|
assert res["contamination"] is True
|
||||||
|
assert res["is_force"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_refspec_master_detected():
|
||||||
|
res = guard.classify_push_command("git push prgs :master")
|
||||||
|
assert res["contamination"] is True
|
||||||
|
assert res["is_delete"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_flag_master_detected():
|
||||||
|
res = guard.classify_push_command("git push --delete prgs master")
|
||||||
|
assert res["contamination"] is True
|
||||||
|
assert res["is_delete"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_push_detected_inside_compound_command():
|
||||||
|
res = guard.classify_push_command("cd repo && git push prgs master && echo ok")
|
||||||
|
assert res["contamination"] is True
|
||||||
|
assert res["stable_refs"] == ["master"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── AC5: no-op / dry-run push still proves intent ────────────────────────────
|
||||||
|
|
||||||
|
def test_dry_run_push_to_master_proves_intent():
|
||||||
|
res = guard.classify_push_command("git push --dry-run prgs master")
|
||||||
|
assert res["is_dry_run"] is True
|
||||||
|
assert res["contamination"] is True
|
||||||
|
assert res["proves_intent"] is True
|
||||||
|
assert "intent" in " ".join(res["reasons"]).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_dry_run_flag_n_detected():
|
||||||
|
res = guard.classify_push_command("git push -n prgs master")
|
||||||
|
assert res["is_dry_run"] is True
|
||||||
|
assert res["contamination"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── AC5 negative: feature-branch push still allowed ──────────────────────────
|
||||||
|
|
||||||
|
def test_feature_branch_push_not_flagged():
|
||||||
|
res = guard.classify_push_command(
|
||||||
|
"git push prgs fix/issue-671-block-stable-branch-push"
|
||||||
|
)
|
||||||
|
assert res["is_git_push"] is True
|
||||||
|
assert res["targets_stable"] is False
|
||||||
|
assert res["contamination"] is False
|
||||||
|
assert res["reasons"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_branch_head_refspec_not_flagged():
|
||||||
|
res = guard.classify_push_command(
|
||||||
|
"git push prgs HEAD:fix/issue-671-block-stable-branch-push"
|
||||||
|
)
|
||||||
|
assert res["contamination"] is False
|
||||||
|
assert res["targets_stable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_branch_named_like_master_substring_not_flagged():
|
||||||
|
# 'master-notes' is not the stable 'master'.
|
||||||
|
res = guard.classify_push_command("git push prgs master-notes")
|
||||||
|
assert res["contamination"] is False
|
||||||
|
assert res["targets_stable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── AC5 negative: fetch-only operations ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_git_fetch_not_a_push():
|
||||||
|
res = guard.classify_push_command("git fetch prgs")
|
||||||
|
assert res["is_git_push"] is False
|
||||||
|
assert res["is_fetch_or_pull"] is True
|
||||||
|
assert res["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_pull_ff_only_master_not_a_push():
|
||||||
|
res = guard.classify_push_command("git pull --ff-only prgs master")
|
||||||
|
assert res["is_git_push"] is False
|
||||||
|
assert res["is_fetch_or_pull"] is True
|
||||||
|
assert res["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── AC5 negative: sanctioned Gitea merge is not a push ───────────────────────
|
||||||
|
|
||||||
|
def test_gitea_merge_pr_tool_is_not_a_push():
|
||||||
|
res = guard.classify_push_command("gitea_merge_pr(pr_number=671, remote='prgs')")
|
||||||
|
assert res["is_git_push"] is False
|
||||||
|
assert res["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_api_merge_is_not_a_push():
|
||||||
|
res = guard.classify_push_command(
|
||||||
|
"curl -X POST https://gitea.example/api/v1/repos/o/r/pulls/671/merge"
|
||||||
|
)
|
||||||
|
assert res["is_git_push"] is False
|
||||||
|
assert res["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── ambiguous bare push: reported, not auto-contaminating ────────────────────
|
||||||
|
|
||||||
|
def test_bare_push_is_ambiguous_not_contaminating():
|
||||||
|
res = guard.classify_push_command("git push prgs")
|
||||||
|
assert res["is_git_push"] is True
|
||||||
|
assert res["ambiguous_target"] is True
|
||||||
|
assert res["contamination"] is False
|
||||||
|
assert res["reasons"] # surfaced as a warning
|
||||||
|
|
||||||
|
|
||||||
|
# ── AC2: root/control-checkout local commit detection ────────────────────────
|
||||||
|
|
||||||
|
def test_root_checkout_commit_ahead_of_master_flagged():
|
||||||
|
res = guard.assess_root_checkout_local_commit(
|
||||||
|
current_branch="master",
|
||||||
|
head_sha="a" * 40,
|
||||||
|
remote_master_sha="b" * 40,
|
||||||
|
is_under_branches=False,
|
||||||
|
)
|
||||||
|
assert res["contamination"] is True
|
||||||
|
assert res["reasons"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_checkout_clean_at_master_not_flagged():
|
||||||
|
sha = "c" * 40
|
||||||
|
res = guard.assess_root_checkout_local_commit(
|
||||||
|
current_branch="master",
|
||||||
|
head_sha=sha,
|
||||||
|
remote_master_sha=sha,
|
||||||
|
is_under_branches=False,
|
||||||
|
)
|
||||||
|
assert res["contamination"] is False
|
||||||
|
assert res["unknown"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_branches_worktree_commit_is_exempt():
|
||||||
|
res = guard.assess_root_checkout_local_commit(
|
||||||
|
current_branch="fix/issue-671-block-stable-branch-push",
|
||||||
|
head_sha="a" * 40,
|
||||||
|
remote_master_sha="b" * 40,
|
||||||
|
is_under_branches=True,
|
||||||
|
)
|
||||||
|
assert res["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_checkout_ahead_count_flagged():
|
||||||
|
res = guard.assess_root_checkout_local_commit(
|
||||||
|
current_branch="master",
|
||||||
|
head_sha="",
|
||||||
|
remote_master_sha="",
|
||||||
|
is_under_branches=False,
|
||||||
|
ahead_count=2,
|
||||||
|
)
|
||||||
|
assert res["contamination"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_checkout_unknown_when_state_missing():
|
||||||
|
res = guard.assess_root_checkout_local_commit(
|
||||||
|
current_branch="master",
|
||||||
|
head_sha="",
|
||||||
|
remote_master_sha="",
|
||||||
|
is_under_branches=False,
|
||||||
|
)
|
||||||
|
assert res["contamination"] is False
|
||||||
|
assert res["unknown"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_checkout_non_stable_branch_out_of_scope():
|
||||||
|
res = guard.assess_root_checkout_local_commit(
|
||||||
|
current_branch="feature/x",
|
||||||
|
head_sha="a" * 40,
|
||||||
|
remote_master_sha="b" * 40,
|
||||||
|
is_under_branches=False,
|
||||||
|
)
|
||||||
|
assert res["contamination"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── AC3: contamination record shape ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_build_contamination_record_shape_and_redaction():
|
||||||
|
rec = guard.build_contamination_record(
|
||||||
|
reason_class="stable_branch_push",
|
||||||
|
command_redacted="git push https://user:[email protected]/o/r.git master",
|
||||||
|
session_id="prgs-author-123",
|
||||||
|
remote="prgs",
|
||||||
|
ref="master",
|
||||||
|
role="author",
|
||||||
|
)
|
||||||
|
assert rec["kind"] == guard.CONTAMINATION_KIND
|
||||||
|
assert rec["reason_class"] == "stable_branch_push"
|
||||||
|
assert rec["remote"] == "prgs"
|
||||||
|
assert rec["ref"] == "master"
|
||||||
|
assert rec["cleared_by_reconciler"] is False
|
||||||
|
# secret must never survive into the durable record
|
||||||
|
assert "tok@" not in rec["command_summary"]
|
||||||
|
assert "***@" in rec["command_summary"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── AC4: fail-closed gate on gated mutations ─────────────────────────────────
|
||||||
|
|
||||||
|
def _marker():
|
||||||
|
return guard.build_contamination_record(
|
||||||
|
reason_class="stable_branch_push",
|
||||||
|
command_redacted="git push prgs master",
|
||||||
|
remote="prgs",
|
||||||
|
ref="master",
|
||||||
|
role="author",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_blocks_gated_mutations_for_author():
|
||||||
|
marker = _marker()
|
||||||
|
for task in ("create_pr", "merge_pr", "close_issue", "review_pr",
|
||||||
|
"submit_pr_review", "commit_files", "close_pr"):
|
||||||
|
gate = guard.assess_contamination_gate(marker, task=task, actual_role="author")
|
||||||
|
assert gate["block"] is True, task
|
||||||
|
assert gate["reasons"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_allows_comment_and_lock_for_handoff():
|
||||||
|
marker = _marker()
|
||||||
|
for task in ("comment_issue", "lock_issue", "create_issue", "mark_issue"):
|
||||||
|
gate = guard.assess_contamination_gate(marker, task=task, actual_role="author")
|
||||||
|
assert gate["block"] is False, task
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_exempts_reconciler_audit_path():
|
||||||
|
marker = _marker()
|
||||||
|
gate = guard.assess_contamination_gate(marker, task="close_pr", actual_role="reconciler")
|
||||||
|
assert gate["block"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_no_marker_allows_everything():
|
||||||
|
gate = guard.assess_contamination_gate(None, task="merge_pr", actual_role="merger")
|
||||||
|
assert gate["block"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_cleared_marker_allows_everything():
|
||||||
|
marker = _marker()
|
||||||
|
marker["cleared_by_reconciler"] = True
|
||||||
|
gate = guard.assess_contamination_gate(marker, task="merge_pr", actual_role="merger")
|
||||||
|
assert gate["block"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_worker_cannot_self_clear_by_role():
|
||||||
|
# A merger/reviewer/author role is still gated — only reconciler is exempt.
|
||||||
|
marker = _marker()
|
||||||
|
for role in ("author", "reviewer", "merger"):
|
||||||
|
gate = guard.assess_contamination_gate(marker, task="merge_pr", actual_role=role)
|
||||||
|
assert gate["block"] is True, role
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_gate_error_mentions_issue():
|
||||||
|
marker = _marker()
|
||||||
|
gate = guard.assess_contamination_gate(marker, task="merge_pr", actual_role="merger")
|
||||||
|
msg = guard.format_contamination_gate_error(gate)
|
||||||
|
assert "#671" in msg
|
||||||
|
assert "contaminat" in msg.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── redaction unit coverage ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_redact_url_userinfo():
|
||||||
|
out = guard.redact_command("git push https://bob:secretpat@host/o/r.git master")
|
||||||
|
assert "secretpat" not in out
|
||||||
|
assert "***@" in out
|
||||||
|
assert "master" in out # structure preserved for audit
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_token_assignment():
|
||||||
|
out = guard.redact_command("GITEA_TOKEN=abcdef123456 git push prgs master")
|
||||||
|
assert "abcdef123456" not in out
|
||||||
|
assert "GITEA_TOKEN=***" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_redact_empty():
|
||||||
|
assert guard.redact_command(None) == ""
|
||||||
|
assert guard.redact_command("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── detect_stable_push over iterables ────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_detect_stable_push_iterable_finds_first_contamination():
|
||||||
|
cmds = ["git status", "git push prgs master", "echo done"]
|
||||||
|
res = guard.detect_stable_push(cmds)
|
||||||
|
assert res["contamination"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_stable_push_iterable_all_safe():
|
||||||
|
cmds = ["git status", "git push prgs feature/x", "git fetch prgs"]
|
||||||
|
res = guard.detect_stable_push(cmds)
|
||||||
|
assert res["contamination"] is False
|
||||||
@@ -14,8 +14,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||||||
import author_mutation_worktree as amw # noqa: E402
|
import author_mutation_worktree as amw # noqa: E402
|
||||||
import gitea_mcp_server as srv # noqa: E402
|
import gitea_mcp_server as srv # noqa: E402
|
||||||
|
|
||||||
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
|
current_file_path = Path(__file__).resolve()
|
||||||
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
|
if "branches" in current_file_path.parts:
|
||||||
|
CONTROL_ROOT = str(current_file_path.parents[3])
|
||||||
|
BRANCHES_WORKTREE = str(current_file_path.parents[1])
|
||||||
|
else:
|
||||||
|
CONTROL_ROOT = str(current_file_path.parents[1])
|
||||||
|
BRANCHES_WORKTREE = str(current_file_path.parents[1] / "branches" / "mock-worktree")
|
||||||
MCP_PROCESS_ROOT = BRANCHES_WORKTREE
|
MCP_PROCESS_ROOT = BRANCHES_WORKTREE
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +100,23 @@ class TestRuntimeContextGuardAlignment(unittest.TestCase):
|
|||||||
srv._preflight_in_test_mode = self._orig_in_test
|
srv._preflight_in_test_mode = self._orig_in_test
|
||||||
self._env_patch.stop()
|
self._env_patch.stop()
|
||||||
|
|
||||||
def test_runtime_context_and_guard_share_resolved_workspace(self):
|
@mock.patch("subprocess.run")
|
||||||
|
@mock.patch("os.path.isdir", return_value=True)
|
||||||
|
@mock.patch("os.path.exists", return_value=True)
|
||||||
|
def test_runtime_context_and_guard_share_resolved_workspace(
|
||||||
|
self, _exists, _isdir, mock_run
|
||||||
|
):
|
||||||
|
def run_side_effect(cmd, *args, **kwargs):
|
||||||
|
res = MagicMock(returncode=0)
|
||||||
|
if "--git-common-dir" in cmd:
|
||||||
|
res.stdout = f"{CONTROL_ROOT}/.git\n"
|
||||||
|
elif "--show-toplevel" in cmd:
|
||||||
|
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
|
||||||
|
res.stdout = f"{cwd}\n"
|
||||||
|
else:
|
||||||
|
res.stdout = f"{CONTROL_ROOT}\n"
|
||||||
|
return res
|
||||||
|
mock_run.side_effect = run_side_effect
|
||||||
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
|
||||||
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
|
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
|
||||||
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ from merged_cleanup_reconcile import (
|
|||||||
read_issue_lock,
|
read_issue_lock,
|
||||||
read_local_worktree_state,
|
read_local_worktree_state,
|
||||||
)
|
)
|
||||||
|
from reviewer_worktree import REVIEW_WORKTREE_RE
|
||||||
_REVIEW_WORKTREE_RE = re.compile(
|
|
||||||
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
CLASSIFICATIONS = frozenset({
|
CLASSIFICATIONS = frozenset({
|
||||||
"active-pr",
|
"active-pr",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
|
from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state
|
||||||
from reviewer_worktree import parse_dirty_tracked_files
|
from reviewer_worktree import parse_dirty_tracked_files, REVIEW_WORKTREE_RE
|
||||||
|
|
||||||
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
|
||||||
DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24)
|
DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24)
|
||||||
@@ -508,10 +508,8 @@ DISPOSITIONS = frozenset({
|
|||||||
"unsafe_unknown",
|
"unsafe_unknown",
|
||||||
})
|
})
|
||||||
|
|
||||||
REVIEW_WORKTREE_RE = re.compile(
|
|
||||||
r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_path(path: str) -> str:
|
def normalize_path(path: str) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user