Files
codex-app-server/cloud/gate_verification.py
T

436 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""合并/发布门禁证据的只读核对器(集成 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): # type: ignore[misc]
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}