Add lock_provenance metadata on gitea_lock_issue writes and fail closed at gitea_create_pr when provenance is missing. Final-report validation now requires explicit External-state mutations disclosure for issue-lock read/write/delete and blocks mixed author PR creation with reviewer approval. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
269 lines
8.7 KiB
Python
269 lines
8.7 KiB
Python
"""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"
|
|
],
|
|
} |