"""合并/发布门禁证据的只读核对器(集成 v2,代码层)。 首轮缺口(门禁与审计 9.0 扣 0.5):硬门禁与发布门禁强制 的是“调用方提交的证据齐全且全绿”,不是“服务端已向 GitHub 求证”——调用方谎报 CI 全绿 / 保护状态 / 协调员 批准时,门禁会放行;docstring 称覆盖控制是“审计与 PR/Checks 的只读核对”,但在合并/发布路径上没有这样 一段“调 GitHub Checks API 核对证据”的代码(#210 的 实时保护检查只属于 auto-config execute 端点,不属于 merge/release 端点)。 本轨的结论分两半,缺一不可: A. 核对器本身已实现(本模块,纯函数 + 注入式 reader, 有测试):verify_gate_evidence(evidence, reader) 把 调用方证据与 reader 给出的实时数据逐项比对——CI 状态、每项 required 检查的状态、分支保护状态;不 一致即 mismatch(逐条列出),一致即 verified。reader 是调用方注入的 callable:reader(repo, ref) -> { "ci_checks": {name: status}, "branch_protection": "protected"|"unprotected"|... },真实 reader 用 github_app 的 _github_api_request 走 GET Checks / GET 分支保护(只读,绝不写 PR、不改保护、不合并)。 reader 为 None 或抛异常时结果是“未核对” (checked=False, verified=False),绝不是“通过”—— 调用方把 skipped 当 pass 用,是调用方的 bug,本 模块的返回值结构让这种误用一眼可见(verified 恒 False,除非逐项比对全过)。 B. 实时求证在当前合并/发布路径上**接不上**(不可实现 证明,不是未做):① 请求体里没有 repo 与 ref—— merge/release 的证据字段只有 files_changed、ci_checks、 branch_protection 等,没有仓库名、没有提交 SHA, reader 不知道向哪个仓库的哪次提交求证,猜仓库 就是编造求证;② 服务端没有可信的求证通道保证—— github_app.resolve_app_token 对未安装 App 的仓库 返回 None(调用方回落 PAT),PAT 是调用方自己的 凭证,用它“核对”调用方自己的证据不是独立求证; ③ 任务行里没有 repo/ref 列,服务端无法从任务 id 反推求证目标。接上实时求证须先改契约(请求体或任 务行加 repo + ref,安装保证或服务端侧可信 token), 那是契约决定,须协调员/用户在合轨时定;本轨不编 一个“看起来在求证”的假 reader、不把调用方证据 自己与自己比对后写成 verified。 当前真实控制(写清,不拔高):门禁强制证据齐全全绿 (硬模式 fail-closed);审计记 outcome 与 blocks (merge_gate_blocked / merge_task / release_task), 但审计 detail 不保存证据体全文——事后追责能查到 “谁在何时以何 blocks 通过/被挡”,查不到“当时提交 的 ci_checks 原文”。谎报证据的发现仍靠事后审计与 GitHub 侧记录比对,不是当场发现。本模块的核对器 是为契约补齐后的接线准备的:届时服务端只需注入 真实 reader 并把 verified=False(无论 skipped 还是 mismatch)当作阻断,核对器本身不用改。 零第三方依赖,只用标准库。本模块不 import cloud 其它模块(reader 由调用方注入),不发网络请求。 v7 机制第 6 项(生产证据 fail-closed live 接线,本批新增, 只动本模块,不动其它 7 项): - 新增可注入 reader 接口 LiveEvidenceReader(协议): get_checks(repo, ref) 与 get_branch_protection(repo, branch),返回结构含状态与原始证据(LiveChecksResult / LiveProtectionResult,或等价 dict)。本模块的生产代码 里绝不实现真实 HTTP 调用、绝不读真实 token、绝不调 用真实 GitHub API——接口是未来真实 reader 的插入缝, 不是真实 reader 本身。 - 新增 verify_live_evidence(claimed, reader, repo, ref, branch=None):协议 reader 或旧式 callable reader 均可; 无 reader、无 repo/ref、reader 失败、reader 返回与声 称不一致时 verified 一律 False;声称 CI 全绿但实时 数据失败/缺失时记 mismatch。比对规则与 verify_gate_evidence 逐项一致(共用 _compare),不另 造口径;空声称不由本函数放行——缺失证据由硬门禁 (merge_gate_hard)阻断,本函数只核对“声称了的项”。 - 新增 live_gate_decision:端点层判定函数,verified 为 False 时返回 allowed=False、http_status=409、 status="blocked"、blocked=True(mismatch 与未核对用 不同 code 区分:live_evidence_mismatch / live_evidence_unverified);verified 为 True 时返回 allowed=True、http_status=200、status="allowed"。 - 诚实残留(不得冒充已接线):HTTP 端点 _merge_task / _release_task 本批未接 live_gate_decision——当前无 生产 reader 可注入:以 reader=None 接线会挡住全部合 并/发布(fail-closed 的本意不是停摆),以桩 reader 接线则是假求证。真实 live 验证仍待 B6 真实 App 安装 与 B1 等用户侧事项,未在本批完成。本批不改 #238 auto_apply(仍 False)、不改 #247“纠正为 pass 本身 不解封”、不改 v1.1 契约、不改任何阈值(阈值仍 test_default_unapproved)。 """ from dataclasses import dataclass try: from typing import Protocol from typing import runtime_checkable except ImportError: # pragma: no cover - Python < 3.8 Protocol = object # type: ignore def runtime_checkable(cls): return cls def _compare_claimed_vs_live(evidence, live): """声称证据与实时数据的逐项比对(两核对函数共用,同一口径)。 只比对调用方声称的项;实时数据缺失某项时记 mismatch (声称全绿却查无此项,不是“查不到所以通过”)。 返回 mismatches 列表(空即逐项全过)。 """ mismatches = [] live_checks = live.get("ci_checks") or {} if not isinstance(live_checks, dict): live_checks = {} evidence = evidence or {} if evidence.get("ci_status") == "success": failed_live = sorted( name for name, status in live_checks.items() if status != "success") if failed_live: mismatches.append( "ci_not_green_live: %s" % ", ".join(failed_live)) for check in (evidence.get("ci_checks") or []): if not isinstance(check, dict): continue name = check.get("name") if not name: continue if check.get("status") == "success" \ and live_checks.get(name) != "success": mismatches.append( "check_not_green_live: %s" % name) for name in (evidence.get("required_checks") or []): if live_checks.get(name) != "success": mismatches.append( "required_check_not_green_live: %s" % name) claimed_protection = evidence.get("branch_protection") live_protection = live.get("branch_protection") if claimed_protection == "protected" \ and live_protection != "protected": mismatches.append( "branch_protection_not_protected_live: %s" % live_protection) return mismatches def verify_gate_evidence(evidence, reader=None, repo=None, ref=None): """只读核对调用方门禁证据与实时数据。 evidence:调用方提交的门禁证据 dict(与硬门禁同款 字段:ci_status、ci_checks[{name, status}]、 required_checks、branch_protection)。 reader:callable (repo, ref) -> {"ci_checks": {名: 状态}, "branch_protection": str},或 None(无可信求证通道)。 repo/ref:求证目标;reader 非 None 但 repo/ref 缺失 时同样视为未核对(不猜目标)。 返回 {"checked": bool, "verified": bool, "mismatches": [...], "reason": str|None}: - checked=False:未核对(无 reader、无目标、reader 失败), verified 恒 False,reason 说明原因;调用方绝不能把 checked=False 当通过。 - checked=True 且 mismatches 为空:逐项比对全过, verified=True。 - checked=True 且 mismatches 非空:证据与实时数据不 一致,verified=False,mismatches 逐条说明(ci 状态、 某项检查、分支保护)。 比对规则:只比对调用方声称的项——调用方没声称的 检查不判(缺失由硬门禁的证据缺失码处理,不归本 模块);实时数据缺失某项时记 mismatch(声称全绿却 查无此项,不是“查不到所以通过”)。 """ if reader is None: return {"checked": False, "verified": False, "mismatches": [], "reason": "no_trusted_reader"} if not repo or not ref: return {"checked": False, "verified": False, "mismatches": [], "reason": "no_verification_target"} try: live = reader(repo, ref) except Exception as exc: return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: %s" % exc} if not isinstance(live, dict): return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: non-dict result"} mismatches = _compare_claimed_vs_live(evidence, live) return {"checked": True, "verified": not mismatches, "mismatches": mismatches, "reason": None} @dataclass(frozen=True) class LiveChecksResult: """get_checks 的返回结构:状态 + 原始证据。 checks:{检查名: 状态},状态与声称口径一致 ("success" 为绿,其它一律非绿)。 raw:原始证据体(真实 reader 未来填 GitHub Checks API 的原始 payload;假 reader 可填任意可追溯值, 也可为 None)。比对只用 checks,raw 只供审计追溯, 绝不参与“通过”判定。 """ checks: dict raw: object = None @dataclass(frozen=True) class LiveProtectionResult: """get_branch_protection 的返回结构:状态 + 原始证据。 status:"protected" / "unprotected" / 其它;None 表 示实时数据无此项(声称 protected 时即 mismatch)。 raw:原始证据体(真实 reader 未来填分支保护 API 的原始 payload;假 reader 可填任意可追溯值,也可 为 None),同样只供追溯、不参与判定。 """ status: object = None raw: object = None @runtime_checkable class LiveEvidenceReader(Protocol): """可注入的实时证据 reader 接口(v7 机制第 6 项)。 生产代码只定义接口、不实现真实 reader:真实实现 (走 GitHub Checks / 分支保护只读接口)须在 B6 真 实 App 安装与 B1 等用户侧事项就绪后另行接入,本 模块绝不内置 HTTP 调用、token 读取或 GitHub API 调用。测试与验收一律用实现本协议的假 reader。 """ def get_checks(self, repo, ref): """返回 LiveChecksResult(或等价 dict,见 _normalize_checks):repo 在 ref 处的 CI 检查 实时状态与原始证据。""" raise NotImplementedError def get_branch_protection(self, repo, branch): """返回 LiveProtectionResult(或等价 dict / 纯 状态字符串,见 _normalize_protection):repo 的 branch 分支保护实时状态与原始证据。""" raise NotImplementedError def _normalize_checks(result): """归一化 get_checks 返回值 -> (checks dict, raw) 或 None。 接受 LiveChecksResult、含 "checks"/"ci_checks" dict 的 dict、以及本身就是 {名: 状态} 的 dict;其它形状 (非 dict checks、非字符串状态)返回 None,调用方 按 reader_failed 处理,不猜、不放行。 """ if isinstance(result, LiveChecksResult): checks, raw = result.checks, result.raw elif isinstance(result, dict): raw = result.get("raw") if isinstance(result.get("checks"), dict): checks = result["checks"] elif isinstance(result.get("ci_checks"), dict): checks = result["ci_checks"] else: checks = result else: return None if not isinstance(checks, dict): return None if any(not isinstance(status, str) for status in checks.values()): return None return checks, raw def _normalize_protection(result): """归一化 get_branch_protection 返回值。 返回 (status, raw, malformed):status 为字符串或 None(无此项);malformed 为 True 时调用方按 reader_failed 处理(例如返回了整数等非法形状)。 纯字符串即状态本身;无 "status"/"branch_protection" 键的 dict 视为无此项(None),不是 malformed—— 声称 protected 时它仍会记 mismatch。 """ if isinstance(result, LiveProtectionResult): status, raw = result.status, result.raw elif isinstance(result, str): status, raw = result, None elif result is None: status, raw = None, None elif isinstance(result, dict): raw = result.get("raw") if isinstance(result.get("status"), str): status = result["status"] elif isinstance(result.get("branch_protection"), str): status = result["branch_protection"] else: status = None else: return None, None, True if status is not None and not isinstance(status, str): return None, None, True return status, raw, False def _is_live_reader(reader): """是否协议 reader:同时有 get_checks 与 get_branch_protection 两个方法(鸭子类型,不强制 isinstance,因为假 reader 未必显式继承协议)。""" return callable(getattr(reader, "get_checks", None)) \ and callable(getattr(reader, "get_branch_protection", None)) def verify_live_evidence(claimed, reader=None, repo=None, ref=None, branch=None): """实时证据比对(v7 机制第 6 项,fail-closed)。 claimed:调用方声称的门禁证据 dict(与硬门禁同款 字段:ci_status、ci_checks[{name, status}]、 required_checks、branch_protection)。 reader:LiveEvidenceReader 协议实例,或旧式 callable (repo, ref) -> {"ci_checks": {...}, "branch_protection": str}(与 verify_gate_evidence 同款;兼容旧假 reader)。 repo/ref:求证目标;branch:分支保护的求证分支, 未给时用 ref 作分支名(调用方须保证 ref 在此语义 下就是分支;SHA 求证时请显式传 branch,绝不由本 函数猜分支与 SHA 的对应关系)。 返回 {"checked", "verified", "mismatches", "reason", "live"}:live 为归一化后的实时数据(含原始证据 raw)或 None(未核对时)。verified 为 False 的情形: 无 reader、无 repo/ref、reader 抛异常、reader 返回 恶化形状、实时数据与声称不一致——一律不是“通过”。 比对规则与 verify_gate_evidence 共用 _compare,口 径一致;本函数不发网络请求、不读 token。 """ if reader is None: return {"checked": False, "verified": False, "mismatches": [], "reason": "no_trusted_reader", "live": None} if not repo or not ref: return {"checked": False, "verified": False, "mismatches": [], "reason": "no_verification_target", "live": None} if _is_live_reader(reader): try: checks_result = reader.get_checks(repo, ref) except Exception as exc: return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: %s" % exc, "live": None} normalized = _normalize_checks(checks_result) if normalized is None: return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: malformed checks", "live": None} checks, checks_raw = normalized try: protection_result = reader.get_branch_protection( repo, branch if branch else ref) except Exception as exc: return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: %s" % exc, "live": None} protection, protection_raw, malformed = \ _normalize_protection(protection_result) if malformed: return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: malformed protection", "live": None} live = {"ci_checks": checks, "branch_protection": protection, "checks_raw": checks_raw, "protection_raw": protection_raw} elif callable(reader): try: live_raw = reader(repo, ref) except Exception as exc: return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: %s" % exc, "live": None} if not isinstance(live_raw, dict): return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: non-dict result", "live": None} live = live_raw else: return {"checked": False, "verified": False, "mismatches": [], "reason": "reader_failed: unsupported reader type", "live": None} mismatches = _compare_claimed_vs_live(claimed, live) return {"checked": True, "verified": not mismatches, "mismatches": mismatches, "reason": None, "live": live} def live_gate_decision(claimed, reader=None, repo=None, ref=None, branch=None): """端点层 live 门禁判定(v7 机制第 6 项,fail-closed)。 verified=False 一律阻断,返回 allowed=False、 http_status=409、status="blocked"、blocked=True: code 为 live_evidence_mismatch(已核对但不一致) 或 live_evidence_unverified(未核对:无 reader、 无目标、reader 失败)。verified=True 时返回 allowed=True、http_status=200、status="allowed"、 code="live_evidence_verified"。返回里附完整 live 比对结果(live 键),供端点写进 409 响应与审计。 注意:本函数是判定缝,不是端点接线——HTTP 端点 须在有生产 reader 可注入时才调用它;无 reader 时 调用它会阻断(这是 fail-closed 的正确语义),所 以在生产 reader 就绪前端点不得接线(见模块头残 留)。本函数本身是纯函数,不写审计、不改状态。 """ result = verify_live_evidence(claimed, reader, repo, ref, branch=branch) if result["verified"]: return {"allowed": True, "blocked": False, "status": "allowed", "http_status": 200, "code": "live_evidence_verified", "live": result} code = "live_evidence_mismatch" if result["checked"] \ else "live_evidence_unverified" return {"allowed": False, "blocked": True, "status": "blocked", "http_status": 409, "code": code, "live": result}