"""合并/发布门禁证据的只读核对器(集成 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 由调用方注入),不发网络请求。 """ 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 = [] 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 {"checked": True, "verified": not mismatches, "mismatches": mismatches, "reason": None}