Files
Gitea-Tools/irrecoverable_provenance.py
T
sysadminandGrok 4.5 2b359e0c26 fix(workflow): durable HMAC, dedicated mint capability, head-exact approve match (#709)
Address formal review 435 REQUEST_CHANGES on PR #710:
- F4: require durable GITEA_IRRECOVERABLE_AUTH_HMAC_KEY (fail closed; no ephemeral
  per-process secret); bind key_version into HMAC; cross-process verify works
- F5: dedicated gitea.decision_lock.irrecoverable_recovery only; reject reconciler
  equivalence; authoritative incident body + author + content_digest; reject
  self-authored incident evidence
- F3 residual: lock_targets_merged_pr_approval requires recorded-head match when
  expected_head_sha is provided (legacy no-head approve no longer primary-clears)

Co-Authored-By: Grok 4.5 (xAI) <[email protected]>
2026-07-14 02:13:47 -04:00

1210 lines
44 KiB
Python

"""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,
}