v2 evidence: bind release evidence with ts/nonce, pin actual-only quota, ledger onboarding checklist, ledger failure outbox, read-only gate verifier (no live wiring)

- cloud/evidence_binding.py: bind/validate/nonce single-use store;
  release endpoint consumes nonce on first validated submission,
  replay -> 409 evidence_replayed, stale/future -> 409
  evidence_stale, partial -> 400, absent -> old path + label.
  Binding blocks exact replay and stale evidence, NOT restamping
  (unsigned evidence cannot prove content freshness).
- cloud/quota.py docstring: actual-only is the v2 integration
  position (already converged in v1 base via fef09ae, NOT in
  main); original PR semantics (#86 all-rows vs #90 actual-only)
  still pending coordinator confirmation at merge time.
- cloud/ledger_onboarding.py: DDL checklist referencing
  PG_DDL/PG_MARKER_DDL constants, read-only verify_ledger_ready
  and ledger_setup_report; default stays OFF, PG DDL stays an
  operator action (never claimed as executed).
- cloud/ledger_outbox.py + worker hook: ledger write failures
  go to a JSONL outbox in addition to the log and can be
  replayed; task outcome still unaffected (rule 7 kept),
  audit fail-closed untouched -- asymmetry documented, not
  silently changed.
- cloud/gate_verification.py: read-only verifier over an
  injected reader (match/mismatch/skipped); live wiring in
  merge/release is NOT possible on the current contract (no
  repo/ref in body or task row, no guaranteed trusted token),
  proven in the module header; no fake verification claimed.
- cloud/tests/test_v2_evidence.py: 18 new tests, all passing.

(cherry picked from commit bd85e33cd5e846fa6ee5d7e2440182dbd9c758b0)
This commit is contained in:
host committed 2026-11-03 02:49:26 +00:00
1 parent 2dc35bd331
commit 773945d136
9 files changed
+1263 -2

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
"""合并/发布门禁证据的只读核对器(集成 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}