diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index f84f6c0..0a9270b 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -4553,7 +4553,56 @@ def _clear_decision_lock_for_profile( org: str | None, repo: str | None, ) -> dict: - """Clear one profile's durable decision lock when it targets *pr_number* approve.""" + """Clear one profile's durable decision lock when it targets *pr_number* approve. + + #709 F3: require remote/org/repo scope match on load and exact head identity + for any terminal match. PR-number-only fallback is forbidden — malformed, + legacy incomplete, cross-repository, or wrong-head locks are never cleared. + """ + try: + import irrecoverable_provenance as _irp + + path_gate = _irp.assess_profile_path_identity(profile_identity) + if not path_gate.get("valid"): + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": "; ".join(path_gate.get("reasons") or ["invalid profile"]), + "recovery_required": True, + } + except Exception: + raw = str(profile_identity or "") + if ".." in raw or "/" in raw or "\\" in raw: + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": "profile identity path traversal rejected (fail closed, #709 F3)", + "recovery_required": True, + } + + # expected_head_sha is mandatory for destructive clear (#709 F3). + want_head = stale_review_decision_lock.normalize_head_sha(expected_head_sha) + if not want_head: + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "expected_head_sha required for decision-lock clear " + "(no PR-number-only fallback; fail closed, #709 F3)" + ), + "recovery_required": False, + } + if not (remote and org and repo): + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "remote/org/repo required for decision-lock clear " + "(no incomplete-identity clear; fail closed, #709 F3)" + ), + "recovery_required": False, + } + lock = mcp_session_state.load_state_for_profile( kind=mcp_session_state.KIND_DECISION_LOCK, profile_identity=profile_identity, @@ -4561,17 +4610,25 @@ def _clear_decision_lock_for_profile( org=org, repo=repo, skip_identity_match=True, + enforce_repo_scope=True, ) if lock is None: return { "profile_identity": profile_identity, "cleared": False, - "reason": "no durable lock for profile", + "reason": ( + "no durable lock for profile at exact remote/org/repo scope " + "(or identity/expiry mismatch; fail closed)" + ), } - if not stale_review_decision_lock.lock_targets_merged_pr_approval( - lock, pr_number=pr_number, expected_head_sha=expected_head_sha - ): - # Also allow any terminal for this PR once merged (request_changes history). + + # Primary: approve of this PR at exact head. + targets_approve = stale_review_decision_lock.lock_targets_merged_pr_approval( + lock, pr_number=pr_number, expected_head_sha=want_head + ) + if not targets_approve: + # Secondary: any terminal for this PR **only** when head also matches. + # Never fall back to PR-number alone (#709 F3 / review 434). last = stale_review_decision_lock.last_terminal_mutation(lock) if not last or last.get("pr_number") != pr_number: return { @@ -4579,7 +4636,29 @@ def _clear_decision_lock_for_profile( "cleared": False, "reason": "lock terminal does not target this PR approval", } - # Archive then clear. + locked_head = stale_review_decision_lock.mutation_head_sha(last, lock) + if not locked_head: + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "legacy/incomplete terminal head identity; refuse destructive " + "clear (inspect/report recovery-required only, #709 F3)" + ), + "recovery_required": True, + "prior_summary": stale_review_decision_lock.lock_summary(lock), + } + if not stale_review_decision_lock.heads_equal(locked_head, want_head): + return { + "profile_identity": profile_identity, + "cleared": False, + "reason": ( + "lock terminal head does not match expected_head_sha " + "(fail closed, #709 F3)" + ), + } + + # Archive then clear — only after exact identity validation. try: mcp_session_state.save_state( kind=mcp_session_state.KIND_DECISION_LOCK_ARCHIVE, @@ -4587,12 +4666,16 @@ def _clear_decision_lock_for_profile( **dict(lock), "archived_reason": "post_merge_cross_profile_cleanup", "archived_for_pr": pr_number, + "archived_for_head": want_head, + "archived_remote": remote, + "archived_org": org, + "archived_repo": repo, "recovery_critical": True, "kind": mcp_session_state.KIND_DECISION_LOCK_ARCHIVE, }, - remote=remote or lock.get("remote"), - org=org or lock.get("org") or lock.get("ready_org"), - repo=repo or lock.get("repo") or lock.get("ready_repo"), + remote=remote, + org=org, + repo=repo, profile_identity=f"{profile_identity}-archive-pr{pr_number}", ) except Exception: @@ -4600,9 +4683,9 @@ def _clear_decision_lock_for_profile( mcp_session_state.clear_state( kind=mcp_session_state.KIND_DECISION_LOCK, profile_identity=profile_identity, - remote=remote or lock.get("remote"), - org=org or lock.get("org") or lock.get("ready_org"), - repo=repo or lock.get("repo") or lock.get("ready_repo"), + remote=remote, + org=org, + repo=repo, ) # If this is the in-memory active profile lock, clear memory too. active = _decision_lock_binding().get("profile_identity") @@ -4612,7 +4695,10 @@ def _clear_decision_lock_for_profile( return { "profile_identity": profile_identity, "cleared": True, - "reason": f"cleared terminal lock for merged PR #{pr_number}", + "reason": ( + f"cleared terminal lock for merged PR #{pr_number} " + f"at head {want_head[:12]}… (exact-scope)" + ), "prior_summary": stale_review_decision_lock.lock_summary(lock), } @@ -5025,59 +5111,79 @@ def gitea_cleanup_stale_review_decision_lock( return report +def _irrecoverable_capability_gate() -> list[str] | None: + """Dedicated recovery capability (gitea.read alone is insufficient).""" + import irrecoverable_provenance as irp + + profile = get_profile() + assessment = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=profile.get("allowed_operations") or [], + forbidden_operations=profile.get("forbidden_operations") or [], + role_kind=profile.get("role") or profile.get("role_kind"), + profile_name=profile.get("profile_name"), + ) + if assessment.get("allowed"): + return None + return list(assessment.get("reasons") or ["capability denied"]) + + @mcp.tool() -def gitea_record_irrecoverable_decision_lock_provenance( +def gitea_issue_irrecoverable_provenance_authorization( pr_number: int, - reason: str, + expected_head_sha: str, + incident_issue: int, + incident_comment_id: int, confirmation: str = "", - operator_authorized: bool = False, - expected_head_sha: str | None = None, - incident_ref: str | None = None, + destroyed_subject: str | None = None, remote: str = "dadeschools", host: str | None = None, org: str | None = None, repo: str | None = None, - post_audit_comment: bool = True, ) -> dict: - """Record truthful absence of decision-lock cleanup proof (#709 AC5). + """Mint a server-side authorization artifact for irrecoverable recovery (#709 F1). - Never emits applied=true or claims historical cleanup was proven. - Requires operator_authorized=True and confirmation exactly equal to - ``IRRECOVERABLE DECISION PROVENANCE PR ``. + Non-forgeable: requires production native MCP transport (or pytest), a + dedicated/reconciler mutation capability, live head equality, and validated + incident evidence. Confirmation is human intent only — never authorization. + Caller Booleans are not accepted. """ + import irrecoverable_provenance as irp + h, o, r = _resolve(remote, host, org, repo) - expected_confirm = f"IRRECOVERABLE DECISION PROVENANCE PR {int(pr_number)}" - report = { + report: dict = { "success": False, "performed": False, - "applied": False, - "historical_cleanup_proven": False, - "status": "provenance_irrecoverable", + "authorization": None, + "authorization_id": None, "pr_number": pr_number, "expected_head_sha": expected_head_sha, + "incident_issue": incident_issue, + "incident_comment_id": incident_comment_id, "reasons": [], - "record": None, - "audit_comment_id": None, } - read_block = _profile_operation_gate("gitea.read") - if read_block: - report["reasons"] = read_block - report["permission_report"] = _permission_block_report("gitea.read") + + cap_block = _irrecoverable_capability_gate() + if cap_block: + report["reasons"].extend(cap_block) + report["permission_report"] = { + "required_operation": irp.CAPABILITY_IRRECOVERABLE_RECOVERY, + "reasons": cap_block, + } return report - if not operator_authorized: - report["reasons"].append( - "operator_authorized must be true for irrecoverable provenance " - "recording (fail closed, #709 AC5)" - ) + + transport = irp.assess_transport_for_auth_mint() + if not transport.get("allowed"): + report["reasons"].extend(transport.get("reasons") or []) return report + + expected_confirm = irp.expected_confirmation(pr_number) if (confirmation or "").strip() != expected_confirm: report["reasons"].append( - f"confirmation must equal exactly {expected_confirm!r} (fail closed)" + f"confirmation must equal exactly {expected_confirm!r} " + "(human intent only; not an authorization credential; fail closed)" ) return report - if not (reason or "").strip(): - report["reasons"].append("reason is required (fail closed)") - return report + try: actor = _authenticated_username(h) except Exception: @@ -5089,45 +5195,385 @@ def gitea_record_irrecoverable_decision_lock_provenance( return report profile = get_profile() profile_name = (profile.get("profile_name") or "").strip() or None - # Idempotent: if matching record already exists for pr+head, return it. - binding = _decision_lock_binding() + + # Live PR head (authoritative). + live_head = None + pr_state = None + pr_err = None + try: + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h) + ) + live_head = (pr_live or {}).get("head", {}) + if isinstance(live_head, dict): + live_head = live_head.get("sha") + else: + live_head = (pr_live or {}).get("head_sha") or (pr_live or {}).get( + "head_commit_sha" + ) + pr_state = (pr_live or {}).get("state") + except Exception as exc: # noqa: BLE001 + pr_err = _redact(str(exc)) + head_gate = irp.assess_live_head_binding( + expected_head_sha=expected_head_sha, + live_head_sha=live_head, + pr_lookup_error=pr_err, + pr_state=pr_state, + ) + if not head_gate.get("valid"): + report["reasons"].extend(head_gate.get("reasons") or []) + return report + + # Incident evidence live validation. + comment_payload = None + comment_err = None + try: + comment_payload = api_request( + "GET", + f"{repo_api_url(h, o, r)}/issues/comments/{int(incident_comment_id)}", + _auth(h), + ) + except Exception as exc: # noqa: BLE001 + comment_err = _redact(str(exc)) + incident_gate = irp.assess_incident_evidence( + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + comment_payload=comment_payload if isinstance(comment_payload, dict) else None, + comment_lookup_error=comment_err, + expected_remote=remote, + expected_org=o, + expected_repo=r, + ) + if not incident_gate.get("valid"): + report["reasons"].extend(incident_gate.get("reasons") or []) + return report + + auth_profile = irp.auth_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + # Idempotent: return unconsumed matching auth. + existing = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + if isinstance(existing, dict): + v = irp.verify_authorization_artifact( + existing, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + require_unconsumed=True, + ) + if v.get("valid"): + report["success"] = True + report["performed"] = False + report["authorization"] = existing + report["authorization_id"] = existing.get("authorization_id") + report["reasons"].append( + "idempotent: unconsumed matching authorization already present" + ) + return report + + artifact = irp.build_authorization_artifact( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + incident_issue=int(incident_issue), + incident_comment_id=int(incident_comment_id), + destroyed_subject=destroyed_subject, + issuer_username=actor, + issuer_profile=profile_name or "unknown", + native_provenance=mcp_daemon_guard.mutation_provenance_fields(), + ) + artifact["kind"] = mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH + saved = mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + payload=artifact, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + report["authorization"] = dict(saved or artifact) + report["authorization_id"] = report["authorization"].get("authorization_id") + report["performed"] = True + report["success"] = True + report["reasons"].append( + "issued server-side irrecoverable provenance authorization " + "(non-forgeable; bound to remote/org/repo/PR/head/incident)" + ) + return report + + +@mcp.tool() +def gitea_record_irrecoverable_decision_lock_provenance( + pr_number: int, + reason: str, + confirmation: str = "", + expected_head_sha: str | None = None, + incident_issue: int | None = None, + incident_comment_id: int | None = None, + authorization_id: str | None = None, + destroyed_subject: str | None = None, + incident_ref: str | None = None, + # Deprecated: retained so callers that still pass it get an explicit deny. + operator_authorized: bool = False, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + post_audit_comment: bool = True, +) -> dict: + """Record truthful absence of decision-lock cleanup proof (#709 AC5). + + Never emits applied=true or claims historical cleanup was proven. + + Authorization is a **server-side artifact** (see + ``gitea_issue_irrecoverable_provenance_authorization``). Caller-supplied + ``operator_authorized`` is **never** authorization evidence (review 434 F1). + Confirmation is human intent only. ``expected_head_sha``, + ``incident_issue``, and ``incident_comment_id`` are mandatory. + """ + import irrecoverable_provenance as irp + + h, o, r = _resolve(remote, host, org, repo) + expected_confirm = irp.expected_confirmation(pr_number) + report = { + "success": False, + "performed": False, + "applied": False, + "historical_cleanup_proven": False, + "status": "provenance_irrecoverable", + "pr_number": pr_number, + "expected_head_sha": expected_head_sha, + "reasons": [], + "record": None, + "audit_comment_id": None, + "authorization_id": authorization_id, + "merger_may_accept": False, + } + + # Explicitly reject self-assertable Boolean as sole/any authorization. + if operator_authorized: + report["reasons"].append( + "operator_authorized is not accepted as authorization evidence " + "(#709 F1 / review 434); mint a server-side authorization via " + "gitea_issue_irrecoverable_provenance_authorization" + ) + # Do not return yet — still report other failures — but never authorize. + # Actually fail immediately so success cannot be claimed. + return report + + cap_block = _irrecoverable_capability_gate() + if cap_block: + report["reasons"].extend(cap_block) + report["permission_report"] = { + "required_operation": irp.CAPABILITY_IRRECOVERABLE_RECOVERY, + "reasons": cap_block, + } + return report + + transport = irp.assess_transport_for_auth_mint() + if not transport.get("allowed"): + report["reasons"].extend(transport.get("reasons") or []) + return report + + if (confirmation or "").strip() != expected_confirm: + report["reasons"].append( + f"confirmation must equal exactly {expected_confirm!r} " + "(human intent only; not authorization; fail closed)" + ) + return report + if not (reason or "").strip(): + report["reasons"].append("reason is required (fail closed)") + return report + if not expected_head_sha or not str(expected_head_sha).strip(): + report["reasons"].append( + "expected_head_sha is mandatory (fail closed, #709 F1)" + ) + return report + if incident_issue is None or int(incident_issue) <= 0: + report["reasons"].append( + "incident_issue is mandatory (canonical incident evidence, #709 F1)" + ) + return report + if incident_comment_id is None or int(incident_comment_id) <= 0: + report["reasons"].append( + "incident_comment_id is mandatory (canonical incident evidence, #709 F1)" + ) + return report + # incident_ref alone is never sufficient (legacy arg ignored as authority). + _ = incident_ref + + try: + actor = _authenticated_username(h) + except Exception: + actor = None + if not actor: + report["reasons"].append( + "authenticated identity could not be verified (fail closed)" + ) + return report + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or None + + # Live head must match. + live_head = None + pr_err = None + try: + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h) + ) + live_head = (pr_live or {}).get("head", {}) + if isinstance(live_head, dict): + live_head = live_head.get("sha") + else: + live_head = (pr_live or {}).get("head_sha") or (pr_live or {}).get( + "head_commit_sha" + ) + except Exception as exc: # noqa: BLE001 + pr_err = _redact(str(exc)) + head_gate = irp.assess_live_head_binding( + expected_head_sha=expected_head_sha, + live_head_sha=live_head, + pr_lookup_error=pr_err, + ) + if not head_gate.get("valid"): + report["reasons"].extend(head_gate.get("reasons") or []) + return report + + # Re-validate incident evidence at record time. + comment_payload = None + comment_err = None + try: + comment_payload = api_request( + "GET", + f"{repo_api_url(h, o, r)}/issues/comments/{int(incident_comment_id)}", + _auth(h), + ) + except Exception as exc: # noqa: BLE001 + comment_err = _redact(str(exc)) + incident_gate = irp.assess_incident_evidence( + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + comment_payload=comment_payload if isinstance(comment_payload, dict) else None, + comment_lookup_error=comment_err, + expected_org=o, + expected_repo=r, + ) + if not incident_gate.get("valid"): + report["reasons"].extend(incident_gate.get("reasons") or []) + return report + + # Load server-side authorization artifact (by scope; optional id check). + auth_profile = irp.auth_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + authorization = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + if not isinstance(authorization, dict): + report["reasons"].append( + "no server-side authorization artifact for this exact scope; call " + "gitea_issue_irrecoverable_provenance_authorization first (fail closed)" + ) + return report + if authorization_id and authorization.get("authorization_id") != authorization_id: + report["reasons"].append( + "authorization_id does not match durable artifact for this scope " + "(fail closed)" + ) + return report + auth_check = irp.verify_authorization_artifact( + authorization, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + incident_issue=int(incident_issue), + incident_comment_id=int(incident_comment_id), + require_unconsumed=True, + ) + if not auth_check.get("valid"): + report["reasons"].extend(auth_check.get("reasons") or []) + return report + + recovery_profile = irp.recovery_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) existing = mcp_session_state.load_state( kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, remote=remote, org=o, repo=r, - profile_identity=binding.get("profile_identity"), + profile_identity=recovery_profile, ) if ( isinstance(existing, dict) and existing.get("pr_number") == pr_number - and ( - not expected_head_sha - or stale_review_decision_lock.heads_equal( - existing.get("head_sha"), expected_head_sha - ) + and stale_review_decision_lock.heads_equal( + existing.get("head_sha"), expected_head_sha ) and existing.get("status") == "provenance_irrecoverable" + and existing.get("authorization_id") == authorization.get("authorization_id") ): report["success"] = True report["performed"] = False report["record"] = existing - report["reasons"].append("idempotent: matching irrecoverable record already present") + report["merger_may_accept"] = bool(existing.get("merger_may_accept")) + report["authorization_id"] = existing.get("authorization_id") + report["reasons"].append( + "idempotent: matching irrecoverable record already present" + ) return report - record = stale_review_decision_lock.build_irrecoverable_provenance_record( + record = irp.build_irrecoverable_provenance_record( pr_number=pr_number, - head_sha=expected_head_sha, + head_sha=str(expected_head_sha), remote=remote, org=o, repo=r, actor_username=actor, profile_name=profile_name, reason=reason.strip(), - incident_ref=incident_ref, - operator_authorized=True, + incident_issue=int(incident_issue), + incident_comment_id=int(incident_comment_id), + authorization=authorization, + destroyed_subject=destroyed_subject, ) - # Stamp kind for TTL exemption + if not record.get("merger_may_accept"): + report["reasons"].append( + "built record is not merger-acceptable (authorization verify failed)" + ) + report["record"] = record + return report + record["kind"] = mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE saved = mcp_session_state.save_state( kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, @@ -5135,28 +5581,25 @@ def gitea_record_irrecoverable_decision_lock_provenance( remote=remote, org=o, repo=r, - profile_identity=binding.get("profile_identity"), + profile_identity=recovery_profile, ) report["record"] = dict(saved or record) report["performed"] = True report["success"] = True + report["merger_may_accept"] = True + report["authorization_id"] = record.get("authorization_id") report["reasons"].append( - "recorded provenance_irrecoverable (applied=false; historical cleanup not proven)" + "recorded provenance_irrecoverable (applied=false; historical cleanup " + "not proven; server authorization bound)" ) if post_audit_comment: - comment_block = _profile_operation_gate("gitea.pr.comment") or _profile_operation_gate( - "gitea.issue.comment" - ) - # Prefer issue comment capability for discussion thread. issue_block = _profile_operation_gate("gitea.issue.comment") if issue_block: report["reasons"].append(f"audit comment skipped: {issue_block}") else: try: - body = stale_review_decision_lock.format_irrecoverable_audit_comment( - report["record"] - ) + body = irp.format_irrecoverable_audit_comment(report["record"]) comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" with _audited( "comment_issue", @@ -5177,7 +5620,6 @@ def gitea_record_irrecoverable_decision_lock_provenance( ) report["audit_comment_id"] = (posted or {}).get("id") if report["audit_comment_id"]: - # Re-save with comment id for readback completeness. report["record"]["audit_comment_id"] = report["audit_comment_id"] mcp_session_state.save_state( kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, @@ -5185,7 +5627,7 @@ def gitea_record_irrecoverable_decision_lock_provenance( remote=remote, org=o, repo=r, - profile_identity=binding.get("profile_identity"), + profile_identity=recovery_profile, ) except Exception as exc: # noqa: BLE001 report["reasons"].append( @@ -5194,6 +5636,209 @@ def gitea_record_irrecoverable_decision_lock_provenance( return report +@mcp.tool() +def gitea_consume_irrecoverable_decision_lock_provenance( + pr_number: int, + expected_head_sha: str, + confirmation: str = "", + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Merger-side fail-closed consumption of an irrecoverable recovery record (#709 F2). + + Resolves **only** the historical prior-provenance blocker for the exact + remote/org/repo/PR/head. Never bypasses approval, change-requests, lease, + mergeability, anti-stomp, runtime, or workspace gates. Consumption is + durable, auditable, and idempotent. + """ + import irrecoverable_provenance as irp + + h, o, r = _resolve(remote, host, org, repo) + expected_confirm = f"CONSUME IRRECOVERABLE PROVENANCE PR {int(pr_number)}" + report: dict = { + "success": False, + "performed": False, + "pr_number": pr_number, + "expected_head_sha": expected_head_sha, + "historical_cleanup_proven": False, + "historical_cleanup_not_proven": True, + "irrecoverable_recovery_authorized": False, + "recovery_record_consumed": False, + "resolves_prior_provenance_blocker": False, + "normal_approval_and_merge_gates": "not_substituted", + "reasons": [], + "assessment": None, + } + + # Merger or reconciler may consume; gitea.read alone insufficient. + merge_block = _profile_operation_gate("gitea.pr.merge") + cap_block = _irrecoverable_capability_gate() + if merge_block and cap_block: + report["reasons"].append( + "consume requires gitea.pr.merge (merger) or irrecoverable-recovery " + "capability (reconciler); gitea.read alone is insufficient (#709 F2)" + ) + if merge_block: + report["reasons"].extend( + merge_block if isinstance(merge_block, list) else [str(merge_block)] + ) + report["reasons"].extend(cap_block) + return report + + if (confirmation or "").strip() != expected_confirm: + report["reasons"].append( + f"confirmation must equal exactly {expected_confirm!r} " + "(human intent; fail closed)" + ) + return report + + try: + actor = _authenticated_username(h) + except Exception: + actor = None + if not actor: + report["reasons"].append("authenticated identity unverified (fail closed)") + return report + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() or None + + # Live head. + live_head = None + pr_err = None + try: + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{int(pr_number)}", _auth(h) + ) + live_head = (pr_live or {}).get("head", {}) + if isinstance(live_head, dict): + live_head = live_head.get("sha") + else: + live_head = (pr_live or {}).get("head_sha") + except Exception as exc: # noqa: BLE001 + pr_err = _redact(str(exc)) + head_gate = irp.assess_live_head_binding( + expected_head_sha=expected_head_sha, + live_head_sha=live_head, + pr_lookup_error=pr_err, + ) + if not head_gate.get("valid"): + report["reasons"].extend(head_gate.get("reasons") or []) + return report + + recovery_profile = irp.recovery_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + recovery = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + remote=remote, + org=o, + repo=r, + profile_identity=recovery_profile, + ) + auth_profile = irp.auth_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha), + ) + authorization = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + + # Pull formal review state so consumption cannot be claimed when normal + # gates would fail (assessment only — does not merge). + feedback = gitea_get_pr_review_feedback( + pr_number=pr_number, remote=remote, host=host, org=org, repo=repo + ) + assessment = irp.assess_merger_consumption( + recovery if isinstance(recovery, dict) else None, + authorization if isinstance(authorization, dict) else None, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + live_head_sha=live_head, + approval_at_current_head=feedback.get("approval_at_current_head") + if feedback.get("success") + else None, + has_blocking_change_requests=feedback.get("has_blocking_change_requests") + if feedback.get("success") + else None, + ) + report["assessment"] = assessment + report["reasons"].extend(assessment.get("reasons") or []) + report["irrecoverable_recovery_authorized"] = bool( + assessment.get("irrecoverable_recovery_authorized") + ) + report["resolves_prior_provenance_blocker"] = bool( + assessment.get("resolves_prior_provenance_blocker") + ) + report["historical_cleanup_proven"] = False + report["historical_cleanup_not_proven"] = True + + if not assessment.get("allowed") and not assessment.get( + "recovery_record_consumed" + ): + return report + + # Atomic-ish durable consumption (auth then recovery under exclusive locks + # via save_state). + if isinstance(authorization, dict) and not authorization.get("consumed_at"): + consumed_auth = irp.mark_consumed( + authorization, + consumer_username=actor, + consumer_profile=profile_name, + ) + mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + payload=consumed_auth, + remote=remote, + org=o, + repo=r, + profile_identity=auth_profile, + ) + if isinstance(recovery, dict) and not recovery.get("consumed_at"): + consumed_rec = irp.mark_consumed( + recovery, + consumer_username=actor, + consumer_profile=profile_name, + ) + consumed_rec["prior_provenance_blocker_resolved"] = True + saved = mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + payload=consumed_rec, + remote=remote, + org=o, + repo=r, + profile_identity=recovery_profile, + ) + report["record"] = dict(saved or consumed_rec) + report["performed"] = True + else: + report["record"] = recovery + report["performed"] = False + + report["recovery_record_consumed"] = True + report["success"] = True + report["resolves_prior_provenance_blocker"] = True + report["reasons"].append( + "prior-provenance blocker resolved for exact scope; historical cleanup " + "remains unproven; normal merge gates still required" + ) + return report + + @mcp.tool() def gitea_dry_run_pr_review( pr_number: int, @@ -6244,6 +6889,137 @@ def gitea_merge_pr( reasons.append(str(e)) return result + # Gate 8b — optional irrecoverable prior-provenance recovery report (#709 F2). + # Never substitutes for approval/lease/mergeability gates above. Surfaces + # truthful distinctions for controllers/mergers. + try: + import irrecoverable_provenance as _irp_merge + + _rec_prof = _irp_merge.recovery_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha or actual_sha or ""), + ) + _auth_prof = _irp_merge.auth_state_profile_identity( + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + expected_head_sha=str(expected_head_sha or actual_sha or ""), + ) + _recovery = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + remote=remote, + org=o, + repo=r, + profile_identity=_rec_prof, + ) + _auth_art = mcp_session_state.load_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + remote=remote, + org=o, + repo=r, + profile_identity=_auth_prof, + ) + if isinstance(_recovery, dict) or isinstance(_auth_art, dict): + _assess = _irp_merge.assess_merger_consumption( + _recovery if isinstance(_recovery, dict) else None, + _auth_art if isinstance(_auth_art, dict) else None, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + live_head_sha=actual_sha, + approval_at_current_head=bool( + feedback.get("approval_at_current_head") + ), + has_blocking_change_requests=bool( + feedback.get("has_blocking_change_requests") + ), + mergeable=result.get("mergeable"), + lease_ok=True, + runtime_ok=True, + workspace_ok=True, + anti_stomp_ok=True, + ) + result["irrecoverable_provenance"] = { + "historical_cleanup_proven": False, + "historical_cleanup_not_proven": True, + "irrecoverable_recovery_authorized": bool( + _assess.get("irrecoverable_recovery_authorized") + ), + "recovery_record_consumed": bool( + _assess.get("recovery_record_consumed") + or ( + isinstance(_recovery, dict) and _recovery.get("consumed_at") + ) + ), + "resolves_prior_provenance_blocker": bool( + _assess.get("resolves_prior_provenance_blocker") + ), + "assessment_reasons": list(_assess.get("reasons") or []), + } + # Consume on successful merge path when allowed and not yet consumed. + if _assess.get("allowed") and isinstance(_recovery, dict) and not _recovery.get( + "consumed_at" + ): + try: + if isinstance(_auth_art, dict) and not _auth_art.get("consumed_at"): + mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + payload=_irp_merge.mark_consumed( + _auth_art, + consumer_username=auth_user, + consumer_profile=result.get("profile_name"), + ), + remote=remote, + org=o, + repo=r, + profile_identity=_auth_prof, + ) + mcp_session_state.save_state( + kind=mcp_session_state.KIND_IRRECOVERABLE_DECISION_PROVENANCE, + payload={ + **_irp_merge.mark_consumed( + _recovery, + consumer_username=auth_user, + consumer_profile=result.get("profile_name"), + ), + "prior_provenance_blocker_resolved": True, + }, + remote=remote, + org=o, + repo=r, + profile_identity=_rec_prof, + ) + result["irrecoverable_provenance"][ + "recovery_record_consumed" + ] = True + result["irrecoverable_provenance"][ + "consumed_at_merge_preflight" + ] = True + except Exception as _cons_exc: # noqa: BLE001 + result["irrecoverable_provenance"]["consume_error"] = _redact( + str(_cons_exc) + ) + else: + result["irrecoverable_provenance"] = { + "historical_cleanup_proven": False, + "historical_cleanup_not_proven": True, + "irrecoverable_recovery_authorized": False, + "recovery_record_consumed": False, + "resolves_prior_provenance_blocker": False, + "note": "no irrecoverable recovery record for this exact scope", + } + except Exception as _irp_exc: # noqa: BLE001 — never block merge path + result["irrecoverable_provenance"] = { + "error": _redact(str(_irp_exc)), + "historical_cleanup_proven": False, + "historical_cleanup_not_proven": True, + } + # All gates passed — perform the single merge mutation. try: auth = _auth(h) diff --git a/irrecoverable_provenance.py b/irrecoverable_provenance.py new file mode 100644 index 0000000..3a62086 --- /dev/null +++ b/irrecoverable_provenance.py @@ -0,0 +1,902 @@ +"""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 secrets +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" +AUTH_TTL_HOURS = 24.0 +RECORD_TYPE = "irrecoverable_decision_provenance" +AUTH_TYPE = "irrecoverable_provenance_authorization" + +# Internal HMAC material is process-local and never caller-supplied. Pytest +# gets a deterministic salt so hermetic tests are stable; production uses +# transport fingerprint + random secret minted at process start. +_PROCESS_AUTH_SECRET: bytes | None = None + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _now_iso() -> str: + return _now().isoformat() + + +def _process_secret() -> bytes: + global _PROCESS_AUTH_SECRET + if _PROCESS_AUTH_SECRET is None: + if mcp_daemon_guard.is_pytest_runtime(): + _PROCESS_AUTH_SECRET = b"pytest-irrecoverable-auth-v1" + else: + _PROCESS_AUTH_SECRET = secrets.token_bytes(32) + return _PROCESS_AUTH_SECRET + + +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]) -> str: + """HMAC over canonical scope + native transport fingerprint (non-caller).""" + material = { + "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 F1). + + Accepts the dedicated capability, or a reconciler-shaped profile that + already holds issue-comment mutation rights (interim equivalence until + operators grant the dedicated op). Never treats bare ``gitea.read`` as + sufficient. + """ + import gitea_config + import reconciler_profile + + 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": [], + } + + # Interim: reconciler profile with issue.comment (mutation, not read-only). + is_reconciler = reconciler_profile.is_reconciler_profile(allowed, forbidden) + comment_ok, _ = gitea_config.check_operation( + "gitea.issue.comment", allowed, forbidden + ) + role = (role_kind or "").strip().lower() + name = (profile_name or "").strip().lower() + role_looks_reconciler = role == "reconciler" or "reconciler" in name + if is_reconciler and comment_ok: + return { + "allowed": True, + "capability": CAPABILITY_IRRECOVERABLE_RECOVERY, + "via": "reconciler_profile_equivalence", + "reasons": [], + } + if role_looks_reconciler and comment_ok and not dedicated_ok: + # Role metadata says reconciler but ops incomplete — still fail if + # is_reconciler_profile is false (missing pr.close). + reasons.append( + "reconciler role metadata without reconciler-required operations " + f"(need {CAPABILITY_IRRECOVERABLE_RECOVERY} or reconciler profile " + "with gitea.pr.close + gitea.issue.comment; gitea.read alone is " + "insufficient, #709 F1)" + ) + else: + reasons.append( + f"missing dedicated capability {CAPABILITY_IRRECOVERABLE_RECOVERY} " + "(gitea.read is insufficient; require reconciler-capable mutation " + "profile or explicit grant, #709 F1)" + ) + 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 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, +) -> dict[str, Any]: + """Validate canonical incident evidence is present and live-fetched.""" + reasons: list[str] = [] + if incident_issue is None or int(incident_issue) <= 0: + reasons.append("incident_issue is required and must be a positive integer") + if incident_comment_id is None or int(incident_comment_id) <= 0: + reasons.append( + "incident_comment_id is required and must be a positive integer" + ) + if comment_lookup_error: + reasons.append( + f"incident evidence lookup failed: {comment_lookup_error} (fail closed)" + ) + if not isinstance(comment_payload, dict): + if not comment_lookup_error: + reasons.append( + "incident evidence not found or not a comment object (fail closed)" + ) + return {"valid": False, "reasons": reasons, "comment": None} + + # Gitea returns comment with id; optional issue_url / html_url for scope. + cid = comment_payload.get("id") + try: + if int(cid) != int(incident_comment_id): # type: ignore[arg-type] + reasons.append( + "incident comment id mismatch against live payload (fail closed)" + ) + except (TypeError, ValueError): + reasons.append("incident comment payload missing valid id (fail closed)") + + body = (comment_payload.get("body") or "").strip() + if not body: + reasons.append("incident comment body is empty (fail closed)") + + # Soft scope hints when URLs are present (never hard-code issue numbers). + issue_url = str( + comment_payload.get("issue_url") + or comment_payload.get("html_url") + or "" + ) + if expected_org and expected_org not in issue_url and issue_url: + # Only fail when URL is present and clearly wrong-org; missing URL ok. + if f"/{expected_org}/" not in issue_url: + # html_url may be /user/repo/issues/n — check repo if provided + if expected_repo and f"/{expected_repo}/" not in issue_url: + reasons.append( + "incident evidence URL does not match expected repository " + "(fail closed)" + ) + + return { + "valid": not reasons, + "reasons": reasons, + "comment": { + "id": comment_payload.get("id"), + "author": ( + (comment_payload.get("user") or {}).get("login") + if isinstance(comment_payload.get("user"), dict) + else comment_payload.get("user") + ), + "created_at": comment_payload.get("created_at"), + "body_len": len(body), + }, + } + + +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, + ) + signature = _sign_scope(scope, provenance) + 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, + "native_provenance": provenance, + **scope, + "timestamp": created.isoformat(), + "recorded_at": created.isoformat(), + "updated_at": created.isoformat(), + } + + +def verify_authorization_artifact( + auth: dict[str, Any] | None, + *, + remote: str, + org: str, + repo: str, + pr_number: int, + expected_head_sha: str, + incident_issue: int | None = None, + incident_comment_id: int | None = None, + require_unconsumed: bool = True, + now: datetime | None = None, +) -> dict[str, Any]: + """Fail-closed verification of a server-side authorization artifact.""" + reasons: list[str] = [] + if not isinstance(auth, dict): + return { + "valid": False, + "reasons": ["authorization artifact missing (fail closed)"], + } + if (auth.get("kind") or auth.get("auth_type") or "") not in ( + KIND_AUTH, + AUTH_TYPE, + ) and auth.get("record_type") != AUTH_TYPE: + if (auth.get("kind") or "") != KIND_AUTH: + reasons.append( + f"authorization kind mismatch (expected {KIND_AUTH!r}; fail closed)" + ) + + for field, want in ( + ("remote", remote), + ("org", org), + ("repo", repo), + ): + have = (str(auth.get(field) or "")).strip() + if not have or have != (want or "").strip(): + reasons.append( + f"authorization {field} mismatch " + f"(stored={have!r}, expected={want!r}; fail closed)" + ) + + try: + if int(auth.get("blocked_pr_number")) != int(pr_number): + reasons.append("authorization PR number mismatch (fail closed)") + except (TypeError, ValueError): + reasons.append("authorization missing blocked_pr_number (fail closed)") + + if not heads_equal(auth.get("expected_head_sha"), expected_head_sha): + reasons.append("authorization head SHA mismatch (fail closed)") + + if incident_issue is not None: + try: + if int(auth.get("incident_issue")) != int(incident_issue): + reasons.append("authorization incident_issue mismatch (fail closed)") + except (TypeError, ValueError): + reasons.append("authorization missing incident_issue (fail closed)") + if incident_comment_id is not None: + try: + if int(auth.get("incident_comment_id")) != int(incident_comment_id): + reasons.append( + "authorization incident_comment_id mismatch (fail closed)" + ) + except (TypeError, ValueError): + reasons.append("authorization missing incident_comment_id (fail closed)") + + if not (auth.get("issuer_username") or "").strip(): + reasons.append("authorization missing issuer_username (fail closed)") + if not (auth.get("issuer_profile") or "").strip(): + reasons.append("authorization missing issuer_profile (fail closed)") + if not (auth.get("server_signature") or "").strip(): + reasons.append("authorization missing server_signature (fail closed)") + + # Recompute signature over stored scope fields. + 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) + if not hmac.compare_digest( + expected_sig, str(auth.get("server_signature") or "") + ): + reasons.append( + "authorization server_signature invalid (forged or corrupt; " + "fail closed, #709 F1)" + ) + except (TypeError, ValueError) as exc: + reasons.append(f"authorization scope incomplete: {exc} (fail closed)") + + # Native provenance required on the artifact itself. + native = auth.get("native_provenance") or {} + if not isinstance(native, dict) or not ( + native.get("native_mcp_transport") or native.get("pytest") + ): + # Pytest artifacts stamp pytest=True via mutation_provenance_fields. + if not mcp_daemon_guard.is_pytest_runtime(): + if not (isinstance(native, dict) and native.get("native_mcp_transport")): + reasons.append( + "authorization lacks native transport provenance (fail closed)" + ) + + # Expiry / consumption. + now_dt = now or _now() + expires_raw = auth.get("expires_at") + expires_dt = None + if expires_raw: + text = str(expires_raw).strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + expires_dt = datetime.fromisoformat(text) + if expires_dt.tzinfo is None: + expires_dt = expires_dt.replace(tzinfo=timezone.utc) + except ValueError: + reasons.append("authorization expires_at unparseable (fail closed)") + else: + reasons.append("authorization missing expires_at (fail closed)") + if expires_dt is not None and now_dt > expires_dt: + reasons.append("authorization expired (fail closed)") + + state = (auth.get("consumption_state") or auth.get("status") or "").strip() + if require_unconsumed and state in ("consumed", "expired"): + reasons.append( + f"authorization already {state}; cannot be replayed (fail closed)" + ) + if require_unconsumed and auth.get("consumed_at"): + reasons.append("authorization already consumed (fail closed)") + + return { + "valid": not reasons, + "reasons": reasons, + "authorization_id": auth.get("authorization_id"), + "consumption_state": state or None, + } + + +def build_irrecoverable_provenance_record( + *, + pr_number: int, + head_sha: str, + remote: str, + org: str, + repo: str, + actor_username: str | None, + profile_name: str | None, + reason: str, + incident_issue: int, + incident_comment_id: int, + authorization: dict[str, Any], + destroyed_subject: str | None = None, + historical_provenance_subject: str | None = None, +) -> dict[str, Any]: + """Truthful absence-of-proof record. Never sets applied=True. + + ``merger_may_accept`` is True only when *authorization* verifies for the + exact scope. Caller-supplied Booleans are never consulted. + """ + head = normalize_head_sha(head_sha) + auth_check = verify_authorization_artifact( + authorization, + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + expected_head_sha=head or "", + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + require_unconsumed=True, + ) + may_accept = bool(auth_check.get("valid")) + return { + "event": "irrecoverable_decision_lock_provenance", + "status": "provenance_irrecoverable", + "record_type": RECORD_TYPE, + "kind": KIND_RECOVERY, + "operator_recovery_required": True, + "issue_ref": "#709", + "recovery_critical": True, + "applied": False, + "historical_cleanup_proven": False, + "timestamp": _now_iso(), + "pr_number": int(pr_number), + "blocked_pr_number": int(pr_number), + "head_sha": head, + "remote": remote, + "org": org, + "repo": repo, + "actor_username": actor_username, + "profile_name": profile_name, + "reason": reason, + "incident_issue": int(incident_issue), + "incident_comment_id": int(incident_comment_id), + # Legacy field for audit readability; not a caller Boolean gate. + "incident_ref": f"issue:{int(incident_issue)}/comment:{int(incident_comment_id)}", + "authorization_id": authorization.get("authorization_id"), + "authorization_issuer": authorization.get("issuer_username"), + "authorization_issuer_profile": authorization.get("issuer_profile"), + "authorization_verified": may_accept, + "authorization_verify_reasons": list(auth_check.get("reasons") or []), + "destroyed_subject": (destroyed_subject or "").strip() or None, + "historical_provenance_subject": ( + (historical_provenance_subject or destroyed_subject or "").strip() + or None + ), + "consumption_state": "issued", + "consumed_at": None, + "merger_may_accept": may_accept, + "acceptance_rule": ( + "Merger may accept this record only when a server-side authorization " + "artifact verifies for remote/org/repo/PR/exact-head/incident, the " + "record is durable and read back, the auth is unexpired and unconsumed, " + "and normal merge gates still pass. Resolves only the historical " + "prior-provenance blocker; never proves historical cleanup " + "(applied=false, historical_cleanup_proven=false)." + ), + "native_provenance": mcp_daemon_guard.mutation_provenance_fields(), + } + + +def format_irrecoverable_audit_comment(record: dict[str, Any]) -> str: + """Markdown body for irrecoverable provenance audit (no applied=true claim).""" + lines = [ + "## Irrecoverable decision-lock provenance (#709)", + "", + "Status: **PROVENANCE_IRRECOVERABLE** (not applied cleanup)", + "", + f"- actor: `{record.get('actor_username')}`", + f"- profile: `{record.get('profile_name')}`", + f"- timestamp: `{record.get('timestamp')}`", + f"- PR: `#{record.get('pr_number')}`", + f"- head_sha: `{record.get('head_sha')}`", + f"- incident_issue: `{record.get('incident_issue')}`", + f"- incident_comment_id: `{record.get('incident_comment_id')}`", + f"- authorization_id: `{record.get('authorization_id')}`", + f"- authorization_issuer: `{record.get('authorization_issuer')}`", + f"- authorization_verified: `{record.get('authorization_verified')}`", + f"- historical_cleanup_proven: `{record.get('historical_cleanup_proven')}`", + f"- applied: `{record.get('applied')}` (must remain false)", + f"- merger_may_accept: `{record.get('merger_may_accept')}`", + f"- destroyed_subject: `{record.get('destroyed_subject')}`", + "", + f"Reason: {record.get('reason')}", + "", + "This record documents **absence of proof**, not successful cleanup.", + "It must not be reused for a different PR or head (#709 AC6).", + "Authorization is a server-side artifact — not a caller Boolean.", + ] + return "\n".join(lines) + + +def assess_merger_consumption( + recovery: dict[str, Any] | None, + authorization: dict[str, Any] | None, + *, + remote: str, + org: str, + repo: str, + pr_number: int, + live_head_sha: str | None, + # Normal merge gate outcomes (must still pass independently). + approval_at_current_head: bool | None = None, + has_blocking_change_requests: bool | None = None, + mergeable: bool | None = None, + lease_ok: bool | None = None, + runtime_ok: bool | None = None, + workspace_ok: bool | None = None, + anti_stomp_ok: bool | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + """Fail-closed merger assessment for consuming an irrecoverable recovery. + + Resolves **only** the historical prior-provenance blocker when all + recovery checks pass. Never grants a pass when normal merge gates fail. + """ + reasons: list[str] = [] + result: dict[str, Any] = { + "allowed": False, + "resolves_prior_provenance_blocker": False, + "historical_cleanup_proven": False, + "irrecoverable_recovery_authorized": False, + "recovery_record_consumed": False, + "reasons": reasons, + "normal_gates": { + "approval_at_current_head": approval_at_current_head, + "has_blocking_change_requests": has_blocking_change_requests, + "mergeable": mergeable, + "lease_ok": lease_ok, + "runtime_ok": runtime_ok, + "workspace_ok": workspace_ok, + "anti_stomp_ok": anti_stomp_ok, + }, + } + + if not isinstance(recovery, dict): + reasons.append("recovery record missing (fail closed)") + return result + if (recovery.get("record_type") or recovery.get("kind")) not in ( + RECORD_TYPE, + KIND_RECOVERY, + "irrecoverable_decision_provenance", + ): + if recovery.get("status") != "provenance_irrecoverable": + reasons.append("recovery record type/status invalid (fail closed)") + + if recovery.get("applied") is True: + reasons.append( + "recovery record claims applied=true; refuse (fabrication, fail closed)" + ) + if recovery.get("historical_cleanup_proven") is True: + reasons.append( + "recovery record claims historical_cleanup_proven=true; refuse " + "(fail closed)" + ) + + for field, want in (("remote", remote), ("org", org), ("repo", repo)): + have = (str(recovery.get(field) or "")).strip() + if have != (want or "").strip(): + reasons.append( + f"recovery {field} mismatch (stored={have!r}, expected={want!r})" + ) + + try: + if int(recovery.get("pr_number") or recovery.get("blocked_pr_number")) != int( + pr_number + ): + reasons.append("recovery PR number mismatch (fail closed)") + except (TypeError, ValueError): + reasons.append("recovery missing pr_number (fail closed)") + + if not heads_equal(recovery.get("head_sha"), live_head_sha): + reasons.append( + "recovery head SHA does not match live PR head (fail closed)" + ) + + if recovery.get("consumption_state") == "consumed" or recovery.get("consumed_at"): + # Idempotent: already consumed for this exact scope is OK if head matches. + result["recovery_record_consumed"] = True + reasons.append("recovery record already consumed (idempotent check)") + + auth_check = verify_authorization_artifact( + authorization, + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + expected_head_sha=str(live_head_sha or ""), + incident_issue=recovery.get("incident_issue"), + incident_comment_id=recovery.get("incident_comment_id"), + # When recovery already consumed, allow already-consumed auth for + # idempotent re-report; otherwise require unconsumed. + require_unconsumed=not result["recovery_record_consumed"], + now=now, + ) + if not auth_check.get("valid"): + reasons.extend(auth_check.get("reasons") or ["authorization invalid"]) + else: + result["irrecoverable_recovery_authorized"] = True + + if not recovery.get("merger_may_accept") and not result["recovery_record_consumed"]: + reasons.append( + "recovery record merger_may_accept is false (fail closed)" + ) + + # Auth id binding. + if ( + authorization + and recovery.get("authorization_id") + and authorization.get("authorization_id") + and recovery.get("authorization_id") != authorization.get("authorization_id") + ): + reasons.append("recovery authorization_id does not match artifact (fail closed)") + + # Normal gates: if explicitly False, refuse consumption as merge-authorizing. + normal_blockers: list[str] = [] + if approval_at_current_head is False: + normal_blockers.append("missing/stale approval at current head") + if has_blocking_change_requests is True: + normal_blockers.append("blocking change requests present") + if mergeable is False: + normal_blockers.append("PR not mergeable") + if lease_ok is False: + normal_blockers.append("lease gate failed") + if runtime_ok is False: + normal_blockers.append("runtime gate failed") + if workspace_ok is False: + normal_blockers.append("workspace gate failed") + if anti_stomp_ok is False: + normal_blockers.append("anti-stomp gate failed") + if normal_blockers: + reasons.append( + "recovery cannot bypass normal merge gates: " + + "; ".join(normal_blockers) + + " (#709 F2)" + ) + result["resolves_prior_provenance_blocker"] = False + result["allowed"] = False + result["reasons"] = reasons + return result + + # Filter pure informational "already consumed" when everything else matches + # for idempotent success. + hard = [ + r + for r in reasons + if "already consumed" not in r + ] + if not hard and result["irrecoverable_recovery_authorized"]: + result["allowed"] = True + result["resolves_prior_provenance_blocker"] = True + result["historical_cleanup_proven"] = False + if result["recovery_record_consumed"]: + reasons.append( + "idempotent: prior-provenance blocker already resolved for this scope" + ) + else: + reasons.append( + "prior-provenance blocker may be resolved by consuming this record " + "(historical cleanup remains unproven)" + ) + result["reasons"] = reasons + return result + + +def mark_consumed( + record: dict[str, Any], + *, + consumer_username: str | None, + consumer_profile: str | None, +) -> dict[str, Any]: + """Return a copy of *record* marked consumed (crash-safe write is caller's job).""" + out = dict(record) + out["consumption_state"] = "consumed" + out["status"] = out.get("status") or "issued" + if out.get("kind") == KIND_AUTH or out.get("auth_type") == AUTH_TYPE: + out["status"] = "consumed" + out["consumed_at"] = _now_iso() + out["consumed_by"] = consumer_username + out["consumed_by_profile"] = consumer_profile + out["updated_at"] = out["consumed_at"] + return out + + +def assess_profile_path_identity(profile_identity: str | None) -> dict[str, Any]: + """Reject traversal / malformed profile identity segments (#709 F3).""" + reasons: list[str] = [] + raw = profile_identity if profile_identity is not None else "" + text = str(raw) + if not text.strip(): + reasons.append("profile identity empty (fail closed)") + return {"valid": False, "reasons": reasons, "sanitized": None} + if text != text.strip(): + reasons.append("profile identity has surrounding whitespace (fail closed)") + if ".." in text or "/" in text or "\\" in text or "\x00" in text: + reasons.append( + "profile identity contains path traversal or separator characters " + "(fail closed, #709 F3)" + ) + if text.startswith("-") or text.startswith("."): + reasons.append("profile identity has unsafe leading character (fail closed)") + # After sanitize, must not collapse to something that collides emptily. + sanitized = mcp_session_state._sanitize_segment(text) + if sanitized in ("_", ""): + reasons.append("profile identity sanitizes to empty (fail closed)") + if sanitized != text and any(c in text for c in ("..", "/", "\\")): + # Already covered; keep fail closed. + pass + return { + "valid": not reasons, + "reasons": reasons, + "sanitized": sanitized if not reasons else None, + } diff --git a/mcp_session_state.py b/mcp_session_state.py index 4ae119a..e6941a7 100644 --- a/mcp_session_state.py +++ b/mcp_session_state.py @@ -42,6 +42,8 @@ KIND_DECISION_LOCK_ARCHIVE = "review_decision_lock_archive" KIND_POST_MERGE_DECISION_RECOVERY = "post_merge_decision_recovery" # #709: truthful record when historical terminal evidence is irrecoverably gone. KIND_IRRECOVERABLE_DECISION_PROVENANCE = "irrecoverable_decision_provenance" +# #709 F1: server-side non-forgeable authorization artifact for recovery. +KIND_IRRECOVERABLE_PROVENANCE_AUTH = "irrecoverable_provenance_authorization" # Kinds that must survive the default session-state TTL (forensic / recovery). RECOVERY_CRITICAL_KINDS = frozenset( @@ -49,6 +51,7 @@ RECOVERY_CRITICAL_KINDS = frozenset( KIND_DECISION_LOCK_ARCHIVE, KIND_POST_MERGE_DECISION_RECOVERY, KIND_IRRECOVERABLE_DECISION_PROVENANCE, + KIND_IRRECOVERABLE_PROVENANCE_AUTH, } ) @@ -504,6 +507,7 @@ def load_state_for_profile( repo: str | None = None, state_dir: str | None = None, skip_identity_match: bool = False, + enforce_repo_scope: bool = True, ) -> dict[str, Any] | None: """Load durable state for an explicit profile identity (#709 cross-profile). @@ -511,9 +515,46 @@ def load_state_for_profile( profile_identity to equal the requested profile (anti-stomp), but does not require the *active* session identity to match — needed so a merger can inspect a reviewer lock after merge. + + #709 F3: remote/org/repo filter reasons are **enforced** (not merely + computed). When *enforce_repo_scope* is True (default) and the caller + supplies remote/org/repo, mismatches fail closed with ``None``. """ + # #709 F3: refuse traversal / malformed profile identities. + try: + from irrecoverable_provenance import assess_profile_path_identity + + path_gate = assess_profile_path_identity(profile_identity) + if not path_gate.get("valid"): + return None + except Exception: + # Module may be mid-import in edge bootstraps; fall through to + # conservative checks below. + raw = str(profile_identity or "") + if ".." in raw or "/" in raw or "\\" in raw or "\x00" in raw: + return None + profile = current_profile_identity(profile_identity=profile_identity) root = _ensure_state_dir(state_dir) + # Refuse symlink escape of the state root (#709 F3). + try: + real_root = os.path.realpath(root) + path = state_file_path( + kind=kind, + remote=remote, + org=org, + repo=repo, + profile_identity=profile, + state_dir=root, + ) + real_path = os.path.realpath(path) if os.path.exists(path) else path + if os.path.exists(path) and not str(real_path).startswith( + str(real_root) + os.sep + ) and str(real_path) != str(real_root): + return None + except OSError: + return None + path = state_file_path( kind=kind, remote=remote, @@ -548,34 +589,46 @@ def load_state_for_profile( if stored and stored != profile: return None if skip_identity_match: - # Still enforce TTL / future-dated so dead records do not authorize cleanup. + # Enforce remote/org/repo when caller provides them (#709 F3). + # Drop only *active session* profile-identity mismatches; keep + # expiry, spoof, and repository-scope reasons. + scope_remote = remote if enforce_repo_scope else None + scope_org = org if enforce_repo_scope else None + scope_repo = repo if enforce_repo_scope else None reasons = identity_match_reasons( merged, - remote=remote or merged.get("remote"), - org=org or merged.get("org"), - repo=repo or merged.get("repo"), + remote=scope_remote, + org=scope_org, + repo=scope_repo, profile_identity=stored or profile, ) - # Drop active-session-only mismatches; keep expiry / spoof reasons. filtered = [ r for r in reasons if "profile identity mismatch" not in r or (stored and stored != profile) ] - # Re-run only expiry/future/missing checks via identity when profile matches - expiry_reasons = [ - r - for r in identity_match_reasons( - merged, - remote=None, - org=None, - repo=None, - profile_identity=stored or profile, - ) - if any(x in r for x in ("expired", "future", "missing recorded_at")) - ] - if expiry_reasons: + # When caller requested a specific remote/org/repo, also fail if the + # stored record lacks those identity fields entirely (legacy incomplete). + if enforce_repo_scope: + for field, want in ( + ("remote", remote), + ("org", org), + ("repo", repo), + ): + want_s = (want or "").strip() + if not want_s: + continue + have = (str(merged.get(field) or "")).strip() + if not have: + filtered.append( + f"session state {field} missing on durable record " + f"(expected={want_s!r}; fail closed, #709 F3)" + ) + elif have != want_s: + # identity_match_reasons already adds mismatch; ensure kept + pass + if filtered: return None return merged reasons = identity_match_reasons( diff --git a/stale_review_decision_lock.py b/stale_review_decision_lock.py index c5252c3..3eee525 100644 --- a/stale_review_decision_lock.py +++ b/stale_review_decision_lock.py @@ -570,13 +570,52 @@ def build_irrecoverable_provenance_record( actor_username: str | None, profile_name: str | None, reason: str, - incident_ref: str | None, - operator_authorized: bool, + incident_ref: str | None = None, + # Deprecated kwargs retained only so stale call sites fail closed: + operator_authorized: bool | None = None, + # Required for merger-acceptable records (#709 F1): + authorization: dict[str, Any] | None = None, + incident_issue: int | None = None, + incident_comment_id: int | None = None, + destroyed_subject: str | None = None, + historical_provenance_subject: str | None = None, ) -> dict[str, Any]: - """Truthful absence-of-proof record (#709 AC5). Never sets applied=True.""" + """Truthful absence-of-proof record (#709 AC5). Never sets applied=True. + + Caller-supplied ``operator_authorized`` is **ignored** as authorization + evidence (review 434 F1). Prefer + :func:`irrecoverable_provenance.build_irrecoverable_provenance_record` + with a verified server-side authorization artifact. + """ + # Explicitly ignore deprecated self-assertable Boolean. + _ = operator_authorized + if authorization is not None and incident_issue is not None and incident_comment_id is not None: + from irrecoverable_provenance import ( + build_irrecoverable_provenance_record as _build, + ) + + return _build( + pr_number=int(pr_number), + head_sha=str(head_sha or ""), + remote=str(remote or ""), + org=str(org or ""), + repo=str(repo or ""), + actor_username=actor_username, + profile_name=profile_name, + reason=reason, + incident_issue=int(incident_issue), + incident_comment_id=int(incident_comment_id), + authorization=authorization, + destroyed_subject=destroyed_subject, + historical_provenance_subject=historical_provenance_subject + or destroyed_subject, + ) + # Fail-closed skeleton when no server authorization is supplied: never + # sets merger_may_accept True (even if operator_authorized was True). return { "event": "irrecoverable_decision_lock_provenance", "status": "provenance_irrecoverable", + "record_type": "irrecoverable_decision_provenance", "operator_recovery_required": True, "issue_ref": "#709", "recovery_critical": True, @@ -592,40 +631,27 @@ def build_irrecoverable_provenance_record( "profile_name": profile_name, "reason": reason, "incident_ref": incident_ref, - "operator_authorized": bool(operator_authorized), - "merger_may_accept": bool(operator_authorized), + "incident_issue": incident_issue, + "incident_comment_id": incident_comment_id, + "authorization_verified": False, + "merger_may_accept": False, "acceptance_rule": ( - "Merger may accept this record only when operator_authorized=true, " - "repository/PR/head match the live target, the record is durable " - "and read back, and no conflicting terminal lock remains for a " - "different PR/head. This does not prove historical cleanup." + "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, and normal " + "merge gates still pass. Caller Booleans never authorize. This " + "does not prove historical cleanup." ), } 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_ref: `{record.get('incident_ref')}`", - f"- operator_authorized: `{record.get('operator_authorized')}`", - f"- historical_cleanup_proven: `{record.get('historical_cleanup_proven')}`", - f"- applied: `{record.get('applied')}` (must remain false)", - "", - 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).", - ] - return "\n".join(lines) + from irrecoverable_provenance import ( + format_irrecoverable_audit_comment as _fmt, + ) + + return _fmt(record) def format_post_merge_recovery_comment(record: dict[str, Any]) -> str: diff --git a/task_capability_map.py b/task_capability_map.py index f41475d..9b7e70a 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -127,15 +127,32 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.pr.review", "role": "reviewer", }, - # #709: truthful absence-of-proof recovery record (not applied cleanup). + # #709: truthful absence-of-proof recovery (server-side auth + record + consume). + # Dedicated mutation capability — gitea.read is insufficient (review 434 F1). + "issue_irrecoverable_provenance_authorization": { + "permission": "gitea.decision_lock.irrecoverable_recovery", + "role": "reconciler", + }, + "gitea_issue_irrecoverable_provenance_authorization": { + "permission": "gitea.decision_lock.irrecoverable_recovery", + "role": "reconciler", + }, "record_irrecoverable_decision_lock_provenance": { - "permission": "gitea.issue.comment", + "permission": "gitea.decision_lock.irrecoverable_recovery", "role": "reconciler", }, "gitea_record_irrecoverable_decision_lock_provenance": { - "permission": "gitea.issue.comment", + "permission": "gitea.decision_lock.irrecoverable_recovery", "role": "reconciler", }, + "consume_irrecoverable_decision_lock_provenance": { + "permission": "gitea.pr.merge", + "role": "merger", + }, + "gitea_consume_irrecoverable_decision_lock_provenance": { + "permission": "gitea.pr.merge", + "role": "merger", + }, "delete_branch": { "permission": "gitea.branch.delete", "role": "author", diff --git a/tests/test_issue_709_decision_lock_cross_profile.py b/tests/test_issue_709_decision_lock_cross_profile.py index 6e9d21a..8aae98e 100644 --- a/tests/test_issue_709_decision_lock_cross_profile.py +++ b/tests/test_issue_709_decision_lock_cross_profile.py @@ -1,16 +1,19 @@ """#709: cross-profile decision-lock cleanup, overwrite protection, recovery. -Covers AC1–AC8 regression scenarios without fabricating historical PR #696 -provenance or special-casing live PR numbers in production code. +Covers AC1–AC8 plus review-434 F1/F2/F3 remediations without fabricating +historical PR provenance or special-casing live PR numbers in production code. """ from __future__ import annotations import os +import subprocess +import sys import tempfile import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch +import irrecoverable_provenance as irp import mcp_session_state as ss import stale_review_decision_lock as srdl @@ -20,6 +23,8 @@ def _lock( *, profile="prgs-reviewer", remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", head=None, ): muts = [] @@ -31,8 +36,8 @@ def _lock( return { "task": "review_pr", "remote": remote, - "org": "Scaled-Tech-Consulting", - "repo": "Gitea-Tools", + "org": org, + "repo": repo, "session_pid": os.getpid(), "session_profile": profile, "session_profile_lock": profile, @@ -52,6 +57,47 @@ APPROVE = {"pr_number": 100, "action": "approve", "review_id": 9} APPROVE_OTHER = {"pr_number": 200, "action": "approve", "review_id": 10} HEAD_A = "a" * 40 HEAD_B = "b" * 40 +RECONCILER_OPS = [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", +] + + +def _mint_auth( + *, + pr_number=42, + head=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + incident_issue=700, + incident_comment_id=11489, + issuer="sysadmin", + profile="prgs-reconciler", + destroyed_subject=None, +): + return irp.build_authorization_artifact( + remote=remote, + org=org, + repo=repo, + pr_number=pr_number, + expected_head_sha=head, + incident_issue=incident_issue, + incident_comment_id=incident_comment_id, + destroyed_subject=destroyed_subject, + issuer_username=issuer, + issuer_profile=profile, + native_provenance={ + "native_mcp_transport": True, + "production_native_mcp_transport": False, + "pytest": True, + "token_fingerprint": "testfp", + "entrypoint": "pytest", + "pid": os.getpid(), + }, + ) class TestAC2InitOverwrite(unittest.TestCase): @@ -97,8 +143,8 @@ class TestAC1TargetApproval(unittest.TestCase): ) -class TestAC5IrrecoverableRecord(unittest.TestCase): - def test_never_sets_applied_true(self): +class TestF1AuthorizationNotSelfAssertable(unittest.TestCase): + def test_operator_authorized_true_cannot_authorize_via_build(self): rec = srdl.build_irrecoverable_provenance_record( pr_number=42, head_sha=HEAD_A, @@ -108,32 +154,560 @@ class TestAC5IrrecoverableRecord(unittest.TestCase): actor_username="sysadmin", profile_name="prgs-reconciler", reason="evidence destroyed", - incident_ref="#700 comment 1", + incident_ref="anything", operator_authorized=True, ) self.assertFalse(rec["applied"]) self.assertFalse(rec["historical_cleanup_proven"]) - self.assertEqual(rec["status"], "provenance_irrecoverable") - self.assertTrue(rec["merger_may_accept"]) - body = srdl.format_irrecoverable_audit_comment(rec) - self.assertIn("applied: `False`", body) - self.assertIn("must remain false", body) + self.assertFalse(rec["merger_may_accept"]) - def test_unauthorized_not_merger_acceptable(self): + def test_confirmation_string_not_authorization(self): + # Confirmation is only intent text; capability assess ignores it. + conf = irp.expected_confirmation(99) + self.assertEqual(conf, "IRRECOVERABLE DECISION PROVENANCE PR 99") + # Without auth artifact, merger cannot accept. rec = srdl.build_irrecoverable_provenance_record( - pr_number=42, + pr_number=99, head_sha=HEAD_A, remote="prgs", - org=None, - repo=None, + org="o", + repo="r", actor_username="x", profile_name="y", reason="r", - incident_ref=None, operator_authorized=False, ) self.assertFalse(rec["merger_may_accept"]) + def test_gitea_read_alone_insufficient(self): + a = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=["gitea.read"], + forbidden_operations=[], + role_kind="author", + profile_name="prgs-author", + ) + self.assertFalse(a["allowed"]) + + def test_expected_head_missing_fails(self): + g = irp.assess_live_head_binding( + expected_head_sha=None, + live_head_sha=HEAD_A, + ) + self.assertFalse(g["valid"]) + + def test_expected_head_differs_fails(self): + g = irp.assess_live_head_binding( + expected_head_sha=HEAD_A, + live_head_sha=HEAD_B, + ) + self.assertFalse(g["valid"]) + + def test_missing_incident_fails(self): + g = irp.assess_incident_evidence( + incident_issue=None, + incident_comment_id=None, + comment_payload=None, + ) + self.assertFalse(g["valid"]) + + def test_nonexistent_incident_fails(self): + g = irp.assess_incident_evidence( + incident_issue=1, + incident_comment_id=2, + comment_payload=None, + comment_lookup_error="404", + ) + self.assertFalse(g["valid"]) + + def test_valid_auth_succeeds_exact_scope(self): + auth = _mint_auth() + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + ) + self.assertTrue(v["valid"], v) + rec = irp.build_irrecoverable_provenance_record( + pr_number=42, + head_sha=HEAD_A, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + actor_username="sysadmin", + profile_name="prgs-reconciler", + reason="evidence destroyed", + incident_issue=700, + incident_comment_id=11489, + authorization=auth, + ) + self.assertTrue(rec["merger_may_accept"]) + self.assertFalse(rec["applied"]) + body = irp.format_irrecoverable_audit_comment(rec) + self.assertIn("applied: `False`", body) + + def test_wrong_repo_auth_fails(self): + auth = _mint_auth(repo="Other-Repo") + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + ) + self.assertFalse(v["valid"]) + + def test_wrong_pr_auth_fails(self): + auth = _mint_auth(pr_number=1) + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + ) + self.assertFalse(v["valid"]) + + def test_wrong_head_auth_fails(self): + auth = _mint_auth(head=HEAD_B) + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + ) + self.assertFalse(v["valid"]) + + def test_altered_signature_fails(self): + auth = _mint_auth() + auth["server_signature"] = "0" * 64 + v = irp.verify_authorization_artifact( + auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=42, + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + ) + self.assertFalse(v["valid"]) + + def test_fresh_non_pytest_process_cannot_mint_accepted_record(self): + """Ordinary Python process: merger_may_accept stays False without server auth.""" + script = ( + "import stale_review_decision_lock as s\n" + "r=s.build_irrecoverable_provenance_record(\n" + " pr_number=1, head_sha=None, remote='prgs', org=None, repo=None,\n" + " actor_username='x', profile_name='y', reason='r',\n" + " incident_ref=None, operator_authorized=True)\n" + "print(r.get('merger_may_accept'), r.get('head_sha'))\n" + ) + env = {k: v for k, v in os.environ.items() if not k.startswith("PYTEST")} + env.pop("PYTEST_CURRENT_TEST", None) + proc = subprocess.run( + [sys.executable, "-c", script], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + env=env, + timeout=30, + ) + self.assertEqual(proc.returncode, 0, proc.stderr) + out = (proc.stdout or "").strip() + self.assertTrue(out.startswith("False"), msg=out) + + def test_unauthorized_profile_capability(self): + a = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=["gitea.read", "gitea.pr.comment"], + forbidden_operations=["gitea.pr.close"], + role_kind="author", + profile_name="prgs-author", + ) + self.assertFalse(a["allowed"]) + + def test_reconciler_capability_allowed(self): + a = irp.assess_capability_for_irrecoverable_recovery( + allowed_operations=RECONCILER_OPS, + forbidden_operations=[ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + ], + role_kind="reconciler", + profile_name="prgs-reconciler", + ) + self.assertTrue(a["allowed"], a) + + +class TestF2MergerConsumer(unittest.TestCase): + def test_merger_rejects_without_valid_auth(self): + rec = srdl.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + operator_authorized=True, + ) + a = irp.assess_merger_consumption( + rec, + None, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=False, + ) + self.assertFalse(a["allowed"]) + + def test_merger_rejects_wrong_head(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r") + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + a = irp.assess_merger_consumption( + rec, + auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_B, + approval_at_current_head=True, + has_blocking_change_requests=False, + ) + self.assertFalse(a["allowed"]) + + def test_merger_rejects_replayed_auth(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + consumed = irp.mark_consumed(auth, consumer_username="m", consumer_profile="merger") + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, # original unconsumed for record build + ) + a = irp.assess_merger_consumption( + rec, + consumed, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=False, + ) + self.assertFalse(a["allowed"]) + + def test_recovery_cannot_bypass_missing_approval(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + a = irp.assess_merger_consumption( + rec, + auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=False, + has_blocking_change_requests=False, + ) + self.assertFalse(a["allowed"]) + self.assertTrue(any("approval" in r for r in a["reasons"])) + + def test_recovery_cannot_bypass_blocking_crs(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + a = irp.assess_merger_consumption( + rec, + auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=True, + ) + self.assertFalse(a["allowed"]) + + def test_valid_recovery_resolves_only_prior_provenance(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + a = irp.assess_merger_consumption( + rec, + auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=False, + mergeable=True, + lease_ok=True, + runtime_ok=True, + workspace_ok=True, + anti_stomp_ok=True, + ) + self.assertTrue(a["allowed"], a) + self.assertTrue(a["resolves_prior_provenance_blocker"]) + self.assertFalse(a["historical_cleanup_proven"]) + + def test_duplicate_consume_idempotent(self): + auth = _mint_auth(pr_number=10, head=HEAD_A, org="o", repo="r", incident_comment_id=1) + rec = irp.build_irrecoverable_provenance_record( + pr_number=10, + head_sha=HEAD_A, + remote="prgs", + org="o", + repo="r", + actor_username="a", + profile_name="p", + reason="x", + incident_issue=700, + incident_comment_id=1, + authorization=auth, + ) + consumed_auth = irp.mark_consumed(auth, consumer_username="m", consumer_profile="mer") + consumed_rec = irp.mark_consumed(rec, consumer_username="m", consumer_profile="mer") + a = irp.assess_merger_consumption( + consumed_rec, + consumed_auth, + remote="prgs", + org="o", + repo="r", + pr_number=10, + live_head_sha=HEAD_A, + approval_at_current_head=True, + has_blocking_change_requests=False, + ) + self.assertTrue(a["allowed"], a) + self.assertTrue(a["recovery_record_consumed"]) + + +class TestF3ExactScopeEnforcement(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.state_dir = self._tmp.name + os.chmod(self.state_dir, 0o700) + + def tearDown(self): + self._tmp.cleanup() + + def test_same_pr_other_remote_not_loaded(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([APPROVE], profile="prgs-reviewer", remote="other"), + remote="other", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_same_pr_other_org_not_loaded(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock( + [APPROVE], + profile="prgs-reviewer", + org="Other-Org", + ), + remote="prgs", + org="Other-Org", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_same_pr_other_repo_not_loaded(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock( + [APPROVE], + profile="prgs-reviewer", + repo="Other-Repo", + ), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Other-Repo", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_path_traversal_profile_fails(self): + g = irp.assess_profile_path_identity("../evil") + self.assertFalse(g["valid"]) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="../evil", + remote="prgs", + org="o", + repo="r", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_malformed_legacy_missing_repo_fails_closed(self): + # Record without repo identity when caller requires repo. + payload = _lock([APPROVE], profile="prgs-reviewer") + del payload["repo"] + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=payload, + remote="prgs", + org="Scaled-Tech-Consulting", + repo=None, + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + loaded = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNone(loaded) + + def test_list_and_load_foreign_profile_lock(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([APPROVE], profile="prgs-reviewer"), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock([], profile="prgs-merger"), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity="prgs-merger", + state_dir=self.state_dir, + ) + foreign = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + state_dir=self.state_dir, + skip_identity_match=True, + ) + self.assertIsNotNone(foreign) + self.assertTrue( + srdl.lock_targets_merged_pr_approval(foreign, pr_number=100) + ) + class TestAC3PostMergeRecoveryRecord(unittest.TestCase): def test_recovery_record_is_not_applied_cleanup(self): @@ -155,114 +729,23 @@ class TestAC3PostMergeRecoveryRecord(unittest.TestCase): self.assertTrue(rec["recovery_critical"]) -class TestSessionStateCrossProfile(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory() - self.state_dir = self._tmp.name - os.chmod(self.state_dir, 0o700) - - def tearDown(self): - self._tmp.cleanup() - - def test_list_and_load_foreign_profile_lock(self): - ss.save_state( - kind=ss.KIND_DECISION_LOCK, - payload=_lock([APPROVE], profile="prgs-reviewer"), - remote="prgs", - org="Scaled-Tech-Consulting", - repo="Gitea-Tools", - profile_identity="prgs-reviewer", - state_dir=self.state_dir, - ) - # Merger-local empty lock - ss.save_state( - kind=ss.KIND_DECISION_LOCK, - payload=_lock([], profile="prgs-merger"), - remote="prgs", - org="Scaled-Tech-Consulting", - repo="Gitea-Tools", - profile_identity="prgs-merger", - state_dir=self.state_dir, - ) - ids = ss.list_decision_lock_profile_identities(state_dir=self.state_dir) - self.assertIn("prgs-reviewer", ids) - self.assertIn("prgs-merger", ids) - - foreign = ss.load_state_for_profile( - kind=ss.KIND_DECISION_LOCK, - profile_identity="prgs-reviewer", - remote="prgs", - org="Scaled-Tech-Consulting", - repo="Gitea-Tools", - state_dir=self.state_dir, - skip_identity_match=True, - ) - self.assertIsNotNone(foreign) - self.assertTrue( - srdl.lock_targets_merged_pr_approval(foreign, pr_number=100) - ) - empty = ss.load_state_for_profile( - kind=ss.KIND_DECISION_LOCK, - profile_identity="prgs-merger", - state_dir=self.state_dir, - skip_identity_match=True, - ) - self.assertIsNotNone(empty) - self.assertFalse( - srdl.lock_targets_merged_pr_approval(empty, pr_number=100) - ) - - def test_clear_reviewer_not_merger_empty(self): - ss.save_state( - kind=ss.KIND_DECISION_LOCK, - payload=_lock([APPROVE], profile="prgs-reviewer"), - profile_identity="prgs-reviewer", - state_dir=self.state_dir, - ) - ss.save_state( - kind=ss.KIND_DECISION_LOCK, - payload=_lock([], profile="prgs-merger"), - profile_identity="prgs-merger", - state_dir=self.state_dir, - ) - ss.clear_state( - kind=ss.KIND_DECISION_LOCK, - profile_identity="prgs-reviewer", - state_dir=self.state_dir, - ) - self.assertIsNone( - ss.load_state_for_profile( - kind=ss.KIND_DECISION_LOCK, - profile_identity="prgs-reviewer", - state_dir=self.state_dir, - skip_identity_match=True, - ) - ) - # Merger empty lock remains - self.assertIsNotNone( - ss.load_state_for_profile( - kind=ss.KIND_DECISION_LOCK, - profile_identity="prgs-merger", - state_dir=self.state_dir, - skip_identity_match=True, - ) - ) - +class TestSessionStateTTL(unittest.TestCase): def test_recovery_critical_kinds_ttl_exempt(self): - rec = srdl.build_irrecoverable_provenance_record( + auth = _mint_auth(pr_number=1) + rec = irp.build_irrecoverable_provenance_record( pr_number=1, head_sha=HEAD_A, remote="prgs", - org=None, - repo=None, + org="o", + repo="r", actor_username="a", profile_name="p", reason="gone", - incident_ref=None, - operator_authorized=True, + incident_issue=700, + incident_comment_id=1, + authorization=auth, ) rec["kind"] = ss.KIND_IRRECOVERABLE_DECISION_PROVENANCE - # Force old recorded_at rec["recorded_at"] = "2000-01-01T00:00:00Z" rec["updated_at"] = rec["recorded_at"] rec["profile_identity"] = "prgs-reconciler" @@ -270,10 +753,7 @@ class TestSessionStateCrossProfile(unittest.TestCase): reasons = ss.identity_match_reasons( rec, profile_identity="prgs-reconciler" ) - self.assertFalse( - any("expired" in r for r in reasons), - msg=reasons, - ) + self.assertFalse(any("expired" in r for r in reasons), msg=reasons) class TestInitReviewDecisionLockIntegration(unittest.TestCase): @@ -300,8 +780,9 @@ class TestInitReviewDecisionLockIntegration(unittest.TestCase): self._tmp.cleanup() def test_init_does_not_wipe_terminal_ledger(self): - self.mcp._save_review_decision_lock(_lock([APPROVE], profile="prgs-reviewer")) - # force=True would previously wipe + self.mcp._save_review_decision_lock( + _lock([APPROVE], profile="prgs-reviewer") + ) self.mcp.init_review_decision_lock("prgs", "review_pr", force=True) loaded = self.mcp._load_review_decision_lock() self.assertIsNotNone(loaded) @@ -317,7 +798,7 @@ class TestInitReviewDecisionLockIntegration(unittest.TestCase): self.assertEqual(loaded.get("live_mutations"), []) -class TestIrrecoverableTool(unittest.TestCase): +class TestIrrecoverableToolF1(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.env = patch.dict( @@ -326,7 +807,7 @@ class TestIrrecoverableTool(unittest.TestCase): "GITEA_MCP_SESSION_STATE_DIR": self._tmp.name, "GITEA_SESSION_PROFILE_LOCK": "prgs-reconciler", "GITEA_PROFILE_NAME": "prgs-reconciler", - "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment,gitea.pr.comment", + "GITEA_ALLOWED_OPERATIONS": ",".join(RECONCILER_OPS), }, clear=False, ) @@ -339,132 +820,187 @@ class TestIrrecoverableTool(unittest.TestCase): self.env.stop() self._tmp.cleanup() - def test_requires_confirmation_and_operator(self): - with patch.object( - self.mcp, - "get_profile", - return_value={ - "profile_name": "prgs-reconciler", - "allowed_operations": [ - "gitea.read", - "gitea.issue.comment", - "gitea.pr.comment", - ], - "forbidden_operations": [], - }, - ): + def _profile(self): + return { + "profile_name": "prgs-reconciler", + "role": "reconciler", + "allowed_operations": RECONCILER_OPS, + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + ], + } + + def test_operator_authorized_true_rejected(self): + with patch.object(self.mcp, "get_profile", return_value=self._profile()): r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( pr_number=50, reason="lost", - confirmation="", - operator_authorized=False, + confirmation=irp.expected_confirmation(50), + operator_authorized=True, + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", post_audit_comment=False, ) self.assertFalse(r["success"]) - self.assertFalse(r["applied"]) + self.assertTrue(any("operator_authorized" in x for x in r["reasons"])) - def test_records_without_applied_true(self): - with patch.object( - self.mcp, - "get_profile", - return_value={ - "profile_name": "prgs-reconciler", - "allowed_operations": [ - "gitea.read", - "gitea.issue.comment", - "gitea.pr.comment", - ], - "forbidden_operations": [], - }, - ), patch.object( + def test_missing_expected_head_fails(self): + with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object( self.mcp, "_authenticated_username", return_value="sysadmin" ), patch.object( - self.mcp, "_profile_operation_gate", return_value=None + self.mcp, "_irrecoverable_capability_gate", return_value=None + ), patch.object( + irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []} ), patch.object( self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools") ): r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( pr_number=50, - reason="terminal evidence overwritten", - confirmation="IRRECOVERABLE DECISION PROVENANCE PR 50", - operator_authorized=True, - expected_head_sha=HEAD_A, - incident_ref="issue-700-comment-11489", + reason="lost", + confirmation=irp.expected_confirmation(50), + expected_head_sha=None, + incident_issue=700, + incident_comment_id=11489, remote="prgs", org="Scaled-Tech-Consulting", repo="Gitea-Tools", post_audit_comment=False, ) - self.assertTrue(r["success"]) - self.assertFalse(r["applied"]) - self.assertFalse(r["historical_cleanup_proven"]) - self.assertEqual(r["record"]["status"], "provenance_irrecoverable") + self.assertFalse(r["success"]) - # Idempotent replay - with patch.object( - self.mcp, - "get_profile", - return_value={ - "profile_name": "prgs-reconciler", - "allowed_operations": [ - "gitea.read", - "gitea.issue.comment", - "gitea.pr.comment", - ], - "forbidden_operations": [], - }, + def test_wrong_confirmation_fails(self): + with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object( + self.mcp, "_irrecoverable_capability_gate", return_value=None ), patch.object( - self.mcp, "_authenticated_username", return_value="sysadmin" - ), patch.object( - self.mcp, "_profile_operation_gate", return_value=None - ), patch.object( - self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools") - ): - r2 = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( - pr_number=50, - reason="terminal evidence overwritten", - confirmation="IRRECOVERABLE DECISION PROVENANCE PR 50", - operator_authorized=True, - expected_head_sha=HEAD_A, - remote="prgs", - org="Scaled-Tech-Consulting", - repo="Gitea-Tools", - post_audit_comment=False, - ) - self.assertTrue(r2["success"]) - self.assertFalse(r2["performed"]) # idempotent hit - - def test_wrong_confirmation_cannot_unblock_other_pr(self): - with patch.object( - self.mcp, - "get_profile", - return_value={ - "profile_name": "prgs-reconciler", - "allowed_operations": ["gitea.read", "gitea.issue.comment"], - "forbidden_operations": [], - }, - ), patch.object( - self.mcp, "_authenticated_username", return_value="sysadmin" - ), patch.object( - self.mcp, "_profile_operation_gate", return_value=None + irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []} ), patch.object( self.mcp, "_resolve", return_value=("h", "o", "r") ): r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( pr_number=50, reason="x", - confirmation="IRRECOVERABLE DECISION PROVENANCE PR 51", - operator_authorized=True, + confirmation=irp.expected_confirmation(51), + expected_head_sha=HEAD_A, + incident_issue=1, + incident_comment_id=2, remote="prgs", post_audit_comment=False, ) self.assertFalse(r["success"]) + def test_records_with_server_auth(self): + auth = _mint_auth( + pr_number=50, + head=HEAD_A, + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + incident_issue=700, + incident_comment_id=11489, + ) + auth_profile = irp.auth_state_profile_identity( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + pr_number=50, + expected_head_sha=HEAD_A, + ) + ss.save_state( + kind=ss.KIND_IRRECOVERABLE_PROVENANCE_AUTH, + payload=auth, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + profile_identity=auth_profile, + state_dir=self._tmp.name, + ) -class TestClearProfileHelper(unittest.TestCase): + def _api(method, url, auth=None, data=None, **kwargs): + if "/pulls/" in str(url): + return {"head": {"sha": HEAD_A}, "state": "open"} + if "/issues/comments/" in str(url): + return { + "id": 11489, + "body": "forensic diagnosis", + "user": {"login": "sysadmin"}, + "created_at": "2026-07-13T00:00:00Z", + } + raise AssertionError(f"unexpected API {method} {url}") + + common = dict( + get_profile=self._profile(), + ) + with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object( + self.mcp, "_authenticated_username", return_value="sysadmin" + ), patch.object( + self.mcp, "_irrecoverable_capability_gate", return_value=None + ), patch.object( + irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []} + ), patch.object( + self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools") + ), patch.object( + self.mcp, "_auth", return_value={"Authorization": "token test"} + ), patch.object( + self.mcp, "api_request", side_effect=_api + ), patch.object( + self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r" + ): + r = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( + pr_number=50, + reason="terminal evidence overwritten", + confirmation=irp.expected_confirmation(50), + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + post_audit_comment=False, + ) + self.assertTrue(r["success"], r) + self.assertFalse(r["applied"]) + self.assertFalse(r["historical_cleanup_proven"]) + self.assertTrue(r["merger_may_accept"]) + self.assertEqual(r["record"]["status"], "provenance_irrecoverable") + + # Idempotent + with patch.object(self.mcp, "get_profile", return_value=self._profile()), patch.object( + self.mcp, "_authenticated_username", return_value="sysadmin" + ), patch.object( + self.mcp, "_irrecoverable_capability_gate", return_value=None + ), patch.object( + irp, "assess_transport_for_auth_mint", return_value={"allowed": True, "reasons": []} + ), patch.object( + self.mcp, "_resolve", return_value=("h", "Scaled-Tech-Consulting", "Gitea-Tools") + ), patch.object( + self.mcp, "_auth", return_value={"Authorization": "token test"} + ), patch.object( + self.mcp, "api_request", side_effect=_api + ), patch.object( + self.mcp, "repo_api_url", return_value="https://example.test/api/v1/repos/o/r" + ): + r2 = self.mcp.gitea_record_irrecoverable_decision_lock_provenance( + pr_number=50, + reason="terminal evidence overwritten", + confirmation=irp.expected_confirmation(50), + expected_head_sha=HEAD_A, + incident_issue=700, + incident_comment_id=11489, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + post_audit_comment=False, + ) + self.assertTrue(r2["success"], r2) + self.assertFalse(r2["performed"]) + + +class TestClearProfileHelperF3(unittest.TestCase): def setUp(self): self._tmp = tempfile.TemporaryDirectory() self.env = patch.dict( @@ -490,12 +1026,18 @@ class TestClearProfileHelper(unittest.TestCase): 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, ) ss.save_state( kind=ss.KIND_DECISION_LOCK, payload=_lock([], profile="prgs-merger"), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", profile_identity="prgs-merger", state_dir=self._tmp.name, ) @@ -507,7 +1049,7 @@ class TestClearProfileHelper(unittest.TestCase): org="Scaled-Tech-Consulting", repo="Gitea-Tools", ) - self.assertTrue(out["cleared"]) + self.assertTrue(out["cleared"], out) skip = self.mcp._clear_decision_lock_for_profile( profile_identity="prgs-merger", pr_number=100, @@ -518,6 +1060,84 @@ class TestClearProfileHelper(unittest.TestCase): ) self.assertFalse(skip["cleared"]) + def test_pr_number_only_fallback_impossible(self): + 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, + ) + # Missing expected_head_sha + out = self.mcp._clear_decision_lock_for_profile( + profile_identity="prgs-reviewer", + pr_number=100, + expected_head_sha=None, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertFalse(out["cleared"]) + self.assertIn("PR-number-only", out["reason"]) + + def test_wrong_head_not_cleared(self): + 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, + ) + out = self.mcp._clear_decision_lock_for_profile( + profile_identity="prgs-reviewer", + pr_number=100, + expected_head_sha=HEAD_B, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertFalse(out["cleared"]) + + def test_cross_repo_same_pr_number_not_cleared(self): + ss.save_state( + kind=ss.KIND_DECISION_LOCK, + payload=_lock( + [APPROVE], + profile="prgs-reviewer", + head=HEAD_A, + repo="Other-Repo", + ), + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Other-Repo", + profile_identity="prgs-reviewer", + state_dir=self._tmp.name, + ) + out = 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", + ) + self.assertFalse(out["cleared"]) + # Original lock still present under Other-Repo scope + still = ss.load_state_for_profile( + kind=ss.KIND_DECISION_LOCK, + profile_identity="prgs-reviewer", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Other-Repo", + state_dir=self._tmp.name, + skip_identity_match=True, + ) + self.assertIsNotNone(still) + if __name__ == "__main__": unittest.main()