Files
Gitea-Tools/irrecoverable_provenance.py
T
sysadminandClaude Opus 4.8 573e721437 fix(workflow): fail-closed key version, strict incident evidence, archive-gated clear (#709)
Address formal review 438 REQUEST_CHANGES on PR #710 (F6/F7/F8).

F6 — HMAC key-version validation fails closed:
- verify_authorization_artifact validates key_version BEFORE any MAC work, so an
  attacker-chosen version can never select the signing key.
- Require exactly one nonempty, well-formed key-version field; missing, empty,
  unknown, malformed, duplicated (including identical-valued and nested
  aliases), and mismatched versions all fail. No versionless legacy fallback.
- Artifact version must equal the configured active version; production now
  requires GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION explicitly (an implicit
  default made rotation ambiguous). Version stays inside the signed material.

F7 — strictly canonical incident evidence:
- Replace substring/first-match parsing with an exact schema: marker on line 1,
  every field once, fixed order, no duplicate/unknown/empty/conflicting fields
  in or outside the signed block. The parsed body is re-rendered and compared
  for exact equality before acceptance.
- content_digest now binds the full recovery scope: repository identity, PR,
  decision-lock identity, destroyed subject, recovery action, recorded and
  expected head, incident issue, evidence author, minting actor, key version,
  nonce and issued_at.
- Actor identity is the immutable user id with login consistency; conflicting
  ids/logins and display-name-only identities fail closed. Edited comments are
  rejected. The independent-author rule is preserved and enforced by stable id.
- build_canonical_incident_body is the single source of the accepted format and
  refuses to emit ambiguous evidence.

F8 — archival is a prerequisite for clearing terminal evidence:
- _clear_decision_lock_for_profile no longer swallows archive failures. It
  requires a successful write plus a durable read-back matching the PR/head,
  and otherwise returns a structured, retry-safe failure that retains the lock
  and records actionable recovery evidence.
- Fixes a latent bug the read-back exposed: the archive payload inherited the
  source lock's session_profile_lock, so save_state keyed the archive under the
  reviewer profile instead of the archive identity and it never read back.

Adversarial regressions added for every listed case: key-version missing/empty/
unknown/malformed/duplicate/rotation/wrong-key-after-restart, reordered fields,
duplicate identical and conflicting fields, conflicting actor ids/names,
digest-preserving substitution, decision-lock and recovery-action substitution,
cross-PR/repo/org/remote/head replay, archive exception/timeout/false/empty/
partial-readback with proof the lock survives, exactly-one permitted clear, and
retry after archive failure. No existing assertion was weakened.

Validation: focused 150 passed in three module orders; full tests/ 2809 passed,
6 skipped, 1 warning (pre-existing StarletteDeprecationWarning in
tests/test_webui_audit.py:8), 161 subtests passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-15 13:55:06 -04:00

1723 lines
64 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 re
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"
# #709 F7 (review 438): strictly canonical incident evidence. The digest binds
# every security-relevant field, so evidence cannot be replayed or substituted
# across repositories, PRs, decision locks, heads, actors, or recovery actions.
INCIDENT_SCHEMA_VERSION = "2"
RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE = "irrecoverable_decision_lock_provenance"
SUPPORTED_RECOVERY_ACTIONS = frozenset({RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE})
INCIDENT_FIELD_ORDER = (
"schema_version",
"remote",
"org",
"repo",
"pr_number",
"decision_lock_id",
"destroyed_subject",
"recovery_action",
"recorded_head_sha",
"expected_head_sha",
"incident_issue",
"evidence_author_id",
"evidence_author_login",
"mint_actor_id",
"mint_actor_login",
"key_version",
"nonce",
"issued_at",
)
INCIDENT_DIGEST_FIELD = "content_digest"
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"
# #709 F6 (review 438): key version is part of the authenticated data and must
# be present exactly once, well-formed, and equal to the configured active
# version. There is deliberately no legacy fallback for versionless artifacts.
KEY_VERSION_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
KEY_VERSION_FIELD = "key_version"
# Any of these aliases anywhere in the artifact counts as a key-version field;
# more than one occurrence is a duplicate and fails closed even when the values
# are identical.
_KEY_VERSION_ALIASES = (
"key_version",
"keyVersion",
"key-version",
"auth_key_version",
)
_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 _validate_key_version_text(value: Any, *, source: str) -> tuple[str, list[str]]:
"""Validate a key-version string. Never echoes key material — versions only."""
if isinstance(value, bool) or not isinstance(value, str):
return "", [
f"{source} key version is malformed (must be a string; fail closed, #709 F6)"
]
text = value.strip()
if not text:
return "", [f"{source} key version is empty (fail closed, #709 F6)"]
if value != text:
return "", [
f"{source} key version has surrounding whitespace (fail closed, #709 F6)"
]
if not KEY_VERSION_RE.match(text):
return "", [
f"{source} key version is malformed (allowed charset A-Za-z0-9._-, "
"1-64 chars; fail closed, #709 F6)"
]
return text, []
def auth_key_version() -> str:
"""Return the active configured signing-key version (never secret material)."""
_process_secret() # ensure version is resolved with the secret
version = _PROCESS_AUTH_KEY_VERSION or ""
if not version:
raise AuthSecretError(
"active HMAC key version is unresolved (fail closed, #709 F6 review 438)"
)
return version
def supported_key_versions() -> tuple[str, ...]:
"""Versions accepted for verification.
Exactly one version — the configured active one — is honored. Rotation is
performed by changing ``GITEA_IRRECOVERABLE_AUTH_HMAC_KEY_VERSION`` (and the
key); artifacts minted under a superseded version stop verifying, which is
the intended fail-closed rotation behavior (#709 F6 review 438).
"""
return (auth_key_version(),)
def extract_artifact_key_version(auth: dict[str, Any] | None) -> dict[str, Any]:
"""Require exactly one nonempty, well-formed key-version field (#709 F6).
Missing, empty, malformed, or duplicated (even identical-valued) key-version
fields fail closed. Versionless artifacts are never accepted — there is no
legacy fallback.
"""
if not isinstance(auth, dict):
return {
"valid": False,
"key_version": None,
"reasons": ["authorization artifact missing (fail closed)"],
}
containers: list[tuple[str, dict[str, Any]]] = [("artifact", auth)]
for nested in ("native_provenance", "scope"):
value = auth.get(nested)
if isinstance(value, dict):
containers.append((nested, value))
seen: list[tuple[str, Any]] = []
for container_name, container in containers:
for alias in _KEY_VERSION_ALIASES:
if alias in container:
seen.append((f"{container_name}.{alias}", container[alias]))
if not seen:
return {
"valid": False,
"key_version": None,
"reasons": [
"authorization is missing key_version; versionless artifacts are "
"never accepted (no legacy fallback; fail closed, #709 F6 review 438)"
],
}
if len(seen) > 1:
names = ", ".join(sorted(name for name, _ in seen))
return {
"valid": False,
"key_version": None,
"reasons": [
f"authorization carries duplicate key-version fields ({names}); "
"exactly one is required, even when the values are identical "
"(fail closed, #709 F6 review 438)"
],
}
name, raw = seen[0]
text, reasons = _validate_key_version_text(raw, source=f"authorization {name}")
if reasons:
return {"valid": False, "key_version": None, "reasons": reasons}
return {"valid": True, "key_version": text, "reasons": []}
def assess_artifact_key_version(
auth: dict[str, Any] | None,
*,
expected_version: str | None = None,
) -> dict[str, Any]:
"""Artifact key version must exactly equal the configured active version."""
extracted = extract_artifact_key_version(auth)
if not extracted.get("valid"):
return extracted
have = str(extracted.get("key_version") or "")
try:
want = (expected_version or "").strip() or auth_key_version()
except AuthSecretError as exc:
return {
"valid": False,
"key_version": None,
"reasons": [
f"configured HMAC key version unavailable: {exc} "
"(fail closed, #709 F6)"
],
}
if have != want:
return {
"valid": False,
"key_version": None,
"reasons": [
f"authorization key_version {have!r} is unknown or does not match "
f"the configured expected version {want!r} (fail closed, #709 F6 "
"review 438)"
],
}
return {"valid": True, "key_version": have, "reasons": []}
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:
secret = _decode_key_material(env_raw)
if not env_ver and not pytest:
# #709 F6 (review 438): production must configure the key version
# explicitly; silently defaulting it makes rotation ambiguous.
raise AuthSecretError(
f"{ENV_AUTH_HMAC_KEY_VERSION} is required for production "
"irrecoverable auth HMAC; an implicit default key version is "
"forbidden (fail closed, #709 F6 review 438)"
)
version, version_reasons = _validate_key_version_text(
env_ver or (_PYTEST_KEY_VERSION if pytest else ""),
source="configured",
)
if version_reasons:
raise AuthSecretError("; ".join(version_reasons))
_PROCESS_AUTH_SECRET = secret
_PROCESS_AUTH_KEY_VERSION = version
_PROCESS_AUTH_SECRET_SOURCE = "env"
return _PROCESS_AUTH_SECRET
if pytest:
version, version_reasons = _validate_key_version_text(
env_ver or _PYTEST_KEY_VERSION, source="configured"
)
if version_reasons:
raise AuthSecretError("; ".join(version_reasons))
_PROCESS_AUTH_SECRET = _PYTEST_AUTH_SECRET
_PROCESS_AUTH_KEY_VERSION = 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_actor_identity(payload: dict[str, Any] | None) -> dict[str, Any]:
"""Resolve one stable actor identity from a comment payload (#709 F7).
Uses the immutable numeric user id as the primary identity and requires the
login to be internally consistent. Multiple or conflicting ids/logins (e.g.
``user.login`` disagreeing with ``user.username``) fail closed rather than
silently preferring one string (review 438 F7).
"""
reasons: list[str] = []
if not isinstance(payload, dict):
return {
"valid": False,
"user_id": None,
"login": None,
"reasons": ["actor payload missing (fail closed, #709 F7)"],
}
ids: set[int] = set()
logins: set[str] = set()
def _add_id(value: Any) -> None:
if isinstance(value, bool) or value is None:
return
if isinstance(value, int):
ids.add(int(value))
elif isinstance(value, str) and value.strip().lstrip("-").isdigit():
ids.add(int(value.strip()))
def _add_login(value: Any) -> None:
if isinstance(value, str) and value.strip():
logins.add(value.strip())
user = payload.get("user")
if isinstance(user, dict):
_add_id(user.get("id"))
_add_id(user.get("user_id"))
_add_login(user.get("login"))
_add_login(user.get("username"))
elif isinstance(user, str):
_add_login(user)
for key in ("login", "author", "username"):
_add_login(payload.get(key))
_add_id(payload.get("user_id"))
if len(logins) > 1:
reasons.append(
"incident comment author has conflicting logins "
f"({sorted(logins)!r}); a single stable identity is required "
"(fail closed, #709 F7 review 438)"
)
if len(ids) > 1:
reasons.append(
"incident comment author has conflicting user ids "
f"({sorted(ids)!r}); a single stable identity is required "
"(fail closed, #709 F7 review 438)"
)
if not ids:
reasons.append(
"incident comment author is missing a stable user id; "
"display-name-only identity is not accepted "
"(fail closed, #709 F7 review 438)"
)
if not logins:
reasons.append(
"incident comment author login is missing (fail closed, #709 F7)"
)
return {
"valid": not reasons,
"user_id": next(iter(ids)) if len(ids) == 1 else None,
"login": next(iter(logins)) if len(logins) == 1 else None,
"reasons": reasons,
}
def canonical_incident_fields(
*,
remote: str,
org: str,
repo: str,
pr_number: int,
decision_lock_id: str,
destroyed_subject: str,
recovery_action: str,
recorded_head_sha: str,
expected_head_sha: str,
incident_issue: int,
evidence_author_id: int,
evidence_author_login: str,
mint_actor_id: int,
mint_actor_login: str,
key_version: str,
nonce: str,
issued_at: str,
) -> dict[str, str]:
"""Ordered fields that form the authoritative incident content digest.
Every security-relevant element of the recovery scope is bound here so the
digest cannot be replayed across repositories, PRs, decision locks, heads,
actors, or recovery actions (#709 F7 review 438).
"""
action = str(recovery_action or "").strip()
if action not in SUPPORTED_RECOVERY_ACTIONS:
raise ValueError(
f"unsupported recovery_action {action!r}; supported="
f"{sorted(SUPPORTED_RECOVERY_ACTIONS)!r} (fail closed, #709 F7)"
)
fields = {
"schema_version": INCIDENT_SCHEMA_VERSION,
"remote": str(remote or ""),
"org": str(org or ""),
"repo": str(repo or ""),
"pr_number": str(int(pr_number)),
"decision_lock_id": str(decision_lock_id or ""),
"destroyed_subject": str(destroyed_subject or ""),
"recovery_action": action,
"recorded_head_sha": normalize_head_sha(recorded_head_sha) or "",
"expected_head_sha": normalize_head_sha(expected_head_sha) or "",
"incident_issue": str(int(incident_issue)),
"evidence_author_id": str(int(evidence_author_id)),
"evidence_author_login": str(evidence_author_login or ""),
"mint_actor_id": str(int(mint_actor_id)),
"mint_actor_login": str(mint_actor_login or ""),
"key_version": str(key_version or ""),
"nonce": str(nonce or ""),
"issued_at": str(issued_at or ""),
}
for name in INCIDENT_FIELD_ORDER:
value = fields[name]
if not value.strip():
raise ValueError(
f"canonical incident field {name!r} is empty (fail closed, #709 F7)"
)
if value != value.strip():
raise ValueError(
f"canonical incident field {name!r} has surrounding whitespace "
"(fail closed, #709 F7)"
)
if "\n" in value or "\r" in value:
raise ValueError(
f"canonical incident field {name!r} contains a newline; ambiguous "
"evidence cannot be built (fail closed, #709 F7)"
)
return fields
def incident_content_digest(fields: dict[str, str]) -> str:
"""SHA-256 hex digest over the canonical field lines (fixed order, no sort)."""
lines = [INCIDENT_MARKER]
for name in INCIDENT_FIELD_ORDER:
if name not in fields:
raise ValueError(
f"canonical incident field {name!r} missing for digest "
"(fail closed, #709 F7)"
)
lines.append(f"{name}={fields[name]}")
blob = "\n".join(lines).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
def render_canonical_incident_block(fields: dict[str, str]) -> str:
"""Render the one accepted canonical representation of *fields*."""
lines = [INCIDENT_MARKER]
lines.extend(f"{name}: {fields[name]}" for name in INCIDENT_FIELD_ORDER)
lines.append(f"{INCIDENT_DIGEST_FIELD}: {incident_content_digest(fields)}")
return "\n".join(lines)
def _narrative_is_ambiguous(narrative: str) -> list[str]:
"""Reject narrative text that could be mistaken for canonical evidence."""
reasons: list[str] = []
known = set(INCIDENT_FIELD_ORDER) | {INCIDENT_DIGEST_FIELD}
for line in narrative.split("\n"):
text = line.strip()
if text == INCIDENT_MARKER:
reasons.append(
"narrative repeats the canonical marker (ambiguous evidence)"
)
continue
if ":" in text and text.split(":", 1)[0].strip() in known:
reasons.append(
f"narrative contains canonical field line {text.split(':', 1)[0]!r} "
"(ambiguous evidence)"
)
return reasons
def build_canonical_incident_body(
*,
remote: str,
org: str,
repo: str,
pr_number: int,
decision_lock_id: str,
destroyed_subject: str,
recovery_action: str,
recorded_head_sha: str,
expected_head_sha: str,
incident_issue: int,
evidence_author_id: int,
evidence_author_login: str,
mint_actor_id: int,
mint_actor_login: str,
key_version: str,
nonce: str,
issued_at: str,
narrative: str | None = None,
) -> str:
"""Build the one accepted canonical incident comment body (#709 F7).
The builder is the single source of the accepted format and refuses to emit
anything ambiguous: empty/multiline fields, unsupported recovery actions, and
narrative text that mimics canonical evidence all raise ``ValueError``. Its
own output is re-parsed before return, so a body this function produces
always validates as canonical.
"""
fields = canonical_incident_fields(
remote=remote,
org=org,
repo=repo,
pr_number=pr_number,
decision_lock_id=decision_lock_id,
destroyed_subject=destroyed_subject,
recovery_action=recovery_action,
recorded_head_sha=recorded_head_sha,
expected_head_sha=expected_head_sha,
incident_issue=incident_issue,
evidence_author_id=evidence_author_id,
evidence_author_login=evidence_author_login,
mint_actor_id=mint_actor_id,
mint_actor_login=mint_actor_login,
key_version=key_version,
nonce=nonce,
issued_at=issued_at,
)
body = render_canonical_incident_block(fields)
if narrative and narrative.strip():
text = narrative.strip()
ambiguous = _narrative_is_ambiguous(text)
if ambiguous:
raise ValueError(
"; ".join(ambiguous) + " (fail closed, #709 F7 review 438)"
)
body = f"{body}\n\n{text}"
check = parse_canonical_incident_body(body)
if not check.get("valid"):
raise ValueError(
"builder produced a non-canonical body: "
+ "; ".join(check.get("reasons") or ["unknown"])
)
return body
def parse_canonical_incident_body(body: str | None) -> dict[str, Any]:
"""Strictly parse the canonical incident block (#709 F7 review 438).
Enforces the exact schema: marker on the first line, every field present
exactly once, in fixed canonical order, with no unknown, duplicate,
conflicting, or empty fields anywhere in the body.
"""
reasons: list[str] = []
text = (body or "").replace("\r\n", "\n").replace("\r", "\n").strip("\n")
if not text.strip():
return {
"valid": False,
"reasons": ["incident comment body is empty (fail closed)"],
"fields": {},
}
lines = text.split("\n")
if lines[0] != INCIDENT_MARKER:
return {
"valid": False,
"reasons": [
f"incident body line 1 must be exactly {INCIDENT_MARKER!r} "
"(canonical marker position; fail closed, #709 F7 review 438)"
],
"fields": {},
}
order = list(INCIDENT_FIELD_ORDER) + [INCIDENT_DIGEST_FIELD]
block_len = 1 + len(order)
if len(lines) < block_len:
return {
"valid": False,
"reasons": [
"incident body canonical block is incomplete; every field is "
"required exactly once in canonical order (fail closed, #709 F7)"
],
"fields": {},
}
fields: dict[str, str] = {}
for index, name in enumerate(order, start=1):
line = lines[index]
prefix = f"{name}: "
if not line.startswith(prefix):
return {
"valid": False,
"reasons": [
f"incident body line {index + 1} must be field {name!r} in "
"canonical order (reordered, duplicated, missing, or unknown "
"field; fail closed, #709 F7 review 438)"
],
"fields": {},
}
value = line[len(prefix) :]
if not value.strip():
reasons.append(
f"incident field {name!r} is empty (fail closed, #709 F7)"
)
elif value != value.strip():
reasons.append(
f"incident field {name!r} has surrounding whitespace "
"(fail closed, #709 F7)"
)
fields[name] = value
# Nothing outside the canonical block may restate the marker or any field.
known = set(order)
tail = lines[block_len:]
if tail and tail[0].strip():
reasons.append(
"narrative must be separated from the canonical block by a blank "
"line (fail closed, #709 F7)"
)
for line in tail:
stripped = line.strip()
if stripped == INCIDENT_MARKER:
reasons.append(
"incident body repeats the canonical marker outside the signed "
"block (ambiguous evidence; fail closed, #709 F7 review 438)"
)
continue
if ":" in stripped and stripped.split(":", 1)[0].strip() in known:
duplicated = stripped.split(":", 1)[0].strip()
reasons.append(
f"incident body restates canonical field {duplicated!r} outside "
"the signed block (duplicate/conflicting; fail closed, #709 F7)"
)
return {
"valid": not reasons,
"reasons": reasons,
"fields": fields,
"canonical_block": "\n".join(lines[:block_len]),
}
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,
expected_recorded_head_sha: str | None = None,
expected_decision_lock_id: str | None = None,
expected_recovery_action: str | None = None,
expected_key_version: str | None = None,
mint_actor_id: int | None = None,
mint_actor_username: str | None = None,
reject_self_authored: bool = True,
) -> dict[str, Any]:
"""Validate strictly canonical, fully scoped incident evidence (#709 F7).
The body must be exactly the one canonical representation produced by
:func:`build_canonical_incident_body`: marker first, every field present
once in fixed order, no duplicates/unknowns/conflicts, digest binding the
complete recovery scope, and a single stable actor identity independent of
the minting actor. Reordered, duplicated, conflicting, replayed, or
substituted evidence fails closed (review 438 F7).
"""
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}
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)")
# Edited evidence is not authoritative (#709 F5 review 435).
created_at = str(comment_payload.get("created_at") or "").strip()
updated_at = str(comment_payload.get("updated_at") or "").strip()
if updated_at and created_at and updated_at != created_at:
reasons.append(
"incident comment has been edited after creation; edited evidence is "
"not authoritative (fail closed, #709 F5 review 435)"
)
actor = incident_actor_identity(comment_payload)
if not actor.get("valid"):
reasons.extend(actor.get("reasons") or [])
parsed = parse_canonical_incident_body(comment_payload.get("body"))
fields = parsed.get("fields") or {}
if not parsed.get("valid"):
reasons.extend(parsed.get("reasons") or [])
else:
# Every field is bound to the exact live recovery scope.
def _match(name: str, expected: Any, *, label: str | None = None) -> None:
if expected is None or str(expected).strip() == "":
return
have = fields.get(name, "")
if have != str(expected).strip():
reasons.append(
f"incident body {label or name} does not match the live "
f"recovery scope (fail closed, #709 F7 review 438)"
)
if fields.get("schema_version") != INCIDENT_SCHEMA_VERSION:
reasons.append(
f"incident schema_version must be {INCIDENT_SCHEMA_VERSION!r} "
"(fail closed, #709 F7)"
)
_match("remote", expected_remote)
_match("org", expected_org)
_match("repo", expected_repo)
_match("decision_lock_id", expected_decision_lock_id)
_match("recovery_action", expected_recovery_action)
_match("key_version", expected_key_version)
if fields.get("recovery_action") not in SUPPORTED_RECOVERY_ACTIONS:
reasons.append(
f"incident recovery_action {fields.get('recovery_action')!r} is "
"not a supported recovery action (fail closed, #709 F7)"
)
if expected_pr_number is not None:
try:
if int(fields.get("pr_number", "")) != int(expected_pr_number):
reasons.append(
"incident body pr_number does not match mint PR "
"(fail closed, #709 F7)"
)
except (TypeError, ValueError):
reasons.append("incident body pr_number invalid (fail closed)")
try:
if int(fields.get("incident_issue", "")) != int(incident_issue): # type: ignore[arg-type]
reasons.append(
"incident body incident_issue does not match provided "
"incident_issue (fail closed, #709 F7)"
)
except (TypeError, ValueError):
reasons.append("incident body incident_issue invalid (fail closed)")
if expected_head_sha and not heads_equal(
fields.get("expected_head_sha"), expected_head_sha
):
reasons.append(
"incident body expected_head_sha does not match the live mint "
"head (fail closed, #709 F7)"
)
if expected_recorded_head_sha and not heads_equal(
fields.get("recorded_head_sha"), expected_recorded_head_sha
):
reasons.append(
"incident body recorded_head_sha does not match the recorded "
"decision-lock head (fail closed, #709 F7 review 438)"
)
# Evidence author must be the actual live comment author.
if actor.get("valid"):
try:
if int(fields.get("evidence_author_id", "")) != int(
actor.get("user_id")
):
reasons.append(
"incident body evidence_author_id does not match the live "
"comment author (fail closed, #709 F7 review 438)"
)
except (TypeError, ValueError):
reasons.append(
"incident body evidence_author_id invalid (fail closed)"
)
if fields.get("evidence_author_login") != str(actor.get("login") or ""):
reasons.append(
"incident body evidence_author_login does not match the live "
"comment author (fail closed, #709 F7 review 438)"
)
# Minting actor must be the live caller, and must not be the author.
if mint_actor_id is not None:
try:
if int(fields.get("mint_actor_id", "")) != int(mint_actor_id):
reasons.append(
"incident body mint_actor_id does not match the minting "
"actor (fail closed, #709 F7 review 438)"
)
except (TypeError, ValueError):
reasons.append("incident body mint_actor_id invalid (fail closed)")
if mint_actor_username and fields.get("mint_actor_login") != str(
mint_actor_username
).strip():
reasons.append(
"incident body mint_actor_login does not match the minting actor "
"(fail closed, #709 F7 review 438)"
)
# Digest binds the complete canonical content.
try:
expected_digest = incident_content_digest(fields)
if not hmac.compare_digest(
expected_digest.lower(),
str(fields.get(INCIDENT_DIGEST_FIELD, "")).strip().lower(),
):
reasons.append(
"incident content_digest mismatch (forged, substituted, or "
"incomplete canonical body; fail closed, #709 F7)"
)
except (TypeError, ValueError) as exc:
reasons.append(
f"incident content_digest recompute failed: {exc} (fail closed)"
)
# Reconstruct the exact canonical representation and require equality.
try:
rebuilt = render_canonical_incident_block(fields)
if rebuilt != parsed.get("canonical_block"):
reasons.append(
"incident body is not the exact canonical representation "
"(fail closed, #709 F7 review 438)"
)
except (TypeError, ValueError) as exc:
reasons.append(
f"incident canonical reconstruction failed: {exc} (fail closed)"
)
# Independent-author requirement (#709 F5 review 435), by stable identity.
if reject_self_authored and actor.get("valid"):
if mint_actor_id is not None and int(actor.get("user_id") or -1) == int(
mint_actor_id
):
reasons.append(
"incident comment author is the minting actor; self-authored "
"incident evidence is not accepted (fail closed, #709 F5 review 435)"
)
elif (
mint_actor_username
and str(actor.get("login") or "").strip().lower()
== str(mint_actor_username).strip().lower()
):
reasons.append(
"incident comment author is the minting actor; self-authored "
"incident evidence is not accepted (fail closed, #709 F5 review 435)"
)
# 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,
"fields": fields,
"comment": {
"id": comment_payload.get("id"),
"author": actor.get("login"),
"author_id": actor.get("user_id"),
"created_at": comment_payload.get("created_at"),
"canonical": bool(parsed.get("valid")),
},
}
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)")
# #709 F6 (review 438): validate key version *before* any MAC work so an
# attacker-chosen version can never select the signing key. Missing, empty,
# malformed, duplicated, unknown, and mismatched versions all fail closed.
key_version_gate = assess_artifact_key_version(auth)
verified_key_version = (
key_version_gate.get("key_version") if key_version_gate.get("valid") else None
)
if not key_version_gate.get("valid"):
reasons.extend(
key_version_gate.get("reasons")
or ["authorization key_version invalid (fail closed, #709 F6)"]
)
# Recompute signature over stored scope fields + verified key_version.
if verified_key_version is None:
reasons.append(
"authorization server_signature not verified: key version failed "
"validation (fail closed, #709 F6 review 438)"
)
else:
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 = {}
expected_sig = _sign_scope(
scope, native, key_version=verified_key_version
)
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,
"key_version": verified_key_version,
}
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,
}