Entry point B of #812 is the state where an author's work has already advanced to a local commit: the worktree is registered, clean, on the issue branch, and carries the only copy of the implementation, but the branch has never been published. Two individually correct predicates close a cycle around it: exact-owner lease renewal refuses without an observable remote head, and every publication path is lock-derived under #618, so nothing can create that remote head without first holding the lock renewal would grant.
This adds the missing operation. gitea_publish_unpublished_issue_branch publishes an already-committed local head to its remote branch, so exact-owner renewal has the evidence its model requires. Publication is the whole of its authority: it renews, reclaims, rebinds, and clears nothing.
Why this is not a lock bypass: the operation can only publish a branch whose durable issue-lock record already names the caller as claimant. Ownership is read from the lock file, never asserted by the caller. Guard strictness is unchanged (AC15): a dirty tree, an untracked file the commit does not carry, an unregistered worktree, a non-issue or stable branch, a changed local HEAD, a remote head that is not an ancestor of the commit, a competing open PR for the same issue on another branch, and any declared-hash mismatch each fail closed. The refspec names the commit SHA explicitly and never forces.
Record separation (AC23): the durable issue-lock file and the control-plane workflow lease are distinct records. This reads the former as ownership evidence and writes neither.
Truthful process evidence (AC24): the recorded owner pid's liveness is never consulted or asserted. A regression pins the recorded pid to a live process, proves publication still succeeds, and proves expired-lock reclaim still refuses for that same pid.
Scope is AC20 only. AC21 cannot unblock on its own, so the two are separable and only the smaller one is implemented here.
Tests: 36 new cases against synthetic fixtures only, using a real git repository with a real local bare remote so publication and read-after-write verification are genuinely executed rather than mocked. AC17 is honoured and a regression asserts the protected worktree is never referenced.
Full suite: 11 failed, 4354 passed, 6 skipped, 533 subtests passed. The 11 failures are the documented pre-existing drift baseline at 9eb0f29, unchanged in count and identity.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
828 lines
32 KiB
Python
828 lines
32 KiB
Python
"""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 create_issue_bootstrap
|
|
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",
|
|
"edit_issue",
|
|
"mark_issue",
|
|
"lock_issue",
|
|
"set_issue_labels",
|
|
"create_label",
|
|
"create_pr",
|
|
"close_pr",
|
|
"edit_pr",
|
|
"commit_files",
|
|
"gitea_commit_files",
|
|
"publish_unpublished_branch",
|
|
"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,
|
|
create_issue_bootstrap_assessment: dict[str, Any] | None = None,
|
|
# 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,
|
|
)
|
|
# #757: the #274 guard consults the server-derived create_issue
|
|
# bootstrap before blocking the canonical control checkout. Route this
|
|
# guard's decision through the *same* predicate on the *same*
|
|
# assessment so the two cannot disagree about identical evidence.
|
|
# Only the wrong-worktree verdict is waived; every other check in this
|
|
# assessment (root checkout, repo, role, stale runtime, lease, ...) is
|
|
# evaluated independently and still applies.
|
|
bootstrap_waived = wt.get("block") and (
|
|
create_issue_bootstrap.bootstrap_permits_control_checkout(
|
|
create_issue_bootstrap_assessment,
|
|
task=task_name,
|
|
workspace_path=workspace_path,
|
|
canonical_repo_root=project_root,
|
|
)
|
|
)
|
|
checks["worktree"] = {
|
|
"block": bool(wt.get("block")) and not bootstrap_waived,
|
|
"reasons": list(wt.get("reasons") or []),
|
|
"under_branches": wt.get("under_branches"),
|
|
"create_issue_bootstrap_waived": bool(bootstrap_waived),
|
|
}
|
|
if wt.get("block") and not bootstrap_waived:
|
|
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, []
|