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]>
This commit is contained in:
2026-07-15 13:55:06 -04:00
co-authored by Claude Opus 4.8
parent 2b359e0c26
commit 573e721437
3 changed files with 1659 additions and 211 deletions
+160 -10
View File
@@ -1824,6 +1824,8 @@ _UNSET = object()
# Best-effort identity cache keyed by host, so an enabled audit trail resolves # Best-effort identity cache keyed by host, so an enabled audit trail resolves
# the authenticated username at most once per host per process. # the authenticated username at most once per host per process.
_IDENTITY_CACHE: dict = {} _IDENTITY_CACHE: dict = {}
# Stable actor identity (id + login) for recovery evidence binding (#709 F7).
_ACTOR_IDENTITY_CACHE: dict = {}
def _authenticated_username(host: str): def _authenticated_username(host: str):
@@ -1846,6 +1848,33 @@ def _authenticated_username(host: str):
return user return user
def _authenticated_actor(host: str) -> dict:
"""Resolve the authenticated actor's stable identity (#709 F7 review 438).
Display names are mutable, so recovery evidence is bound to the immutable
numeric user id with the login carried alongside for consistency checks.
Read-only and fail-soft; never surfaces credential material.
"""
cached = _ACTOR_IDENTITY_CACHE.get(host)
if cached is not None:
return dict(cached)
actor: dict = {"user_id": None, "login": None}
try:
header = get_auth_header(host)
if header:
who = api_request("GET", gitea_url(host, "/api/v1/user"), header)
if isinstance(who, dict):
raw_id = who.get("id")
actor = {
"user_id": int(raw_id) if isinstance(raw_id, int) else None,
"login": (who.get("login") or None),
}
except Exception:
actor = {"user_id": None, "login": None}
_ACTOR_IDENTITY_CACHE[host] = dict(actor)
return dict(actor)
def _ensure_matching_profile(required_permission: str, required_role: str, remote: str | None, host: str | None = None) -> str | None: def _ensure_matching_profile(required_permission: str, required_role: str, remote: str | None, host: str | None = None) -> str | None:
"""Check if the active profile is allowed to perform *required_permission*. """Check if the active profile is allowed to perform *required_permission*.
If not, automatically switch to the first matching usable configured profile. If not, automatically switch to the first matching usable configured profile.
@@ -1891,6 +1920,7 @@ def _ensure_matching_profile(required_permission: str, required_role: str, remot
h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None) h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None)
if h: if h:
_IDENTITY_CACHE.pop(h, None) _IDENTITY_CACHE.pop(h, None)
_ACTOR_IDENTITY_CACHE.pop(h, None)
username = _authenticated_username(h) if h else None username = _authenticated_username(h) if h else None
# Update mutation authority # Update mutation authority
global _MUTATION_AUTHORITY global _MUTATION_AUTHORITY
@@ -4658,11 +4688,11 @@ def _clear_decision_lock_for_profile(
), ),
} }
# Archive then clear — only after exact identity validation. # Archive then clear — archival is a hard prerequisite (#709 F8 review 438).
try: # A failed, empty, or unconfirmed archive must never be followed by a clear:
mcp_session_state.save_state( # that is exactly the terminal-evidence destruction #709 exists to prevent.
kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE, archive_identity = f"{profile_identity}-archive-pr{pr_number}"
payload={ archive_payload = {
**dict(lock), **dict(lock),
"archived_reason": "post_merge_cross_profile_cleanup", "archived_reason": "post_merge_cross_profile_cleanup",
"archived_for_pr": pr_number, "archived_for_pr": pr_number,
@@ -4672,14 +4702,94 @@ def _clear_decision_lock_for_profile(
"archived_repo": repo, "archived_repo": repo,
"recovery_critical": True, "recovery_critical": True,
"kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE, "kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
}, # The copied lock carries the *source* profile's identity. Leaving it in
# place makes save_state key the archive under that profile instead of
# the archive identity, so the archive would silently overwrite/land
# elsewhere and never read back (#709 F8 review 438).
"profile_identity": archive_identity,
"session_profile_lock": archive_identity,
"archived_from_profile_identity": profile_identity,
}
def _archive_failure(step: str, detail: str) -> dict:
"""Retain the terminal lock and report an actionable, retryable failure."""
try:
_record_post_merge_decision_recovery(
pr_number=int(pr_number),
head_sha=want_head,
merge_commit_sha=None,
target_profile_identity=profile_identity,
failed_step=step,
error=detail,
remote=remote, remote=remote,
org=org, org=org,
repo=repo, repo=repo,
profile_identity=f"{profile_identity}-archive-pr{pr_number}",
) )
except Exception: except Exception as exc: # noqa: BLE001
pass detail = f"{detail}; recovery record write also failed: {_redact(str(exc))}"
return {
"profile_identity": profile_identity,
"cleared": False,
"archive_ok": False,
"archive_failed_step": step,
"archive_identity": archive_identity,
"terminal_lock_retained": True,
"recovery_required": True,
"retry_safe": True,
"reason": (
f"decision-lock archival failed at {step}: {detail}. Terminal "
"evidence retained and NOT cleared; resolve the archive failure "
"and retry this cleanup (fail closed, #709 F8 review 438)"
),
"prior_summary": stale_review_decision_lock.lock_summary(lock),
}
try:
archived = mcp_session_state.save_state(
kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
payload=archive_payload,
remote=remote,
org=org,
repo=repo,
profile_identity=archive_identity,
)
except Exception as exc: # noqa: BLE001
return _archive_failure("archive_save_state", _redact(str(exc)))
if not archived:
return _archive_failure(
"archive_save_state",
"save_state returned no durable archive record (false/empty result)",
)
# Durable read-back: a write that cannot be re-read is not an archive.
try:
confirmed = mcp_session_state.load_state_for_profile(
kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE,
profile_identity=archive_identity,
remote=remote,
org=org,
repo=repo,
skip_identity_match=True,
enforce_repo_scope=True,
)
except Exception as exc: # noqa: BLE001
return _archive_failure("archive_readback", _redact(str(exc)))
if not confirmed:
return _archive_failure(
"archive_readback",
"archive record could not be read back from durable session state",
)
if int(confirmed.get("archived_for_pr") or -1) != int(
pr_number
) or not stale_review_decision_lock.heads_equal(
confirmed.get("archived_for_head"), want_head
):
return _archive_failure(
"archive_readback",
"archive read-back does not match the PR/head being cleaned "
"(partial or stale archive)",
)
mcp_session_state.clear_state( mcp_session_state.clear_state(
kind=mcp_session_state.KIND_DECISION_LOCK, kind=mcp_session_state.KIND_DECISION_LOCK,
profile_identity=profile_identity, profile_identity=profile_identity,
@@ -4695,9 +4805,11 @@ def _clear_decision_lock_for_profile(
return { return {
"profile_identity": profile_identity, "profile_identity": profile_identity,
"cleared": True, "cleared": True,
"archive_ok": True,
"archive_identity": archive_identity,
"reason": ( "reason": (
f"cleared terminal lock for merged PR #{pr_number} " f"cleared terminal lock for merged PR #{pr_number} "
f"at head {want_head[:12]}… (exact-scope)" f"at head {want_head[:12]}… (exact-scope; durable archive confirmed)"
), ),
"prior_summary": stale_review_decision_lock.lock_summary(lock), "prior_summary": stale_review_decision_lock.lock_summary(lock),
} }
@@ -5133,8 +5245,10 @@ def gitea_issue_irrecoverable_provenance_authorization(
expected_head_sha: str, expected_head_sha: str,
incident_issue: int, incident_issue: int,
incident_comment_id: int, incident_comment_id: int,
decision_lock_id: str = "",
confirmation: str = "", confirmation: str = "",
destroyed_subject: str | None = None, destroyed_subject: str | None = None,
recorded_head_sha: str | None = None,
remote: str = "dadeschools", remote: str = "dadeschools",
host: str | None = None, host: str | None = None,
org: str | None = None, org: str | None = None,
@@ -5186,6 +5300,13 @@ def gitea_issue_irrecoverable_provenance_authorization(
) )
return report return report
if not (decision_lock_id or "").strip():
report["reasons"].append(
"decision_lock_id is required so evidence is bound to the exact "
"decision lock being recovered (fail closed, #709 F7 review 438)"
)
return report
try: try:
actor = _authenticated_username(h) actor = _authenticated_username(h)
except Exception: except Exception:
@@ -5195,6 +5316,13 @@ def gitea_issue_irrecoverable_provenance_authorization(
"authenticated identity could not be verified (fail closed)" "authenticated identity could not be verified (fail closed)"
) )
return report return report
actor_identity = _authenticated_actor(h)
if actor_identity.get("user_id") is None:
report["reasons"].append(
"authenticated actor id could not be verified; display-name-only "
"identity is not accepted (fail closed, #709 F7 review 438)"
)
return report
profile = get_profile() profile = get_profile()
profile_name = (profile.get("profile_name") or "").strip() or None profile_name = (profile.get("profile_name") or "").strip() or None
@@ -5237,6 +5365,11 @@ def gitea_issue_irrecoverable_provenance_authorization(
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
comment_err = _redact(str(exc)) comment_err = _redact(str(exc))
try:
active_key_version = irp.auth_key_version()
except irp.AuthSecretError as exc:
report["reasons"].append(str(exc))
return report
incident_gate = irp.assess_incident_evidence( incident_gate = irp.assess_incident_evidence(
incident_issue=incident_issue, incident_issue=incident_issue,
incident_comment_id=incident_comment_id, incident_comment_id=incident_comment_id,
@@ -5247,6 +5380,11 @@ def gitea_issue_irrecoverable_provenance_authorization(
expected_repo=r, expected_repo=r,
expected_pr_number=pr_number, expected_pr_number=pr_number,
expected_head_sha=str(expected_head_sha), expected_head_sha=str(expected_head_sha),
expected_recorded_head_sha=recorded_head_sha,
expected_decision_lock_id=decision_lock_id.strip(),
expected_recovery_action=irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE,
expected_key_version=active_key_version,
mint_actor_id=actor_identity.get("user_id"),
mint_actor_username=actor, mint_actor_username=actor,
reject_self_authored=True, reject_self_authored=True,
) )
@@ -5338,6 +5476,7 @@ def gitea_record_irrecoverable_decision_lock_provenance(
incident_issue: int | None = None, incident_issue: int | None = None,
incident_comment_id: int | None = None, incident_comment_id: int | None = None,
authorization_id: str | None = None, authorization_id: str | None = None,
decision_lock_id: str = "",
destroyed_subject: str | None = None, destroyed_subject: str | None = None,
incident_ref: str | None = None, incident_ref: str | None = None,
# Deprecated: retained so callers that still pass it get an explicit deny. # Deprecated: retained so callers that still pass it get an explicit deny.
@@ -5477,6 +5616,11 @@ def gitea_record_irrecoverable_decision_lock_provenance(
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
comment_err = _redact(str(exc)) comment_err = _redact(str(exc))
try:
active_key_version = irp.auth_key_version()
except irp.AuthSecretError as exc:
report["reasons"].append(str(exc))
return report
incident_gate = irp.assess_incident_evidence( incident_gate = irp.assess_incident_evidence(
incident_issue=incident_issue, incident_issue=incident_issue,
incident_comment_id=incident_comment_id, incident_comment_id=incident_comment_id,
@@ -5487,6 +5631,10 @@ def gitea_record_irrecoverable_decision_lock_provenance(
expected_repo=r, expected_repo=r,
expected_pr_number=pr_number, expected_pr_number=pr_number,
expected_head_sha=str(expected_head_sha), expected_head_sha=str(expected_head_sha),
expected_decision_lock_id=(decision_lock_id or "").strip() or None,
expected_recovery_action=irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE,
expected_key_version=active_key_version,
mint_actor_id=_authenticated_actor(h).get("user_id"),
mint_actor_username=actor, mint_actor_username=actor,
reject_self_authored=True, reject_self_authored=True,
) )
@@ -8869,6 +9017,7 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
if tok: if tok:
gitea_config._active_profile_override = p_name gitea_config._active_profile_override = p_name
_IDENTITY_CACHE.clear() _IDENTITY_CACHE.clear()
_ACTOR_IDENTITY_CACHE.clear()
return True return True
except Exception: except Exception:
pass pass
@@ -11989,6 +12138,7 @@ def gitea_activate_profile(
# 3. Clear identity cache to force a fresh verification # 3. Clear identity cache to force a fresh verification
if h: if h:
_IDENTITY_CACHE.pop(h, None) _IDENTITY_CACHE.pop(h, None)
_ACTOR_IDENTITY_CACHE.pop(h, None)
# 4. Resolve fresh identity # 4. Resolve fresh identity
after_profile = get_profile()["profile_name"] after_profile = get_profile()["profile_name"]
+662 -149
View File
@@ -14,6 +14,7 @@ import hashlib
import hmac import hmac
import json import json
import os import os
import re
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
@@ -31,6 +32,34 @@ KIND_RECOVERY = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE
CONFIRMATION_PREFIX = "IRRECOVERABLE DECISION PROVENANCE PR" CONFIRMATION_PREFIX = "IRRECOVERABLE DECISION PROVENANCE PR"
# Canonical incident-comment marker (#709 review 435 F5). # Canonical incident-comment marker (#709 review 435 F5).
INCIDENT_MARKER = "IRRECOVERABLE DECISION PROVENANCE INCIDENT" 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 AUTH_TTL_HOURS = 24.0
RECORD_TYPE = "irrecoverable_decision_provenance" RECORD_TYPE = "irrecoverable_decision_provenance"
AUTH_TYPE = "irrecoverable_provenance_authorization" AUTH_TYPE = "irrecoverable_provenance_authorization"
@@ -44,6 +73,21 @@ DEFAULT_KEY_VERSION = "v1"
_PYTEST_AUTH_SECRET = b"pytest-irrecoverable-auth-v1" _PYTEST_AUTH_SECRET = b"pytest-irrecoverable-auth-v1"
_PYTEST_KEY_VERSION = "pytest-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_SECRET: bytes | None = None
_PROCESS_AUTH_KEY_VERSION: str | None = None _PROCESS_AUTH_KEY_VERSION: str | None = None
_PROCESS_AUTH_SECRET_SOURCE: str | None = None _PROCESS_AUTH_SECRET_SOURCE: str | None = None
@@ -101,10 +145,135 @@ def reset_process_auth_secret_for_tests() -> None:
_PROCESS_AUTH_SECRET_SOURCE = 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: def auth_key_version() -> str:
"""Return the active signing-key version string (never secret material).""" """Return the active configured signing-key version (never secret material)."""
_process_secret() # ensure version is resolved with the secret _process_secret() # ensure version is resolved with the secret
return _PROCESS_AUTH_KEY_VERSION or DEFAULT_KEY_VERSION 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: def _process_secret() -> bytes:
@@ -126,16 +295,34 @@ def _process_secret() -> bytes:
pytest = mcp_daemon_guard.is_pytest_runtime() pytest = mcp_daemon_guard.is_pytest_runtime()
if env_raw: if env_raw:
_PROCESS_AUTH_SECRET = _decode_key_material(env_raw) secret = _decode_key_material(env_raw)
_PROCESS_AUTH_KEY_VERSION = env_ver or ( if not env_ver and not pytest:
_PYTEST_KEY_VERSION if pytest else DEFAULT_KEY_VERSION # #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" _PROCESS_AUTH_SECRET_SOURCE = "env"
return _PROCESS_AUTH_SECRET return _PROCESS_AUTH_SECRET
if pytest: 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_SECRET = _PYTEST_AUTH_SECRET
_PROCESS_AUTH_KEY_VERSION = env_ver or _PYTEST_KEY_VERSION _PROCESS_AUTH_KEY_VERSION = version
_PROCESS_AUTH_SECRET_SOURCE = "pytest_constant" _PROCESS_AUTH_SECRET_SOURCE = "pytest_constant"
return _PROCESS_AUTH_SECRET return _PROCESS_AUTH_SECRET
@@ -328,19 +515,79 @@ def assess_transport_for_auth_mint() -> dict[str, Any]:
} }
def _incident_author(comment_payload: dict[str, Any]) -> str | None: def incident_actor_identity(payload: dict[str, Any] | None) -> dict[str, Any]:
user = comment_payload.get("user") """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): if isinstance(user, dict):
login = (user.get("login") or user.get("username") or "").strip() _add_id(user.get("id"))
return login or None _add_id(user.get("user_id"))
if isinstance(user, str) and user.strip(): _add_login(user.get("login"))
return user.strip() _add_login(user.get("username"))
# Some payloads flatten author. elif isinstance(user, str):
_add_login(user)
for key in ("login", "author", "username"): for key in ("login", "author", "username"):
val = comment_payload.get(key) _add_login(payload.get(key))
if isinstance(val, str) and val.strip(): _add_id(payload.get("user_id"))
return val.strip()
return None 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( def canonical_incident_fields(
@@ -349,85 +596,272 @@ def canonical_incident_fields(
org: str, org: str,
repo: str, repo: str,
pr_number: int, pr_number: int,
decision_lock_id: str,
destroyed_subject: str,
recovery_action: str,
recorded_head_sha: str,
expected_head_sha: str, expected_head_sha: str,
incident_issue: int, 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]: ) -> dict[str, str]:
"""Ordered fields that form the authoritative incident content digest.""" """Ordered fields that form the authoritative incident content digest.
head = normalize_head_sha(expected_head_sha) or ""
return { Every security-relevant element of the recovery scope is bound here so the
"marker": INCIDENT_MARKER, digest cannot be replayed across repositories, PRs, decision locks, heads,
"remote": str(remote or "").strip(), actors, or recovery actions (#709 F7 review 438).
"org": str(org or "").strip(), """
"repo": str(repo or "").strip(), 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)), "pr_number": str(int(pr_number)),
"expected_head_sha": head, "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)), "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: def incident_content_digest(fields: dict[str, str]) -> str:
"""SHA-256 hex digest over canonical field lines (stable, sort-free order).""" """SHA-256 hex digest over the canonical field lines (fixed order, no sort)."""
# Fixed key order — do not sort; operator body must match this order. lines = [INCIDENT_MARKER]
order = ( for name in INCIDENT_FIELD_ORDER:
"marker", if name not in fields:
"remote", raise ValueError(
"org", f"canonical incident field {name!r} missing for digest "
"repo", "(fail closed, #709 F7)"
"pr_number",
"expected_head_sha",
"incident_issue",
) )
lines = [f"{k}={fields[k]}" for k in order] lines.append(f"{name}={fields[name]}")
blob = "\n".join(lines).encode("utf-8") blob = "\n".join(lines).encode("utf-8")
return hashlib.sha256(blob).hexdigest() 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( def build_canonical_incident_body(
*, *,
remote: str, remote: str,
org: str, org: str,
repo: str, repo: str,
pr_number: int, pr_number: int,
decision_lock_id: str,
destroyed_subject: str,
recovery_action: str,
recorded_head_sha: str,
expected_head_sha: str, expected_head_sha: str,
incident_issue: int, 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, narrative: str | None = None,
) -> str: ) -> str:
"""Build an operator-postable canonical incident comment body (#709 F5).""" """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( fields = canonical_incident_fields(
remote=remote, remote=remote,
org=org, org=org,
repo=repo, repo=repo,
pr_number=pr_number, 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, expected_head_sha=expected_head_sha,
incident_issue=incident_issue, 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,
) )
digest = incident_content_digest(fields) body = render_canonical_incident_block(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(): if narrative and narrative.strip():
lines.extend(["", narrative.strip()]) text = narrative.strip()
return "\n".join(lines) 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_incident_field(body: str, name: str) -> str | None: def parse_canonical_incident_body(body: str | None) -> dict[str, Any]:
"""Extract ``name: value`` or ``name=value`` from incident body lines.""" """Strictly parse the canonical incident block (#709 F7 review 438).
for line in (body or "").splitlines():
text = line.strip() Enforces the exact schema: marker on the first line, every field present
if not text or text.startswith("#"): 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 continue
for sep in (":", "="): if ":" in stripped and stripped.split(":", 1)[0].strip() in known:
prefix = f"{name}{sep}" duplicated = stripped.split(":", 1)[0].strip()
if text.lower().startswith(prefix.lower()): reasons.append(
return text[len(prefix) :].strip() f"incident body restates canonical field {duplicated!r} outside "
return None "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( def assess_incident_evidence(
@@ -441,16 +875,22 @@ def assess_incident_evidence(
expected_repo: str | None = None, expected_repo: str | None = None,
expected_pr_number: int | None = None, expected_pr_number: int | None = None,
expected_head_sha: str | 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, mint_actor_username: str | None = None,
reject_self_authored: bool = True, reject_self_authored: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Validate authoritative incident evidence (live-fetched, #709 F5). """Validate strictly canonical, fully scoped incident evidence (#709 F7).
Requires: The body must be exactly the one canonical representation produced by
- live comment id match :func:`build_canonical_incident_body`: marker first, every field present
- non-empty author identity once in fixed order, no duplicates/unknowns/conflicts, digest binding the
- canonical marker + scope fields + content_digest integrity complete recovery scope, and a single stable actor identity independent of
- optional mint-actor independence (mint actor ≠ comment author) the minting actor. Reordered, duplicated, conflicting, replayed, or
substituted evidence fails closed (review 438 F7).
""" """
reasons: list[str] = [] reasons: list[str] = []
if incident_issue is None or int(incident_issue) <= 0: if incident_issue is None or int(incident_issue) <= 0:
@@ -470,7 +910,6 @@ def assess_incident_evidence(
) )
return {"valid": False, "reasons": reasons, "comment": None} return {"valid": False, "reasons": reasons, "comment": None}
# Gitea returns comment with id; optional issue_url / html_url for scope.
cid = comment_payload.get("id") cid = comment_payload.get("id")
try: try:
if int(cid) != int(incident_comment_id): # type: ignore[arg-type] if int(cid) != int(incident_comment_id): # type: ignore[arg-type]
@@ -480,119 +919,171 @@ def assess_incident_evidence(
except (TypeError, ValueError): except (TypeError, ValueError):
reasons.append("incident comment payload missing valid id (fail closed)") reasons.append("incident comment payload missing valid id (fail closed)")
body = (comment_payload.get("body") or "").strip() # Edited evidence is not authoritative (#709 F5 review 435).
if not body: created_at = str(comment_payload.get("created_at") or "").strip()
reasons.append("incident comment body is empty (fail closed)") updated_at = str(comment_payload.get("updated_at") or "").strip()
if updated_at and created_at and updated_at != created_at:
author = _incident_author(comment_payload)
if not author:
reasons.append( reasons.append(
"incident comment missing author identity (fail closed, #709 F5)" "incident comment has been edited after creation; edited evidence is "
"not authoritative (fail closed, #709 F5 review 435)"
) )
# Self-mint prevention: minting actor must not be the sole incident author. actor = incident_actor_identity(comment_payload)
if ( if not actor.get("valid"):
reject_self_authored reasons.extend(actor.get("reasons") or [])
and author
and mint_actor_username parsed = parse_canonical_incident_body(comment_payload.get("body"))
and author.strip().lower() == str(mint_actor_username).strip().lower() 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( reasons.append(
"incident comment author matches mint actor; self-authored incident " f"incident body {label or name} does not match the live "
"evidence is not accepted (fail closed, #709 F5 review 435)" f"recovery scope (fail closed, #709 F7 review 438)"
) )
# Canonical content + digest (not any non-empty body). if fields.get("schema_version") != INCIDENT_SCHEMA_VERSION:
if body and INCIDENT_MARKER not in body:
reasons.append( reasons.append(
f"incident comment missing canonical marker {INCIDENT_MARKER!r} " f"incident schema_version must be {INCIDENT_SCHEMA_VERSION!r} "
"(fail closed, #709 F5)" "(fail closed, #709 F7)"
) )
elif body: _match("remote", expected_remote)
remote_f = _parse_incident_field(body, "remote") _match("org", expected_org)
org_f = _parse_incident_field(body, "org") _match("repo", expected_repo)
repo_f = _parse_incident_field(body, "repo") _match("decision_lock_id", expected_decision_lock_id)
pr_f = _parse_incident_field(body, "pr_number") _match("recovery_action", expected_recovery_action)
head_f = _parse_incident_field(body, "expected_head_sha") _match("key_version", expected_key_version)
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(): if fields.get("recovery_action") not in SUPPORTED_RECOVERY_ACTIONS:
reasons.append( reasons.append(
"incident body remote does not match expected remote (fail closed)" f"incident recovery_action {fields.get('recovery_action')!r} is "
) "not a supported recovery action (fail closed, #709 F7)"
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: if expected_pr_number is not None:
try: try:
if pr_f is None or int(pr_f) != int(expected_pr_number): if int(fields.get("pr_number", "")) != int(expected_pr_number):
reasons.append( reasons.append(
"incident body pr_number does not match mint PR " "incident body pr_number does not match mint PR "
"(fail closed, #709 F5)" "(fail closed, #709 F7)"
) )
except (TypeError, ValueError): except (TypeError, ValueError):
reasons.append( reasons.append("incident body pr_number invalid (fail closed)")
"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: try:
if issue_f is None or int(issue_f) != int(incident_issue): # type: ignore[arg-type] if int(fields.get("incident_issue", "")) != int(incident_issue): # type: ignore[arg-type]
reasons.append( reasons.append(
"incident body incident_issue does not match provided " "incident body incident_issue does not match provided "
"incident_issue (fail closed, #709 F5)" "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): except (TypeError, ValueError):
reasons.append( reasons.append(
"incident body incident_issue missing or invalid (fail closed)" "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)"
) )
# Content digest integrity when we have enough scope to recompute. # Minting actor must be the live caller, and must not be the author.
if ( if mint_actor_id is not None:
remote_f
and org_f
and repo_f
and pr_f
and head_f
and issue_f
and digest_f
):
try: try:
fields = canonical_incident_fields( if int(fields.get("mint_actor_id", "")) != int(mint_actor_id):
remote=remote_f, reasons.append(
org=org_f, "incident body mint_actor_id does not match the minting "
repo=repo_f, "actor (fail closed, #709 F7 review 438)"
pr_number=int(pr_f),
expected_head_sha=head_f,
incident_issue=int(issue_f),
) )
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) expected_digest = incident_content_digest(fields)
if not hmac.compare_digest( if not hmac.compare_digest(
expected_digest.lower(), str(digest_f).strip().lower() expected_digest.lower(),
str(fields.get(INCIDENT_DIGEST_FIELD, "")).strip().lower(),
): ):
reasons.append( reasons.append(
"incident content_digest mismatch (forged or incomplete " "incident content_digest mismatch (forged, substituted, or "
"canonical body; fail closed, #709 F5)" "incomplete canonical body; fail closed, #709 F7)"
) )
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
reasons.append( reasons.append(
f"incident content_digest recompute failed: {exc} (fail closed)" f"incident content_digest recompute failed: {exc} (fail closed)"
) )
else:
# Reconstruct the exact canonical representation and require equality.
try:
rebuilt = render_canonical_incident_block(fields)
if rebuilt != parsed.get("canonical_block"):
reasons.append( reasons.append(
"incident body missing required canonical fields " "incident body is not the exact canonical representation "
"(remote/org/repo/pr_number/expected_head_sha/incident_issue/" "(fail closed, #709 F7 review 438)"
"content_digest; fail closed, #709 F5)" )
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. # Hard scope check when URLs are present.
@@ -611,12 +1102,13 @@ def assess_incident_evidence(
return { return {
"valid": not reasons, "valid": not reasons,
"reasons": reasons, "reasons": reasons,
"fields": fields,
"comment": { "comment": {
"id": comment_payload.get("id"), "id": comment_payload.get("id"),
"author": author, "author": actor.get("login"),
"author_id": actor.get("user_id"),
"created_at": comment_payload.get("created_at"), "created_at": comment_payload.get("created_at"),
"body_len": len(body), "canonical": bool(parsed.get("valid")),
"has_canonical_marker": INCIDENT_MARKER in body if body else False,
}, },
} }
@@ -790,7 +1282,26 @@ def verify_authorization_artifact(
if not (auth.get("server_signature") or "").strip(): if not (auth.get("server_signature") or "").strip():
reasons.append("authorization missing server_signature (fail closed)") reasons.append("authorization missing server_signature (fail closed)")
# Recompute signature over stored scope fields + key_version. # #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: try:
scope = _scope_payload( scope = _scope_payload(
remote=str(auth.get("remote") or ""), remote=str(auth.get("remote") or ""),
@@ -810,8 +1321,9 @@ def verify_authorization_artifact(
native = auth.get("native_provenance") or {} native = auth.get("native_provenance") or {}
if not isinstance(native, dict): if not isinstance(native, dict):
native = {} native = {}
stored_kv = str(auth.get("key_version") or auth_key_version()) expected_sig = _sign_scope(
expected_sig = _sign_scope(scope, native, key_version=stored_kv) scope, native, key_version=verified_key_version
)
if not hmac.compare_digest( if not hmac.compare_digest(
expected_sig, str(auth.get("server_signature") or "") expected_sig, str(auth.get("server_signature") or "")
): ):
@@ -869,6 +1381,7 @@ def verify_authorization_artifact(
"reasons": reasons, "reasons": reasons,
"authorization_id": auth.get("authorization_id"), "authorization_id": auth.get("authorization_id"),
"consumption_state": state or None, "consumption_state": state or None,
"key_version": verified_key_version,
} }
@@ -7,6 +7,7 @@ live PR numbers in production code.
from __future__ import annotations from __future__ import annotations
import json
import os import os
import subprocess import subprocess
import sys import sys
@@ -69,6 +70,19 @@ DEDICATED_RECOVERY_OPS = RECONCILER_OPS + [
] ]
DURABLE_TEST_HMAC_KEY = "0" * 64 # 32-byte hex durable key for F4 tests DURABLE_TEST_HMAC_KEY = "0" * 64 # 32-byte hex durable key for F4 tests
# #709 F7 (review 438): canonical incident evidence binds the full recovery
# scope, so the fixtures carry stable actor ids, the decision-lock identity,
# the recovery action, both heads, the key version, and a replay nonce.
DECISION_LOCK_ID = "review_decision_lock-prgs-reviewer"
DESTROYED_SUBJECT = "prgs-reviewer terminal approval ledger"
RECOVERY_ACTION = irp.RECOVERY_ACTION_IRRECOVERABLE_PROVENANCE
INCIDENT_NONCE = "11111111-2222-3333-4444-555555555555"
INCIDENT_ISSUED_AT = "2026-07-13T00:00:00+00:00"
AUTHOR_LOGIN = "controller-ops"
AUTHOR_ID = 4242
MINT_ACTOR_LOGIN = "sysadmin"
MINT_ACTOR_ID = 7
def _canonical_incident_body( def _canonical_incident_body(
*, *,
@@ -78,6 +92,17 @@ def _canonical_incident_body(
org="Scaled-Tech-Consulting", org="Scaled-Tech-Consulting",
repo="Gitea-Tools", repo="Gitea-Tools",
incident_issue=700, incident_issue=700,
decision_lock_id=DECISION_LOCK_ID,
destroyed_subject=DESTROYED_SUBJECT,
recovery_action=RECOVERY_ACTION,
recorded_head_sha=HEAD_A,
evidence_author_id=AUTHOR_ID,
evidence_author_login=AUTHOR_LOGIN,
mint_actor_id=MINT_ACTOR_ID,
mint_actor_login=MINT_ACTOR_LOGIN,
key_version=None,
nonce=INCIDENT_NONCE,
issued_at=INCIDENT_ISSUED_AT,
narrative="forensic diagnosis", narrative="forensic diagnosis",
): ):
return irp.build_canonical_incident_body( return irp.build_canonical_incident_body(
@@ -85,8 +110,19 @@ def _canonical_incident_body(
org=org, org=org,
repo=repo, repo=repo,
pr_number=pr_number, 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=head, expected_head_sha=head,
incident_issue=incident_issue, 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 or irp.auth_key_version(),
nonce=nonce,
issued_at=issued_at,
narrative=narrative, narrative=narrative,
) )
@@ -94,26 +130,36 @@ def _canonical_incident_body(
def _incident_comment_payload( def _incident_comment_payload(
*, *,
comment_id=11489, comment_id=11489,
author="controller-ops", author=AUTHOR_LOGIN,
author_id=None,
pr_number=42, pr_number=42,
head=HEAD_A, head=HEAD_A,
remote="prgs", remote="prgs",
org="Scaled-Tech-Consulting", org="Scaled-Tech-Consulting",
repo="Gitea-Tools", repo="Gitea-Tools",
incident_issue=700, incident_issue=700,
body=None,
**body_kwargs,
): ):
uid = AUTHOR_ID if author_id is None else author_id
return { return {
"id": comment_id, "id": comment_id,
"body": _canonical_incident_body( "body": body
if body is not None
else _canonical_incident_body(
pr_number=pr_number, pr_number=pr_number,
head=head, head=head,
remote=remote, remote=remote,
org=org, org=org,
repo=repo, repo=repo,
incident_issue=incident_issue, incident_issue=incident_issue,
evidence_author_id=uid,
evidence_author_login=author,
**body_kwargs,
), ),
"user": {"login": author}, "user": {"id": uid, "login": author},
"created_at": "2026-07-13T00:00:00Z", "created_at": "2026-07-13T00:00:00Z",
"updated_at": "2026-07-13T00:00:00Z",
"html_url": f"https://gitea.example/{org}/{repo}/issues/{incident_issue}#issuecomment-{comment_id}", "html_url": f"https://gitea.example/{org}/{repo}/issues/{incident_issue}#issuecomment-{comment_id}",
} }
@@ -1446,5 +1492,744 @@ class TestClearProfileHelperF3(unittest.TestCase):
self.assertIsNotNone(still) self.assertIsNotNone(still)
def _reorder_canonical_lines(body, first, second):
"""Swap two canonical field lines, leaving the digest untouched."""
lines = body.split("\n")
i = next(i for i, l in enumerate(lines) if l.startswith(f"{first}: "))
j = next(i for i, l in enumerate(lines) if l.startswith(f"{second}: "))
lines[i], lines[j] = lines[j], lines[i]
return "\n".join(lines)
def _insert_after_canonical_line(body, after_field, extra_line):
lines = body.split("\n")
i = next(i for i, l in enumerate(lines) if l.startswith(f"{after_field}: "))
lines.insert(i + 1, extra_line)
return "\n".join(lines)
class TestF6KeyVersionFailsClosed(unittest.TestCase):
"""#709 F6 (review 438): key-version validation must fail closed."""
def _verify(self, auth, **kwargs):
params = {
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"pr_number": 42,
"expected_head_sha": HEAD_A,
}
params.update(kwargs)
return irp.verify_authorization_artifact(auth, **params)
def test_valid_artifact_verifies(self):
self.assertTrue(self._verify(_mint_auth())["valid"])
def test_missing_key_version_rejected(self):
auth = _mint_auth()
del auth["key_version"]
v = self._verify(auth)
self.assertFalse(v["valid"], v)
self.assertTrue(
any("missing key_version" in r for r in v["reasons"]), v["reasons"]
)
def test_empty_key_version_rejected(self):
auth = _mint_auth()
auth["key_version"] = ""
v = self._verify(auth)
self.assertFalse(v["valid"], v)
self.assertTrue(any("empty" in r for r in v["reasons"]), v["reasons"])
def test_unknown_key_version_rejected(self):
auth = _mint_auth()
auth["key_version"] = "totally-unknown-version"
v = self._verify(auth)
self.assertFalse(v["valid"], v)
self.assertTrue(
any("unknown or does not match" in r for r in v["reasons"]), v["reasons"]
)
def test_malformed_key_version_rejected(self):
auth = _mint_auth()
auth["key_version"] = "bad version!"
v = self._verify(auth)
self.assertFalse(v["valid"], v)
self.assertTrue(any("malformed" in r for r in v["reasons"]), v["reasons"])
def test_non_string_key_version_rejected(self):
auth = _mint_auth()
auth["key_version"] = ["v1", "v2"]
v = self._verify(auth)
self.assertFalse(v["valid"], v)
def test_duplicate_key_version_fields_rejected(self):
auth = _mint_auth()
auth["keyVersion"] = auth["key_version"] # identical value, still duplicate
v = self._verify(auth)
self.assertFalse(v["valid"], v)
self.assertTrue(
any("duplicate key-version" in r for r in v["reasons"]), v["reasons"]
)
def test_duplicate_conflicting_key_version_fields_rejected(self):
auth = _mint_auth()
auth["auth_key_version"] = "v9"
v = self._verify(auth)
self.assertFalse(v["valid"], v)
self.assertTrue(
any("duplicate key-version" in r for r in v["reasons"]), v["reasons"]
)
def test_nested_key_version_counts_as_duplicate(self):
auth = _mint_auth()
auth["native_provenance"] = dict(auth["native_provenance"])
auth["native_provenance"]["key_version"] = auth["key_version"]
v = self._verify(auth)
self.assertFalse(v["valid"], v)
def test_rotation_invalidates_prior_version_artifact(self):
"""Rotating the configured version must reject artifacts minted under the old one."""
auth = _mint_auth()
self.assertTrue(self._verify(auth)["valid"])
irp.reset_process_auth_secret_for_tests()
try:
with patch.dict(
os.environ,
{
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
irp.ENV_AUTH_HMAC_KEY_VERSION: "v2",
},
clear=False,
):
v = self._verify(auth)
self.assertFalse(v["valid"], v)
self.assertTrue(
any("does not match the configured" in r for r in v["reasons"]),
v["reasons"],
)
finally:
irp.reset_process_auth_secret_for_tests()
def test_wrong_version_fails_even_when_key_unchanged(self):
"""Version mismatch alone fails: the durable key staying the same is not enough."""
irp.reset_process_auth_secret_for_tests()
try:
with patch.dict(
os.environ,
{
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
},
clear=False,
):
auth = _mint_auth()
self.assertEqual(auth["key_version"], "v1")
self.assertTrue(self._verify(auth)["valid"])
irp.reset_process_auth_secret_for_tests()
with patch.dict(
os.environ,
{
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY, # same key
irp.ENV_AUTH_HMAC_KEY_VERSION: "v2", # rotated version
},
clear=False,
):
self.assertFalse(self._verify(auth)["valid"])
finally:
irp.reset_process_auth_secret_for_tests()
def test_wrong_key_fails_after_restart(self):
"""A different durable key must reject the artifact even at the same version."""
irp.reset_process_auth_secret_for_tests()
try:
with patch.dict(
os.environ,
{
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
},
clear=False,
):
auth = _mint_auth()
irp.reset_process_auth_secret_for_tests() # simulate restart
with patch.dict(
os.environ,
{
irp.ENV_AUTH_HMAC_KEY: "1" * 64, # different durable key
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1", # same version
},
clear=False,
):
v = self._verify(auth)
self.assertFalse(v["valid"], v)
self.assertTrue(
any("server_signature invalid" in r for r in v["reasons"]),
v["reasons"],
)
finally:
irp.reset_process_auth_secret_for_tests()
def test_same_durable_key_verifies_after_restart(self):
irp.reset_process_auth_secret_for_tests()
try:
env = {
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
}
with patch.dict(os.environ, env, clear=False):
auth = _mint_auth()
irp.reset_process_auth_secret_for_tests() # simulate restart
with patch.dict(os.environ, env, clear=False):
self.assertTrue(self._verify(auth)["valid"])
finally:
irp.reset_process_auth_secret_for_tests()
def test_key_version_is_inside_authenticated_data(self):
"""Editing key_version must break the MAC, not just the version check."""
irp.reset_process_auth_secret_for_tests()
try:
with patch.dict(
os.environ,
{
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
},
clear=False,
):
auth = _mint_auth()
signature_v1 = auth["server_signature"]
irp.reset_process_auth_secret_for_tests()
with patch.dict(
os.environ,
{
irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY,
irp.ENV_AUTH_HMAC_KEY_VERSION: "v2",
},
clear=False,
):
rotated = _mint_auth()
# Same key + same scope, different version => different MAC.
self.assertNotEqual(signature_v1, rotated["server_signature"])
finally:
irp.reset_process_auth_secret_for_tests()
def test_production_requires_configured_key_version(self):
irp.reset_process_auth_secret_for_tests()
try:
with patch.object(
irp.mcp_daemon_guard, "is_pytest_runtime", return_value=False
), patch.dict(
os.environ, {irp.ENV_AUTH_HMAC_KEY: DURABLE_TEST_HMAC_KEY}, clear=False
):
os.environ.pop(irp.ENV_AUTH_HMAC_KEY_VERSION, None)
with self.assertRaises(irp.AuthSecretError) as ctx:
irp.auth_key_version()
self.assertIn(irp.ENV_AUTH_HMAC_KEY_VERSION, str(ctx.exception))
finally:
irp.reset_process_auth_secret_for_tests()
def test_production_requires_durable_key(self):
irp.reset_process_auth_secret_for_tests()
try:
with patch.object(
irp.mcp_daemon_guard, "is_pytest_runtime", return_value=False
), patch.dict(os.environ, {}, clear=False):
os.environ.pop(irp.ENV_AUTH_HMAC_KEY, None)
with self.assertRaises(irp.AuthSecretError):
irp.auth_key_version()
finally:
irp.reset_process_auth_secret_for_tests()
def test_errors_never_leak_key_material(self):
irp.reset_process_auth_secret_for_tests()
try:
secret = "s3cr3t" + "9" * 58
with patch.dict(
os.environ,
{
irp.ENV_AUTH_HMAC_KEY: secret,
irp.ENV_AUTH_HMAC_KEY_VERSION: "v1",
},
clear=False,
):
auth = _mint_auth()
self.assertNotIn("key", {k.lower(): 1 for k in ()}) # no-op guard
blob = json.dumps(auth)
self.assertNotIn(secret, blob)
auth["key_version"] = "nope"
v = self._verify(auth)
self.assertNotIn(secret, json.dumps(v["reasons"]))
finally:
irp.reset_process_auth_secret_for_tests()
class TestF7StrictCanonicalIncidentEvidence(unittest.TestCase):
"""#709 F7 (review 438): only the exact canonical representation is accepted."""
def _assess(self, payload, **kwargs):
params = {
"incident_issue": 700,
"incident_comment_id": 11489,
"comment_payload": payload,
"expected_remote": "prgs",
"expected_org": "Scaled-Tech-Consulting",
"expected_repo": "Gitea-Tools",
"expected_pr_number": 42,
"expected_head_sha": HEAD_A,
"expected_decision_lock_id": DECISION_LOCK_ID,
"expected_recovery_action": RECOVERY_ACTION,
"mint_actor_id": MINT_ACTOR_ID,
"mint_actor_username": MINT_ACTOR_LOGIN,
}
params.update(kwargs)
return irp.assess_incident_evidence(**params)
def test_canonical_evidence_accepted(self):
g = self._assess(_incident_comment_payload())
self.assertTrue(g["valid"], g)
def test_reordered_fields_rejected(self):
body = _reorder_canonical_lines(_canonical_incident_body(), "org", "repo")
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
self.assertTrue(
any("canonical order" in r for r in g["reasons"]), g["reasons"]
)
def test_duplicate_identical_field_rejected(self):
body = _canonical_incident_body()
body = _insert_after_canonical_line(body, "repo", "repo: Gitea-Tools")
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
def test_duplicate_conflicting_field_rejected(self):
body = _canonical_incident_body()
body = _insert_after_canonical_line(body, "repo", "repo: Other-Repo")
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
def test_conflicting_field_in_narrative_rejected(self):
body = _canonical_incident_body(narrative="context") + "\nrepo: Other-Repo"
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
self.assertTrue(
any("outside the signed block" in r for r in g["reasons"]), g["reasons"]
)
def test_second_marker_rejected(self):
body = _canonical_incident_body(narrative="context")
body = f"{body}\n\n{irp.INCIDENT_MARKER}"
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
def test_marker_not_first_rejected(self):
body = "preamble\n" + _canonical_incident_body()
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
self.assertTrue(
any("marker position" in r for r in g["reasons"]), g["reasons"]
)
def test_unknown_extra_field_rejected(self):
body = _insert_after_canonical_line(
_canonical_incident_body(), "repo", "sneaky: value"
)
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
def test_missing_field_rejected(self):
body = "\n".join(
l
for l in _canonical_incident_body().split("\n")
if not l.startswith("nonce: ")
)
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
def test_empty_field_rejected(self):
body = _canonical_incident_body().replace(
f"decision_lock_id: {DECISION_LOCK_ID}", "decision_lock_id: "
)
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
def test_conflicting_actor_logins_rejected(self):
payload = _incident_comment_payload()
payload["user"] = {"id": AUTHOR_ID, "login": AUTHOR_LOGIN, "username": "someone-else"}
g = self._assess(payload)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("conflicting logins" in r for r in g["reasons"]), g["reasons"]
)
def test_conflicting_actor_ids_rejected(self):
payload = _incident_comment_payload()
payload["user"] = {"id": AUTHOR_ID, "user_id": 999, "login": AUTHOR_LOGIN}
g = self._assess(payload)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("conflicting user ids" in r for r in g["reasons"]), g["reasons"]
)
def test_display_name_only_actor_rejected(self):
payload = _incident_comment_payload()
payload["user"] = {"login": AUTHOR_LOGIN} # no stable id
g = self._assess(payload)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("stable user id" in r for r in g["reasons"]), g["reasons"]
)
def test_author_id_substitution_rejected(self):
"""Body claims one author id, live comment is authored by another."""
payload = _incident_comment_payload()
payload["user"] = {"id": 5150, "login": AUTHOR_LOGIN}
g = self._assess(payload)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("evidence_author_id" in r for r in g["reasons"]), g["reasons"]
)
def test_edited_comment_rejected(self):
payload = _incident_comment_payload()
payload["updated_at"] = "2026-07-14T00:00:00Z"
g = self._assess(payload)
self.assertFalse(g["valid"], g)
self.assertTrue(any("edited" in r for r in g["reasons"]), g["reasons"])
def test_self_authored_by_stable_id_rejected(self):
payload = _incident_comment_payload(author=MINT_ACTOR_LOGIN, author_id=MINT_ACTOR_ID)
g = self._assess(payload)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("self-authored" in r for r in g["reasons"]), g["reasons"]
)
def test_mint_actor_substitution_rejected(self):
g = self._assess(_incident_comment_payload(), mint_actor_id=999, mint_actor_username="someone")
self.assertFalse(g["valid"], g)
self.assertTrue(
any("mint_actor" in r for r in g["reasons"]), g["reasons"]
)
def test_decision_lock_substitution_rejected(self):
payload = _incident_comment_payload(decision_lock_id="review_decision_lock-prgs-merger")
g = self._assess(payload)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("decision_lock_id" in r for r in g["reasons"]), g["reasons"]
)
def test_recovery_action_substitution_rejected(self):
body = _canonical_incident_body().replace(
f"recovery_action: {RECOVERY_ACTION}", "recovery_action: clear_decision_lock"
)
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
def test_unsupported_recovery_action_cannot_be_built(self):
with self.assertRaises(ValueError):
_canonical_incident_body(recovery_action="merge_pr")
def test_cross_pr_replay_rejected(self):
payload = _incident_comment_payload(pr_number=99)
g = self._assess(payload)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("pr_number" in r for r in g["reasons"]), g["reasons"]
)
def test_cross_repository_replay_rejected(self):
payload = _incident_comment_payload(repo="Other-Repo")
g = self._assess(payload)
self.assertFalse(g["valid"], g)
def test_cross_org_replay_rejected(self):
payload = _incident_comment_payload(org="Other-Org")
g = self._assess(payload)
self.assertFalse(g["valid"], g)
def test_cross_remote_replay_rejected(self):
payload = _incident_comment_payload(remote="dadeschools")
g = self._assess(payload)
self.assertFalse(g["valid"], g)
def test_cross_head_replay_rejected(self):
payload = _incident_comment_payload(head=HEAD_B)
g = self._assess(payload)
self.assertFalse(g["valid"], g)
def test_recorded_head_substitution_rejected(self):
payload = _incident_comment_payload(recorded_head_sha=HEAD_B)
g = self._assess(payload, expected_recorded_head_sha=HEAD_A)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("recorded_head_sha" in r for r in g["reasons"]), g["reasons"]
)
def test_key_version_substitution_rejected(self):
payload = _incident_comment_payload(key_version="v9")
g = self._assess(payload, expected_key_version=irp.auth_key_version())
self.assertFalse(g["valid"], g)
def test_digest_preserving_substitution_rejected(self):
"""Swap a field *and* its digest from another scope: still refused.
The attacker mints a fully valid canonical body for a different PR (so
the digest is internally consistent) and presents it for this scope.
"""
foreign = _canonical_incident_body(pr_number=99)
parsed = irp.parse_canonical_incident_body(foreign)
self.assertTrue(parsed["valid"], parsed) # internally consistent
g = self._assess(_incident_comment_payload(body=foreign))
self.assertFalse(g["valid"], g)
def test_field_swap_without_digest_update_rejected(self):
body = _canonical_incident_body().replace(
"repo: Gitea-Tools", "repo: Other-Repo"
)
g = self._assess(
_incident_comment_payload(body=body), expected_repo="Other-Repo"
)
self.assertFalse(g["valid"], g)
self.assertTrue(
any("content_digest" in r for r in g["reasons"]), g["reasons"]
)
def test_nonce_binds_digest(self):
body = _canonical_incident_body().replace(
f"nonce: {INCIDENT_NONCE}", "nonce: 00000000-0000-0000-0000-000000000000"
)
g = self._assess(_incident_comment_payload(body=body))
self.assertFalse(g["valid"], g)
self.assertTrue(
any("content_digest" in r for r in g["reasons"]), g["reasons"]
)
def test_builder_refuses_ambiguous_narrative(self):
with self.assertRaises(ValueError):
_canonical_incident_body(narrative=f"{irp.INCIDENT_MARKER}\nrepo: evil")
def test_builder_refuses_multiline_field(self):
with self.assertRaises(ValueError):
_canonical_incident_body(destroyed_subject="line1\nrepo: evil")
def test_builder_refuses_empty_field(self):
with self.assertRaises(ValueError):
_canonical_incident_body(decision_lock_id="")
def test_builder_output_is_the_accepted_format(self):
body = _canonical_incident_body()
parsed = irp.parse_canonical_incident_body(body)
self.assertTrue(parsed["valid"], parsed)
self.assertEqual(
parsed["canonical_block"],
irp.render_canonical_incident_block(parsed["fields"]),
)
class TestF8ArchivePrerequisiteForClear(unittest.TestCase):
"""#709 F8 (review 438): never clear terminal evidence without a durable archive."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.env = patch.dict(
os.environ,
{
"GITEA_MCP_SESSION_STATE_DIR": self._tmp.name,
"GITEA_SESSION_PROFILE_LOCK": "prgs-merger",
},
clear=False,
)
self.env.start()
import mcp_server
self.mcp = mcp_server
self.mcp._REVIEW_DECISION_LOCK = None
ss.save_state(
kind=ss.KIND_DECISION_LOCK,
payload=_lock([APPROVE], profile="prgs-reviewer", head=HEAD_A),
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
profile_identity="prgs-reviewer",
state_dir=self._tmp.name,
)
def tearDown(self):
self.mcp._REVIEW_DECISION_LOCK = None
self.env.stop()
self._tmp.cleanup()
def _clear(self):
return self.mcp._clear_decision_lock_for_profile(
profile_identity="prgs-reviewer",
pr_number=100,
expected_head_sha=HEAD_A,
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
)
def _lock_still_present(self):
return ss.load_state_for_profile(
kind=ss.KIND_DECISION_LOCK,
profile_identity="prgs-reviewer",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
skip_identity_match=True,
)
def _fail_archive_only(self, behavior):
"""Patch save_state so archive writes fail but other writes pass through."""
real = ss.save_state
def _fake(**kwargs):
if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE:
return behavior()
return real(**kwargs)
return patch.object(self.mcp.mcp_session_state, "save_state", side_effect=_fake)
def test_archive_exception_retains_lock(self):
def _boom():
raise OSError("disk failure")
with self._fail_archive_only(_boom):
out = self._clear()
self.assertFalse(out["cleared"], out)
self.assertTrue(out["terminal_lock_retained"], out)
self.assertTrue(out["recovery_required"], out)
self.assertEqual(out["archive_failed_step"], "archive_save_state")
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
def test_archive_false_response_retains_lock(self):
with self._fail_archive_only(lambda: False):
out = self._clear()
self.assertFalse(out["cleared"], out)
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
def test_archive_empty_response_retains_lock(self):
with self._fail_archive_only(lambda: {}):
out = self._clear()
self.assertFalse(out["cleared"], out)
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
def test_archive_timeout_retains_lock(self):
def _timeout():
raise TimeoutError("session state write timed out")
with self._fail_archive_only(_timeout):
out = self._clear()
self.assertFalse(out["cleared"], out)
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
def test_archive_unreadable_retains_lock(self):
"""Write claims success but read-back finds nothing: still refuse to clear."""
real = ss.load_state_for_profile
def _fake(**kwargs):
if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE:
return None
return real(**kwargs)
with patch.object(
self.mcp.mcp_session_state, "load_state_for_profile", side_effect=_fake
):
out = self._clear()
self.assertFalse(out["cleared"], out)
self.assertEqual(out["archive_failed_step"], "archive_readback")
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
def test_partial_archive_readback_retains_lock(self):
"""Read-back returns a record for a different PR/head: refuse to clear."""
real = ss.load_state_for_profile
def _fake(**kwargs):
if kwargs.get("kind") == ss.KIND_DECISION_LOCK_ARCHIVE:
return {"archived_for_pr": 999, "archived_for_head": HEAD_B}
return real(**kwargs)
with patch.object(
self.mcp.mcp_session_state, "load_state_for_profile", side_effect=_fake
):
out = self._clear()
self.assertFalse(out["cleared"], out)
self.assertEqual(out["archive_failed_step"], "archive_readback")
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
def test_archive_failure_records_actionable_recovery_evidence(self):
with self._fail_archive_only(lambda: False):
out = self._clear()
self.assertFalse(out["cleared"], out)
self.assertTrue(out["retry_safe"], out)
self.assertIn("archival failed", out["reason"])
self.assertIsNotNone(out.get("prior_summary"), out)
# The recovery row is keyed by the active (merging) session profile.
recovery = ss.load_state_for_profile(
kind=ss.KIND_POST_MERGE_DECISION_RECOVERY,
profile_identity="prgs-merger",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
skip_identity_match=True,
)
self.assertIsNotNone(recovery, "no durable recovery evidence recorded")
self.assertEqual(recovery["failed_step"], "archive_save_state")
self.assertEqual(recovery["target_profile_identity"], "prgs-reviewer")
def test_successful_archive_clears_exactly_once(self):
out = self._clear()
self.assertTrue(out["cleared"], out)
self.assertTrue(out["archive_ok"], out)
self.assertIsNone(self._lock_still_present(), "lock should be cleared")
archived = ss.load_state_for_profile(
kind=ss.KIND_DECISION_LOCK_ARCHIVE,
profile_identity="prgs-reviewer-archive-pr100",
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
skip_identity_match=True,
)
self.assertIsNotNone(archived, "archive not durable")
self.assertEqual(archived["archived_for_pr"], 100)
# A second clear is a no-op, not a duplicate clear.
again = self._clear()
self.assertFalse(again["cleared"], again)
def test_retry_after_archive_failure_succeeds(self):
with self._fail_archive_only(lambda: False):
first = self._clear()
self.assertFalse(first["cleared"], first)
self.assertIsNotNone(self._lock_still_present())
retry = self._clear()
self.assertTrue(retry["cleared"], retry)
self.assertIsNone(self._lock_still_present())
def test_no_alternate_path_clears_after_archive_failure(self):
"""The post-merge reconciler must not clear the lock when archival failed."""
with self._fail_archive_only(lambda: False), patch.object(
self.mcp, "api_request", return_value={}
), patch.object(
self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r"
):
report = self.mcp._reconcile_decision_locks_after_merge(
pr_number=100,
head_sha=HEAD_A,
merge_commit_sha="c" * 40,
remote="prgs",
host="h",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
auth={"Authorization": "token test"},
)
self.assertFalse(report.get("cleared_any"), report)
self.assertIsNotNone(self._lock_still_present(), "terminal lock destroyed")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()