"""Issue-lock provenance and external-state disclosure (#447). Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery #442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be blocked at PR creation unless explicit operator override proof is recorded. """ from __future__ import annotations import os import re from datetime import datetime, timezone ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json") SOURCE_LOCK_ISSUE = "gitea_lock_issue" SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption" SOURCE_OPERATOR_OVERRIDE = "operator_override" SANCTIONED_LOCK_SOURCES = frozenset({ SOURCE_LOCK_ISSUE, SOURCE_LOCK_ADOPTION, SOURCE_OPERATOR_OVERRIDE, }) _OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE" _ISSUE_LOCK_PATH_RE = re.compile( r"(?:/tmp/)?gitea_issue_lock\.json", re.IGNORECASE, ) _LOCK_SEED_RE = re.compile( r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|" r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock", re.IGNORECASE | re.DOTALL, ) _LOCK_REMOVE_RE = re.compile( r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock", re.IGNORECASE | re.DOTALL, ) _LOCK_READ_RE = re.compile( r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock", re.IGNORECASE | re.DOTALL, ) _EXTERNAL_NONE_RE = re.compile( r"external[- ]state mutations\s*:\s*none\b", re.IGNORECASE, ) _EXTERNAL_FIELD_RE = re.compile( r"external[- ]state mutations\s*:\s*(.+)$", re.IGNORECASE | re.MULTILINE, ) _CLEANUP_ONLY_RE = re.compile( r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)", re.IGNORECASE, ) _PR_CREATED_RE = re.compile( r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|" r"PR\s+creation\s+(?:succeeded|complete))", re.IGNORECASE, ) _REVIEW_APPROVE_RE = re.compile( r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|" r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)", re.IGNORECASE, ) _OVERRIDE_PROOF_RE = re.compile( r"operator[- ]override\s+proof\s*:\s*(.+)$", re.IGNORECASE | re.MULTILINE, ) def _utc_now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def build_sanctioned_lock_provenance( *, tool: str, source: str = SOURCE_LOCK_ISSUE, claimant: dict | None = None, adoption: dict | None = None, ) -> dict: """Return provenance metadata stored with a sanctioned lock write.""" entry = { "source": source, "written_at": _utc_now_iso(), "written_by_tool": tool, "lock_file_path": ISSUE_LOCK_FILE, } if claimant: entry["claimant"] = claimant if adoption: entry["adoption"] = adoption return entry def operator_override_requested() -> bool: return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in { "1", "true", "yes", } def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict: text = (reason or "").strip() if not text: raise ValueError( "operator override requires a non-empty override reason (fail closed)" ) entry = build_sanctioned_lock_provenance( tool="operator_override", source=SOURCE_OPERATOR_OVERRIDE, claimant=claimant, ) entry["override_reason"] = text return entry def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict: """Fail closed when lock file lacks sanctioned provenance (#447).""" data = lock_data if isinstance(lock_data, dict) else {} reasons: list[str] = [] provenance = data.get("lock_provenance") if not isinstance(provenance, dict): reasons.append( "issue lock file lacks sanctioned lock_provenance; manual seeding is " "not a normal recovery path — call gitea_lock_issue or use #442 adoption" ) return _provenance_result(False, reasons, provenance) source = str(provenance.get("source") or "").strip() if source not in SANCTIONED_LOCK_SOURCES: reasons.append( f"issue lock provenance source '{source or '(missing)'}' is not sanctioned" ) if source == SOURCE_OPERATOR_OVERRIDE and not str( provenance.get("override_reason") or "" ).strip(): reasons.append( "operator_override lock provenance requires override_reason proof" ) if not data.get("work_lease"): reasons.append("issue lock file missing work_lease metadata") if not str(provenance.get("written_by_tool") or "").strip(): reasons.append("issue lock provenance missing written_by_tool") proven = not reasons return _provenance_result(proven, reasons, provenance) def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict: return { "proven": proven, "block": not proven, "reasons": reasons, "lock_provenance": provenance, } def format_lock_provenance_error(assessment: dict) -> str: reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"]) return f"Issue lock provenance guard (#447): {reasons} (fail closed)" def _lock_activity_detected(text: str) -> dict[str, bool]: body = text or "" return { "seed_or_restore": bool(_LOCK_SEED_RE.search(body)), "remove": bool(_LOCK_REMOVE_RE.search(body)), "read": bool(_LOCK_READ_RE.search(body)), } def _external_state_discloses_lock(text: str) -> bool: match = _EXTERNAL_FIELD_RE.search(text or "") if not match: return False value = (match.group(1) or "").strip().lower() if value in {"", "none", "n/a"}: return False return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value def assess_issue_lock_external_state_report(report_text: str) -> dict: """Require explicit external-state disclosure for issue-lock mutations (#447).""" text = report_text or "" activity = _lock_activity_detected(text) if not any(activity.values()): return {"proven": True, "block": False, "reasons": [], "activity": activity} reasons: list[str] = [] disclosed = _external_state_discloses_lock(text) if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text): reasons.append( "report mentions seeding/restoring gitea_issue_lock.json but claims " "External-state mutations: none" ) elif activity["seed_or_restore"] and not disclosed: reasons.append( "report mentions issue-lock file activity but External-state mutations " "does not disclose read/write of gitea_issue_lock.json" ) if activity["remove"]: if _EXTERNAL_NONE_RE.search(text): reasons.append( "report mentions removing gitea_issue_lock.json but claims " "External-state mutations: none" ) elif not disclosed and _CLEANUP_ONLY_RE.search(text): reasons.append( "report removes issue lock but classifies it as cleanup only; " "record under External-state mutations" ) elif not disclosed: reasons.append( "report mentions deleting issue lock without External-state " "mutation disclosure" ) proven = not reasons return { "proven": proven, "block": not proven, "reasons": reasons, "activity": activity, } def assess_manual_lock_pr_without_override(report_text: str) -> dict: """Block reports that created a PR via manual lock seed without override proof.""" text = report_text or "" seeded = bool(_LOCK_SEED_RE.search(text)) created = bool(_PR_CREATED_RE.search(text)) if not (seeded and created): return {"proven": True, "block": False, "reasons": []} if _OVERRIDE_PROOF_RE.search(text): return {"proven": True, "block": False, "reasons": []} return { "proven": False, "block": True, "reasons": [ "report created/opened a PR after manual issue-lock seeding without " "operator override proof" ], } def assess_author_reviewer_same_run_report(report_text: str) -> dict: """Reviewer handoff must not create and approve the same PR in one run (#447).""" text = report_text or "" if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)): return {"proven": True, "block": False, "reasons": []} return { "proven": False, "block": True, "reasons": [ "report mixes author-side PR creation and reviewer approval in one " "final handoff; split author and reviewer sessions" ], }