feat: common anti-stomp preflight before every mutation tool (Closes #604) #680

Open
jcwalker3 wants to merge 2 commits from feat/issue-604-anti-stomp-preflight into master
3 changed files with 1534 additions and 1 deletions
Showing only changes of commit ea8e186549 - Show all commits
+705
View File
@@ -0,0 +1,705 @@
"""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 this preflight before acting.
# Covers create issue/comment, lease acquire, submit review, approve,
# request changes, merge, cleanup, and label mutation (issue #604 AC1).
MUTATION_TASKS = frozenset({
"create_issue",
"comment_issue",
"close_issue",
"claim_issue",
"mark_issue",
"lock_issue",
"set_issue_labels",
"create_label",
"create_pr",
"comment_pr",
"close_pr",
"commit_files",
"gitea_commit_files",
"create_branch",
"push_branch",
"delete_branch",
"cleanup_merged_pr_branch",
"cleanup_stale_claims",
"cleanup_stale_review_decision_lock",
"gitea_cleanup_stale_review_decision_lock",
"reconcile_merged_cleanups",
"reconcile_already_landed_pr",
"reconcile_close_superseded_pr",
"reconciliation_cleanup",
"post_heartbeat",
"acquire_reviewer_pr_lease",
"gitea_acquire_reviewer_pr_lease",
"adopt_merger_pr_lease",
"heartbeat_reviewer_pr_lease",
"release_reviewer_pr_lease",
"review_pr",
"submit_pr_review",
"approve_pr",
"request_changes_pr",
"merge_pr",
"mark_final_review_decision",
"save_review_draft",
"resume_review_draft",
})
# 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",
"mark_final_review_decision",
"acquire_reviewer_pr_lease",
"gitea_acquire_reviewer_pr_lease",
"adopt_merger_pr_lease",
"heartbeat_reviewer_pr_lease",
"release_reviewer_pr_lease",
"resume_review_draft",
})
_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.
"""
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 _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
profile_name: str | None = None,
profile_role: str | None = None,
required_role: str | None = 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
checks: dict[str, Any] = {
"task": task_name or None,
"profile_name": profile_name,
"profile_role": role,
"required_role": req_role,
}
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 ─────────────────────────────────────────────────────────
if check_role and req_role and role:
if not roles_compatible(role, req_role):
reasons = [
f"active profile role '{role}' does not match required role "
f"'{req_role}' for task '{task_name or '(unknown)'}' "
f"(profile={profile_name or '(unknown)'})"
]
checks["role"] = {"block": True, "reasons": reasons}
blockers.append(
_blocker(
BLOCKER_WRONG_ROLE,
reasons,
detail={
"profile_name": profile_name,
"profile_role": role,
"required_role": req_role,
},
)
)
else:
checks["role"] = {"block": False, "reasons": []}
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, []
+263 -1
View File
@@ -619,18 +619,212 @@ 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()
req_role = None
if task:
try:
req_role = task_capability_map.required_role(task)
except KeyError:
req_role = _preflight_resolved_role
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",
"mark_final_review_decision",
}:
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,
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(
remote: str | None = None,
worktree_path: 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
in_test = _preflight_in_test_mode()
if in_test and not (
os.environ.get("GITEA_TEST_FORCE_DIRTY")
or os.environ.get("GITEA_TEST_PORCELAIN") is not None
or os.environ.get("GITEA_TEST_FORCE_ANTI_STOMP")
):
return
@@ -717,6 +911,30 @@ def verify_preflight_purity(
_enforce_root_checkout_guard(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()
@@ -930,6 +1148,7 @@ import author_mutation_worktree # noqa: E402
import root_checkout_guard # noqa: E402
import stable_branch_push_guard # noqa: E402
import remote_repo_guard # noqa: E402
import anti_stomp_preflight # noqa: E402
import issue_claim_heartbeat # noqa: E402
import issue_work_duplicate_gate # noqa: E402
import issue_workflow_labels # noqa: E402
@@ -3833,6 +4052,30 @@ def _evaluate_pr_review_submission(
result["pr_work_lease"] = lease_block
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():
gate = _canonical_comment_gate(body, context="pr_review")
if gate["blocked"]:
@@ -5269,6 +5512,25 @@ def gitea_merge_pr(
reasons.extend(lease_block.get("reasons") or [])
result["pr_work_lease"] = lease_block
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:
reasons.append(
"expected head SHA does not match current PR head (fail closed)"
+566
View File
@@ -0,0 +1,566 @@
"""Regression coverage for the common anti-stomp preflight (#604).
Acceptance criteria:
1. All mutation tools call the common preflight (wired via
``verify_preflight_purity`` / ``_run_anti_stomp_preflight``).
2. Failure returns a typed blocker and exact next action.
3. Tests cover wrong repo defaulting to Timesheet, stale runtime, wrong
worktree, foreign lease, terminal lock, and contaminated approval.
4. No mutation can proceed using stale prompt data if live state disagrees.
5. Existing successful paths continue to work (happy-path assessment).
"""
from __future__ import annotations
import os
import unittest
from unittest import mock
import anti_stomp_preflight as asp
LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
STARTUP = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
ADVANCED = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
HEAD_A = "1111111111111111111111111111111111111111"
HEAD_B = "2222222222222222222222222222222222222222"
def _happy_kwargs(**overrides):
base = dict(
task="create_issue",
remote="prgs",
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Gitea-Tools",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=True,
repo_explicit=True,
profile_name="prgs-author",
profile_role="author",
required_role="author",
workspace_path="/repo/branches/issue-604",
project_root="/repo",
current_branch="feat/issue-604-anti-stomp-preflight",
root_head_sha=STARTUP,
root_porcelain="",
remote_master_sha=STARTUP,
startup_head=STARTUP,
current_code_head=STARTUP,
source_contaminated=False,
manual_bypass_attempted=False,
)
base.update(overrides)
return base
class TestIsMutationTask(unittest.TestCase):
def test_core_mutation_tasks(self):
for task in (
"create_issue",
"comment_issue",
"set_issue_labels",
"acquire_reviewer_pr_lease",
"submit_pr_review",
"approve_pr",
"request_changes_pr",
"merge_pr",
"cleanup_stale_claims",
"delete_branch",
):
self.assertTrue(asp.is_mutation_task(task), task)
def test_gitea_prefix_accepted(self):
self.assertTrue(asp.is_mutation_task("gitea_create_issue"))
self.assertTrue(asp.is_mutation_task("gitea_merge_pr"))
def test_read_only_not_mutation(self):
self.assertFalse(asp.is_mutation_task("list_prs"))
self.assertFalse(asp.is_mutation_task("whoami"))
self.assertFalse(asp.is_mutation_task(""))
self.assertFalse(asp.is_mutation_task(None))
class TestHappyPath(unittest.TestCase):
def test_allowed_when_all_checks_pass(self):
result = asp.assess_anti_stomp_preflight(**_happy_kwargs())
self.assertTrue(result["allowed"])
self.assertFalse(result["block"])
self.assertEqual(result["blockers"], [])
self.assertEqual(result["exact_next_action"], "proceed")
self.assertIsNone(result["blocker_kind"])
def test_reviewer_happy_path_skips_author_worktree(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="review_pr",
profile_name="prgs-reviewer",
profile_role="reviewer",
required_role="reviewer",
# Under branches/ the root guard short-circuits for non-merger.
workspace_path="/repo/branches/review-pr-42",
project_root="/repo",
current_branch="review/pr-42",
foreign_lease=False,
terminal_lock_blocks=False,
expected_head_sha=HEAD_A,
live_head_sha=HEAD_A,
workflow_hash_valid=True,
)
)
self.assertTrue(result["allowed"], result.get("reasons"))
self.assertTrue(result["checks"]["worktree"].get("skipped"))
class TestWrongRepoTimesheet(unittest.TestCase):
"""AC3: wrong repo defaulting to Timesheet."""
def test_prgs_default_timesheet_vs_local_gitea_tools(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
resolved_org="Scaled-Tech-Consulting",
resolved_repo="Timesheet",
local_remote_url=LOCAL_GITEA_TOOLS_URL,
org_explicit=False,
repo_explicit=False,
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_REPO)
self.assertTrue(result["exact_next_action"])
self.assertIn("org=", result["exact_next_action"])
self.assertTrue(
any("Timesheet" in r or "does not match" in r for r in result["reasons"])
)
def test_explicit_org_repo_skips_mismatch(self):
# Explicit intent is authoritative (remote_repo_guard contract).
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
resolved_repo="Timesheet",
org_explicit=True,
repo_explicit=True,
)
)
self.assertTrue(result["allowed"])
class TestStaleRuntime(unittest.TestCase):
"""AC3: stale runtime."""
def test_stale_runtime_blocks(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
startup_head=STARTUP,
current_code_head=ADVANCED,
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_STALE_RUNTIME)
self.assertIn("Restart", result["exact_next_action"])
self.assertTrue(result["checks"]["stale_runtime"]["stale"])
def test_in_parity_allows(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
startup_head=STARTUP,
current_code_head=STARTUP,
)
)
self.assertTrue(result["allowed"])
class TestWrongWorktree(unittest.TestCase):
"""AC3: wrong worktree."""
def test_author_on_control_checkout_blocked(self):
# Clean master control checkout: root guard may pass for a clean master
# HEAD, but the author worktree guard must still refuse mutation from
# outside branches/.
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
workspace_path="/repo",
project_root="/repo",
current_branch="master",
root_porcelain="",
root_head_sha=STARTUP,
remote_master_sha=STARTUP,
profile_role="author",
required_role="author",
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_WORKTREE)
self.assertIn("branches/", result["exact_next_action"])
def test_author_under_branches_allowed(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
workspace_path="/repo/branches/issue-604",
project_root="/repo",
)
)
self.assertTrue(result["allowed"])
class TestForeignLease(unittest.TestCase):
"""AC3: foreign lease."""
def test_foreign_lease_blocks(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="merge_pr",
profile_role="merger",
required_role="merger",
# Merger is not auto-exempted under branches/; pass clean master.
workspace_path="/repo",
project_root="/repo",
current_branch="master",
root_porcelain="",
foreign_lease=True,
lease_reasons=["lease owned by other-session"],
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_FOREIGN_LEASE)
self.assertIn("foreign lease", result["exact_next_action"].lower())
def test_lease_required_without_ownership_fails_closed(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="review_pr",
profile_role="reviewer",
required_role="reviewer",
workspace_path="/repo/branches/review-pr-1",
project_root="/repo",
lease_required=True,
lease_owner_session="sess-A",
active_session_id="sess-B",
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_FOREIGN_LEASE)
def test_owned_lease_allows(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="review_pr",
profile_role="reviewer",
required_role="reviewer",
workspace_path="/repo/branches/review-pr-1",
project_root="/repo",
foreign_lease=False,
)
)
self.assertTrue(result["allowed"])
class TestTerminalLock(unittest.TestCase):
"""AC3: terminal lock."""
def test_terminal_lock_blocks(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="approve_pr",
profile_role="reviewer",
required_role="reviewer",
workspace_path="/repo/branches/review-pr-9",
project_root="/repo",
terminal_lock_blocks=True,
terminal_lock_reasons=["#332 terminal lock active for this head"],
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_TERMINAL_LOCK)
self.assertIn("#332", result["exact_next_action"])
def test_no_terminal_lock_allows(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="approve_pr",
profile_role="reviewer",
required_role="reviewer",
workspace_path="/repo/branches/review-pr-9",
project_root="/repo",
terminal_lock_blocks=False,
)
)
self.assertTrue(result["allowed"])
class TestHeadShaStalePrompt(unittest.TestCase):
"""AC4: no mutation with stale prompt head SHA."""
def _merger_kwargs(self, **overrides):
base = _happy_kwargs(
task="merge_pr",
profile_role="merger",
required_role="merger",
workspace_path="/repo",
project_root="/repo",
current_branch="master",
root_porcelain="",
root_head_sha=STARTUP,
remote_master_sha=STARTUP,
)
base.update(overrides)
return base
def test_head_mismatch_blocks(self):
result = asp.assess_anti_stomp_preflight(
**self._merger_kwargs(
expected_head_sha=HEAD_A,
live_head_sha=HEAD_B,
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_HEAD_SHA)
self.assertIn("stale prompt", " ".join(result["reasons"]).lower())
def test_require_head_sha_missing_blocks(self):
result = asp.assess_anti_stomp_preflight(
**self._merger_kwargs(
require_head_sha=True,
expected_head_sha=None,
live_head_sha=HEAD_A,
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_HEAD_SHA)
def test_matching_head_allows(self):
result = asp.assess_anti_stomp_preflight(
**self._merger_kwargs(
expected_head_sha=HEAD_A,
live_head_sha=HEAD_A,
)
)
self.assertTrue(result["allowed"])
class TestContaminatedApproval(unittest.TestCase):
"""AC3: contaminated approval / source contamination."""
def test_source_contamination_blocks(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="merge_pr",
profile_role="merger",
required_role="merger",
workspace_path="/repo",
project_root="/repo",
current_branch="master",
root_porcelain="",
source_contaminated=True,
contamination_reasons=[
"session contaminated by direct stable-branch push attempt"
],
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_SOURCE_CONTAMINATION)
self.assertIn("reconciler", result["exact_next_action"].lower())
def test_manual_bypass_blocks(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
manual_bypass_attempted=True,
manual_bypass_reasons=[
"attempted manual deletion of session-state lock files"
],
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_MANUAL_BYPASS)
class TestWrongRole(unittest.TestCase):
def test_author_cannot_merge(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="merge_pr",
profile_role="author",
required_role="merger",
workspace_path="/repo/branches/issue-604",
project_root="/repo",
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_WRONG_ROLE)
def test_reconciler_may_run_author_class_tasks(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="comment_issue",
profile_name="prgs-reconciler",
profile_role="reconciler",
required_role="author",
workspace_path="/repo",
project_root="/repo",
current_branch="master",
root_porcelain="",
)
)
self.assertTrue(result["allowed"], result.get("reasons"))
self.assertTrue(asp.roles_compatible("reconciler", "author"))
self.assertFalse(asp.roles_compatible("author", "merger"))
class TestWorkflowHash(unittest.TestCase):
def test_stale_workflow_hash_blocks(self):
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="review_pr",
profile_role="reviewer",
required_role="reviewer",
workspace_path="/repo/branches/review-pr-3",
project_root="/repo",
workflow_hash_valid=False,
workflow_hash_reasons=["stored workflow hash is stale"],
)
)
self.assertTrue(result["block"])
self.assertEqual(result["blocker_kind"], asp.BLOCKER_WORKFLOW_HASH)
class TestTypedBlockerResponse(unittest.TestCase):
"""AC2: typed blocker + exact next action in response payload."""
def test_block_response_shape(self):
assessment = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
task="review_pr",
profile_role="reviewer",
required_role="reviewer",
workspace_path="/repo/branches/review-pr-7",
project_root="/repo",
foreign_lease=True,
)
)
payload = asp.block_response(assessment, pr_number=42)
self.assertFalse(payload["success"])
self.assertFalse(payload["performed"])
self.assertTrue(payload["blocked"])
self.assertTrue(payload["anti_stomp"])
self.assertEqual(payload["blocker_kind"], asp.BLOCKER_FOREIGN_LEASE)
self.assertTrue(payload["exact_next_action"])
self.assertEqual(payload["pr_number"], 42)
self.assertTrue(payload["blockers"])
self.assertEqual(payload["blockers"][0]["kind"], asp.BLOCKER_FOREIGN_LEASE)
def test_format_error_includes_kind_and_next_action(self):
assessment = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
startup_head=STARTUP,
current_code_head=ADVANCED,
)
)
msg = asp.format_anti_stomp_error(assessment)
self.assertIn("#604", msg)
self.assertIn(asp.BLOCKER_STALE_RUNTIME, msg)
self.assertIn("exact_next_action", msg)
class TestRootCheckoutContamination(unittest.TestCase):
def test_dirty_control_checkout_blocks_author(self):
# Workspace is control checkout with dirty porcelain.
result = asp.assess_anti_stomp_preflight(
**_happy_kwargs(
workspace_path="/repo",
project_root="/repo",
current_branch="master",
root_porcelain=" M gitea_mcp_server.py\n",
root_head_sha=STARTUP,
remote_master_sha=STARTUP,
)
)
self.assertTrue(result["block"])
# Author worktree check or root checkout may fire first.
self.assertIn(
result["blocker_kind"],
{asp.BLOCKER_ROOT_CHECKOUT, asp.BLOCKER_WRONG_WORKTREE},
)
class TestMutationTaskInventory(unittest.TestCase):
"""AC1: mutation task set covers issue-listed paths."""
def test_issue_required_paths_covered(self):
required = {
"create_issue",
"comment_issue",
"set_issue_labels",
"acquire_reviewer_pr_lease",
"submit_pr_review",
"approve_pr",
"request_changes_pr",
"merge_pr",
"cleanup_merged_pr_branch",
"cleanup_stale_claims",
}
missing = required - asp.MUTATION_TASKS
self.assertFalse(missing, f"missing mutation tasks: {missing}")
class TestServerWiring(unittest.TestCase):
"""Smoke: MCP server imports anti_stomp and exposes the runner."""
def test_server_imports_anti_stomp(self):
import gitea_mcp_server as server
self.assertTrue(hasattr(server, "_run_anti_stomp_preflight"))
self.assertTrue(hasattr(server, "anti_stomp_preflight"))
self.assertIs(server.anti_stomp_preflight, asp)
def test_runner_skipped_under_pytest_by_default(self):
import gitea_mcp_server as server
# Default pytest path must not raise (suite isolation).
self.assertIsNone(
server._run_anti_stomp_preflight(
"create_issue",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Timesheet",
)
)
def test_runner_blocks_when_forced(self):
import gitea_mcp_server as server
with mock.patch.dict(
os.environ,
{"GITEA_TEST_FORCE_ANTI_STOMP": "1"},
clear=False,
):
# Force assessor to return a block without full git/workspace setup.
blocked = {
"allowed": False,
"block": True,
"blockers": [{
"kind": asp.BLOCKER_STALE_RUNTIME,
"reasons": ["stale"],
"exact_next_action": "restart",
"detail": {},
}],
"reasons": ["stale"],
"exact_next_action": "restart",
"blocker_kind": asp.BLOCKER_STALE_RUNTIME,
"checks": {},
}
with mock.patch.object(
asp, "assess_anti_stomp_preflight", return_value=blocked
):
with self.assertRaises(RuntimeError) as ctx:
server._run_anti_stomp_preflight(
"create_issue",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
self.assertIn("#604", str(ctx.exception))
self.assertIn(asp.BLOCKER_STALE_RUNTIME, str(ctx.exception))
if __name__ == "__main__":
unittest.main()