"""Workflow scope ownership and production-guard hardening (#683). Implements fail-closed enforcement so sessions cannot: * mutate source/tests on the root/control checkout (including temporary diagnostic edits) without binding an issue-backed ``branches/`` worktree; * continue out-of-scope source work while locked to a different issue; * disable, skip, or conceal production root/branches/porcelain guards solely because pytest/unittest is loaded. This module is pure assessment + small durable ledger helpers. Callers gather live facts (lock, branch, porcelain, worktree path) and pass them in. Existing root_checkout_guard / author_mutation_worktree assessors remain authoritative; this module composes typed blockers with exact recovery actions. Do **not** reintroduce the rejected #681 / ``300a4ca`` patterns: * early-return from workspace verification under ``_preflight_in_test_mode()`` * porcelain filtering that strips ``*.py`` lines under pytest """ from __future__ import annotations import os import re import threading from typing import Any import author_mutation_worktree from reviewer_worktree import parse_dirty_tracked_files # ── force-on / test isolation ──────────────────────────────────────────────── # When set, production root/branches/scope guards MUST run even under pytest. # Unit tests that only need preflight-order isolation leave this unset and # use GITEA_TEST_PORCELAIN / fixtures; real-entrypoint proof sets this to "1". FORCE_PRODUCTION_GUARDS_ENV = "GITEA_TEST_FORCE_PRODUCTION_GUARDS" # Existing force signals also mean "exercise production dirtiness paths". _FORCE_DIRTY_ENV = "GITEA_TEST_FORCE_DIRTY" _FORCE_PORCELAIN_ENV = "GITEA_TEST_PORCELAIN" # ── typed blocker kinds ────────────────────────────────────────────────────── BLOCKER_ROOT_DIAGNOSTIC_EDIT = "root_diagnostic_edit" BLOCKER_MISSING_ISSUE_SCOPE = "missing_issue_scope" BLOCKER_OUT_OF_SCOPE_ISSUE = "out_of_scope_issue" BLOCKER_MISSING_WORKTREE = "missing_issue_worktree" BLOCKER_UNRECORDED_FAILURE = "unrecorded_workflow_failure" BLOCKER_PRODUCTION_GUARD = "production_guard_violation" BLOCKER_KINDS = frozenset( { BLOCKER_ROOT_DIAGNOSTIC_EDIT, BLOCKER_MISSING_ISSUE_SCOPE, BLOCKER_OUT_OF_SCOPE_ISSUE, BLOCKER_MISSING_WORKTREE, BLOCKER_UNRECORDED_FAILURE, BLOCKER_PRODUCTION_GUARD, } ) _NEXT_ACTIONS: dict[str, str] = { BLOCKER_ROOT_DIAGNOSTIC_EDIT: ( "Stop editing the control/root checkout. Preserve or discard root WIP " "durably, restore root to clean master, lock or create the owning issue, " "bind branches/issue--*, set GITEA_AUTHOR_WORKTREE to that worktree, " "then re-run the mutation." ), BLOCKER_MISSING_ISSUE_SCOPE: ( "Select or create the owning Gitea issue, claim/lock it " "(gitea_mark_issue + gitea_lock_issue), bind branches/issue--* " "from clean master, then re-run the mutation from that worktree." ), BLOCKER_OUT_OF_SCOPE_ISSUE: ( "Stop. The active issue lock does not own this work. Release or finish " "the current issue lease, then select/create and lock the correct " "owning issue, bind its branches/issue--* worktree, and re-run." ), BLOCKER_MISSING_WORKTREE: ( "Bind an issue-backed worktree under branches/ (scripts/worktree-start " "or git worktree add branches/issue--*), set GITEA_AUTHOR_WORKTREE / " "worktree_path to that path, keep the control checkout clean on master, " "then re-run the mutation." ), BLOCKER_UNRECORDED_FAILURE: ( "Record the workflow/tool failure durably first (issue comment or " "workflow_scope_guard.record_workflow_failure), then continue only " "inside the owning issue-backed worktree." ), BLOCKER_PRODUCTION_GUARD: ( "Resolve the production guard violation: clean or isolate the control " "checkout, bind the owning issue worktree under branches/, and re-run " "with production guards active." ), } _ISSUE_IN_BRANCH_RE = re.compile(r"issue-(\d+)", re.IGNORECASE) # In-process durable failure ledger (also written via optional sink callback). _ledger_lock = threading.Lock() _failure_ledger: list[dict[str, Any]] = [] class ProductionGuardError(RuntimeError): """Fail-closed production guard with typed blocker metadata (#683).""" def __init__( self, message: str, *, blocker_kind: str, exact_next_action: str | None = None, reasons: list[str] | None = None, details: dict[str, Any] | None = None, ) -> None: super().__init__(message) kind = (blocker_kind or "").strip() if kind not in BLOCKER_KINDS: kind = BLOCKER_PRODUCTION_GUARD self.blocker_kind = kind self.exact_next_action = ( (exact_next_action or "").strip() or _NEXT_ACTIONS[kind] ) self.reasons = list(reasons or [message]) self.details = dict(details or {}) def production_guards_forced() -> bool: """True when the explicit #683 force-on flag requests production guards.""" return (os.environ.get(FORCE_PRODUCTION_GUARDS_ENV) or "").strip().lower() in { "1", "true", "yes", "on", } def purity_order_forced() -> bool: """True when tests force preflight-order dirtiness paths (legacy flags).""" if os.environ.get(_FORCE_DIRTY_ENV): return True # GITEA_TEST_PORCELAIN present (even empty) means dirtiness paths are live. if os.environ.get(_FORCE_PORCELAIN_ENV) is not None: return True return False def production_guards_active(*, in_test_mode: bool) -> bool: """Whether production root/branches/scope guards must execute. Production (non-test) always active. Under pytest, active when either the explicit #683 force-on flag or legacy dirty/porcelain force signals are set — never skip production enforcement solely because tests are running when force-on is requested. """ if production_guards_forced() or purity_order_forced(): return True return not bool(in_test_mode) def extract_issue_number_from_branch(branch_name: str | None) -> int | None: """Return the first issue-N number embedded in a branch name, if any.""" text = (branch_name or "").strip() if not text: return None match = _ISSUE_IN_BRANCH_RE.search(text) if not match: return None try: return int(match.group(1)) except ValueError: return None def is_source_or_test_path(path: str) -> bool: """True for tracked source/test paths that must not land as root WIP.""" p = (path or "").replace("\\", "/").lstrip("./") if not p: return False if p.startswith("tests/") or "/tests/" in f"/{p}": return True if p.endswith((".py", ".pyi", ".toml", ".cfg", ".ini", ".sh")): return True if p in {"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"}: return True return False def dirty_source_files(porcelain_status: str) -> list[str]: """Tracked dirty paths that count as source/test contamination.""" dirty = parse_dirty_tracked_files(porcelain_status or "") return [p for p in dirty if is_source_or_test_path(p)] def assess_issue_scope_ownership( *, locked_issue_number: int | None, target_issue_number: int | None = None, branch_name: str | None = None, role_kind: str | None = None, require_lock_for_author: bool = False, ) -> dict[str, Any]: """Fail closed when the session issue lock does not own the attempted work. * Author sessions that require a lock fail when none is held. * When a lock exists, the target issue (tool argument) and/or the issue number embedded in the branch must match the locked issue. * Reviewer/merger/reconciler roles are not issue-scope owners of author implementation work and skip the author lock requirement. """ role = (role_kind or "").strip().lower() locked = locked_issue_number if isinstance(locked, str) and locked.isdigit(): locked = int(locked) if locked is not None: try: locked = int(locked) except (TypeError, ValueError): locked = None target = target_issue_number if target is not None: try: target = int(target) except (TypeError, ValueError): target = None branch_issue = extract_issue_number_from_branch(branch_name) reasons: list[str] = [] blocker_kind: str | None = None # Non-author roles do not take author issue locks for implementation. if role in {"reviewer", "merger", "reconciler"}: return _scope_ok(locked, target, branch_issue) if require_lock_for_author and locked is None: reasons.append( "no owning issue lock is bound for this author session; " "source/test mutation requires selecting or creating an owning issue first" ) blocker_kind = BLOCKER_MISSING_ISSUE_SCOPE if locked is not None and target is not None and locked != target: reasons.append( f"session is locked to issue #{locked} but mutation targets issue " f"#{target}; out-of-scope until the owning issue is selected" ) blocker_kind = BLOCKER_OUT_OF_SCOPE_ISSUE if locked is not None and branch_issue is not None and locked != branch_issue: reasons.append( f"session is locked to issue #{locked} but workspace branch is for " f"issue #{branch_issue}; bind the matching issue-backed worktree" ) blocker_kind = BLOCKER_OUT_OF_SCOPE_ISSUE if reasons: kind = blocker_kind or BLOCKER_MISSING_ISSUE_SCOPE return { "proven": False, "block": True, "blocker_kind": kind, "exact_next_action": _NEXT_ACTIONS[kind], "reasons": reasons, "locked_issue_number": locked, "target_issue_number": target, "branch_issue_number": branch_issue, } return _scope_ok(locked, target, branch_issue) def assess_root_source_mutation( *, workspace_path: str, canonical_repo_root: str, porcelain_status: str, current_branch: str | None = None, locked_issue_number: int | None = None, role_kind: str | None = None, ) -> dict[str, Any]: """Fail closed for diagnostic/source edits on the control/root checkout. Allowed only when the active workspace is under ``branches/``. Dirty tracked source/test files on the control checkout always block, including temporary/diagnostic/test-only intent. """ role = (role_kind or "").strip().lower() if role == "reconciler": return { "proven": True, "block": False, "blocker_kind": None, "exact_next_action": "proceed", "reasons": [], "dirty_source_files": [], } root = os.path.realpath(canonical_repo_root or "") workspace = os.path.realpath(workspace_path or root or ".") under_branches = author_mutation_worktree.is_path_under_branches(workspace, root) dirty_src = dirty_source_files(porcelain_status) reasons: list[str] = [] blocker_kind: str | None = None if not under_branches and workspace == root and dirty_src: # Root workspace with source dirtiness is unattributed root WIP. # (Clean-root author binding is enforced by branches-only #274.) reasons.append( "control/root checkout has tracked source or test edits " f"(dirty files: {', '.join(dirty_src)}); diagnostic or temporary " "edits on the root checkout are forbidden" ) blocker_kind = BLOCKER_ROOT_DIAGNOSTIC_EDIT if ( not under_branches and workspace == root and not dirty_src and role == "author" ): # Explicit missing-worktree signal for force-on author entrypoints. reasons.append( "author source/test mutation from the stable control checkout is " "forbidden; bind an issue-backed worktree under branches/ first" ) blocker_kind = BLOCKER_MISSING_WORKTREE if reasons: kind = blocker_kind or BLOCKER_ROOT_DIAGNOSTIC_EDIT return { "proven": False, "block": True, "blocker_kind": kind, "exact_next_action": _NEXT_ACTIONS[kind], "reasons": reasons, "dirty_source_files": dirty_src, "workspace_path": workspace, "canonical_repo_root": root, "under_branches": under_branches, "locked_issue_number": locked_issue_number, } return { "proven": True, "block": False, "blocker_kind": None, "exact_next_action": "proceed", "reasons": [], "dirty_source_files": dirty_src, "workspace_path": workspace, "canonical_repo_root": root, "under_branches": under_branches, "locked_issue_number": locked_issue_number, } def assess_production_mutation_guards( *, workspace_path: str, canonical_repo_root: str, porcelain_status: str, current_branch: str | None = None, locked_issue_number: int | None = None, target_issue_number: int | None = None, role_kind: str | None = None, require_author_lock: bool = False, in_test_mode: bool = False, ) -> dict[str, Any]: """Compose root + scope production guards when they must be active (#683).""" if not production_guards_active(in_test_mode=in_test_mode): return { "proven": True, "block": False, "blocker_kind": None, "exact_next_action": "proceed", "reasons": [], "skipped": True, "skip_reason": "production guards not active (test isolation without force-on)", } root_assess = assess_root_source_mutation( workspace_path=workspace_path, canonical_repo_root=canonical_repo_root, porcelain_status=porcelain_status, current_branch=current_branch, locked_issue_number=locked_issue_number, role_kind=role_kind, ) if root_assess["block"]: return {**root_assess, "skipped": False} scope_assess = assess_issue_scope_ownership( locked_issue_number=locked_issue_number, target_issue_number=target_issue_number, branch_name=current_branch, role_kind=role_kind, require_lock_for_author=require_author_lock, ) if scope_assess["block"]: return {**scope_assess, "skipped": False} return { "proven": True, "block": False, "blocker_kind": None, "exact_next_action": "proceed", "reasons": [], "skipped": False, "root": root_assess, "scope": scope_assess, } def raise_if_blocked(assessment: dict[str, Any]) -> None: """Raise :class:`ProductionGuardError` when *assessment* blocks.""" if not assessment or not assessment.get("block"): return kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD reasons = list(assessment.get("reasons") or ["production guard violation"]) message = ( f"Workflow scope guard (#683) [{kind}]: {'; '.join(reasons)}. " f"exact_next_action: {assessment.get('exact_next_action') or _NEXT_ACTIONS.get(kind, '')}" ) raise ProductionGuardError( message, blocker_kind=kind, exact_next_action=assessment.get("exact_next_action"), reasons=reasons, details={ k: v for k, v in assessment.items() if k not in { "proven", "block", "blocker_kind", "exact_next_action", "reasons", } }, ) def block_response( assessment: dict[str, Any] | ProductionGuardError | None = None, *, blocker_kind: str | None = None, reasons: list[str] | None = None, exact_next_action: str | None = None, **extra: Any, ) -> dict[str, Any]: """Structured fail-closed tool response with typed blocker fields.""" if isinstance(assessment, ProductionGuardError): kind = assessment.blocker_kind reason_list = list(assessment.reasons) next_action = assessment.exact_next_action extra = {**assessment.details, **extra} elif isinstance(assessment, dict) and assessment.get("block"): kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD reason_list = list(assessment.get("reasons") or []) next_action = assessment.get("exact_next_action") or _NEXT_ACTIONS.get( kind, _NEXT_ACTIONS[BLOCKER_PRODUCTION_GUARD] ) else: kind = (blocker_kind or BLOCKER_PRODUCTION_GUARD).strip() if kind not in BLOCKER_KINDS: kind = BLOCKER_PRODUCTION_GUARD reason_list = list(reasons or ["production guard violation"]) next_action = exact_next_action or _NEXT_ACTIONS[kind] if kind not in BLOCKER_KINDS: kind = BLOCKER_PRODUCTION_GUARD if not reason_list: reason_list = ["production guard violation"] next_action = (next_action or "").strip() or _NEXT_ACTIONS[kind] out: dict[str, Any] = { "success": False, "performed": False, "blocker_kind": kind, "exact_next_action": next_action, "reasons": reason_list, } for key, value in extra.items(): if key not in out and value is not None: out[key] = value return out def format_production_guard_error(assessment: dict[str, Any]) -> str: """Single RuntimeError string carrying kind + exact next action.""" kind = assessment.get("blocker_kind") or BLOCKER_PRODUCTION_GUARD reasons = "; ".join(assessment.get("reasons") or ["production guard violation"]) next_action = assessment.get("exact_next_action") or _NEXT_ACTIONS.get( kind, _NEXT_ACTIONS[BLOCKER_PRODUCTION_GUARD] ) return ( f"Workflow scope guard (#683) [{kind}]: {reasons}. " f"exact_next_action: {next_action}" ) # ── durable failure recording ──────────────────────────────────────────────── def record_workflow_failure( *, kind: str, detail: str, issue_number: int | None = None, task: str | None = None, sink: Any | None = None, ) -> dict[str, Any]: """Record a workflow/tool failure before source edits continue (#683 AC8). *sink* may be a callable ``sink(record)`` (e.g. tests) or omitted for the in-process ledger only. Returns the durable record. """ record = { "kind": (kind or "workflow_failure").strip() or "workflow_failure", "detail": (detail or "").strip(), "issue_number": issue_number, "task": task, "pid": os.getpid(), } with _ledger_lock: _failure_ledger.append(dict(record)) if callable(sink): sink(record) return record def clear_workflow_failure_ledger() -> None: """Test helper: reset the in-process failure ledger.""" with _ledger_lock: _failure_ledger.clear() def workflow_failure_ledger() -> list[dict[str, Any]]: """Copy of durable in-process failure records.""" with _ledger_lock: return [dict(r) for r in _failure_ledger] def assess_durable_failure_recorded( *, require_record: bool, pending_source_mutation: bool, ) -> dict[str, Any]: """Block source mutation when a workflow failure was not recorded first.""" if not require_record or not pending_source_mutation: return { "proven": True, "block": False, "blocker_kind": None, "exact_next_action": "proceed", "reasons": [], } with _ledger_lock: has_record = bool(_failure_ledger) if has_record: return { "proven": True, "block": False, "blocker_kind": None, "exact_next_action": "proceed", "reasons": [], } return { "proven": False, "block": True, "blocker_kind": BLOCKER_UNRECORDED_FAILURE, "exact_next_action": _NEXT_ACTIONS[BLOCKER_UNRECORDED_FAILURE], "reasons": [ "workflow/tool failure triggered a need for source changes but no " "durable failure record exists yet" ], } def porcelain_preserves_python_paths(porcelain_status: str) -> bool: """Regression helper: dirty ``*.py`` lines must remain visible (#683).""" text = porcelain_status or "" for line in text.splitlines(): stripped = line.strip() if stripped.endswith(".py") or ".py " in stripped or stripped.endswith(".py"): # Any py path present proves no silent strip of all *.py lines. if " M " in f" {stripped}" or stripped[:1] in "MADRCTU" or len(line) >= 4: return True # Empty porcelain is fine; integrity means we did not strip when present. return ".py" not in text def assert_no_pytest_porcelain_filter(source_text: str) -> list[str]: """Static check: production reader must not strip ``*.py`` under pytest.""" findings: list[str] = [] lowered = source_text or "" if "endswith(\".py\")" in lowered or "endswith('.py')" in lowered: if "pytest" in lowered and "porcelain" in lowered.lower(): findings.append( "production porcelain reader must not filter *.py under pytest " "(rejected 300a4ca pattern)" ) if "if \"pytest\" in sys.modules" in lowered and "porcelain" in lowered.lower(): if ".py" in lowered and ("join" in lowered or "endswith" in lowered): findings.append( "test-mode porcelain filtering of source files is forbidden (#683)" ) return findings def _scope_ok( locked: int | None, target: int | None, branch_issue: int | None, ) -> dict[str, Any]: return { "proven": True, "block": False, "blocker_kind": None, "exact_next_action": "proceed", "reasons": [], "locked_issue_number": locked, "target_issue_number": target, "branch_issue_number": branch_issue, }