"""Server-side irrecoverable decision-lock provenance authorization (#709 AC5). Authorization is **not** a caller-supplied Boolean. A durable, non-forgeable authorization artifact must be minted under production native MCP transport (or pytest) with a dedicated mutation capability, live head binding, and validated incident evidence. Merger consumption is fail-closed and resolves only the historical-provenance blocker — never normal approval, lease, mergeability, anti-stomp, or workspace gates. """ from __future__ import annotations import hashlib import hmac import json import os import uuid from datetime import datetime, timedelta, timezone from typing import Any import mcp_daemon_guard import mcp_session_state from stale_review_decision_lock import heads_equal, normalize_head_sha # Dedicated mutation capability (#709 review 434 F1). CAPABILITY_IRRECOVERABLE_RECOVERY = "gitea.decision_lock.irrecoverable_recovery" KIND_AUTH = "irrecoverable_provenance_authorization" KIND_RECOVERY = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE CONFIRMATION_PREFIX = "IRRECOVERABLE DECISION PROVENANCE PR" # Canonical incident-comment marker (#709 review 435 F5). INCIDENT_MARKER = "IRRECOVERABLE DECISION PROVENANCE INCIDENT" AUTH_TTL_HOURS = 24.0 RECORD_TYPE = "irrecoverable_decision_provenance" AUTH_TYPE = "irrecoverable_provenance_authorization" # Durable HMAC key origin (#709 review 435 F4). Never generate an ephemeral # production key — mint/verify across reconciler/merger processes and restarts # requires a shared secret from env (or pytest constant / explicit env override). ENV_AUTH_HMAC_KEY = "GITEA_IRRECOVERABLE_AUTH_HMAC_KEY" ENV_AUTH_HMAC_KEY_VERSION = "GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION" DEFAULT_KEY_VERSION = "v1" _PYTEST_AUTH_SECRET = b"pytest-irrecoverable-auth-v1" _PYTEST_KEY_VERSION = "pytest-v1" _PROCESS_AUTH_SECRET: bytes | None = None _PROCESS_AUTH_KEY_VERSION: str | None = None _PROCESS_AUTH_SECRET_SOURCE: str | None = None class AuthSecretError(RuntimeError): """Raised when the durable HMAC signing key cannot be resolved (fail closed).""" def _now() -> datetime: return datetime.now(timezone.utc) def _now_iso() -> str: return _now().isoformat() def _decode_key_material(raw: str) -> bytes: """Decode operator-supplied key material (hex, base64, or utf-8 literal).""" text = (raw or "").strip() if not text: raise AuthSecretError("empty HMAC key material (fail closed, #709 F4)") # Prefer hex (64 chars = 32 bytes). if len(text) >= 32 and all(c in "0123456789abcdefABCDEF" for c in text): try: if len(text) % 2 == 0: decoded = bytes.fromhex(text) if len(decoded) >= 16: return decoded except ValueError: pass # base64 try: import base64 decoded = base64.b64decode(text, validate=True) if len(decoded) >= 16: return decoded except Exception: pass # utf-8 literal (min 16 chars after strip) encoded = text.encode("utf-8") if len(encoded) < 16: raise AuthSecretError( "HMAC key material too short (need >=16 bytes; fail closed, #709 F4)" ) return encoded def reset_process_auth_secret_for_tests() -> None: """Clear cached HMAC key (tests only).""" global _PROCESS_AUTH_SECRET, _PROCESS_AUTH_KEY_VERSION, _PROCESS_AUTH_SECRET_SOURCE _PROCESS_AUTH_SECRET = None _PROCESS_AUTH_KEY_VERSION = None _PROCESS_AUTH_SECRET_SOURCE = None def auth_key_version() -> str: """Return the active signing-key version string (never secret material).""" _process_secret() # ensure version is resolved with the secret return _PROCESS_AUTH_KEY_VERSION or DEFAULT_KEY_VERSION def _process_secret() -> bytes: """Resolve durable HMAC key for irrecoverable auth artifacts (#709 F4). Production (non-pytest): **requires** ``GITEA_IRRECOVERABLE_AUTH_HMAC_KEY``. Silently generating an ephemeral per-process key is forbidden — that made mint+verify fail across merger/reconciler processes and restarts. Pytest: uses a fixed constant unless the env key is set (so cross-process regression tests can inject a shared durable key). """ global _PROCESS_AUTH_SECRET, _PROCESS_AUTH_KEY_VERSION, _PROCESS_AUTH_SECRET_SOURCE if _PROCESS_AUTH_SECRET is not None: return _PROCESS_AUTH_SECRET env_raw = (os.environ.get(ENV_AUTH_HMAC_KEY) or "").strip() env_ver = (os.environ.get(ENV_AUTH_HMAC_KEY_VERSION) or "").strip() pytest = mcp_daemon_guard.is_pytest_runtime() if env_raw: _PROCESS_AUTH_SECRET = _decode_key_material(env_raw) _PROCESS_AUTH_KEY_VERSION = env_ver or ( _PYTEST_KEY_VERSION if pytest else DEFAULT_KEY_VERSION ) _PROCESS_AUTH_SECRET_SOURCE = "env" return _PROCESS_AUTH_SECRET if pytest: _PROCESS_AUTH_SECRET = _PYTEST_AUTH_SECRET _PROCESS_AUTH_KEY_VERSION = env_ver or _PYTEST_KEY_VERSION _PROCESS_AUTH_SECRET_SOURCE = "pytest_constant" return _PROCESS_AUTH_SECRET # Production fail-closed: do NOT call secrets.token_bytes. raise AuthSecretError( f"{ENV_AUTH_HMAC_KEY} is required for production irrecoverable auth HMAC; " "ephemeral per-process key generation is forbidden (fail closed, #709 F4 " "review 435). Configure a durable shared secret for mint+verify across " "processes and restarts." ) def expected_confirmation(pr_number: int) -> str: """Human intent confirmation text (not an authorization credential).""" return f"{CONFIRMATION_PREFIX} {int(pr_number)}" def auth_state_profile_identity( *, remote: str, org: str, repo: str, pr_number: int, expected_head_sha: str, ) -> str: """Stable durable key segment for one exact-scope authorization.""" head = normalize_head_sha(expected_head_sha) or "nohead" segs = [ KIND_AUTH, mcp_session_state._sanitize_segment(remote), mcp_session_state._sanitize_segment(org), mcp_session_state._sanitize_segment(repo), f"pr{int(pr_number)}", head[:16], ] return "-".join(segs) def recovery_state_profile_identity( *, remote: str, org: str, repo: str, pr_number: int, expected_head_sha: str, ) -> str: head = normalize_head_sha(expected_head_sha) or "nohead" segs = [ KIND_RECOVERY, mcp_session_state._sanitize_segment(remote), mcp_session_state._sanitize_segment(org), mcp_session_state._sanitize_segment(repo), f"pr{int(pr_number)}", head[:16], ] return "-".join(segs) def _scope_payload( *, remote: str, org: str, repo: str, pr_number: int, expected_head_sha: str, incident_issue: int, incident_comment_id: int, destroyed_subject: str | None, issuer_username: str, issuer_profile: str, created_at: str, expires_at: str, authorization_id: str, ) -> dict[str, Any]: return { "authorization_id": authorization_id, "remote": remote, "org": org, "repo": repo, "blocked_pr_number": int(pr_number), "expected_head_sha": normalize_head_sha(expected_head_sha), "incident_issue": int(incident_issue), "incident_comment_id": int(incident_comment_id), "destroyed_subject": (destroyed_subject or "").strip() or None, "issuer_username": issuer_username, "issuer_profile": issuer_profile, "created_at": created_at, "expires_at": expires_at, } def _sign_scope( scope: dict[str, Any], native_provenance: dict[str, Any], *, key_version: str | None = None, ) -> str: """HMAC over key_version + canonical scope + native transport fingerprint. The signing key itself is never serialized. Key *version* is bound into the MAC so operators can rotate durable keys without ambiguous verification. """ material = { "key_version": (key_version or auth_key_version()), "scope": scope, "native": { "native_mcp_transport": bool( native_provenance.get("native_mcp_transport") ), "production_native_mcp_transport": bool( native_provenance.get("production_native_mcp_transport") ), "token_fingerprint": native_provenance.get("token_fingerprint"), "entrypoint": native_provenance.get("entrypoint"), "pid": native_provenance.get("pid"), }, } blob = json.dumps(material, sort_keys=True, separators=(",", ":")).encode( "utf-8" ) return hmac.new(_process_secret(), blob, hashlib.sha256).hexdigest() def assess_capability_for_irrecoverable_recovery( *, allowed_operations: list[str] | None, forbidden_operations: list[str] | None = None, role_kind: str | None = None, profile_name: str | None = None, ) -> dict[str, Any]: """Whether the active profile may mint/use irrecoverable recovery (#709 F5). **Dedicated capability only.** Reconciler role / ``gitea.issue.comment`` equivalence is intentionally rejected so a reconciler cannot self-mint recovery authority by authoring its own incident comment (#709 review 435 F5). Bare ``gitea.read`` is never sufficient. """ import gitea_config allowed = list(allowed_operations or []) forbidden = list(forbidden_operations or []) reasons: list[str] = [] dedicated_ok, _ = gitea_config.check_operation( CAPABILITY_IRRECOVERABLE_RECOVERY, allowed, forbidden ) if dedicated_ok: return { "allowed": True, "capability": CAPABILITY_IRRECOVERABLE_RECOVERY, "via": "dedicated_capability", "reasons": [], } role = (role_kind or "").strip().lower() name = (profile_name or "").strip().lower() role_hint = role or name or "unknown" reasons.append( f"missing dedicated capability {CAPABILITY_IRRECOVERABLE_RECOVERY} " f"(profile={role_hint!r}; reconciler/issue.comment equivalence is " "not accepted — dedicated grant required, #709 F5 review 435; " "gitea.read alone is insufficient)" ) return { "allowed": False, "capability": CAPABILITY_IRRECOVERABLE_RECOVERY, "via": None, "reasons": reasons, } def assess_transport_for_auth_mint() -> dict[str, Any]: """Native transport required for minting non-forgeable auth artifacts.""" reasons: list[str] = [] native = mcp_daemon_guard.is_native_mcp_transport() pytest = mcp_daemon_guard.is_pytest_runtime() production = mcp_daemon_guard.is_production_native_mcp_transport() if not native and not pytest: reasons.append( "irrecoverable provenance authorization requires production native " "MCP transport; ordinary Python processes cannot mint acceptable " "recovery authorization (#709 F1)" ) return { "allowed": not reasons, "native_mcp_transport": native, "production_native_mcp_transport": production, "pytest": pytest, "reasons": reasons, } def _incident_author(comment_payload: dict[str, Any]) -> str | None: user = comment_payload.get("user") if isinstance(user, dict): login = (user.get("login") or user.get("username") or "").strip() return login or None if isinstance(user, str) and user.strip(): return user.strip() # Some payloads flatten author. for key in ("login", "author", "username"): val = comment_payload.get(key) if isinstance(val, str) and val.strip(): return val.strip() return None def canonical_incident_fields( *, remote: str, org: str, repo: str, pr_number: int, expected_head_sha: str, incident_issue: int, ) -> dict[str, str]: """Ordered fields that form the authoritative incident content digest.""" head = normalize_head_sha(expected_head_sha) or "" return { "marker": INCIDENT_MARKER, "remote": str(remote or "").strip(), "org": str(org or "").strip(), "repo": str(repo or "").strip(), "pr_number": str(int(pr_number)), "expected_head_sha": head, "incident_issue": str(int(incident_issue)), } def incident_content_digest(fields: dict[str, str]) -> str: """SHA-256 hex digest over canonical field lines (stable, sort-free order).""" # Fixed key order — do not sort; operator body must match this order. order = ( "marker", "remote", "org", "repo", "pr_number", "expected_head_sha", "incident_issue", ) lines = [f"{k}={fields[k]}" for k in order] blob = "\n".join(lines).encode("utf-8") return hashlib.sha256(blob).hexdigest() def build_canonical_incident_body( *, remote: str, org: str, repo: str, pr_number: int, expected_head_sha: str, incident_issue: int, narrative: str | None = None, ) -> str: """Build an operator-postable canonical incident comment body (#709 F5).""" fields = canonical_incident_fields( remote=remote, org=org, repo=repo, pr_number=pr_number, expected_head_sha=expected_head_sha, incident_issue=incident_issue, ) digest = incident_content_digest(fields) lines = [ fields["marker"], f"remote: {fields['remote']}", f"org: {fields['org']}", f"repo: {fields['repo']}", f"pr_number: {fields['pr_number']}", f"expected_head_sha: {fields['expected_head_sha']}", f"incident_issue: {fields['incident_issue']}", f"content_digest: {digest}", ] if narrative and narrative.strip(): lines.extend(["", narrative.strip()]) return "\n".join(lines) def _parse_incident_field(body: str, name: str) -> str | None: """Extract ``name: value`` or ``name=value`` from incident body lines.""" for line in (body or "").splitlines(): text = line.strip() if not text or text.startswith("#"): continue for sep in (":", "="): prefix = f"{name}{sep}" if text.lower().startswith(prefix.lower()): return text[len(prefix) :].strip() return None def assess_incident_evidence( *, incident_issue: int | None, incident_comment_id: int | None, comment_payload: dict[str, Any] | None, comment_lookup_error: str | None = None, expected_remote: str | None = None, expected_org: str | None = None, expected_repo: str | None = None, expected_pr_number: int | None = None, expected_head_sha: str | None = None, mint_actor_username: str | None = None, reject_self_authored: bool = True, ) -> dict[str, Any]: """Validate authoritative incident evidence (live-fetched, #709 F5). Requires: - live comment id match - non-empty author identity - canonical marker + scope fields + content_digest integrity - optional mint-actor independence (mint actor ≠ comment author) """ reasons: list[str] = [] if incident_issue is None or int(incident_issue) <= 0: reasons.append("incident_issue is required and must be a positive integer") if incident_comment_id is None or int(incident_comment_id) <= 0: reasons.append( "incident_comment_id is required and must be a positive integer" ) if comment_lookup_error: reasons.append( f"incident evidence lookup failed: {comment_lookup_error} (fail closed)" ) if not isinstance(comment_payload, dict): if not comment_lookup_error: reasons.append( "incident evidence not found or not a comment object (fail closed)" ) return {"valid": False, "reasons": reasons, "comment": None} # Gitea returns comment with id; optional issue_url / html_url for scope. cid = comment_payload.get("id") try: if int(cid) != int(incident_comment_id): # type: ignore[arg-type] reasons.append( "incident comment id mismatch against live payload (fail closed)" ) except (TypeError, ValueError): reasons.append("incident comment payload missing valid id (fail closed)") body = (comment_payload.get("body") or "").strip() if not body: reasons.append("incident comment body is empty (fail closed)") author = _incident_author(comment_payload) if not author: reasons.append( "incident comment missing author identity (fail closed, #709 F5)" ) # Self-mint prevention: minting actor must not be the sole incident author. if ( reject_self_authored and author and mint_actor_username and author.strip().lower() == str(mint_actor_username).strip().lower() ): reasons.append( "incident comment author matches mint actor; self-authored incident " "evidence is not accepted (fail closed, #709 F5 review 435)" ) # Canonical content + digest (not any non-empty body). if body and INCIDENT_MARKER not in body: reasons.append( f"incident comment missing canonical marker {INCIDENT_MARKER!r} " "(fail closed, #709 F5)" ) elif body: remote_f = _parse_incident_field(body, "remote") org_f = _parse_incident_field(body, "org") repo_f = _parse_incident_field(body, "repo") pr_f = _parse_incident_field(body, "pr_number") head_f = _parse_incident_field(body, "expected_head_sha") issue_f = _parse_incident_field(body, "incident_issue") digest_f = _parse_incident_field(body, "content_digest") if expected_remote and remote_f and remote_f != str(expected_remote).strip(): reasons.append( "incident body remote does not match expected remote (fail closed)" ) if expected_org and org_f and org_f != str(expected_org).strip(): reasons.append( "incident body org does not match expected org (fail closed)" ) if expected_repo and repo_f and repo_f != str(expected_repo).strip(): reasons.append( "incident body repo does not match expected repo (fail closed)" ) if expected_pr_number is not None: try: if pr_f is None or int(pr_f) != int(expected_pr_number): reasons.append( "incident body pr_number does not match mint PR " "(fail closed, #709 F5)" ) except (TypeError, ValueError): reasons.append( "incident body pr_number missing or invalid (fail closed)" ) if expected_head_sha: if not head_f or not heads_equal(head_f, expected_head_sha): reasons.append( "incident body expected_head_sha does not match live mint " "head (fail closed, #709 F5)" ) try: if issue_f is None or int(issue_f) != int(incident_issue): # type: ignore[arg-type] reasons.append( "incident body incident_issue does not match provided " "incident_issue (fail closed, #709 F5)" ) except (TypeError, ValueError): reasons.append( "incident body incident_issue missing or invalid (fail closed)" ) # Content digest integrity when we have enough scope to recompute. if ( remote_f and org_f and repo_f and pr_f and head_f and issue_f and digest_f ): try: fields = canonical_incident_fields( remote=remote_f, org=org_f, repo=repo_f, pr_number=int(pr_f), expected_head_sha=head_f, incident_issue=int(issue_f), ) expected_digest = incident_content_digest(fields) if not hmac.compare_digest( expected_digest.lower(), str(digest_f).strip().lower() ): reasons.append( "incident content_digest mismatch (forged or incomplete " "canonical body; fail closed, #709 F5)" ) except (TypeError, ValueError) as exc: reasons.append( f"incident content_digest recompute failed: {exc} (fail closed)" ) else: reasons.append( "incident body missing required canonical fields " "(remote/org/repo/pr_number/expected_head_sha/incident_issue/" "content_digest; fail closed, #709 F5)" ) # Hard scope check when URLs are present. issue_url = str( comment_payload.get("issue_url") or comment_payload.get("html_url") or "" ) if expected_org and issue_url and f"/{expected_org}/" not in issue_url: if expected_repo and f"/{expected_repo}/" not in issue_url: reasons.append( "incident evidence URL does not match expected repository " "(fail closed)" ) return { "valid": not reasons, "reasons": reasons, "comment": { "id": comment_payload.get("id"), "author": author, "created_at": comment_payload.get("created_at"), "body_len": len(body), "has_canonical_marker": INCIDENT_MARKER in body if body else False, }, } def assess_live_head_binding( *, expected_head_sha: str | None, live_head_sha: str | None, pr_lookup_error: str | None = None, pr_state: str | None = None, ) -> dict[str, Any]: """expected_head_sha mandatory and must equal live PR head.""" reasons: list[str] = [] want = normalize_head_sha(expected_head_sha) have = normalize_head_sha(live_head_sha) if not want: reasons.append( "expected_head_sha is mandatory and must be a non-empty SHA " "(fail closed, #709 F1)" ) if pr_lookup_error: reasons.append(f"live PR head lookup failed: {pr_lookup_error} (fail closed)") if want and not have: reasons.append("live PR head SHA unavailable (fail closed)") if want and have and not heads_equal(want, have): reasons.append( "expected_head_sha does not equal live PR head " f"(expected={want[:12]}… live={have[:12]}…; fail closed, #709 F1)" ) return { "valid": not reasons, "expected_head_sha": want, "live_head_sha": have, "pr_state": pr_state, "reasons": reasons, } def build_authorization_artifact( *, remote: str, org: str, repo: str, pr_number: int, expected_head_sha: str, incident_issue: int, incident_comment_id: int, destroyed_subject: str | None, issuer_username: str, issuer_profile: str, native_provenance: dict[str, Any] | None = None, ttl_hours: float = AUTH_TTL_HOURS, ) -> dict[str, Any]: """Build a server-side authorization artifact (caller cannot forge signature).""" provenance = dict( native_provenance or mcp_daemon_guard.mutation_provenance_fields() ) created = _now() expires = created + timedelta(hours=float(ttl_hours)) authorization_id = str(uuid.uuid4()) scope = _scope_payload( remote=remote, org=org, repo=repo, pr_number=pr_number, expected_head_sha=expected_head_sha, incident_issue=incident_issue, incident_comment_id=incident_comment_id, destroyed_subject=destroyed_subject, issuer_username=issuer_username, issuer_profile=issuer_profile, created_at=created.isoformat(), expires_at=expires.isoformat(), authorization_id=authorization_id, ) try: kv = auth_key_version() signature = _sign_scope(scope, provenance, key_version=kv) except AuthSecretError as exc: # Surface fail-closed mint: caller must not get a forgeable blank sig. raise AuthSecretError(str(exc)) from exc return { "kind": KIND_AUTH, "auth_type": AUTH_TYPE, "record_type": AUTH_TYPE, "status": "issued", "consumption_state": "issued", "consumed_at": None, "recovery_critical": True, "issue_ref": "#709", "server_signature": signature, "key_version": kv, # Never serialize the secret; only the version id. "native_provenance": provenance, **scope, "timestamp": created.isoformat(), "recorded_at": created.isoformat(), "updated_at": created.isoformat(), } def verify_authorization_artifact( auth: dict[str, Any] | None, *, remote: str, org: str, repo: str, pr_number: int, expected_head_sha: str, incident_issue: int | None = None, incident_comment_id: int | None = None, require_unconsumed: bool = True, now: datetime | None = None, ) -> dict[str, Any]: """Fail-closed verification of a server-side authorization artifact.""" reasons: list[str] = [] if not isinstance(auth, dict): return { "valid": False, "reasons": ["authorization artifact missing (fail closed)"], } if (auth.get("kind") or auth.get("auth_type") or "") not in ( KIND_AUTH, AUTH_TYPE, ) and auth.get("record_type") != AUTH_TYPE: if (auth.get("kind") or "") != KIND_AUTH: reasons.append( f"authorization kind mismatch (expected {KIND_AUTH!r}; fail closed)" ) for field, want in ( ("remote", remote), ("org", org), ("repo", repo), ): have = (str(auth.get(field) or "")).strip() if not have or have != (want or "").strip(): reasons.append( f"authorization {field} mismatch " f"(stored={have!r}, expected={want!r}; fail closed)" ) try: if int(auth.get("blocked_pr_number")) != int(pr_number): reasons.append("authorization PR number mismatch (fail closed)") except (TypeError, ValueError): reasons.append("authorization missing blocked_pr_number (fail closed)") if not heads_equal(auth.get("expected_head_sha"), expected_head_sha): reasons.append("authorization head SHA mismatch (fail closed)") if incident_issue is not None: try: if int(auth.get("incident_issue")) != int(incident_issue): reasons.append("authorization incident_issue mismatch (fail closed)") except (TypeError, ValueError): reasons.append("authorization missing incident_issue (fail closed)") if incident_comment_id is not None: try: if int(auth.get("incident_comment_id")) != int(incident_comment_id): reasons.append( "authorization incident_comment_id mismatch (fail closed)" ) except (TypeError, ValueError): reasons.append("authorization missing incident_comment_id (fail closed)") if not (auth.get("issuer_username") or "").strip(): reasons.append("authorization missing issuer_username (fail closed)") if not (auth.get("issuer_profile") or "").strip(): reasons.append("authorization missing issuer_profile (fail closed)") if not (auth.get("server_signature") or "").strip(): reasons.append("authorization missing server_signature (fail closed)") # Recompute signature over stored scope fields + key_version. try: scope = _scope_payload( remote=str(auth.get("remote") or ""), org=str(auth.get("org") or ""), repo=str(auth.get("repo") or ""), pr_number=int(auth.get("blocked_pr_number")), expected_head_sha=str(auth.get("expected_head_sha") or ""), incident_issue=int(auth.get("incident_issue")), incident_comment_id=int(auth.get("incident_comment_id")), destroyed_subject=auth.get("destroyed_subject"), issuer_username=str(auth.get("issuer_username") or ""), issuer_profile=str(auth.get("issuer_profile") or ""), created_at=str(auth.get("created_at") or ""), expires_at=str(auth.get("expires_at") or ""), authorization_id=str(auth.get("authorization_id") or ""), ) native = auth.get("native_provenance") or {} if not isinstance(native, dict): native = {} stored_kv = str(auth.get("key_version") or auth_key_version()) expected_sig = _sign_scope(scope, native, key_version=stored_kv) if not hmac.compare_digest( expected_sig, str(auth.get("server_signature") or "") ): reasons.append( "authorization server_signature invalid (forged, corrupt, or " "HMAC key mismatch across process/restart; fail closed, " "#709 F4/F1)" ) except AuthSecretError as exc: reasons.append(f"authorization HMAC key unavailable: {exc} (fail closed)") except (TypeError, ValueError) as exc: reasons.append(f"authorization scope incomplete: {exc} (fail closed)") # Native provenance required on the artifact itself. native = auth.get("native_provenance") or {} if not isinstance(native, dict) or not ( native.get("native_mcp_transport") or native.get("pytest") ): # Pytest artifacts stamp pytest=True via mutation_provenance_fields. if not mcp_daemon_guard.is_pytest_runtime(): if not (isinstance(native, dict) and native.get("native_mcp_transport")): reasons.append( "authorization lacks native transport provenance (fail closed)" ) # Expiry / consumption. now_dt = now or _now() expires_raw = auth.get("expires_at") expires_dt = None if expires_raw: text = str(expires_raw).strip() if text.endswith("Z"): text = text[:-1] + "+00:00" try: expires_dt = datetime.fromisoformat(text) if expires_dt.tzinfo is None: expires_dt = expires_dt.replace(tzinfo=timezone.utc) except ValueError: reasons.append("authorization expires_at unparseable (fail closed)") else: reasons.append("authorization missing expires_at (fail closed)") if expires_dt is not None and now_dt > expires_dt: reasons.append("authorization expired (fail closed)") state = (auth.get("consumption_state") or auth.get("status") or "").strip() if require_unconsumed and state in ("consumed", "expired"): reasons.append( f"authorization already {state}; cannot be replayed (fail closed)" ) if require_unconsumed and auth.get("consumed_at"): reasons.append("authorization already consumed (fail closed)") return { "valid": not reasons, "reasons": reasons, "authorization_id": auth.get("authorization_id"), "consumption_state": state or None, } def build_irrecoverable_provenance_record( *, pr_number: int, head_sha: str, remote: str, org: str, repo: str, actor_username: str | None, profile_name: str | None, reason: str, incident_issue: int, incident_comment_id: int, authorization: dict[str, Any], destroyed_subject: str | None = None, historical_provenance_subject: str | None = None, ) -> dict[str, Any]: """Truthful absence-of-proof record. Never sets applied=True. ``merger_may_accept`` is True only when *authorization* verifies for the exact scope. Caller-supplied Booleans are never consulted. """ head = normalize_head_sha(head_sha) auth_check = verify_authorization_artifact( authorization, remote=remote, org=org, repo=repo, pr_number=pr_number, expected_head_sha=head or "", incident_issue=incident_issue, incident_comment_id=incident_comment_id, require_unconsumed=True, ) may_accept = bool(auth_check.get("valid")) return { "event": "irrecoverable_decision_lock_provenance", "status": "provenance_irrecoverable", "record_type": RECORD_TYPE, "kind": KIND_RECOVERY, "operator_recovery_required": True, "issue_ref": "#709", "recovery_critical": True, "applied": False, "historical_cleanup_proven": False, "timestamp": _now_iso(), "pr_number": int(pr_number), "blocked_pr_number": int(pr_number), "head_sha": head, "remote": remote, "org": org, "repo": repo, "actor_username": actor_username, "profile_name": profile_name, "reason": reason, "incident_issue": int(incident_issue), "incident_comment_id": int(incident_comment_id), # Legacy field for audit readability; not a caller Boolean gate. "incident_ref": f"issue:{int(incident_issue)}/comment:{int(incident_comment_id)}", "authorization_id": authorization.get("authorization_id"), "authorization_issuer": authorization.get("issuer_username"), "authorization_issuer_profile": authorization.get("issuer_profile"), "authorization_verified": may_accept, "authorization_verify_reasons": list(auth_check.get("reasons") or []), "destroyed_subject": (destroyed_subject or "").strip() or None, "historical_provenance_subject": ( (historical_provenance_subject or destroyed_subject or "").strip() or None ), "consumption_state": "issued", "consumed_at": None, "merger_may_accept": may_accept, "acceptance_rule": ( "Merger may accept this record only when a server-side authorization " "artifact verifies for remote/org/repo/PR/exact-head/incident, the " "record is durable and read back, the auth is unexpired and unconsumed, " "and normal merge gates still pass. Resolves only the historical " "prior-provenance blocker; never proves historical cleanup " "(applied=false, historical_cleanup_proven=false)." ), "native_provenance": mcp_daemon_guard.mutation_provenance_fields(), } def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str: """Markdown body for irrecoverable provenance audit (no applied=true claim).""" lines = [ "## Irrecoverable decision-lock provenance (#709)", "", "Status: **PROVENANCE_IRRECOVERABLE** (not applied cleanup)", "", f"- actor: `{record.get('actor_username')}`", f"- profile: `{record.get('profile_name')}`", f"- timestamp: `{record.get('timestamp')}`", f"- PR: `#{record.get('pr_number')}`", f"- head_sha: `{record.get('head_sha')}`", f"- incident_issue: `{record.get('incident_issue')}`", f"- incident_comment_id: `{record.get('incident_comment_id')}`", f"- authorization_id: `{record.get('authorization_id')}`", f"- authorization_issuer: `{record.get('authorization_issuer')}`", f"- authorization_verified: `{record.get('authorization_verified')}`", f"- historical_cleanup_proven: `{record.get('historical_cleanup_proven')}`", f"- applied: `{record.get('applied')}` (must remain false)", f"- merger_may_accept: `{record.get('merger_may_accept')}`", f"- destroyed_subject: `{record.get('destroyed_subject')}`", "", f"Reason: {record.get('reason')}", "", "This record documents **absence of proof**, not successful cleanup.", "It must not be reused for a different PR or head (#709 AC6).", "Authorization is a server-side artifact — not a caller Boolean.", ] return "\n".join(lines) def assess_merger_consumption( recovery: dict[str, Any] | None, authorization: dict[str, Any] | None, *, remote: str, org: str, repo: str, pr_number: int, live_head_sha: str | None, # Normal merge gate outcomes (must still pass independently). approval_at_current_head: bool | None = None, has_blocking_change_requests: bool | None = None, mergeable: bool | None = None, lease_ok: bool | None = None, runtime_ok: bool | None = None, workspace_ok: bool | None = None, anti_stomp_ok: bool | None = None, now: datetime | None = None, ) -> dict[str, Any]: """Fail-closed merger assessment for consuming an irrecoverable recovery. Resolves **only** the historical prior-provenance blocker when all recovery checks pass. Never grants a pass when normal merge gates fail. """ reasons: list[str] = [] result: dict[str, Any] = { "allowed": False, "resolves_prior_provenance_blocker": False, "historical_cleanup_proven": False, "irrecoverable_recovery_authorized": False, "recovery_record_consumed": False, "reasons": reasons, "normal_gates": { "approval_at_current_head": approval_at_current_head, "has_blocking_change_requests": has_blocking_change_requests, "mergeable": mergeable, "lease_ok": lease_ok, "runtime_ok": runtime_ok, "workspace_ok": workspace_ok, "anti_stomp_ok": anti_stomp_ok, }, } if not isinstance(recovery, dict): reasons.append("recovery record missing (fail closed)") return result if (recovery.get("record_type") or recovery.get("kind")) not in ( RECORD_TYPE, KIND_RECOVERY, "irrecoverable_decision_provenance", ): if recovery.get("status") != "provenance_irrecoverable": reasons.append("recovery record type/status invalid (fail closed)") if recovery.get("applied") is True: reasons.append( "recovery record claims applied=true; refuse (fabrication, fail closed)" ) if recovery.get("historical_cleanup_proven") is True: reasons.append( "recovery record claims historical_cleanup_proven=true; refuse " "(fail closed)" ) for field, want in (("remote", remote), ("org", org), ("repo", repo)): have = (str(recovery.get(field) or "")).strip() if have != (want or "").strip(): reasons.append( f"recovery {field} mismatch (stored={have!r}, expected={want!r})" ) try: if int(recovery.get("pr_number") or recovery.get("blocked_pr_number")) != int( pr_number ): reasons.append("recovery PR number mismatch (fail closed)") except (TypeError, ValueError): reasons.append("recovery missing pr_number (fail closed)") if not heads_equal(recovery.get("head_sha"), live_head_sha): reasons.append( "recovery head SHA does not match live PR head (fail closed)" ) if recovery.get("consumption_state") == "consumed" or recovery.get("consumed_at"): # Idempotent: already consumed for this exact scope is OK if head matches. result["recovery_record_consumed"] = True reasons.append("recovery record already consumed (idempotent check)") auth_check = verify_authorization_artifact( authorization, remote=remote, org=org, repo=repo, pr_number=pr_number, expected_head_sha=str(live_head_sha or ""), incident_issue=recovery.get("incident_issue"), incident_comment_id=recovery.get("incident_comment_id"), # When recovery already consumed, allow already-consumed auth for # idempotent re-report; otherwise require unconsumed. require_unconsumed=not result["recovery_record_consumed"], now=now, ) if not auth_check.get("valid"): reasons.extend(auth_check.get("reasons") or ["authorization invalid"]) else: result["irrecoverable_recovery_authorized"] = True if not recovery.get("merger_may_accept") and not result["recovery_record_consumed"]: reasons.append( "recovery record merger_may_accept is false (fail closed)" ) # Auth id binding. if ( authorization and recovery.get("authorization_id") and authorization.get("authorization_id") and recovery.get("authorization_id") != authorization.get("authorization_id") ): reasons.append("recovery authorization_id does not match artifact (fail closed)") # Normal gates: if explicitly False, refuse consumption as merge-authorizing. normal_blockers: list[str] = [] if approval_at_current_head is False: normal_blockers.append("missing/stale approval at current head") if has_blocking_change_requests is True: normal_blockers.append("blocking change requests present") if mergeable is False: normal_blockers.append("PR not mergeable") if lease_ok is False: normal_blockers.append("lease gate failed") if runtime_ok is False: normal_blockers.append("runtime gate failed") if workspace_ok is False: normal_blockers.append("workspace gate failed") if anti_stomp_ok is False: normal_blockers.append("anti-stomp gate failed") if normal_blockers: reasons.append( "recovery cannot bypass normal merge gates: " + "; ".join(normal_blockers) + " (#709 F2)" ) result["resolves_prior_provenance_blocker"] = False result["allowed"] = False result["reasons"] = reasons return result # Filter pure informational "already consumed" when everything else matches # for idempotent success. hard = [ r for r in reasons if "already consumed" not in r ] if not hard and result["irrecoverable_recovery_authorized"]: result["allowed"] = True result["resolves_prior_provenance_blocker"] = True result["historical_cleanup_proven"] = False if result["recovery_record_consumed"]: reasons.append( "idempotent: prior-provenance blocker already resolved for this scope" ) else: reasons.append( "prior-provenance blocker may be resolved by consuming this record " "(historical cleanup remains unproven)" ) result["reasons"] = reasons return result def mark_consumed( record: dict[str, Any], *, consumer_username: str | None, consumer_profile: str | None, ) -> dict[str, Any]: """Return a copy of *record* marked consumed (crash-safe write is caller's job).""" out = dict(record) out["consumption_state"] = "consumed" out["status"] = out.get("status") or "issued" if out.get("kind") == KIND_AUTH or out.get("auth_type") == AUTH_TYPE: out["status"] = "consumed" out["consumed_at"] = _now_iso() out["consumed_by"] = consumer_username out["consumed_by_profile"] = consumer_profile out["updated_at"] = out["consumed_at"] return out def assess_profile_path_identity(profile_identity: str | None) -> dict[str, Any]: """Reject traversal / malformed profile identity segments (#709 F3).""" reasons: list[str] = [] raw = profile_identity if profile_identity is not None else "" text = str(raw) if not text.strip(): reasons.append("profile identity empty (fail closed)") return {"valid": False, "reasons": reasons, "sanitized": None} if text != text.strip(): reasons.append("profile identity has surrounding whitespace (fail closed)") if ".." in text or "/" in text or "\\" in text or "\x00" in text: reasons.append( "profile identity contains path traversal or separator characters " "(fail closed, #709 F3)" ) if text.startswith("-") or text.startswith("."): reasons.append("profile identity has unsafe leading character (fail closed)") # After sanitize, must not collapse to something that collides emptily. sanitized = mcp_session_state._sanitize_segment(text) if sanitized in ("_", ""): reasons.append("profile identity sanitizes to empty (fail closed)") if sanitized != text and any(c in text for c in ("..", "/", "\\")): # Already covered; keep fail closed. pass return { "valid": not reasons, "reasons": reasons, "sanitized": sanitized if not reasons else None, }