可验证报告复核修正(第 1 轮):运行信息拿已核对的事件对照,报告徽标连同数字一起算,遮罩判定宁严勿宽,各分支在浏览器里有确定的检查

- 页面核对加 ⑥ 运行信息:运行结果看封存范围内最后一条结束类事件的类型,运行类别看运行开始事件记下的运行参数,
  出具档位认最后一条出具判定(不再信元信息指的那一条);页面显示事件里的值,元信息写得不一样算不通过、计入总览。
  工作流名、版本、起止时间、节点名称绑不进封存,单列一组写明取自运行记录,封存说明里也写明要到系统里一并对照
- 报告标题旁的徽标连同正文里的数字一起算,旁边写分项:重算全部哈希的伪造只在 ⑤ 露馅,徽标不能还写着通过。
  分块条目多时不通过和无法核对的都列在外面,有无法核对的显示「部分无法核对」和分项计数;⑤ 改叫「数字与取值」并分开数,
  读屏分别读作数字、取值,「显示格式由服务端渲染」只附在带出处的数字上;无出处数字的说法改成未使用引用标记
- 服务端不会给出的状态算不通过:被引用的工件不在清单里、非查询快照标着遮罩、工件既没内容也没说明;报告文档导出时
  已不在服务端,总览不再是绿色的通过,写「有 N 份报告未能核对」,报告处写明是服务端已没有这份文档
- 认 json.dumps 写出的裸 NaN / Infinity,没被改过的文件不再误判不通过;哈希对得上但读不懂的照实写「无法核对」,
  和哈希不一致分开说
- 宽屏打开出处面板时正文让出右边一栏,表格里右对齐的数字不再被面板盖住;补上斜体(不用后行断言,较早的 Safari 也能执行)
- 封存哈希、运行 ID、工作流名直接写进页面,不执行脚本时写「尚未核对」而不是一直「正在核对」;脚本挪进 head,
  执行后核一遍页面顶部写出的封存哈希和数据里核对用的一致
- 成果节点和运行结束事件的规范字符串,只在封存范围内每次数据源查询都对得上无遮罩的快照(或确实没交回数据)时才给:
  升级前的运行从工具快照认出查询快照,协作团队成员的查询认不出就按带遮罩算,数据源现在设了遮罩的按工具名就挡
- 封存核对不一致的 409 改成一句固定的话,不再叠冒号、让人去本来就在的记录页;失败结束的运行在页面封存说明和导出提示里
  写明之后继续运行会重新封存;记录页上左边写着「已封存」却不能导出时,原因写在屏幕上
- tests/bundle_samples.py 用虚构数据真实生成九份样本页(遮罩快照、两份报告、早期运行、失败结束、两条出具判定、
  服务端缺文件、裸 NaN、别的运行的文档),check-bundle 断网打开逐项断言,在上面篡改引用链、运行信息、拿掉被引用的口径卡,
  量宽屏面板,看不执行脚本的样子;把核对改成恒通过、徽标不看数字等七种改坏法都会变红。pytest 补遮罩判定的三种形状、
  事后设遮罩(去掉接线即变红)、页面静态内容和样本页本身
This commit is contained in:
ThearchyHelios
2026-10-05 05:30:50 +08:00
parent 183814d2c1
commit e7c8d2a1f3
12 changed files with 1296 additions and 228 deletions
+3 -3
View File
@@ -34,7 +34,8 @@ BUNDLE_SEAL_BROKEN = "bundle_seal_broken"
#: 运行结束的状态:封存是最后一次的,之后不会再换(除非失败的运行又被接着跑,那是另一次封存)
_FINISHED = frozenset({"succeeded", "failed", "cancelled"})
UNSEALED = "运行尚未结束封存,结束后才能导出"
SEAL_BROKEN = "封存核对不一致:{why}。不能导出可验证报告,请先在记录页核对这次运行"
#: 原样出现在记录页的错误提示里,导出按钮旁边就是「核对」。不拼封存核对的结论原句:那句自带冒号和「不一致」
SEAL_BROKEN = "封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果"
@router.get("/{run_id}/bundle.html")
@@ -44,8 +45,7 @@ async def export_bundle(run_id: str) -> Response:
if not sealed.sealed or sealed.status not in _FINISHED:
raise CodedHTTPException(409, UNSEALED, BUNDLE_UNSEALED)
if not sealed.trusted:
why = str(sealed.verdict.get("message") or "封存后有事件被修改、删除或插入").rstrip("。")
raise CodedHTTPException(409, SEAL_BROKEN.format(why=why), BUNDLE_SEAL_BROKEN)
raise CodedHTTPException(409, SEAL_BROKEN, BUNDLE_SEAL_BROKEN)
bound = sealed.seal["manifest_seq"]
async with SessionLocal() as session:
run = await session.get(Run, run_id)
+78 -7
View File
@@ -32,9 +32,10 @@ from urllib.parse import quote
from app.core.artifact_store import canonical_json, content_hash
from app.core.events import EventType
from app.engine.evidence import iter_units
from app.engine.evidence import iter_units, query_entry_fields
from app.engine.merge_query import MERGE_SOURCE
from app.engine.schema import NodeType
from app.engine.toolcalls import QUERY_PREFIX
SCHEMA = "agentlab.bundle/1"
@@ -130,6 +131,71 @@ def _query_artifacts(events: Iterable[tuple[int, str, str | None, Any]]) -> list
return list(dict.fromkeys(a for a in out if a))
_UNSEEN = object()
def _query_returned(data: Mapping[str, Any], store: _Store) -> tuple[str, str | None]:
"""一次数据源查询(db_query__ 工具的 tool.end)交回了什么:("snapshot", 查询快照 id);("none", None) 没交回数据
(查询失败、SQL 被拒的那句原话);认不出来是 ("unknown", None)。
新运行在 query_artifact 里直接记了快照 id。没记的有三种:查询没成功;升级前的运行(证据台账之前,tool.end 只有
工具快照,查询快照的 id 嵌在工具快照记下的返回里,照证据接口 _legacy_pool 的办法认);协作团队成员的查询(连工具
快照都不落,只能看事件里的预览,长一点的返回被截断,解析不了)。"""
artifact = data.get("query_artifact")
if isinstance(artifact, str) and artifact:
return "snapshot", artifact
returned: Any = _UNSEEN
snapshot = data.get("artifact")
# 工具快照要哈希对得上、记的是同一个工具:被改过的不能拿来说这次查询没有遮罩
if isinstance(snapshot, str) and snapshot and store.intact(snapshot):
content = store.content(snapshot)
if isinstance(content, dict) and content.get("tool") == data.get("tool") and "result" in content:
returned = content["result"]
if returned is _UNSEEN:
preview = data.get("preview")
if not isinstance(preview, str):
return "unknown", None
returned = preview
if preview.startswith('"'):
# 调用工具节点的预览是把返回值再 json.dumps 一遍(文本外面套了一层引号),截断了就剥不开
try:
returned = json.loads(preview)
except ValueError:
return "unknown", None
fields = query_entry_fields(returned)
if fields:
return "snapshot", str(fields["artifact"])
# 数据源工具成功时交回的是 JSON 对象;不是对象的是一句原话,对象里没有 rows 的是报错
if isinstance(returned, str) and not returned.lstrip().startswith("{"):
return "none", None
if isinstance(returned, dict) and "rows" not in returned:
return "none", None
return "unknown", None
def _masked_run(events: Sequence[tuple[int, str, str | None, Any]], store: _Store, cited: Iterable[str],
source_masks: Mapping[str, Any]) -> bool:
"""运行里有没有哪次查询的结果可能带着遮罩列。有的话,成果节点和运行结束事件的预览可能直接带出查询结果,只给哈希。
宁严勿宽:封存范围内每一次数据源查询都要对得上一份没有遮罩的查询快照,或者确实没交回数据,才算没有。数据源
现在设了遮罩的,按工具名就知道,不必去认快照;认不出查了什么的,按带遮罩算。"""
known = [*_query_artifacts(events), *cited]
for _, etype, _, data in events:
if etype != EventType.TOOL_END or not isinstance(data, dict):
continue
tool = str(data.get("tool") or "")
if not tool.startswith(QUERY_PREFIX):
continue
if source_masks.get(tool[len(QUERY_PREFIX):]):
return True
kind, artifact = _query_returned(data, store)
if kind == "unknown":
return True
if artifact:
known.append(artifact)
return any(store.masked(a) for a in dict.fromkeys(known))
# --------------------------------------------------------------------------
# 报告文档和它引用到的工件
# --------------------------------------------------------------------------
@@ -330,8 +396,7 @@ def build_bundle(
if kind == "query":
cited_queries.append(artifact)
# 运行里有没有带遮罩的查询:有的话,成果节点和运行结束事件的预览可能直接带出查询结果,只给哈希
masked_run = any(store.masked(a) for a in dict.fromkeys([*_query_artifacts(sealed), *cited_queries]))
masked_run = _masked_run(sealed, store, cited_queries, source_masks or {})
shown_events = []
for seq, etype, node_id, data in sealed:
text = canonical_json(data)
@@ -367,7 +432,7 @@ def build_bundle(
#: U+2028 / U+2029 在老的 JS 解析器里是换行,一并转义。这几个字只会出现在 JSON 的字符串里,转义后 JSON 不变
_JSON_ESCAPES = str.maketrans({"<": "\\u003c", ">": "\\u003e", "&": "\\u0026",
"\u2028": "\\u2028", "\u2029": "\\u2029"})
_PLACEHOLDER = re.compile(r"__BUNDLE_(TITLE|CSP|DATA|SCRIPT)__")
_PLACEHOLDER = re.compile(r"__BUNDLE_(TITLE|NAME|SEAL|RUN|FAILED|CSP|DATA|SCRIPT)__")
@lru_cache(maxsize=1)
@@ -385,10 +450,16 @@ def render_html(bundle: Mapping[str, Any]) -> str:
"""把数据和核对脚本填进模板:样式、脚本都内联,零外链。
内容安全策略按哈希只放行这一段核对脚本,默认什么都不许连:页面打开后不会、也不能向任何地方发请求。
工作流名、封存哈希、运行 ID 直接写进页面:邮件、即时通讯的附件预览和系统的快速查看常常不执行脚本,也要看得到
该拿什么去系统里对照。脚本执行后拿数据里的封存哈希核对,再核一遍这里写出的和它一致。失败结束的运行另写一句:
之后在系统中继续运行会重新封存,这个文件就再也对不上了。
占位符一次替换完,填进去的内容不会再被当成占位符。"""
template, script = _assets()
digest = base64.b64encode(hashlib.sha256(script.encode("utf-8")).digest()).decode("ascii")
title = f"{(bundle.get('meta') or {}).get('workflow_name') or UNSAVED_NAME} · 可验证报告"
values = {"TITLE": html.escape(title), "CSP": f"'sha256-{digest}'", "DATA": bundle_json(bundle),
"SCRIPT": script}
meta = bundle.get("meta") or {}
name = str(meta.get("workflow_name") or UNSAVED_NAME)
values = {"TITLE": html.escape(f"{name} · 可验证报告"), "NAME": html.escape(name),
"SEAL": html.escape(str(meta.get("manifest_hash") or "")), "RUN": html.escape(str(meta.get("run_id") or "")),
"FAILED": "" if meta.get("status") == "failed" else " hidden",
"CSP": f"'sha256-{digest}'", "DATA": bundle_json(bundle), "SCRIPT": script}
return _PLACEHOLDER.sub(lambda m: values[m.group(1)], template)
+26 -12
View File
@@ -14,6 +14,7 @@
--mark: #fff1b8; --code: #eff1f5; --shadow: 0 10px 32px rgba(16, 20, 28, .16);
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--detail-w: 420px;
}
@media (prefers-color-scheme: dark) {
:root {
@@ -24,6 +25,8 @@
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
/* 核对脚本一开始执行就给 html 标上 data-js:没执行(禁用脚本、附件预览)时只显示「尚未核对」那一句 */
html:not([data-js]) .js-only, html[data-js] .no-js { display: none !important; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.65 var(--sans); overflow-wrap: break-word; }
code, pre, .mono { font-family: var(--mono); }
@@ -34,25 +37,31 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
border: 1px solid var(--border); background: var(--panel); font-size: 18px; font-weight: 650; }
.overall[data-overall="pass"] { color: var(--ok); background: var(--ok-soft); border-color: transparent; }
.overall[data-overall="fail"] { color: var(--bad); background: var(--bad-soft); border-color: transparent; }
.overall[data-overall="incomplete"] { color: var(--na); background: var(--na-soft); border-color: var(--na); }
.overall-mark { font-size: 20px; line-height: 1; }
.overall-sub { margin: 4px 2px 0; font-size: 13px; color: var(--dim); }
.seal { margin: 18px 0; padding: 14px 16px; border: 2px solid var(--accent); border-radius: 10px; }
.seal h2 { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: var(--dim); }
.hash { display: block; font-size: 15px; font-weight: 600; line-height: 1.5; word-break: break-all; color: var(--fg); }
.seal p { margin: 10px 0 0; font-size: 13px; color: var(--dim); }
.seal p code { word-break: break-all; }
.seal .seal-warn { padding: 8px 10px; border-radius: 8px; background: var(--bad-soft); color: var(--bad); }
.meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 0; font-size: 13px; }
.meta dt { color: var(--faint); }
.meta dd { margin: 0; min-width: 0; color: var(--dim); overflow-wrap: anywhere; }
.meta-note { margin: 14px 0 6px; font-size: 12px; color: var(--faint); }
.block { margin-top: 36px; }
.block > h2 { margin: 0 0 10px; font-size: 18px; }
.muted { color: var(--faint); }
.bad { color: var(--bad); }
.note { margin: 6px 0 12px; font-size: 13px; color: var(--dim); }
.warn { margin: 0 0 10px; padding: 8px 10px; border-radius: 8px; background: var(--bad-soft); color: var(--bad); font-size: 13px; }
.warn[data-why="missing"], .warn[data-why="unreadable"] { background: var(--na-soft); color: var(--na); }
.report { margin: 14px 0; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--raise); }
.report-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-bottom: 10px; }
.report-head h3 { margin: 0; font-size: 15px; }
.report-head .mono { font-size: 12px; }
.report-parts { flex: 1 1 100%; font-size: 12px; color: var(--dim); }
.doc { font-size: 15px; }
.doc p { margin: 8px 0; }
.doc .doc-h { margin: 18px 0 6px; line-height: 1.4; }
@@ -84,7 +93,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
font-weight: 600; line-height: 1.5; white-space: nowrap; }
.badge[data-result="pass"] { color: var(--ok); background: var(--ok-soft); }
.badge[data-result="fail"] { color: var(--bad); background: var(--bad-soft); }
.badge[data-result="na"] { color: var(--na); background: var(--na-soft); }
.badge[data-result="na"], .badge[data-result="partial"] { color: var(--na); background: var(--na-soft); }
.check { margin: 12px 0; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.check-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 10px 12px; background: var(--panel); }
.check-head h3 { margin: 0; font-size: 14px; }
@@ -95,7 +104,7 @@ h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
.items .why { flex: 1 1 100%; color: var(--dim); }
.items button { font: inherit; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 13px; color: var(--accent); cursor: pointer; }
.detail { position: fixed; top: 0; right: 0; bottom: 0; z-index: 10; width: min(420px, 100vw); overflow-y: auto;
.detail { position: fixed; top: 0; right: 0; bottom: 0; z-index: 10; width: min(var(--detail-w), 100vw); overflow-y: auto;
background: var(--raise); border-left: 1px solid var(--border); box-shadow: var(--shadow); }
.detail-head { position: sticky; top: 0; display: flex; align-items: flex-start; gap: 8px; padding: 14px 16px 10px;
background: var(--raise); border-bottom: 1px solid var(--border); }
@@ -111,12 +120,15 @@ details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font
.sub { margin: 14px 0 4px; font-size: 13px; color: var(--faint); font-weight: 600; }
.foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); }
.foot p { margin: 4px 0; }
.noscript { max-width: 920px; margin: 24px auto; padding: 0 16px; color: var(--bad); }
@media print {
.detail, .close, details > summary { display: none !important; }
.page { max-width: none; padding: 0; }
.page, html.detail-open .page { max-width: none; margin: 0; padding: 0; }
.num { text-decoration-color: currentColor !important; background: none !important; }
}
/* 宽屏上出处面板停在右侧:打开时正文让出这一栏,表格里右对齐的数字不会被面板盖住 */
@media screen and (min-width: 761px) {
html.detail-open .page { margin-right: min(var(--detail-w), 100vw); }
}
@media (max-width: 760px) {
.detail { top: auto; left: 0; width: auto; max-height: 72vh; border-left: 0; border-top: 1px solid var(--border); border-radius: 14px 14px 0 0; }
}
@@ -131,36 +143,38 @@ details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font
.report { padding: 12px; }
}
</style>
<script>__BUNDLE_SCRIPT__</script>
</head>
<body>
<div class="page">
<header>
<p class="brand">可验证报告</p>
<h1 id="title">可验证报告</h1>
<h1 id="title">__BUNDLE_NAME__</h1>
<div id="overall" class="overall" data-overall="running" role="status" aria-live="polite">
<span class="overall-mark" aria-hidden="true">…</span><span id="overall-text">正在核对…</span>
<span class="overall-mark js-only" aria-hidden="true">…</span><span id="overall-text"><span class="js-only">正在核对…</span><span class="no-js">尚未核对:需要在浏览器中打开本文件,并允许执行其中的核对脚本</span></span>
</div>
<p id="overall-sub" class="overall-sub"></p>
<section class="seal" aria-labelledby="seal-label">
<h2 id="seal-label">封存清单哈希</h2>
<code id="seal-hash" class="hash"></code>
<p id="seal-note"></p>
<code id="seal-hash" class="hash">__BUNDLE_SEAL__</code>
<p id="seal-note">这个文件只证明内部一致:报告里的数字能追到口径卡或查询快照,文档、快照和运行记录彼此对得上。它不能单独证明这些记录出自系统、未被整体替换。核对真伪:在系统中打开这次运行(运行 ID <code class="run-id">__BUNDLE_RUN__</code>),确认「凭证详情」里的清单哈希与上面一致,并点「核对」确认事件记录与封存时一致(接口 GET /api/runs/<span class="run-id">__BUNDLE_RUN__</span>/verify)。工作流名、版本、起止时间和节点名称取自运行记录,不在封存范围内,需要在系统中一并对照。</p>
<p id="seal-failed" class="seal-warn"__BUNDLE_FAILED__>这次运行以失败结束:之后若在系统中继续运行,系统会重新封存,本文件的封存哈希将无法再与系统对照。</p>
</section>
<dl id="meta" class="meta"></dl>
<p id="record-note" class="meta-note" hidden></p>
<dl id="record" class="meta"></dl>
</header>
<main>
<main class="js-only">
<section id="reports" class="block" aria-labelledby="reports-title"><h2 id="reports-title">报告</h2></section>
<section id="checks" class="block" aria-labelledby="checks-title"><h2 id="checks-title">核对明细</h2></section>
<section id="appended" class="block" aria-labelledby="appended-title" hidden><h2 id="appended-title">封存之后追加的事件</h2></section>
</main>
<footer id="foot" class="foot"></footer>
<footer id="foot" class="foot js-only"></footer>
</div>
<aside id="detail" class="detail" role="dialog" aria-modal="false" aria-labelledby="detail-title" hidden>
<div class="detail-head"><h2 id="detail-title"></h2><button type="button" id="detail-close" class="close" aria-label="关闭">×</button></div>
<div id="detail-body" class="detail-body"></div>
</aside>
<noscript><p class="noscript">这个文件在浏览器里自行核对,需要启用 JavaScript。</p></noscript>
<script type="application/json" id="bundle-data">__BUNDLE_DATA__</script>
<script>__BUNDLE_SCRIPT__</script>
</body>
</html>
+361 -107
View File
@@ -1,20 +1,24 @@
/*
* 可验证报告的浏览器端核对:原生 JS,无依赖,不连网。
*
* 数据在 <script type="application/json" id="bundle-data"> 里(服务端 bundle/build.py 生成),逐项核对:
* 数据在页面末尾 id 为 bundle-data 的 JSON 数据块里(服务端 bundle/build.py 生成),逐项核对:
* ① 每条带规范字符串的事件:sha256(canonical) 等于它的 data_sha256;
* ② 封存清单:照 runner 的拼法(每行 seq|type|node_id|data_sha256,node_id 为空写空串,按 seq 排序,换行连接,
* 没有结尾换行)重算,等于封存哈希;
* 没有结尾换行)重算,等于封存哈希;页面顶部直接写出的那份封存哈希(不执行脚本也看得到)要和它一致;
* ③ 每份嵌入的工件:sha256(原字节) 等于工件 id;
* ④ 引用链:报告文档的 id 是这个报告节点最后一次自查事件(已核对)记下的那份,文档记的运行、节点对得上;
* 口径卡和快照的 id 出现在已核对的事件内容里,或者已核对的报告文档的证据目录里;
* ⑤ 报告里的每个数字:引用的值等于口径卡里那个指标的值,或者快照里那一格的值。判等照搬服务端
* engine/expressions.py 的 cell_value / same_value;数字怎么显示是服务端渲染好的,这里不重新排版。
* 每项给出通过 / 不通过 / 无法核对和原因。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明。
* ⑤ 报告里的每个数字和取值:引用的值等于口径卡里那个指标的值,或者快照里那一格的值。判等照搬服务端
* engine/expressions.py 的 cell_value / same_value;数字怎么显示是服务端渲染好的,这里不重新排版;
* ⑥ 运行信息:运行结果、运行类别、出具档位拿封存范围内已核对的事件对照。工作流名、版本、起止时间、节点名称
* 绑不进封存,页面上写明它们取自运行记录。
* 每项给出通过 / 不通过 / 无法核对和原因。服务端导出时不会出现的状态(被引用的工件不在清单里、口径卡标着遮罩)
* 一律算不通过:只可能是文件被改过。这些只证明内部一致,真伪要拿封存哈希去系统里对照,页面上写明。
*
* SHA-256 优先用浏览器的 crypto.subtle;拿不到(非安全上下文、某些浏览器打开本地文件时)用下面的纯 JS 实现,
* 两者结果一致。纯 JS 实现启动时先拿标准测试向量自检,自检不过就不下任何结论。
* 来自数据的文字一律经 textContent 写进页面,不拼 HTML。
* 脚本放在 head 里:一开始执行就给 html 标上 data-js(样式表据此把「尚未核对」换成「正在核对」,附件预览这类不执行
* 脚本的地方看到的是前一句),等文档解析完再核对。来自数据的文字一律经 textContent 写进页面,不拼 HTML。
*/
(() => {
'use strict'
@@ -22,15 +26,21 @@
const PASS = 'pass'
const FAIL = 'fail'
const NA = 'na'
/** 汇总用(分块、报告的徽标):一组里有通过也有无法核对,没有不通过 */
const PARTIAL = 'partial'
/** 一块里条目超过这么多就收起来,只把不通过和无法核对的列在外面 */
const FOLD_AT = 20
// ------------------------------------------------------------------ 文案
const RESULT = { pass: '通过', fail: '不通过', na: '无法核对' }
const MARK = { pass: '✓', fail: '✗', na: '?' }
const RESULT = { pass: '通过', fail: '不通过', na: '无法核对', partial: '部分无法核对' }
const MARK = { pass: '✓', fail: '✗', na: '?', partial: '?' }
const KIND_LABEL = { report_doc: '报告文档', metric_set: '口径卡', query: '查询快照' }
const TIER_LABEL = { formal: '完整出具', degraded: '降档出具', withheld: '不予出具' }
const CLASS_LABEL = { formal: '正式运行', exploratory: '探索运行' }
const STATUS_LABEL = { succeeded: '已完成', failed: '已失败', cancelled: '已取消' }
/** 结束类事件 → 它说明的运行结果(runner:成功、失败、取消各发一条,落库之后才封存) */
const TERMINAL = { 'run.finished': 'succeeded', 'run.failed': 'failed', 'run.cancelled': 'cancelled' }
const EVENT_LABEL = {
'run.started': '运行开始', 'run.finished': '运行结束', 'run.failed': '运行失败', 'run.cancelled': '运行取消',
'run.interrupted': '等待人工介入', 'run.resumed': '恢复运行', 'node.started': '节点开始', 'node.finished': '节点完成',
@@ -47,47 +57,58 @@
allPass: '全部通过',
passWithNa: '可核对的项全部通过',
failed: (n) => `有 ${n} 项不通过`,
incomplete: (n) => `有 ${n} 份报告未能核对`,
tally: (p, f, n) => `通过 ${p} 项 · 不通过 ${f} 项 · 无法核对 ${n} 项`,
broken: '页面数据无法解析,无法核对',
selfTestFailed: '本页的 SHA-256 实现在这个浏览器中自检未通过,不给出核对结论',
sealNote: (id) => '这个文件只证明内部一致:报告里的数字能追到口径卡或查询快照,文档、快照和运行记录彼此对得上。'
+ '它不能单独证明这些记录出自系统、未被整体替换。核对真伪:在系统中打开这次运行(运行 ID '
+ `${id}),确认「凭证详情」里的清单哈希与上面一致,并点「核对」确认事件记录与封存时一致`
+ `(接口 GET /api/runs/${id}/verify)。`,
metaRun: '运行 ID', metaWorkflow: '工作流', metaClass: '运行类别', metaStatus: '运行结果', metaVersionHash: '版本哈希',
metaStarted: '开始',
metaFinished: '结束', metaRange: '封存范围', metaTier: '出具档位', metaExported: '导出时间',
metaRun: '运行 ID', metaStatus: '运行结果', metaClass: '运行类别', metaTier: '出具档位', metaRange: '封存范围',
metaExported: '导出时间', metaWorkflow: '工作流', metaVersion: '版本', metaVersionHash: '版本哈希', metaStarted: '开始',
metaFinished: '结束',
recordNote: '以下几项和页面上的节点名称取自运行记录,不在封存范围内,需要在系统中一并对照',
// 序号不连续(模型的流式输出占序号、不落库),后面注明范围内实际有几条。说法同记录页的封存凭证
range: (seq, count) => `第 1–${seq} 条事件(范围内 ${count} 条)`,
rangeLegacy: '(早期运行,范围按结束事件推算)',
tierChecked: (seq) => `第 ${seq} 条事件,已核对`,
tierUnchecked: '与已核对的事件对不上',
markPass: '已核对',
markFail: (claim) => `文件里写的是「${claim}」,见核对明细 ⑥`,
markNa: '无法核对',
noReports: '这次运行没有报告文档,下方只核对运行记录。',
reportTitle: (label) => `报告「${label}」`,
reportParts: (doc, nums) => `文档 ${doc} · 数字与取值 ${nums}`,
reportNoNumbers: '无',
docUntrusted: '这份报告文档没有通过核对(见核对明细 ③ ④),正文仅供参考,其中的数字不予核对。',
docUnreadable: '报告文档无法解析,无法显示正文。',
docUnreadable: '这份报告文档的哈希一致,但内容无法解析,无法显示正文。',
docMissing: '导出时服务端已没有这份报告文档,无法显示正文,其中的数字也无法核对。',
docAbsent: '文件里没有附带这份报告文档。',
checkEvents: '① 事件内容与哈希',
checkManifest: '② 封存清单',
checkArtifacts: '③ 工件与哈希',
checkChain: '④ 引用链',
checkNumbers: '⑤ 报告中的数字',
checkNumbers: '⑤ 报告中的数字与取值',
checkMeta: '⑥ 运行信息',
sumEvents: (n) => `${n} 条带内容的事件;其余事件只附哈希,参与 ② 的清单核对`,
sumManifest: (n) => `按 ${n} 条事件的哈希重算`,
sumCount: (n, unit) => `${n} ${unit}`,
sumNumbers: (numbers, values) => `数字 ${numbers} 个 · 取值 ${values} 个`,
sumMeta: '与封存范围内已核对的事件对照',
showAll: (n) => `展开全部 ${n} 项`,
eventMismatch: '内容与记录的哈希不一致',
eventNotJson: '内容不是有效的 JSON',
manifestComputed: '重算结果',
manifestShown: '页面顶部写出的封存哈希',
manifestLines: (n) => `查看参与清单核对的 ${n} 行(每行 序号|类型|节点|内容哈希,可复制后用任意 SHA-256 工具自行计算)`,
manifestOk: '与封存清单哈希一致',
manifestMismatch: '与封存清单哈希不一致:事件被增删、改动或换了顺序',
beyondSeal: (seq) => `第 ${seq} 条事件的序号超出封存范围`,
rangeMismatch: (seq, last) => `封存范围写的是第 1–${seq} 条,范围内最后一条事件却是第 ${last} 条`,
shownOk: '与核对用的封存哈希一致',
shownMismatch: '与数据里核对用的封存哈希不一致,页面被改过;上方现在显示的是核对用的那一份',
artifactMismatch: '内容与工件 id(哈希)不一致',
artifactUndecodable: '内容无法解码',
artifactNotJson: '内容不是有效的 JSON',
artifactUnparsable: '哈希一致,但内容无法解析',
masked: '含遮罩列,未随文件附带,只能核对到哈希',
maskedOdd: '只有查询快照会因遮罩不随文件附带,这份工件却标着遮罩:导出时不会出现这种情况,文件可能被改过',
artifactMissing: '导出时服务端已没有这份工件的文件',
artifactAbsent: '内容未随文件附带',
artifactAbsent: '内容未随文件附带:导出时不会出现这种情况,文件可能被改过',
chainDoc: (label) => `报告「${label}」的文档`,
chainByEvent: (seq, what) => `第 ${seq} 条事件(${what})`,
chainByDoc: (label, alias) => `报告「${label}」的证据目录(${alias})`,
@@ -95,7 +116,10 @@
chainNoCheckEvent: '找不到这个报告节点的报告自查事件',
chainCheckEventFailed: '报告自查事件的内容没有通过核对',
chainOtherDoc: '这个节点最后一次报告自查记下的不是这份文档',
chainDocNotListed: '文件的工件清单里没有这份报告文档:导出时不会出现这种情况,文件可能被改过',
chainDocUnverified: '文档内容没有通过核对,无法确认它记下的运行和节点',
chainDocMissing: '导出时服务端已没有这份报告文档,无法确认它记下的运行和节点',
chainDocUnparsable: '文档的哈希一致,但内容无法解析,无法确认它记下的运行和节点',
chainDocElsewhere: '文档记下的运行或节点与这次不符',
chainDocLeftOut: '运行记录里有这个报告节点的自查事件,文件里却没有附带它的文档',
chainUnreferenced: '已核对的事件和报告文档里都没有引用这份工件',
@@ -105,20 +129,37 @@
srcInput: (field) => `运行输入「${field}」`,
srcNone: '没有出处',
srcOther: '其他引用',
numUncited: '这个数字没有引用出处(出具时已标为无出处)',
numUncited: '该数字未使用引用标记,无法核对其来源',
numUnresolved: (why) => `报告生成时这处引用未能解析${why ? `:${why}` : ''}`,
numInput: '引用的是运行输入,运行输入未随文件附带',
numOther: '这类引用不在核对范围内',
numDocUntrusted: '所在的报告文档没有通过核对',
numNoEntry: '报告的证据目录里没有这处引用指向的条目',
numArtifactAbsent: '引用的工件未随文件附带',
numArtifactFailed: '引用的工件没有通过哈希核对',
numArtifactNotListed: '引用的工件不在文件的工件清单里:导出时不会出现这种情况,文件可能被改过',
numArtifactFailed: '引用的工件没有通过核对(见核对明细 ③)',
numArtifactUnparsable: '引用的工件哈希一致,但内容无法解析',
numNotChained: '引用的工件不在引用链上',
numNoMetric: '口径卡里没有这个指标',
numMetricDiffers: '引用的值与口径卡里的值不一致',
numNoCell: (why) => `快照里找不到这一格:${why}`,
numCellDiffers: '引用的值与快照里那一格的值不一致',
numAria: (text, result) => `数字 ${text},${result},查看出处`,
numAria: (kind, text, result) => `${kind === 'value' ? '取值' : '数字'} ${text},${result},查看出处`,
statusOk: (seq, what) => `与第 ${seq} 条事件(${what})一致`,
statusMismatch: (claim, seq, what) => `文件里写的是「${claim}」,与封存范围内最后一条结束类事件(第 ${seq} 条,${what})不符`,
statusLegacy: '早期运行在结束事件落库之前封存,结束事件不在封存范围内,运行结果无法核对',
statusNoEvent: '封存范围内没有运行结束、失败或取消的事件,运行结果无法核对',
classOk: (seq) => `与第 ${seq} 条事件(运行开始)记下的运行参数一致`,
classMismatch: (claim, seq) => `文件里写的是「${claim}」,与第 ${seq} 条事件(运行开始)记下的运行参数不符`,
classLegacy: (seq) => `第 ${seq} 条事件(运行开始)出自早期运行,没有记下区分运行类别的参数,运行类别无法核对`,
classUnchecked: (seq) => `第 ${seq} 条事件(运行开始)的内容没有通过核对,运行类别无法核对`,
classAbsent: (seq) => `第 ${seq} 条事件(运行开始)的内容未随文件附带:导出时不会出现这种情况,文件可能被改过`,
classNoEvent: '封存范围内没有运行开始事件,运行类别无法核对',
tierOk: (seq) => `与第 ${seq} 条事件(出具判定)一致`,
tierMismatch: (claim, seq) => `文件里写的是「${claim}」,与封存范围内最后一条出具判定(第 ${seq} 条)不符`,
tierNoEvent: (claim) => `文件里写着出具档位「${claim}」,封存范围内却没有出具判定`,
tierUnchecked: (seq) => `第 ${seq} 条事件(出具判定)的内容没有通过核对,出具档位无法核对`,
tierAbsent: (seq) => `第 ${seq} 条事件(出具判定)的内容未随文件附带:导出时不会出现这种情况,文件可能被改过`,
metaNeedsManifest: '封存清单没有通过核对,事件的类型和序号不可信,无法对照',
dResult: '核对结果', dSource: '出处', dNode: '来源节点', dCaliber: '口径', dCited: '引用的值', dCard: '口径卡里的值',
dCell: '快照里的值', dShown: '正文显示', dShownNote: '显示格式由服务端渲染', dArtifact: '所在工件', dReport: '所在报告',
dSql: 'SQL', dRow: (row) => `快照第 ${row + 1} 行`,
@@ -221,6 +262,48 @@
return out
}
const NONFINITE = { NaN: NaN, Infinity: Infinity, '-Infinity': -Infinity }
/**
* JSON.parse,另认 Python 的 json.dumps 写出的裸 NaN、Infinity、-Infinity(JSON 标准不认)。规范字符串和工件文件
* 都是 json.dumps 写的,查询结果里浮点的 NaN 会原样落进去,标准的 JSON.parse 一读就报错,没被改过的文件也会被判成
* 不通过。字符串外面的这几个词先换成占位的字符串再解析,解析时换回对应的数:判等和服务端一样,NaN 和谁都不相等。
* 只用来读内容,哈希照旧按原字节算
*/
function parseJson(text) {
try {
return JSON.parse(text)
} catch (e) {
// 下面按宽松的认法再试一次
}
let mark = '\uE000nonfinite:'
while (text.includes(mark)) mark += '\uE000'
let out = ''
let from = 0
let inString = false
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (inString) {
if (c === '\\') i++
else if (c === '"') inString = false
continue
}
if (c === '"') {
inString = true
continue
}
const word = c === '-' || c === 'I' || c === 'N' ? ['-Infinity', 'Infinity', 'NaN'].find((w) => text.startsWith(w, i)) : null
if (word) {
out += text.slice(from, i) + JSON.stringify(mark + word)
i += word.length - 1
from = i + 1
}
}
out += text.slice(from)
return JSON.parse(out, (_, v) => (typeof v === 'string' && v.startsWith(mark) && v.slice(mark.length) in NONFINITE
? NONFINITE[v.slice(mark.length)] : v))
}
// ------------------------------------------------------------------ 判等:照搬 engine/expressions.py
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
@@ -319,10 +402,13 @@
}
}
async function verify(data, digest) {
const out = { events: [], manifest: null, artifacts: [], chain: [], numbers: [], docs: [], checked: new Map() }
const events = Array.isArray(data.events) ? data.events : []
const meta = isObj(data.meta) ? data.meta : {}
/** shownSeal:页面顶部直接写出的封存哈希(服务端渲染进模板,不执行脚本也看得到的那一份) */
async function verify(data, digest, shownSeal) {
const out = {
events: [], manifest: [], artifacts: [], chain: [], numbers: [], docs: [], meta: [], checked: new Map(), order: [],
}
const events = Array.isArray(data.events) ? data.events.filter(isObj) : []
const meta = data.meta
// ① 带规范字符串的事件
for (const ev of events) {
@@ -332,7 +418,7 @@
item.reason = T.eventMismatch
} else {
try {
out.checked.set(ev, JSON.parse(ev.canonical))
out.checked.set(ev, parseJson(ev.canonical))
item.result = PASS
} catch (e) {
item.reason = T.eventNotJson
@@ -343,19 +429,31 @@
// ② 封存清单:seq 相同的几条按文件里的先后(服务端按数据库的顺序给,和封存时一样)
const order = events.map((ev, i) => [ev, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1]).map(([ev]) => ev)
out.order = order
const lines = order.map((ev) => `${ev.seq}|${ev.type}|${ev.node_id == null ? '' : ev.node_id}|${ev.data_sha256}`)
const computed = await digest(utf8(lines.join('\n')))
const beyond = order.find((ev) => !(ev.seq <= meta.manifest_seq))
out.manifest = {
computed, count: lines.length, lines,
result: computed === meta.manifest_hash && !beyond ? PASS : FAIL,
reason: computed !== meta.manifest_hash ? T.manifestMismatch : beyond ? T.beyondSeal(beyond.seq) : T.manifestOk,
const last = order[order.length - 1]
const manifest = { kind: 'computed', computed, count: lines.length, lines, result: FAIL, reason: '' }
if (computed !== meta.manifest_hash) manifest.reason = T.manifestMismatch
else if (beyond) manifest.reason = T.beyondSeal(beyond.seq)
// 新运行封存时记下的就是当时最后一条事件的序号(runner._seal);早期运行的范围是推算的,不一定落在某条事件上
else if (!meta.legacy && last && last.seq !== meta.manifest_seq) manifest.reason = T.rangeMismatch(meta.manifest_seq, last.seq)
else {
manifest.result = PASS
manifest.reason = T.manifestOk
}
out.manifest.push(manifest)
// 页面顶部写出的那一份要和核对用的一致:不然改过事件、重算了数据里的哈希,顶上却留着原来那串,读的人拿它去系统
// 里对照会对得上。脚本执行后顶上换成核对用的这一份
const seal = String(meta.manifest_hash ?? '')
out.manifest.push({ kind: 'shown', shown: shownSeal, result: shownSeal === seal ? PASS : FAIL,
reason: shownSeal === seal ? T.shownOk : T.shownMismatch })
// ③ 工件
// ③ 工件。服务端给每份工件的是三样之一:原字节、遮罩(只有查询快照)、服务端已没有文件
const arts = new Map()
for (const a of Array.isArray(data.artifacts) ? data.artifacts : []) {
const item = { a, result: NA, reason: '', json: null, loose: null }
for (const a of Array.isArray(data.artifacts) ? data.artifacts.filter(isObj) : []) {
const item = { a, result: NA, reason: '', json: null, loose: null, parsed: false }
if (typeof a.b64 === 'string') {
let bytes = null
try {
@@ -368,23 +466,34 @@
const intact = (await digest(bytes)) === a.id
try {
// 对不上的也解析出来(loose):被改过的报告仍显示正文,醒目地标着不可信
item.loose = JSON.parse(decoder.decode(bytes))
item.loose = parseJson(decoder.decode(bytes))
item.parsed = true
} catch (e) {
item.loose = null
}
if (!intact) {
item.result = FAIL
item.reason = T.artifactMismatch
} else if (item.loose === null) {
item.result = FAIL
item.reason = T.artifactNotJson
} else if (!item.parsed) {
// 哈希对得上,就是系统里存的那一份:读不懂不是被改过,照实说核对不了
item.reason = T.artifactUnparsable
} else {
item.result = PASS
item.json = item.loose
}
}
} else if (a.masked) {
if (a.kind === 'query') {
item.reason = T.masked
} else {
item.result = FAIL
item.reason = T.maskedOdd
}
} else if (a.missing) {
item.reason = T.artifactMissing
} else {
item.reason = a.masked ? T.masked : a.missing ? T.artifactMissing : T.artifactAbsent
item.result = FAIL
item.reason = T.artifactAbsent
}
arts.set(a.id, item)
out.artifacts.push(item)
@@ -412,9 +521,13 @@
if (!ev) link.reason = T.chainNoCheckEvent
else if (!out.checked.has(ev)) link.reason = T.chainCheckEventFailed
else if (own(out.checked.get(ev), 'doc_artifact') !== report.doc_artifact) link.reason = T.chainOtherDoc
else if (!art || art.result !== PASS) { link.result = NA; link.reason = T.chainDocUnverified }
else if (own(art.json, 'run_id') !== meta.run_id || own(art.json, 'node_id') !== report.node_id) link.reason = T.chainDocElsewhere
else link.result = PASS
else if (!art) link.reason = T.chainDocNotListed
else if (art.result === FAIL) Object.assign(link, { result: NA, reason: T.chainDocUnverified })
else if (art.result !== PASS) {
Object.assign(link, { result: NA, reason: art.a.missing ? T.chainDocMissing : T.chainDocUnparsable })
} else if (own(art.json, 'run_id') !== meta.run_id || own(art.json, 'node_id') !== report.node_id) {
link.reason = T.chainDocElsewhere
} else link.result = PASS
out.chain.push(link)
if (link.result === PASS) {
trusted.set(report, art.json)
@@ -447,11 +560,12 @@
if (ok) chained.add(item.a.id)
}
// ⑤ 报告里的数字
// ⑤ 报告里的数字和取值
for (const report of reports) {
const art = arts.get(report.doc_artifact)
const doc = trusted.get(report) || (art && isObj(art.loose) ? art.loose : null)
out.docs.push({ report, art, doc, trusted: trusted.has(report) })
const link = out.chain.find((c) => c.kind === 'report_doc' && c.report === report)
const doc = trusted.get(report) || (art && art.parsed && isObj(art.loose) ? art.loose : null)
out.docs.push({ report, art, link, doc, trusted: trusted.has(report) })
if (!doc) continue
for (const seg of segmentsOf(doc)) {
if (seg.kind !== 'number' && seg.kind !== 'value') continue
@@ -460,6 +574,9 @@
out.numbers.push({ ...check, seg, report, key: `${report.node_id}|${seg.id}` })
}
}
// ⑥ 运行信息
out.meta = checkMeta(meta, out)
return out
}
@@ -493,9 +610,12 @@
info.artifact = entry.artifact
info.node = entry.node_id
const art = arts.get(entry.artifact)
if (!art) return { ...info, result: NA, reason: T.numArtifactAbsent }
if (art.a.masked || art.a.missing) return { ...info, result: NA, reason: art.reason }
if (art.result !== PASS) return { ...info, result: NA, reason: T.numArtifactFailed }
// 服务端把已核对的文档引用到的口径卡、快照逐个列进清单(拿不到内容的也列,标明原因):不在清单里只能是被拿掉了
if (!art) return { ...info, result: FAIL, reason: T.numArtifactNotListed }
if (art.result === FAIL) return { ...info, result: NA, reason: T.numArtifactFailed }
if (art.result !== PASS) {
return { ...info, result: NA, reason: art.a.masked ? T.masked : art.a.missing ? T.artifactMissing : T.numArtifactUnparsable }
}
if (!chained.has(entry.artifact)) return { ...info, result: NA, reason: T.numNotChained }
if (cite.kind === 'metric') {
const metricId = own(cite.locator, 'metric') ?? own(entry.locator, 'metric')
@@ -519,6 +639,74 @@
return sameValue(cite.value, info.truth) ? { ...info, result: PASS } : { ...info, result: FAIL, reason: T.numCellDiffers }
}
/**
* ⑥ 运行信息:运行结果、运行类别、出具档位不信元信息,拿封存范围内已核对的事件对照,页面上显示的也是事件里的。
* 事件的类型和序号由 ② 的清单绑定,清单没通过就无从对照。每项 { field, shown(显示的值), claim(元信息写的), result, reason }
*/
function checkMeta(meta, out) {
const items = []
const add = (field, shown, claim, result, reason) => items.push({ field, shown, claim, result, reason })
const order = out.order
const sealOk = out.manifest[0].result === PASS
const label = (map, value) => map[value] || (value ? String(value) : '—')
const what = (ev) => EVENT_LABEL[ev.type] || ev.type
// 运行结果:封存范围内最后一条结束类事件的类型。早期运行在 run.finished 落库之前封存,成功结束的那条在范围外,
// 范围内最后一条也就不是结束类事件;失败、取消的那条在范围内
const statusClaim = label(STATUS_LABEL, meta.status)
const end = [...order].reverse().find((ev) => TERMINAL[ev.type])
const lastEv = order[order.length - 1]
if (!sealOk) add('status', statusClaim, statusClaim, NA, T.metaNeedsManifest)
else if (meta.legacy && meta.status === 'succeeded' && !(lastEv && TERMINAL[lastEv.type])) {
add('status', statusClaim, statusClaim, NA, T.statusLegacy)
} else if (!end) add('status', statusClaim, statusClaim, NA, T.statusNoEvent)
else {
const ok = TERMINAL[end.type] === meta.status
add('status', label(STATUS_LABEL, TERMINAL[end.type]), statusClaim, ok ? PASS : FAIL,
ok ? T.statusOk(end.seq, what(end)) : T.statusMismatch(statusClaim, end.seq, what(end)))
}
// 运行类别:运行开始事件记下的工具信任档。正式运行记 formal,探索运行记各工具的档位(一个对象),升级前的运行没有
const classClaim = label(CLASS_LABEL, meta.run_class)
const started = order.find((ev) => ev.type === 'run.started')
if (!sealOk) add('class', classClaim, classClaim, NA, T.metaNeedsManifest)
else if (!started) add('class', classClaim, classClaim, NA, T.classNoEvent)
else if (typeof started.canonical !== 'string') add('class', classClaim, classClaim, FAIL, T.classAbsent(started.seq))
else if (!out.checked.has(started)) add('class', classClaim, classClaim, NA, T.classUnchecked(started.seq))
else {
const trust = own(out.checked.get(started), 'tool_trust')
const expected = trust === 'formal' ? 'formal' : isObj(trust) ? 'exploratory' : null
if (!expected) add('class', classClaim, classClaim, NA, T.classLegacy(started.seq))
else {
const ok = expected === meta.run_class
add('class', label(CLASS_LABEL, expected), classClaim, ok ? PASS : FAIL,
ok ? T.classOk(started.seq) : T.classMismatch(classClaim, started.seq))
}
}
// 出具档位:封存范围内最后一条记下了档位的出具判定(和服务端 build._tier 同一个认法),还要是元信息指的那一条
const tierClaim = isObj(meta.tier) ? label(TIER_LABEL, meta.tier.value) : '—'
let found = null
for (const ev of [...order].reverse()) {
if (ev.type !== 'issuance') continue
if (typeof ev.canonical !== 'string') found = { result: FAIL, reason: T.tierAbsent(ev.seq) }
else if (!out.checked.has(ev)) found = { result: NA, reason: T.tierUnchecked(ev.seq) }
else if (typeof own(out.checked.get(ev), 'tier') === 'string') found = { ev, value: out.checked.get(ev).tier }
if (found) break
}
if (!sealOk) {
if (meta.tier != null || found) add('tier', tierClaim, tierClaim, NA, T.metaNeedsManifest)
} else if (!found) {
if (meta.tier != null) add('tier', tierClaim, tierClaim, FAIL, T.tierNoEvent(tierClaim))
} else if (!found.ev) add('tier', tierClaim, tierClaim, found.result, found.reason)
else {
const ok = isObj(meta.tier) && meta.tier.value === found.value && meta.tier.seq === found.ev.seq
add('tier', label(TIER_LABEL, found.value), tierClaim, ok ? PASS : FAIL,
ok ? T.tierOk(found.ev.seq) : T.tierMismatch(tierClaim, found.ev.seq))
}
return items
}
// ------------------------------------------------------------------ 渲染:工具
const $ = (id) => document.getElementById(id)
@@ -561,18 +749,29 @@
return h('dl', { class: 'kv' }, rows.filter(Boolean).map(([k, v]) => [h('dt', {}, k), h('dd', {}, v)]))
}
function worst(items) {
/** 一组结果汇总成一个:有不通过就是不通过;全通过、全无法核对照实;通过和无法核对都有是「部分无法核对」 */
function summarize(items) {
if (!items.length) return NA
if (items.some((i) => i.result === FAIL)) return FAIL
if (items.some((i) => i.result === PASS)) return PASS
return NA
const pass = items.some((i) => i.result === PASS)
const na = items.some((i) => i.result === NA)
return pass && na ? PARTIAL : pass ? PASS : NA
}
/** 分项计数「通过 24 · 无法核对 3」,是零的不写 */
function tallyText(items) {
return [PASS, FAIL, NA].map((r) => [r, items.filter((i) => i.result === r).length])
.filter(([, n]) => n > 0).map(([r, n]) => `${RESULT[r]} ${n}`).join(' · ')
}
// ------------------------------------------------------------------ 渲染:报告正文
// 行内语法:代码、链接(只显示文字,不给链接:页面不连网)、粗体、删除线。和前端 Markdown.tsx 的规则一致,
// 斜体不认(要用后行断言,老浏览器不支持)
const INLINE = '(`+)([^`]+?)\\1|\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)|\\*\\*([^*]+?)\\*\\*|__([^_]+?)__|~~([^~]+?)~~'
const BIT = { code: 1, bold: 2, strike: 4, link: 8 }
// 行内语法:代码、链接(只显示文字,不给链接:页面不连网)、粗体、斜体、删除线,和前端 run/Markdown.tsx 的规则一致。
// 斜体要求左边不是 *(否则 **粗体** 会被它先拆掉):前端用后行断言,这里不用(较早的 Safari 不认,构造正则时
// 就会报错,整页核对跟着失败),匹配到以后自己看左边那个字
const INLINE = '(`+)([^`]+?)\\1|\\[([^\\]]+)\\]\\(([^)\\s]+)[^)]*\\)|\\*\\*([^*]+?)\\*\\*|__([^_]+?)__'
+ '|\\*([^*\\n]+?)\\*(?!\\*)|~~([^~]+?)~~'
const BIT = { code: 1, bold: 2, strike: 4, link: 8, em: 16 }
// 列表项的开头:行首的 -、*、+ 或者序号
const ITEM_START = /(^|\n)[ \t]*([-*+]|\d+[.)])[ \t]+$/
@@ -585,15 +784,20 @@
const re = new RegExp(INLINE, 'g')
let last = 0
for (let m = re.exec(part); m; m = re.exec(part)) {
const [whole, ticks, code, linkText, , bold1, bold2, italic, strike] = m
if (italic !== undefined && m.index > 0 && part[m.index - 1] === '*') {
// 左边紧挨着 *:不算斜体(前端后行断言排除的情形),从下一个字接着找
re.lastIndex = m.index + 1
continue
}
for (let i = last; i < m.index; i++) style[from + i] = inherited
const [whole, ticks, code, linkText, , bold1, bold2, strike] = m
const inner = code ?? linkText ?? bold1 ?? bold2 ?? strike ?? ''
const inner = code ?? linkText ?? bold1 ?? bold2 ?? italic ?? strike ?? ''
const base = from + m.index
const off = code !== undefined ? ticks.length : whole.indexOf(inner)
for (let i = 0; i < whole.length; i++) hide[base + i] = 1
for (let i = 0; i < inner.length; i++) hide[base + off + i] = 0
const bit = code !== undefined ? BIT.code : linkText !== undefined ? BIT.link
: bold1 !== undefined || bold2 !== undefined ? BIT.bold : BIT.strike
: bold1 !== undefined || bold2 !== undefined ? BIT.bold : italic !== undefined ? BIT.em : BIT.strike
if (code !== undefined || linkText !== undefined || depth >= 3) {
for (let i = 0; i < inner.length; i++) style[base + off + i] = inherited | bit
} else {
@@ -617,6 +821,7 @@
while (j < to && !styles.hide[j] && styles.style[j] === st) j++
let node = document.createTextNode(text.slice(i, j))
if (st & BIT.code) node = h('code', {}, node)
if (st & BIT.em) node = h('em', {}, node)
if (st & BIT.bold) node = h('strong', {}, node)
if (st & BIT.strike) node = h('s', {}, node)
if (st & BIT.link) node = h('span', { class: 'link' }, node)
@@ -724,9 +929,10 @@
const eventWhat = (ev) => [EVENT_LABEL[ev.type] || ev.type, ev.node_id ? nodeLabel(ev.node_id) : null].filter(Boolean).join(' · ')
function numberButton(number, body) {
const kind = number.seg.kind === 'value' ? 'value' : 'number'
const btn = h('button', {
type: 'button', class: 'num', 'data-num': number.key, 'data-result': number.result,
'aria-label': T.numAria(String(number.seg.text ?? ''), RESULT[number.result]),
type: 'button', class: 'num', 'data-num': number.key, 'data-kind': kind, 'data-result': number.result,
'aria-label': T.numAria(kind, String(number.seg.text ?? ''), RESULT[number.result]),
onclick: () => openNumber(number),
}, body)
number.button = btn
@@ -742,6 +948,8 @@
function detailOf(number) {
const cite = isObj(number.seg.cite) ? number.seg.cite : null
const card = number.card
// 只有带出处的数字是服务端按引用的值渲染的;模型自己写的数、表格里的文字取值都是原文
const rendered = number.seg.kind === 'number' && cite && cite.status === 'resolved'
const rows = [
[T.dResult, [badge(number.result), number.reason ? ` ${number.reason}` : '']],
[T.dSource, number.source],
@@ -749,7 +957,7 @@
card ? [T.dCaliber, [card.caliber || '—', card.caliber_version ? ` ${card.caliber_version}` : ''].join('')] : null,
cite && cite.status === 'resolved' && 'value' in cite ? [T.dCited, valueText(cite.value)] : null,
'truth' in number ? [number.kind === 'metric' ? T.dCard : T.dCell, valueText(number.truth)] : null,
[T.dShown, [String(number.seg.text ?? ''), h('span', { class: 'muted' }, `(${T.dShownNote})`)]],
[T.dShown, [String(number.seg.text ?? ''), rendered ? h('span', { class: 'muted' }, `(${T.dShownNote})`) : null]],
number.artifact ? [T.dArtifact, [h('code', {}, short(number.artifact)), artifactState(number.artifact)]] : null,
[T.dReport, nodeLabel(number.report.node_id)],
]
@@ -780,11 +988,19 @@
$('detail-body').replaceChildren(...detailOf(number))
panel.hidden = false
panel.setAttribute('data-open', number.key)
// 宽屏上正文给面板让出右边一栏(样式表 html.detail-open):版面变窄、换了行,点的数字可能挪了位置
document.documentElement.classList.add('detail-open')
$('detail-close').focus({ preventScroll: true })
// 窄屏上面板从底部抽出:点的数字被盖住时把它滚到面板上方
if (!view.active) return
const sheet = panel.getBoundingClientRect()
const spot = view.active ? view.active.getBoundingClientRect() : null
if (spot && sheet.top > 0 && spot.bottom > sheet.top) window.scrollBy({ top: spot.top - Math.max(16, sheet.top / 3) })
const spot = view.active.getBoundingClientRect()
if (sheet.top > 0) {
// 窄屏上面板从底部抽出:点的数字被盖住时把它滚到面板上方
if (spot.bottom > sheet.top) window.scrollBy({ top: spot.top - Math.max(16, sheet.top / 3) })
} else {
// 宽屏:让开之后数字还在视野里(表格横向滚动时也滚到它)
view.active.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
}
function closeDetail() {
@@ -792,6 +1008,7 @@
if (panel.hidden) return
panel.hidden = true
panel.removeAttribute('data-open')
document.documentElement.classList.remove('detail-open')
if (view.active) view.active.classList.remove('is-active')
if (view.back && typeof view.back.focus === 'function') view.back.focus()
view.active = null
@@ -799,33 +1016,50 @@
// ------------------------------------------------------------------ 渲染:页面各块
function renderHead(data) {
function rowsOf(rows) {
return rows.filter(Boolean).flatMap(([k, v]) => [h('dt', {}, k), h('dd', {}, v)])
}
function renderHead(data, out) {
const meta = data.meta
$('title').textContent = String(meta.workflow_name || '')
// 顶上换成核对用的这一份(② 已经核过它和原来写的是否一致)
$('seal-hash').textContent = String(meta.manifest_hash || '')
$('seal-note').textContent = T.sealNote(String(meta.run_id || ''))
let tier = '—'
if (isObj(meta.tier)) {
const ev = data.events.find((e) => e.seq === meta.tier.seq && e.type === 'issuance')
const body = ev ? view.out.checked.get(ev) : undefined
const ok = isObj(body) && body.tier === meta.tier.value
tier = h('span', { 'data-tier': ok ? 'checked' : 'unchecked' }, TIER_LABEL[meta.tier.value] || String(meta.tier.value),
h('span', { class: ok ? 'muted' : 'bad' }, `(${ok ? T.tierChecked(meta.tier.seq) : T.tierUnchecked})`))
for (const el of document.querySelectorAll('.run-id')) el.textContent = String(meta.run_id || '')
const checked = (field, fallback) => {
const item = out.meta.find((m) => m.field === field)
if (!item) return fallback
const note = item.result === PASS ? T.markPass : item.result === FAIL ? T.markFail(item.claim) : T.markNa
return h('span', { 'data-meta': field, 'data-result': item.result }, item.shown,
h('span', { class: item.result === FAIL ? 'bad' : 'muted' }, `(${note})`))
}
const runClass = CLASS_LABEL[meta.run_class] || meta.run_class || '—'
const rows = [
$('meta').replaceChildren(...rowsOf([
[T.metaRun, h('code', {}, String(meta.run_id || ''))],
[T.metaStatus, checked('status', '—')],
[T.metaClass, checked('class', '—')],
[T.metaTier, checked('tier', '—')],
[T.metaRange, `${T.range(meta.manifest_seq, out.order.length)}${meta.legacy ? T.rangeLegacy : ''}`],
[T.metaExported, when(meta.exported_at)],
]))
// 绑不进封存的几样单独一组,写明出处:改了它们,五项核对照样通过,去系统里对照封存哈希也发现不了
const note = $('record-note')
note.textContent = T.recordNote
note.hidden = false
$('record').replaceChildren(...rowsOf([
[T.metaWorkflow, String(meta.workflow_name || '—')],
[T.metaClass, meta.version != null ? `${runClass} v${meta.version}` : runClass],
meta.status ? [T.metaStatus, STATUS_LABEL[meta.status] || String(meta.status)] : null,
meta.version != null ? [T.metaVersion, `v${meta.version}`] : null,
meta.version_hash ? [T.metaVersionHash, h('code', {}, String(meta.version_hash))] : null,
[T.metaStarted, when(meta.started_at)],
[T.metaFinished, when(meta.finished_at)],
[T.metaRange, `${T.range(meta.manifest_seq, data.events.length)}${meta.legacy ? T.rangeLegacy : ''}`],
[T.metaTier, tier],
[T.metaExported, when(meta.exported_at)],
]
$('meta').replaceChildren(...rows.filter(Boolean).flatMap(([k, v]) => [h('dt', {}, k), h('dd', {}, v)]))
]))
}
/** 报告文档没核对上的原因:{ key, text }。key 进 data-why,服务端已没有文件、读不懂的用中性色,不是红的 */
function docWhy(item) {
if (!item.art) return { key: 'absent', text: T.docAbsent }
if (item.art.result === FAIL || (item.link && item.link.result === FAIL)) return { key: 'untrusted', text: T.docUntrusted }
if (item.art.a.missing) return { key: 'missing', text: T.docMissing }
return { key: 'unreadable', text: T.docUnreadable }
}
function renderReports(out) {
@@ -837,31 +1071,42 @@
const numbers = new Map(out.numbers.map((n) => [n.key, n]))
for (const item of out.docs) {
const label = nodeLabel(item.report.node_id)
const link = out.chain.find((c) => c.kind === 'report_doc' && c.report === item.report)
const result = worst([item.art || { result: NA }, link || { result: NA }])
// 报告的徽标连同正文里的数字一起算:重算了全部哈希的伪造只在 ⑤ 露馅,标题旁不能还写着通过
const nums = out.numbers.filter((n) => n.report === item.report)
const docResult = [item.art, item.link].some((x) => x && x.result === FAIL) || !item.art ? FAIL : item.trusted ? PASS : NA
const result = summarize([{ result: docResult }, ...nums])
const box = h('article', { class: 'report', 'data-report': item.report.node_id, 'data-result': result },
h('header', { class: 'report-head' }, h('h3', {}, T.reportTitle(label)), badge(result),
h('span', { class: 'mono muted' }, short(item.report.doc_artifact))))
if (!item.trusted) box.append(h('p', { class: 'warn' }, item.doc ? T.docUntrusted : T.docUnreadable))
h('span', { class: 'mono muted' }, short(item.report.doc_artifact)),
h('span', { class: 'report-parts', 'data-report-parts': '' },
T.reportParts(RESULT[docResult], nums.length ? tallyText(nums) : T.reportNoNumbers))))
if (!item.trusted) {
const why = docWhy(item)
box.append(h('p', { class: 'warn', 'data-why': why.key }, why.text))
}
if (item.doc) box.append(renderDoc(item.doc, { report: item.report, numbers }))
root.append(box)
}
}
function checkBlock(id, title, summary, items, line) {
const result = items.length ? worst(items) : NA
const result = summarize(items)
const tally = items.length > 1 ? tallyText(items) : ''
const block = h('section', { class: 'check', 'data-check': id, 'data-result': result },
h('div', { class: 'check-head' }, badge(result), h('h3', {}, title), h('span', { class: 'check-sum' }, summary)))
h('div', { class: 'check-head' }, badge(result), h('h3', {}, title),
h('span', { class: 'check-sum' }, tally ? `${summary} · ${tally}` : summary)))
const list = (rows) => h('ul', { class: 'items' }, rows.map((item) => {
const li = h('li', { 'data-result': item.result }, badge(item.result), h('span', { class: 'what' }, line(item)))
if (item.reason) li.append(h('span', { class: 'why' }, item.reason))
return li
}))
if (items.length <= 20) {
if (items.length <= FOLD_AT) {
block.append(list(items))
} else {
// 条目多(事件常有几百条):先列没通过的,全部的收起来
block.append(list(items.filter((i) => i.result === FAIL)))
// 条目多(事件、数字常有几百条):不通过的和无法核对的列在外面(不通过的在前),没核到的最需要被看见;
// 全部的收起来
const open = [...items.filter((i) => i.result === FAIL), ...items.filter((i) => i.result === NA)]
if (open.length) block.append(list(open))
block.append(h('details', {}, h('summary', {}, T.showAll(items.length)), list(items)))
}
return block
@@ -871,12 +1116,12 @@
const root = $('checks')
root.append(checkBlock('events', T.checkEvents, T.sumEvents(out.events.length), out.events,
(i) => `${T.chainByEvent(i.ev.seq, eventWhat(i.ev))}`))
const m = out.manifest
const manifest = checkBlock('manifest', T.checkManifest, T.sumManifest(m.count), [m],
() => [T.manifestComputed, ' ', h('code', {}, m.computed)])
const computed = out.manifest[0]
const manifest = checkBlock('manifest', T.checkManifest, T.sumManifest(computed.count), out.manifest,
(m) => [m.kind === 'computed' ? T.manifestComputed : T.manifestShown, ' ', h('code', {}, m.kind === 'computed' ? m.computed : m.shown || '—')])
// 参与核对的原文照拼法列出来:读的人不信这一页的脚本,也能复制出去用别的工具算一遍
manifest.append(h('details', {}, h('summary', {}, T.manifestLines(m.count)),
h('pre', { class: 'sql', 'data-manifest-lines': '' }, m.lines.join('\n'))))
manifest.append(h('details', {}, h('summary', {}, T.manifestLines(computed.count)),
h('pre', { class: 'sql', 'data-manifest-lines': '' }, computed.lines.join('\n'))))
root.append(manifest)
root.append(checkBlock('artifacts', T.checkArtifacts, T.sumCount(out.artifacts.length, '份'), out.artifacts,
(i) => [KIND_LABEL[i.a.kind] || i.a.kind, i.a.node_id ? ` · ${nodeLabel(i.a.node_id)}` : '', ' · ',
@@ -891,11 +1136,14 @@
return [KIND_LABEL[c.kind] || c.kind, ' ', h('code', {}, short(c.art.a.id)),
refs.length ? ` ← ${T.chainFrom}${refs.join('、')}` : '']
}))
root.append(checkBlock('numbers', T.checkNumbers, T.sumCount(out.numbers.length, '个'), out.numbers,
const values = out.numbers.filter((n) => n.seg.kind === 'value').length
root.append(checkBlock('numbers', T.checkNumbers, T.sumNumbers(out.numbers.length - values, values), out.numbers,
(n) => h('button', { type: 'button', 'data-num-item': n.key, onclick: () => {
if (n.button) n.button.scrollIntoView({ block: 'center' })
openNumber(n)
} }, `${String(n.seg.text ?? '')} · ${n.source}`)))
const metaLabel = { status: T.metaStatus, class: T.metaClass, tier: T.metaTier }
root.append(checkBlock('meta', T.checkMeta, T.sumMeta, out.meta, (m) => `${metaLabel[m.field]}:${m.shown}`))
}
function renderAppended(data) {
@@ -909,14 +1157,16 @@
}
function renderOverall(out) {
const all = [...out.events, out.manifest, ...out.artifacts, ...out.chain, ...out.numbers]
const all = [...out.events, ...out.manifest, ...out.artifacts, ...out.chain, ...out.numbers, ...out.meta]
const count = (r) => all.filter((i) => i.result === r).length
const [pass, fail, na] = [count(PASS), count(FAIL), count(NA)]
const result = fail ? FAIL : PASS
// 报告文档核对不了(导出时服务端已没有文件、读不懂):页面上没有可核的正文,不能写成绿色的「通过」
const unverified = out.docs.filter((d) => !d.trusted).length
const result = fail ? FAIL : unverified ? 'incomplete' : PASS
const box = $('overall')
box.setAttribute('data-overall', result)
box.querySelector('.overall-mark').textContent = MARK[result]
$('overall-text').textContent = fail ? T.failed(fail) : na ? T.passWithNa : T.allPass
box.querySelector('.overall-mark').textContent = fail ? MARK.fail : unverified ? MARK.na : MARK.pass
$('overall-text').textContent = fail ? T.failed(fail) : unverified ? T.incomplete(unverified) : na ? T.passWithNa : T.allPass
$('overall-sub').textContent = T.tally(pass, fail, na)
}
@@ -951,8 +1201,8 @@
fatal(T.selfTestFailed)
return
}
view.out = await verify(data, picked.digest)
renderHead(data)
view.out = await verify(data, picked.digest, $('seal-hash').textContent.trim())
renderHead(data, view.out)
renderReports(view.out)
renderChecks(view.out)
renderAppended(data)
@@ -963,8 +1213,12 @@
document.documentElement.setAttribute('data-verified', '')
}
main().catch((e) => {
// 脚本在 head 里:一开始执行就标上 data-js,样式表据此把「尚未核对」换成「正在核对」;文档解析完再核对
document.documentElement.setAttribute('data-js', '')
const start = () => main().catch((e) => {
console.error(e)
fatal(T.broken)
})
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true })
else start()
})()
+200
View File
@@ -0,0 +1,200 @@
"""可验证报告的虚构样本页:给 scripts/check-bundle.mjs 在浏览器里核对(pytest 不执行页面上的核对脚本)。
python -m tests.bundle_samples <目录>
在目录里写下几份页面(<名字>.html)。每份都是 build_bundle + render_html 真实生成的,封存哈希按 runner 的算法
(artifact_store.manifest_hash)对事件真实算出,报告文档用 compose_doc 按证据目录真实组装(标题、段落、列表、
表格、粗体、斜体,数字和取值的引用都和真实运行一样)。check-bundle 断网打开它们,逐项断言核对结果和原因,再在
这几份上做篡改。这样遮罩快照、多份报告、早期运行、服务端缺文件这些分支,不论真数据里有没有,都有人在浏览器里核过。
数据全是虚构的:门店、会员、金额都是编的。
- base:一份报告,引用口径卡、门店流水(没有遮罩)、会员消费(有遮罩)各一处,一个没有出处的数,一张门店流水整表
(数字列右对齐,数字与取值共 21 个,超过折叠的条数);一条封存之后追加的按需裁判
- two-reports:另有一份月报,两份报告各自的引用链
- legacy:早期运行,封存范围按结束事件推算(运行结束事件在范围外,运行结果无法核对)
- failed:失败结束的运行(没有出具判定),封存说明多一句「继续运行会重新封存」
- degraded:两条出具判定,先完整出具、后降档出具,认最后一条
- missing-doc / missing-card:导出时服务端已没有报告文档 / 口径卡的文件
- nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用)
- stray:报告自查记下的文档是另一次运行写的
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
from app.bundle.build import build_bundle, render_html
from app.core.artifact_store import canonical_json, content_hash, manifest_hash
from app.engine.evidence import build_catalog, compose_doc
RUN_ID = "5a3c0f4b9d2e4e1f8b7a6c5d4e3f2a1b"
RUN = {"id": RUN_ID, "workflow_id": "wf-sample-weekly", "workflow_name": "门店周报", "version": 3,
"version_hash": "ab" * 32, "run_class": "formal", "status": "succeeded",
"started_at": "2026-09-07T01:00:00+00:00", "finished_at": "2026-09-07T01:02:03+00:00"}
GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [
("start", "input", "开始"), ("fetch", "tool", "门店流水"), ("members", "tool", "会员消费"),
("card", "metrics", "周报口径"), ("write", "report", "写周报"), ("monthly", "report", "写月报"),
("out", "output", "成果")]]}
EXPORTED_AT = "2026-10-05T08:00:00+00:00"
STORES = [["青石店", 1280.5], ["白沙店", "960.00"], ["临川店", 732], ["望江店", 1105.25], ["枫桥店", 418],
["云岭店", 2210], ["石桥店", 655.5], ["柳湾店", 980]]
CRM = {"columns": ["name", "phone", "spend"], "column_types": {"name": "text", "phone": "text", "spend": "number"},
"rows": [["林一", "000-0001", 320.0], ["周二", "000-0002", 188.5]], "source": "crm",
"sql": "SELECT name, phone, spend FROM members ORDER BY id", "truncated": False, "mask_columns": ["phone"]}
CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2",
"metrics": [{"id": "gmv", "name": "销售额", "value": 8381.25, "unit": "元", "decimals": 2, "format": "thousands"}]}
WEEKLY = ("## 门店周报\n\n本周销售额 [[m:gmv]],*按发生周统计*。青石店流水 [[v:Q1.r0.amount]],白沙店 [[v:Q1.r1.amount]],"
"**会员消费** [[v:Q2.r0.spend]],另有 42 笔退款待入账。[[see:Q1]]\n\n- 云岭店流水最高\n- 枫桥店流水最低\n\n"
"[[table:Q1 cols=store,amount rows=0-7]]")
MONTHLY = "## 门店月报\n\n本月销售额 [[m:gmv]],青石店流水 [[v:Q1.r0.amount]]。[[see:Q1]]"
def stored(obj: Any) -> tuple[str, bytes]:
"""照工件库的样子落一份:规范 JSON 的字节,id 是它的 sha256。"""
raw = canonical_json(obj).encode()
return content_hash(raw), raw
def shop(nan: bool = False) -> dict[str, Any]:
"""门店流水的查询快照。ratio 那一列报告不引用;nan=True 时第一行是浮点 NaN(json.dumps 写成裸的 NaN)。"""
rows = [[name, amount, float("nan") if nan and i == 0 else round(0.05 * (i + 1), 2)]
for i, (name, amount) in enumerate(STORES)]
return {"columns": ["store", "amount", "ratio"],
"column_types": {"store": "text", "amount": "number", "ratio": "number"}, "rows": rows, "source": "shop",
"sql": "SELECT store, amount, ratio FROM orders ORDER BY id", "truncated": False}
class Sample:
"""一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。"""
def __init__(self, *, nan: bool = False) -> None:
self.files: dict[str, bytes] = {}
self.shop_id = self.put(shop(nan))
self.crm_id = self.put(CRM)
self.card_id = self.put(CARD)
self.ledger = [
{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": self.shop_id, "tool": "db_query__shop",
"source": "shop", "columns": ["store", "amount", "ratio"], "rows": len(STORES), "truncated": False},
{"kind": "query", "node_id": "members", "exec": 1, "artifact": self.crm_id, "tool": "db_query__crm",
"source": "crm", "columns": CRM["columns"], "rows": len(CRM["rows"]), "truncated": False},
{"kind": "metric_set", "node_id": "card", "artifact": self.card_id},
]
self.catalog = build_catalog(nodes={"card": CARD}, ledger=self.ledger)
def put(self, obj: Any) -> str:
artifact, raw = stored(obj)
self.files[artifact] = raw
return artifact
def load(self, artifact: str) -> Any:
raw = self.files.get(artifact)
return json.loads(raw) if raw is not None else None
def doc(self, text: str, node_id: str, run_id: str = RUN_ID) -> str:
return self.put(compose_doc(text, self.catalog, node_id=node_id, run_id=run_id, loader=self.load))
def events(self, reports: dict[str, str], *, ending: str = "finished", tiers: tuple[str, ...] = ("formal",),
nan: bool = False) -> tuple[list[tuple[int, str, str | None, Any]], int]:
"""(全部事件, 封存范围)。封存范围到结束事件为止,后面再追加一条按需裁判。"""
rows: list[tuple[int, str, str | None, Any]] = []
def add(etype: str, node_id: str | None, data: dict[str, Any]) -> None:
rows.append((len(rows) + 1, etype, node_id, data))
add("run.started", None, {"nodes": len(GRAPH["nodes"]), "resumed": False, "memory_scope": "default",
"collection": "default", "approval_default": None, "tool_trust": "formal",
"agent_limits": None, "data_versions": None, "replay_protocol": 2,
**({"sample_rate": float("nan")} if nan else {})})
add("node.started", "start", {"node_type": "input", "label": "开始"})
add("node.finished", "start", {"duration_ms": 1, "attempt": 1, "preview": {"week": "2026-W37"}})
add("node.started", "fetch", {"node_type": "tool", "label": "门店流水"})
add("tool.end", "fetch", {"tool": "db_query__shop", "duration_ms": 12, "query_artifact": self.shop_id,
"preview": json.dumps({"columns": ["store", "amount"], "rows": STORES[:2]},
ensure_ascii=False)})
add("node.finished", "fetch", {"duration_ms": 14, "attempt": 1, "preview": "{\"rows\": …}",
"evidence": [self.ledger[0]]})
add("tool.end", "members", {"tool": "db_query__crm", "duration_ms": 9, "query_artifact": self.crm_id,
"preview": json.dumps(CRM["rows"][:1], ensure_ascii=False)})
add("node.finished", "members", {"duration_ms": 10, "attempt": 1, "evidence": [self.ledger[1]]})
add("node.finished", "card", {"duration_ms": 3, "attempt": 1, "preview": {"kind": "metric_set"},
"evidence": [self.ledger[2]]})
for node_id, doc_id in reports.items():
add("llm.end", node_id, {"model": "mock", "output": "(写作草稿)"})
add("report.checked", node_id, {"doc_artifact": doc_id, "ok": True})
add("node.finished", node_id, {"duration_ms": 20, "attempt": 1,
"preview": {"text": "(报告正文)", "doc_artifact": doc_id}})
if ending == "finished":
for tier in tiers:
add("issuance", "out", {"tier": tier, "mode": "citations"})
add("node.finished", "out", {"duration_ms": 2, "attempt": 1, "preview": {"周报": "(报告正文)"}})
add("run.finished", None, {"output": {"周报": "(报告正文)"}})
else:
add("node.failed", "out", {"error": "出口复核没有通过"})
add("run.failed", None, {"error": "出口复核没有通过"})
bound = len(rows)
add("evidence.judged", next(iter(reports), None),
{"verdicts": {"u1": {"verdict": "supported"}}, "model": "judge-x", "post_seal": True})
return rows, bound
def render(sample: Sample, events: list[tuple[int, str, str | None, Any]], bound: int, *, run: dict[str, Any] | None = None,
legacy: bool = False, drop: tuple[str, ...] = ()) -> str:
"""真实封存哈希的页面。drop:导出时服务端已没有文件的工件。"""
sealed = [e for e in events if e[0] <= bound]
files = {k: v for k, v in sample.files.items() if k not in drop}
bundle = build_bundle(run or RUN, events, manifest_seq=bound, manifest_hash=manifest_hash(sealed), read=files.get,
exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks={})
return render_html(bundle)
def pages() -> dict[str, str]:
"""{页面名: HTML}"""
out: dict[str, str] = {}
s = Sample()
weekly = s.doc(WEEKLY, "write")
events, bound = s.events({"write": weekly})
out["base"] = render(s, events, bound)
out["missing-doc"] = render(s, events, bound, drop=(weekly,))
out["missing-card"] = render(s, events, bound, drop=(s.card_id,))
# 早期运行:运行结束事件落库之前封存,范围到它前一条为止(runner.verify_manifest 的老口径)
finished = next(seq for seq, etype, _, _ in events if etype == "run.finished")
out["legacy"] = render(s, events, finished - 1, legacy=True)
monthly = s.doc(MONTHLY, "monthly")
events, bound = s.events({"write": weekly, "monthly": monthly})
out["two-reports"] = render(s, events, bound)
events, bound = s.events({"write": weekly}, ending="failed")
out["failed"] = render(s, events, bound, run={**RUN, "status": "failed"})
events, bound = s.events({"write": weekly}, tiers=("formal", "degraded"))
out["degraded"] = render(s, events, bound)
stray = s.doc(WEEKLY, "write", run_id="another-run-0000")
events, bound = s.events({"write": stray})
out["stray"] = render(s, events, bound)
n = Sample(nan=True)
events, bound = n.events({"write": n.doc(WEEKLY, "write")}, nan=True)
out["nan"] = render(n, events, bound)
return out
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("用法:python -m tests.bundle_samples <目录>", file=sys.stderr)
return 2
target = Path(argv[1])
target.mkdir(parents=True, exist_ok=True)
for name, page in pages().items():
(target / f"{name}.html").write_text(page, encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+37 -3
View File
@@ -5,7 +5,7 @@
- 只给已封存且封存核对一致的运行:没封存、封存后被改过都回 409;
- 导出的数据里,事件行按浏览器的拼法重算等于运行记下的 manifest_hash;
- 遮罩库的快照只有 id 没有内容,它的查询事件只有哈希;
- 遮罩库的快照只有 id 没有内容,它的查询事件只有哈希;运行之后才设的遮罩同样生效;
- 附件名是「<运行名>-可验证报告.html」,中文名按 RFC 5987 编码;
- 只读:导出前后运行记录、事件一条不多一条不少。
@@ -213,6 +213,39 @@ async def test_the_page_is_named_after_the_run(client, monkeypatch):
assert f"filename*=UTF-8''{quote('门店周报-可验证报告.html', safe='')}" in header
async def set_masks(name: str, columns: list[str]) -> None:
"""只改数据源的遮罩列(options.mask_columns),别的不动。"""
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one()
row.options = {**(row.options or {}), "mask_columns": columns}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
async def test_a_mask_set_after_the_run_still_applies(client, monkeypatch):
"""运行之后才给数据源设遮罩:快照里没记 mask_columns,导出时照样按数据源现在的遮罩挡(和证据面板一个道理)。"""
await set_masks("bundle_crm", [])
row = await sealed_run(monkeypatch)
shop = await query_artifact(row.id, "fetch")
def shown(bundle) -> dict[str, bool]:
events = {(e["type"], e["node_id"]): "canonical" in e for e in bundle["events"]}
return {"fetch": events[("tool.end", "fetch")], "members": events[("tool.end", "members")],
"out": events[("node.finished", "out")], "finished": events[("run.finished", None)]}
# 对照:查询时、导出时都没有遮罩,快照随文件附带,成果和运行结束事件带规范字符串
before = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
assert all("b64" in a for a in before["artifacts"])
assert shown(before) == {"fetch": True, "members": True, "out": True, "finished": True}
await set_masks("bundle_shop", ["amount"])
after = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
assert next(a for a in after["artifacts"] if a["id"] == shop) == {
"id": shop, "kind": "query", "node_id": "fetch", "masked": True}
assert shown(after) == {"fetch": False, "members": True, "out": False, "finished": False}
async def test_report_text_cannot_close_the_data_block(client, monkeypatch):
row = await sealed_run(monkeypatch, text="结论见下:</script><script>alert(1)</script> 流水 [[v:Q1.r0.amount]]。")
html = (await client.get(f"/api/runs/{row.id}/bundle.html")).text
@@ -289,8 +322,9 @@ async def test_a_broken_seal_is_refused(client, monkeypatch):
await session.commit()
resp = await client.get(f"/api/runs/{row.id}/bundle.html")
assert resp.status_code == 409
body = resp.json()
assert body["code"] == "bundle_seal_broken" and "封存核对不一致" in body["detail"], body
# 原样出现在记录页的错误提示里:一句说清,不叠冒号、不把封存核对的整句结论再拼进来
assert resp.json() == {"detail": "封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。"
"点「核对」可查看核对结果", "code": "bundle_seal_broken"}
async def test_an_unknown_run_is_404(client):
+149 -2
View File
@@ -5,10 +5,12 @@
- 每条封存范围内的事件都带 data_sha256,按浏览器的拼法(seq|type|node_id|data_sha256 按 seq 排、换行连接)
重算出来的清单哈希等于 runner 记下的那个;
- 只有白名单里的事件带 canonical(逐字等于 canonical_json(data)),其余只给哈希;运行里有带遮罩的快照时,
成果节点和运行结束事件的预览可能带出查询结果,也只给哈希;
成果节点和运行结束事件的预览可能带出查询结果,也只给哈希。认不出快照的查询(升级前的运行从工具快照里认,
协作团队成员的查询认不出)按带遮罩算,查询失败的原话不算;
- 报告文档、它引用到的口径卡和查询快照按原字节嵌入;带遮罩的快照只给 id;没被引用的工件不嵌;
- 封存之后追加的事件单列,不进清单;
- 页面零外链,数据里的 </script> 截不断数据块。
- 页面零外链,数据里的 </script> 截不断数据块;封存哈希、运行 ID、工作流名不执行脚本也写在页面上;
- scripts/check-bundle.mjs 在浏览器里打开的样本页(tests/bundle_samples.py)封存哈希是真算的、形状如预期。
"""
from __future__ import annotations
@@ -198,6 +200,75 @@ def test_an_unreadable_snapshot_counts_as_masked():
assert entry["missing"] is True and "b64" not in entry
# 没有遮罩的报告、口径卡:成果和运行结束事件本该带规范字符串,下面几种查询让它们只给哈希
PLAIN_DOC = {**doc_of(), "catalog": {k: v for k, v in doc_of()["catalog"].items() if k != "Q2"}}
PLAIN_DOC_ID, PLAIN_DOC_RAW = stored(PLAIN_DOC)
TOOL_SNAP_CRM = {"tool": "db_query__crm", "args": {"sql": SNAP_MASKED["sql"]},
"result": json.dumps({**{k: v for k, v in SNAP_MASKED.items() if k != "mask_columns"},
"artifact": MASKED_ID}, ensure_ascii=False)}
TOOL_SNAP_CRM_ID, TOOL_SNAP_CRM_RAW = stored(TOOL_SNAP_CRM)
def plain_events(members_end):
"""报告只引用门店流水(没有遮罩)的运行;第 6 条是会员库那次查询,形状由调用方给。"""
events = []
for seq, etype, node_id, data in sample_events(PLAIN_DOC_ID):
if seq == 6:
data = members_end
events.append((seq, etype, node_id, data))
return events
def shown_seqs(bundle) -> set[int]:
return {e["seq"] for e in bundle["events"] if "canonical" in e}
def test_a_run_without_masked_queries_shows_its_output():
"""对照组:会员库那次查询有快照、没有遮罩,成果节点(14)和运行结束事件(15)带规范字符串。"""
unmasked = {k: v for k, v in SNAP_MASKED.items() if k != "mask_columns"}
plain_id, plain_raw = stored(unmasked)
events = plain_events({"tool": "db_query__crm", "preview": "[[\"林一\"]]", "query_artifact": plain_id})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW, plain_id: plain_raw})
assert {14, 15} <= shown_seqs(bundle)
def test_a_pre_ledger_query_is_traced_through_its_tool_snapshot():
"""升级前的运行:tool.end 只有工具快照(artifact),查询快照的 id 嵌在工具快照的返回里。照证据接口
(_legacy_pool)的办法从工具快照认出查询快照:它带遮罩,成果和运行结束事件只给哈希。"""
events = plain_events({"tool": "db_query__crm", "preview": "{\"rows\": [[\"林一\", \"000-0001\"…",
"artifact": TOOL_SNAP_CRM_ID})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW, TOOL_SNAP_CRM_ID: TOOL_SNAP_CRM_RAW})
assert not {6, 14, 15} & shown_seqs(bundle)
assert "000-0001" not in json.dumps(bundle, ensure_ascii=False)
def test_a_query_that_cannot_be_traced_counts_as_masked():
"""协作团队成员的查询:tool.end 既没有查询快照也没有工具快照,预览是截断的返回,认不出查的是什么。
宁可多挡:按带遮罩算,成果和运行结束事件只给哈希。"""
events = plain_events({"tool": "db_query__crm", "agent": "分析员", "call_id": "c1",
"preview": "{\"columns\": [\"name\", \"phone\"], \"rows\": [[\"林一\", \"000-0001\"]"})
events[13] = (14, "node.finished", "out", {"preview": {"明细": "林一 000-0001"}})
events[14] = (15, "run.finished", None, {"output": {"明细": "林一 000-0001"}})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW})
assert not {6, 14, 15} & shown_seqs(bundle)
assert "000-0001" not in json.dumps(bundle, ensure_ascii=False)
def test_a_failed_query_returned_no_data():
"""查询失败、SQL 被拒:返回的是一句原话,没有数据,不因此挡住成果。"""
events = plain_events({"tool": "db_query__crm", "preview": "查询失败:no such table: members_x",
"call_id": "c2"})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW})
assert {14, 15} <= shown_seqs(bundle)
def test_a_source_masked_now_hides_queries_without_a_snapshot():
"""数据源现在设了遮罩:不必认出快照,按工具名就知道这次查询可能带着遮罩列。"""
events = plain_events({"tool": "db_query__crm", "preview": "查询失败:timeout"})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW}, source_masks={"crm": ["phone"]})
assert not {14, 15} & shown_seqs(bundle)
# --------------------------------------------------------------------------
# 工件:只嵌报告文档和它引用到的口径卡、查询快照
# --------------------------------------------------------------------------
@@ -343,9 +414,85 @@ def test_the_verifier_script_is_inline_and_cannot_close_early():
html = render_html(build())
script = re.search(r"<script>(.*?)</script>", html, re.S).group(1)
assert "</script" not in script.lower() and "<!--" not in script
# 脚本在 <head> 里、排在数据块前面:它的注释里也不能出现「<script」,按标签找数据块的人才不会先找到它
assert "<script" not in script.lower()
assert html.index("<script>") < html.index('<script type="application/json" id="bundle-data">')
assert "bundle-data" in script and "crypto.subtle" in script.replace("window.", "")
def text_of(html: str, element_id: str) -> str:
m = re.search(rf'<(\w+)[^>]*\sid="{element_id}"[^>]*>(.*?)</\1>', html, re.S)
assert m, f"页面里没有 #{element_id}"
return re.sub(r"<[^>]+>", "", m.group(2))
def test_the_seal_and_run_are_written_without_script():
"""附件预览、快速查看常常不执行脚本:封存哈希、运行 ID、工作流名直接写在页面上,状态说清还没核对。"""
nasty = {**RUN, "id": "r1<b>", "workflow_name": "门店<i>周报</i>"}
bundle = build_bundle(nasty, sample_events(), manifest_seq=15, manifest_hash="ab" * 32, graph=GRAPH,
read=STORE.get, exported_at="2026-10-05T08:00:00+00:00")
html = render_html(bundle)
assert text_of(html, "seal-hash") == "ab" * 32
assert "r1&lt;b&gt;" in text_of(html, "seal-note") and "<b>" not in text_of(html, "seal-note")
assert text_of(html, "title") == "门店&lt;i&gt;周报&lt;/i&gt;"
# 没执行脚本时总览写的是「尚未核对」,不是一直转着的「正在核对」(那句只在脚本开始执行后才显示)
assert re.search(r'class="no-js">尚未核对:[^<]*需要在浏览器中打开本文件', html)
assert re.search(r'class="js-only">正在核对', html)
# 成功结束的运行没有「继续运行会重新封存」那一句;失败的有
assert re.search(r'id="seal-failed"[^>]*\shidden', html)
failed = render_html(build_bundle({**RUN, "status": "failed"}, sample_events(), manifest_seq=15,
manifest_hash="ab" * 32, graph=GRAPH, read=STORE.get, exported_at="x"))
assert not re.search(r'id="seal-failed"[^>]*\shidden', failed)
assert "重新封存" in text_of(failed, "seal-failed")
@pytest.mark.parametrize("legacy", [True, False])
def test_legacy_seals_are_flagged(legacy):
assert build(legacy=legacy)["meta"]["legacy"] is legacy
# --------------------------------------------------------------------------
# 浏览器检查用的样本页(tests/bundle_samples.py)
# --------------------------------------------------------------------------
def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
"""scripts/check-bundle.mjs 断网打开这些样本页,断言核对结果、再做篡改。pytest 不执行页面上的脚本,这里守住样本
本身:封存哈希是对事件真算的(浏览器的拼法重算得出),各页的形状是 check-bundle 的断言预期的那样。"""
from tests.bundle_samples import pages
got = {name: json.loads(data_block(page)) for name, page in pages().items()}
assert set(got) == {"base", "two-reports", "legacy", "failed", "degraded", "missing-doc", "missing-card", "nan",
"stray"}
for name, bundle in got.items():
assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name
def segments(bundle, node_id="write"):
doc_id = next(r["doc_artifact"] for r in bundle["reports"] if r["node_id"] == node_id)
doc = json.loads(base64.b64decode(next(a["b64"] for a in bundle["artifacts"] if a["id"] == doc_id)))
return [s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s["kind"] in ("number", "value")]
base = got["base"]
shapes = {(a["kind"], a.get("node_id")): {k for k in a if k in ("b64", "masked", "missing")} for a in base["artifacts"]}
assert shapes == {("report_doc", "write"): {"b64"}, ("metric_set", "card"): {"b64"}, ("query", "fetch"): {"b64"},
("query", "members"): {"masked"}}
cited = segments(base)
# 数字与取值超过折叠的条数(20);没有出处的一个、引用遮罩快照的一个
assert len(cited) == 21
assert sum(1 for s in cited if not s.get("cite")) == 1 and sum(1 for s in cited if s["kind"] == "value") == 8
assert [a["type"] for a in base["appended"]] == ["evidence.judged"]
assert base["meta"]["tier"]["value"] == "formal" and base["meta"]["run_class"] == "formal"
assert [(a["kind"], a.get("missing")) for a in got["missing-doc"]["artifacts"]] == [("report_doc", True)]
assert next(a for a in got["missing-card"]["artifacts"] if a["kind"] == "metric_set").get("missing") is True
assert got["legacy"]["meta"]["legacy"] is True and "run.finished" in [a["type"] for a in got["legacy"]["appended"]]
assert [r["node_id"] for r in got["two-reports"]["reports"]] == ["write", "monthly"] and segments(got["two-reports"], "monthly")
failed = got["failed"]
assert failed["meta"]["status"] == "failed" and failed["meta"]["tier"] is None
assert max(failed["events"], key=lambda e: e["seq"])["type"] == "run.failed"
issuances = [e["seq"] for e in got["degraded"]["events"] if e["type"] == "issuance"]
assert len(issuances) == 2 and got["degraded"]["meta"]["tier"] == {"value": "degraded", "seq": issuances[-1]}
assert [a["kind"] for a in got["stray"]["artifacts"]] == ["report_doc"]
nan = got["nan"]
assert "NaN" in next(e["canonical"] for e in nan["events"] if e["type"] == "run.started")
assert b"NaN" in base64.b64decode(next(a["b64"] for a in nan["artifacts"] if a.get("node_id") == "fetch"))
+4 -1
View File
@@ -10,7 +10,9 @@ import type { Run } from '../../types'
* - bundle-provenance-sealed:已封存,可以导出
* - bundle-provenance-running / -waiting:还在运行、等待审批,按钮不可用并说明原因
* - bundle-provenance-unsealed:结束了却没有封存清单(加封存之前的老运行)
* - bundle-provenance-again:失败后接着跑,留着上一次的封存,正在运行:同样不可用(跑完会换掉清单哈希)
* - bundle-provenance-again:失败后接着跑,留着上一次的封存,正在运行:同样不可用(跑完会换掉清单哈希),左边写着
* 「已封存」,原因写在屏幕上
* - bundle-provenance-failed:失败结束、已封存:可以导出,提示里说明之后继续运行会重新封存
*/
const SEALED: Run = {
id: 'f4b0f4b0f4b0f4b0f4b0f4b0f4b0f4b0',
@@ -50,4 +52,5 @@ export const cases: Record<string, () => JSX.Element> = {
'bundle-provenance-waiting': () => <Frame run={unsealed('interrupted')} phase="waiting" />,
'bundle-provenance-unsealed': () => <Frame run={{ ...unsealed('succeeded'), finished_at: SEALED.finished_at }} phase="succeeded" />,
'bundle-provenance-again': () => <Frame run={{ ...SEALED, status: 'running', finished_at: null }} phase="running" />,
'bundle-provenance-failed': () => <Frame run={{ ...SEALED, status: 'failed' }} phase="failed" />,
}
+4 -1
View File
@@ -2756,11 +2756,14 @@ export const BUNDLE_TEXT = {
title: '可验证报告',
export: '导出可验证报告',
hint: '导出为单个 HTML 文件:收件人不连服务,也能在浏览器中核对数字的出处和运行记录',
/** 不能导出时按钮旁说明的原因:和凭证条左边「未封存 · …」的说法对得上 */
/** 失败结束的运行:之后继续运行会重新封存,导出的文件就再也对不上系统里的封存了 */
hintFailed: '导出为单个 HTML 文件。这次运行以失败结束:之后若继续运行,系统会重新封存,导出的文件将无法再与系统对照',
/** 不能导出时说明的原因:和凭证条左边「未封存 · …」的说法对得上 */
blockedRunning: '运行结束并封存后才能导出',
blockedWaiting: '等待审批中,运行结束并封存后才能导出',
blockedUnsealed: '这次运行没有封存清单,无法导出',
exported: (name: string) => `已导出「${name}」`,
exportedFailed: (name: string) => `已导出「${name}」。之后若继续运行,系统会重新封存,这份文件将无法再与系统对照`,
/** 服务端没给文件名时按运行名补,规则同服务端的附件名 */
fileName: (name: string) => `${name}-可验证报告.html`,
}
+12 -3
View File
@@ -291,13 +291,16 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
: !finished ? BUNDLE_TEXT.blockedRunning
: BUNDLE_TEXT.blockedUnsealed
// 失败结束的运行可以「继续运行」:接着跑完会重新封存、换掉清单哈希,导出的文件从此对不上系统,导出前后都说一句
const failedRun = phase === 'failed'
const doExport = async () => {
setExporting(true)
try {
const { blob, name } = await api.bundle.download(run.id)
const file = name ?? BUNDLE_TEXT.fileName(runName(run))
saveFile(file, blob)
toast.ok(BUNDLE_TEXT.exported(file))
toast.ok(failedRun ? BUNDLE_TEXT.exportedFailed(file) : BUNDLE_TEXT.exported(file))
} catch (e) {
toast.error(e, { key: `bundle:${run.id}` })
} finally {
@@ -346,14 +349,20 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
</button>
)}
{/* 禁用的按钮收不到指针事件,悬停提示挂在外面那层上;读屏经 aria-describedby 读到原因 */}
<span className="inline-flex" title={exportBlocked ?? BUNDLE_TEXT.hint}>
<span className="inline-flex" title={exportBlocked ?? (failedRun ? BUNDLE_TEXT.hintFailed : BUNDLE_TEXT.hint)}>
<button type="button" className="btn btn-xs btn-ghost" disabled={!!exportBlocked || exporting}
onClick={() => void doExport()} aria-describedby={exportBlocked ? blockedId : undefined}
data-bundle-export={exportBlocked ? 'blocked' : exporting ? 'busy' : 'ready'}>
{exporting ? <Spinner size={10} /> : <Download size={11} aria-hidden />} {BUNDLE_TEXT.export}
</button>
</span>
{exportBlocked && <span id={blockedId} className="sr-only" data-bundle-blocked="">{exportBlocked}</span>}
{/* 没封存时左边写着「未封存 · …」,原因只给读屏和悬停;留着旧封存又在运行(失败后接着跑)时左边写着
「已封存」,按钮却不能点,原因得写在屏幕上,触屏上看不到悬停提示 */}
{exportBlocked && (
<span id={blockedId} className={sealed ? 'text-faint' : 'sr-only'} data-bundle-blocked={sealed ? 'shown' : ''}>
{exportBlocked}
</span>
)}
<button
type="button"
className="btn btn-xs btn-ghost"
+1 -1
View File
@@ -96,7 +96,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 10, whatif: 1,
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 1,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+421 -88
View File
@@ -1,32 +1,42 @@
// 可验证报告的检查:真服务端导出、断网打开核对、篡改变红,外加记录页上的导出按钮。
// 可验证报告的检查:真服务端导出、断网打开核对、篡改变红;虚构样本页上的各种形状;外加记录页上的导出按钮。
//
// 跑之前前后端都得起着(check-all 另起的那套,或者 ./scripts/dev.sh)。默认连 5273 / 8000;对别的实例跑时带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-bundle.mjs
// 样本页用后端解释器生成(CHECK_PYTHON,默认 ~/miniforge3/envs/agentlab/bin/python),从这份工作区的 backend 导入。
//
// 分几段:
// - 导出:从服务端找一份已封存、封存核对一致、有报告文档的运行(GET /api/runs 找成功结束的,再看 /verify 和证据接口),
// 下载 bundle.html:状态码、类型、附件名(RFC 5987)、页面本身零外链。找不到可导出的运行时照实报失败并说明原因;
// - 断网打开:拦掉一切网络请求,从本地文件打开:五项核对全部通过、总览通过、没有发出任何请求,数字点开看得到出处和核对结果;
// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过、没有发出任何请求,数字点开看得到出处;
// - 去掉 crypto.subtle:页面加载前删掉它,照常全部通过,页脚写明用的是本页内置的实现;
// - 篡改:① 一条带规范字符串的事件内容、② 一份嵌入工件的一个字节、③ 元信息里的封存哈希,各自对应的核对项不通过、
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单的哈希都重算一遍,
// ①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过(这也是页面要人去系统里对照封存哈希的原因);
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单、页面顶部的封存哈希
// 都重算一遍,①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过,报告标题旁的徽标跟着不通过;
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、两条出具
// 判定、服务端缺文件、裸 NaN、别的运行的文档),逐份断言各项结果和原因文案;在上面篡改 ④ 引用链(换文档、拿掉报告、
// 多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把口径卡标成遮罩、只改页面顶部
// 的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」;
// - 记录页的导出按钮(预览用例 ?syn=bundle-provenance-*,page.route 假装导出接口):已封存时可点、下载的文件名同服务端;
// 未封存时不可点并说明原因;服务端回 409 时提示原因。
// 未封存时不可点并说明原因(左边写着「已封存」却不能点时原因写在屏幕上);服务端回 409 时提示原因;失败结束的运行
// 提示之后继续运行会重新封存。
//
// 读的是真数据:输出里不打印工作流名、报告内容和数字,只打印计数和运行号前 8 位。下载的文件写在临时目录,跑完删掉。
// 导出接口只读(不追加事件、不改运行),页面检查里的写接口一律是假的。
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const BACKEND = join(dirname(dirname(fileURLToPath(import.meta.url))), 'backend')
const PYTHON = [process.env.CHECK_PYTHON, join(homedir(), 'miniforge3/envs/agentlab/bin/python')]
.find((p) => p && existsSync(p))
let failed = 0
const check = (name, cond, detail = '') => {
@@ -59,11 +69,15 @@ async function section(name, fn) {
// ---------------------------------------------------------------- 页面数据的读写(篡改用)
const DATA_RE = /(<script type="application\/json" id="bundle-data">)([\s\S]*?)(<\/script>)/
const SEAL_RE = /(<code id="seal-hash" class="hash">)([^<]*)(<\/code>)/
const dataOf = (html) => JSON.parse(DATA_RE.exec(html)[2])
// 和服务端 bundle_json 一样转义 < > & 和 U+2028 / U+2029,改过的数据照样截不断数据块
const escapeJson = (obj) => JSON.stringify(obj)
.replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
const withData = (html, obj) => html.replace(DATA_RE, (_, open, __, close) => open + escapeJson(obj) + close)
/** 只改页面顶部静态写出的封存哈希(不执行脚本也看得到的那一份) */
const withSeal = (html, hash) => html.replace(SEAL_RE, (_, open, __, close) => open + hash + close)
const sha = (data) => createHash('sha256').update(data).digest('hex')
function writePage(name, html) {
const file = join(TMP, name)
@@ -102,18 +116,81 @@ const stateOf = (page) => page.evaluate(() => ({
overall: document.getElementById('overall')?.getAttribute('data-overall'),
text: document.getElementById('overall-text')?.textContent ?? '',
checks: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check, c.dataset.result])),
sums: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check,
c.querySelector('.check-sum')?.textContent ?? ''])),
failsIn: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check,
new Set([...c.querySelectorAll('li[data-result="fail"]')].map((li) => li.textContent)).size])),
// 每一条核对:在哪一块、结果、文字(含原因)、是不是收在「展开全部」里
items: [...document.querySelectorAll('[data-check] li[data-result]')].map((li) => ({
check: li.closest('[data-check]').dataset.check, result: li.dataset.result, text: li.textContent,
folded: !!li.closest('details'),
})),
reports: [...document.querySelectorAll('article.report')].map((a) => ({
node: a.dataset.report, result: a.dataset.result, parts: a.querySelector('[data-report-parts]')?.textContent ?? '',
warn: a.querySelector('.warn')?.textContent ?? '', why: a.querySelector('.warn')?.dataset.why ?? '',
})),
meta: Object.fromEntries([...document.querySelectorAll('[data-meta]')].map((m) => [m.dataset.meta,
{ result: m.dataset.result, text: m.textContent }])),
numbers: document.querySelectorAll('button.num').length,
numberStates: [...document.querySelectorAll('button.num')].reduce((acc, b) => {
acc[b.dataset.result] = (acc[b.dataset.result] ?? 0) + 1
return acc
}, {}),
sha: document.querySelector('[data-sha]')?.dataset.sha ?? null,
seal: document.getElementById('seal-hash')?.textContent ?? '',
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}))
const describe = (s) => Object.entries(s.checks).map(([k, v]) => `${k}=${v}`).join(' ')
/** 有通过、没有不通过:「部分无法核对」也算(遮罩快照、没有出处的数字是正常的无法核对) */
const noFail = (r) => r === 'pass' || r === 'partial'
const hasItem = (s, check, result, text) => s.items.some((i) => i.check === check && i.result === result
&& (!text || i.text.includes(text)))
/**
* 「哈希全部重算」的伪造:改报告里一个能核对的数字的引用值,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希
* 全部重算。返回改了哪一处的说明,找不到可改的数字返回 null
*/
function forge(data) {
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
if (!docArt) return null
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
let hit = null
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
const c = seg.cite
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
}
if (!hit) return null
hit.cite.value += 1
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
const oldId = docArt.id
const newId = sha(bytes)
docArt.id = newId
docArt.b64 = bytes.toString('base64')
for (const r of data.reports) if (r.doc_artifact === oldId) r.doc_artifact = newId
for (const ev of data.events) {
if (typeof ev.canonical === 'string' && ev.canonical.includes(oldId)) {
ev.canonical = ev.canonical.split(oldId).join(newId)
ev.data_sha256 = sha(ev.canonical)
}
}
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
data.meta.manifest_hash = sha(lines.join('\n'))
return { what: `片段 ${hit.id}(${hit.cite.kind})`, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id }
}
/** 改一份嵌入工件的一个字节(优先改数字),返回说明 */
function flipByte(art) {
const bytes = Buffer.from(art.b64, 'base64')
const at = bytes.findIndex((b) => b >= 0x30 && b <= 0x39)
const index = at >= 0 ? at : bytes.length - 2
bytes[index] = at >= 0 ? (bytes[index] === 0x39 ? 0x30 : bytes[index] + 1) : bytes[index] ^ 0x01
art.b64 = bytes.toString('base64')
return `${art.kind} ${art.id.slice(0, 8)} 的第 ${index} 字节`
}
// ---------------------------------------------------------------- 导出
@@ -188,6 +265,7 @@ await section('导出', async () => {
&& !/<script[^>]*\ssrc\s*=|<link\b|<img\b|@import|url\s*\(/i.test(shell))
check('内容安全策略默认不许连任何地方', /default-src 'none'/.test(shell))
check('数据块里没有裸的 < > &(截不断)', !/[<>&]/.test(DATA_RE.exec(html)?.[2] ?? '<'))
check('封存哈希直接写在页面上(不执行脚本也看得到)', SEAL_RE.exec(html)?.[2] === run.manifest_hash)
const after = await getJson(`/runs/${run.id}/events`).catch(() => null)
check('导出不追加事件', !!before && !!after && before.length === after.length, before && after ? `${before.length} → ${after.length} 条` : '')
// 没封存的,或者还没结束的(失败后接着跑、早年停在审批时封存过的运行留着旧的封存):都该回 409
@@ -221,30 +299,33 @@ await section('断网打开', async () => {
try {
const s = await stateOf(page)
check('总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
for (const key of ['events', 'manifest', 'artifacts', 'chain']) {
check(`核对项「${key}」通过`, s.checks[key] === 'pass', s.checks[key])
}
check('数字核对没有不通过的', s.checks.numbers === 'pass' || s.checks.numbers === 'na',
for (const key of ['events', 'manifest', 'chain']) check(`核对项「${key}」通过`, s.checks[key] === 'pass', s.checks[key])
// 遮罩快照、服务端已没有文件的工件是正常的无法核对;没有出处的数字同理
check('核对项「artifacts」没有不通过', noFail(s.checks.artifacts), s.checks.artifacts)
check('数字与取值没有不通过的', noFail(s.checks.numbers) || s.checks.numbers === 'na',
`${s.checks.numbers}:${JSON.stringify(s.numberStates)}`)
// 早期运行的运行结果、升级前的运行类别核对不了(无法核对),只要求没有对不上的
check('运行信息没有与已核对的事件对不上的', s.checks.meta !== 'fail', `${s.checks.meta}:${JSON.stringify(s.meta)}`)
check('没有发出任何网络请求', requests.length === 0, requests.slice(0, 3).join('、'))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
check('用的是浏览器内置的 SHA-256', s.sha === 'subtle', String(s.sha))
check('封存哈希醒目地写在页面上', await page.locator('#seal-hash').innerText() === tamperable.meta.manifest_hash)
check('封存哈希醒目地写在页面上', s.seal === tamperable.meta.manifest_hash)
// 页面列出的清单原文:拿出去用别的工具(这里是 node 的 crypto)算,等于封存哈希——读的人可以不信页面上的脚本
const lines = await page.locator('[data-manifest-lines]').textContent()
check('页面列出的清单原文在页面之外重算,等于封存哈希',
createHash('sha256').update(lines ?? '', 'utf8').digest('hex') === tamperable.meta.manifest_hash)
check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(await page.locator('#seal-note').innerText())
&& /\/api\/runs\/[^/]+\/verify/.test(await page.locator('#seal-note').innerText()))
check('页面列出的清单原文在页面之外重算,等于封存哈希', sha(lines ?? '') === tamperable.meta.manifest_hash)
const sealNote = await page.locator('#seal-note').innerText()
check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(sealNote) && /\/api\/runs\/[^/]+\/verify/.test(sealNote))
check('页面写明哪几项取自运行记录、不在封存范围内', /取自运行记录/.test(await page.locator('#record-note').innerText()))
if (s.numbers) {
const first = page.locator('button.num[data-result="pass"]').first()
const first = page.locator('button.num[data-kind="number"][data-result="pass"]').first()
const target = (await first.count()) ? first : page.locator('button.num').first()
const key = await target.getAttribute('data-num')
await target.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const text = await page.locator('#detail').innerText()
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text) && /显示格式由服务端渲染/.test(text)
&& await page.locator('#detail').getAttribute('data-open') === key)
const cited = await target.getAttribute('data-kind') === 'number'
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text)
&& (!cited || /显示格式由服务端渲染/.test(text)) && await page.locator('#detail').getAttribute('data-open') === key)
await page.keyboard.press('Escape')
check('Esc 关掉出处面板', await page.locator('#detail').isHidden())
} else {
@@ -270,86 +351,63 @@ await section('去掉 crypto.subtle', async () => {
// ---------------------------------------------------------------- 篡改
async function tampered(label, mutate, expectFail, keepPass = []) {
const data = structuredClone(tamperable)
/**
* 在一份页面上篡改后断网打开。mutate(data) 改数据、返回说明(找不到可改的返回 null);expect(state) 返回
* [[检查名, 条件, 说明]…]。seal:同时把页面顶部的封存哈希换成改过的数据里的那一份(重算全部哈希的伪造会这么做)
*/
async function tamper(source, label, mutate, expect, { seal = false } = {}) {
const data = structuredClone(dataOf(source))
const what = mutate(data)
if (!what) {
check(`篡改:${label}`, false, '这份导出里找不到可以篡改的对象')
check(`篡改:${label}`, false, '这份页面里找不到可以篡改的对象')
return
}
const file = writePage(`tampered-${expectFail}.html`, withData(html, data))
const { ctx, page } = await openFile(file)
let page = withData(source, data)
if (seal) page = withSeal(page, data.meta.manifest_hash)
const opened = await openFile(writePage(`tampered-${label.replace(/[^\w一-鿿]+/g, '_')}.html`, page))
try {
const s = await stateOf(page)
check(`篡改${label}:「${expectFail}」不通过、总览不通过`, s.checks[expectFail] === 'fail' && s.overall === 'fail'
&& s.failsIn[expectFail] >= 1, `${what};${describe(s)}`)
for (const key of keepPass) check(`篡改${label}:「${key}」仍通过`, s.checks[key] === 'pass', s.checks[key])
const s = await stateOf(opened.page)
for (const [name, cond, detail] of expect(s, what)) check(`篡改${label}:${name}`, cond, detail ?? `${typeof what === 'string' ? what : what.what};${describe(s)}`)
} finally {
await ctx.close()
await opened.ctx.close()
}
}
/** 一项变红、总览不通过,另几项仍通过 */
const turnsRed = (key, keepPass = []) => (s) => [
[`「${key}」不通过、总览不通过`, s.checks[key] === 'fail' && s.overall === 'fail' && s.failsIn[key] >= 1],
...keepPass.map((k) => [`「${k}」仍没有不通过`, noFail(s.checks[k]), s.checks[k]]),
]
await section('篡改', async () => {
if (skipped() || !tamperable) return
// ① 改一条带规范字符串的事件内容(不动它记的哈希):只有 ① 变红,清单照样对得上
await tampered('一条事件的内容', (data) => {
await tamper(html, '一条事件的内容', (data) => {
const ev = data.events.find((e) => e.type === 'run.started' && typeof e.canonical === 'string')
?? data.events.find((e) => typeof e.canonical === 'string')
if (!ev) return null
ev.canonical = /\d/.test(ev.canonical) ? ev.canonical.replace(/\d/, (d) => String((Number(d) + 1) % 10)) : `${ev.canonical} `
return `第 ${ev.seq} 条(${ev.type})`
}, 'events', ['manifest'])
}, turnsRed('events', ['manifest']))
// ② 改一份嵌入工件的一个字节:优先口径卡、快照,没有就改报告文档
await tampered('一份工件的一个字节', (data) => {
await tamper(html, '一份工件的一个字节', (data) => {
const embedded = data.artifacts.filter((a) => typeof a.b64 === 'string')
const art = embedded.find((a) => a.kind !== 'report_doc') ?? embedded[0]
if (!art) return null
const bytes = Buffer.from(art.b64, 'base64')
const at = bytes.findIndex((b) => b >= 0x30 && b <= 0x39)
const index = at >= 0 ? at : bytes.length - 2
bytes[index] = at >= 0 ? (bytes[index] === 0x39 ? 0x30 : bytes[index] + 1) : bytes[index] ^ 0x01
art.b64 = bytes.toString('base64')
return `${art.kind} ${art.id.slice(0, 8)} 的第 ${index} 字节`
}, 'artifacts', ['events', 'manifest'])
// 伪造:改报告里一个数字的引用值,再把文档、引用它的事件、清单的哈希全部重算
await tampered('一个数字的引用值并重算全部哈希', (data) => {
const sha = (text) => createHash('sha256').update(text).digest('hex')
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
if (!docArt) return null
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
let hit = null
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
const c = seg.cite
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
}
if (!hit) return null
hit.cite.value += 1
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
const oldId = docArt.id
const newId = sha(bytes)
docArt.id = newId
docArt.b64 = bytes.toString('base64')
for (const r of data.reports) if (r.doc_artifact === oldId) r.doc_artifact = newId
for (const ev of data.events) {
if (typeof ev.canonical === 'string' && ev.canonical.includes(oldId)) {
ev.canonical = ev.canonical.split(oldId).join(newId)
ev.data_sha256 = sha(ev.canonical)
}
}
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
data.meta.manifest_hash = sha(lines.join('\n'))
return `片段 ${hit.id}(${hit.cite.kind})`
}, 'numbers', ['events', 'manifest', 'artifacts', 'chain'])
return art ? flipByte(art) : null
}, turnsRed('artifacts', ['events', 'manifest']))
// 伪造:改报告里一个数字的引用值,再把文档、引用它的事件、清单、页面顶部的封存哈希全部重算
await tamper(html, '一个数字的引用值并重算全部哈希', forge, (s, forged) => [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
['那份报告标题旁的徽标跟着不通过', s.reports.find((r) => r.node === forged.node)?.result === 'fail',
JSON.stringify(s.reports.map((r) => [r.node, r.result]))],
], { seal: true })
// ③ 改元信息里的封存哈希
await tampered('元信息里的封存哈希', (data) => {
await tamper(html, '元信息里的封存哈希', (data) => {
const h = data.meta.manifest_hash
if (typeof h !== 'string' || !h) return null
data.meta.manifest_hash = h.slice(0, -1) + (h.endsWith('0') ? '1' : '0')
return '最后一位'
}, 'manifest', ['events', 'artifacts'])
}, turnsRed('manifest', ['events', 'artifacts']))
})
// ---------------------------------------------------------------- 深浅色、窄屏
@@ -391,9 +449,263 @@ await section('深浅色与窄屏', async () => {
}
})
// ---------------------------------------------------------------- 样本页
/** tests/bundle_samples.py 生成的页面:{名字: HTML}。生成不了时抛出,原因写清 */
function samplePages() {
if (!PYTHON) throw new Error('找不到后端解释器,用 CHECK_PYTHON 指定')
const out = join(TMP, 'samples')
const r = spawnSync(PYTHON, ['-m', 'tests.bundle_samples', out], {
cwd: BACKEND, encoding: 'utf8', timeout: 120_000,
// 从这份工作区的 backend 导入;导入时会建数据目录,指到临时目录里
env: { ...process.env, PYTHONPATH: BACKEND, AGENTLAB_DATA_DIR: join(TMP, 'py-data'), AGENTLAB_SECRET_KEY: 'check-bundle-samples' },
})
if (r.status !== 0) throw new Error(`生成样本页失败:${(r.stderr || String(r.error ?? '')).trim().split('\n').pop()}`)
return Object.fromEntries(readdirSync(out).filter((f) => f.endsWith('.html'))
.map((f) => [f.replace(/\.html$/, ''), readFileSync(join(out, f), 'utf8')]))
}
let samples = null
/** 断网打开一份样本页,交给 fn(state, page) 断言 */
async function sample(name, fn, opts = {}) {
const { ctx, page, requests, errors } = await openFile(writePage(`sample-${name}.html`, samples[name]), opts)
try {
const s = await stateOf(page)
if (requests.length || errors.length) check(`${name}:没有发出请求、没有报错`, false, [...requests, ...errors].slice(0, 2).join(' | '))
await fn(s, page)
} finally {
await ctx.close()
}
}
await section('样本页:各种形状', async () => {
try {
samples = samplePages()
} catch (e) {
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
return
}
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 9, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
`${s.overall}:${s.text}`)
check('base:①②④⑥ 通过,③⑤ 部分无法核对', ['events', 'manifest', 'chain', 'meta'].every((k) => s.checks[k] === 'pass')
&& s.checks.artifacts === 'partial' && s.checks.numbers === 'partial', describe(s))
check('base:遮罩快照在 ③ 无法核对,写明「含遮罩列,未随文件附带,只能核对到哈希」',
hasItem(s, 'artifacts', 'na', '含遮罩列,未随文件附带,只能核对到哈希'))
check('base:引用遮罩快照的数字无法核对,原因同上', hasItem(s, 'numbers', 'na', '含遮罩列,未随文件附带,只能核对到哈希'))
check('base:没有出处的数字写明未使用引用标记', hasItem(s, 'numbers', 'na', '该数字未使用引用标记,无法核对其来源'))
const outside = s.items.filter((i) => i.check === 'numbers' && !i.folded)
check('base:⑤ 超过 20 项收起来时,无法核对的列在外面', outside.length === 2 && outside.every((i) => i.result === 'na')
&& s.items.some((i) => i.check === 'numbers' && i.folded), `外面 ${outside.length} 条`)
check('base:⑤ 分开数数字和取值,写明分项', s.sums.numbers.includes('数字 13 个 · 取值 8 个') && s.sums.numbers.includes('无法核对 2'),
s.sums.numbers)
check('base:报告徽标连同数字一起算(部分无法核对),旁边写分项', s.reports[0]?.result === 'partial'
&& s.reports[0].parts.includes('文档 通过') && s.reports[0].parts.includes('无法核对 2'), JSON.stringify(s.reports[0]))
check('base:运行结果、类别、出具档位标着已核对', ['status', 'class', 'tier'].every((k) => s.meta[k]?.result === 'pass'),
JSON.stringify(s.meta))
check('base:封存之后追加的事件单列,标明不在封存范围内',
(await page.locator('[data-appended]').allTextContents()).some((t) => t.includes('封存之后追加,不在封存范围内')))
check('base:斜体渲染成斜体', (await page.locator('.doc em').allTextContents()).includes('按发生周统计'))
const cell = page.locator('td.n button.num')
check('base:表格里数字列右对齐', await cell.count() === 8)
// 「显示格式由服务端渲染」只附在带出处的数字上:模型自己写的数、表格里的文字取值都是原文
const detailOf = async (locator) => {
await locator.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const text = await page.locator('#detail').innerText()
await page.keyboard.press('Escape')
return text
}
const cellNumber = page.locator('td.n button.num[data-kind="number"]').first()
const value = page.locator('button.num[data-kind="value"]').first()
const bare = page.locator('button.num[data-kind="number"][data-result="na"]').filter({ hasText: /^42$/ })
check('base:带出处的数字注明显示格式由服务端渲染', /显示格式由服务端渲染/.test(await detailOf(cellNumber)))
check('base:文字取值、没有出处的数字不注明显示格式', !/显示格式由服务端渲染/.test(await detailOf(value))
&& !/显示格式由服务端渲染/.test(await detailOf(bare)))
check('base:读屏把文字取值读作「取值」、数字读作「数字」', (await value.getAttribute('aria-label')).startsWith('取值 ')
&& (await cellNumber.getAttribute('aria-label')).startsWith('数字 '))
})
// 宽屏:出处面板停在右侧,正文让出这一栏。点开表格里右对齐的数字,它和同列的数字都不被面板盖住
for (const width of [1280, 1440]) {
await sample('base', async (s, page) => {
const cells = page.locator('td.n button.num')
await cells.nth(2).click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const covered = await cells.nth(2).evaluate((el) => {
const r = el.getBoundingClientRect()
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
return !!at && !!at.closest('#detail')
})
const panel = await page.locator('#detail').boundingBox()
const spot = await cells.nth(2).boundingBox()
check(`${width} 宽:点开的数字不被出处面板盖住`, !covered && !!panel && !!spot && spot.x + spot.width <= panel.x,
spot && panel ? `数字 ${Math.round(spot.x)}–${Math.round(spot.x + spot.width)},面板从 ${Math.round(panel.x)} 起` : '')
const next = await cells.nth(4).click({ timeout: 3000 }).then(() => true, () => false)
check(`${width} 宽:面板开着时同列别的数字照样点得到`, next
&& await page.locator('#detail').getAttribute('data-open') === await cells.nth(4).getAttribute('data-num'))
check(`${width} 宽:不横向溢出`, await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
}, { width })
}
await sample('two-reports', async (s) => {
check('two-reports:两份报告各自显示,引用链都通过', s.reports.length === 2 && s.checks.chain === 'pass'
&& s.items.filter((i) => i.check === 'chain' && i.text.includes('的文档')).length === 2, JSON.stringify(s.reports.map((r) => r.node)))
check('two-reports:月报的数字全部通过', s.reports.find((r) => r.node === 'monthly')?.result === 'pass')
})
await sample('legacy', async (s, page) => {
check('legacy:总览通过,范围写明按结束事件推算', s.overall === 'pass'
&& (await page.locator('#meta').innerText()).includes('早期运行,范围按结束事件推算'), s.text)
check('legacy:运行结果无法核对,写明早期运行的结束事件不在封存范围内', s.meta.status?.result === 'na'
&& hasItem(s, 'meta', 'na', '早期运行在结束事件落库之前封存'), JSON.stringify(s.meta.status))
check('legacy:运行结束事件列在封存之后', (await page.locator('[data-appended]').allTextContents()).some((t) => t.includes('运行结束')))
})
await sample('failed', async (s, page) => {
check('failed:运行结果「已失败」与运行失败事件对上', s.meta.status?.result === 'pass' && s.meta.status.text.includes('已失败'))
check('failed:封存说明写明之后继续运行会重新封存', await page.locator('#seal-failed').isVisible()
&& (await page.locator('#seal-failed').innerText()).includes('重新封存'))
})
await sample('base', async (_, page) => {
check('成功结束的运行不提重新封存', await page.locator('#seal-failed').isHidden())
})
await sample('degraded', async (s) => {
check('degraded:出具档位认最后一条出具判定(降档出具)', s.meta.tier?.result === 'pass' && s.meta.tier.text.includes('降档出具'),
JSON.stringify(s.meta.tier))
})
await sample('missing-doc', async (s, page) => {
check('missing-doc:总览不是绿色的通过,写明有报告未能核对', s.overall === 'incomplete' && s.text.includes('份报告未能核对'),
`${s.overall}:${s.text}`)
check('missing-doc:报告处写明导出时服务端已没有这份报告文档', s.reports[0]?.why === 'missing'
&& s.reports[0].warn.includes('导出时服务端已没有这份报告文档'), s.reports[0]?.warn)
check('missing-doc:③ ④ 无法核对,原因写明', hasItem(s, 'artifacts', 'na', '导出时服务端已没有这份工件的文件')
&& hasItem(s, 'chain', 'na', '导出时服务端已没有这份报告文档'))
check('missing-doc:总览框不是通过的绿底', await page.locator('#overall').evaluate((el) => {
const probe = document.body.appendChild(document.createElement('div'))
probe.style.background = 'var(--ok-soft)'
const ok = getComputedStyle(probe).backgroundColor
probe.remove()
return getComputedStyle(el).backgroundColor !== ok
}))
})
await sample('missing-card', async (s) => {
check('missing-card:引用口径卡的数字无法核对,写明服务端已没有文件', hasItem(s, 'numbers', 'na', '导出时服务端已没有这份工件的文件'))
check('missing-card:服务端缺文件是正常的无法核对,总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
})
await sample('nan', async (s) => {
check('nan:内容里有裸 NaN 的事件和工件照常通过', s.overall === 'pass' && s.checks.events === 'pass'
&& !s.items.some((i) => i.result === 'fail'), describe(s))
})
await sample('stray', async (s) => {
check('stray:另一次运行的文档,④ 不通过并写明', s.overall === 'fail' && hasItem(s, 'chain', 'fail', '文档记下的运行或节点与这次不符'))
check('stray:文档没通过核对时数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')
&& s.reports[0]?.warn.includes('其中的数字不予核对'), s.checks.numbers)
})
// 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」
{
const data = dataOf(samples.base)
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, javaScriptEnabled: false })
try {
const page = await ctx.newPage()
await page.goto(pathToFileURL(writePage('sample-nojs.html', samples.base)).href)
check('不执行脚本:封存哈希写在页面上', await page.locator('#seal-hash').innerText() === data.meta.manifest_hash)
check('不执行脚本:运行 ID、工作流名写在页面上', (await page.locator('#seal-note').innerText()).includes(data.meta.run_id)
&& await page.locator('#title').innerText() === data.meta.workflow_name)
check('不执行脚本:状态写「尚未核对」,不显示「正在核对」', await page.locator('.no-js').isVisible()
&& (await page.locator('#overall').innerText()).includes('尚未核对') && !(await page.locator('#overall').innerText()).includes('正在核对'))
} finally {
await ctx.close()
}
}
})
await section('样本页:篡改', async () => {
if (!samples) {
note('没有样本页(原因见上一节),这一节跳过')
return
}
const base = samples.base
const byKind = (data, kind) => data.artifacts.find((a) => a.kind === kind)
// ④ 引用链
await tamper(base, '报告记录指向另一份工件', (data) => {
data.reports[0].doc_artifact = byKind(data, 'metric_set').id
return '报告的文档换成口径卡'
}, (s) => [...turnsRed('chain')(s), ['原因写明', hasItem(s, 'chain', 'fail', '这个节点最后一次报告自查记下的不是这份文档')]])
await tamper(base, '拿掉报告记录', (data) => {
data.reports = []
return '清空 reports'
}, (s) => [...turnsRed('chain')(s), ['原因写明', hasItem(s, 'chain', 'fail', '运行记录里有这个报告节点的自查事件,文件里却没有附带它的文档')]])
await tamper(base, '多一份没人引用的工件', (data) => {
const bytes = Buffer.from(JSON.stringify({ columns: ['n'], rows: [[7]], source: 'shop', sql: 'SELECT 7 AS n' }), 'utf8')
data.artifacts.push({ id: sha(bytes), kind: 'query', node_id: 'fetch', b64: bytes.toString('base64') })
return '追加一份哈希正确的快照'
}, (s) => [...turnsRed('chain', ['artifacts'])(s), ['原因写明', hasItem(s, 'chain', 'fail', '已核对的事件和报告文档里都没有引用这份工件')]])
// 服务端不会给出的状态:只能是改过
await tamper(base, '拿掉被引用的口径卡', (data) => {
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
return '删掉口径卡条目'
}, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
await tamper(base, '口径卡标成遮罩', (data) => {
const card = byKind(data, 'metric_set')
delete card.b64
card.masked = true
return '口径卡去掉内容、标遮罩'
}, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照会因遮罩不随文件附带')]])
await tamper(base, '报告文档的一个字节', (data) => flipByte(byKind(data, 'report_doc')), (s) => [
...turnsRed('artifacts')(s),
['数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')],
['报告处写明没有通过核对', s.reports[0]?.result === 'fail' && s.reports[0].why === 'untrusted'],
])
// ⑥ 运行信息:改元信息不动事件,清单照样对得上,只有 ⑥ 变红,页面上显示的是事件里的值
await tamper(base, '运行结果', (data) => {
data.meta.status = 'failed'
return 'succeeded → failed'
}, (s) => [...turnsRed('meta', ['events', 'manifest'])(s),
['页面显示事件里的「已完成」并标明文件里写的不同', s.meta.status?.result === 'fail' && s.meta.status.text.includes('已完成')
&& s.meta.status.text.includes('已失败'), JSON.stringify(s.meta.status)]])
await tamper(base, '运行类别', (data) => {
data.meta.run_class = 'exploratory'
return 'formal → exploratory'
}, (s) => [...turnsRed('meta')(s), ['原因写明', hasItem(s, 'meta', 'fail', '记下的运行参数不符')]])
await tamper(base, '去掉出具档位', (data) => {
data.meta.tier = null
return 'tier → null'
}, (s) => [...turnsRed('meta')(s), ['页面显示事件里的档位', s.meta.tier?.result === 'fail' && s.meta.tier.text.includes('完整出具')]])
await tamper(samples.degraded, '出具档位改指早先那条', (data) => {
const first = data.events.find((e) => e.type === 'issuance')
data.meta.tier = { value: 'formal', seq: first.seq }
return `指向第 ${first.seq} 条(完整出具)`
}, (s) => [...turnsRed('meta')(s),
['页面显示最后一条的降档出具', s.meta.tier?.result === 'fail' && s.meta.tier.text.includes('降档出具'), JSON.stringify(s.meta.tier)]])
// 只改页面顶部静态写出的封存哈希:数据里的没动,② 的清单照样对得上,但顶上那份对不上,核对后换成核对用的
{
const data = dataOf(base)
const fake = data.meta.manifest_hash.replace(/^./, (c) => (c === '0' ? '1' : '0'))
const opened = await openFile(writePage('tampered-seal.html', withSeal(base, fake)))
try {
const s = await stateOf(opened.page)
check('篡改页面顶部的封存哈希:② 不通过、总览不通过', s.checks.manifest === 'fail' && s.overall === 'fail'
&& hasItem(s, 'manifest', 'fail', '页面被改过'), describe(s))
check('篡改页面顶部的封存哈希:核对后顶上显示核对用的那一份', s.seal === data.meta.manifest_hash)
} finally {
await opened.ctx.close()
}
}
// 重算全部哈希的伪造在样本页上同样露馅:⑤ 不通过,报告徽标跟着不通过
await tamper(base, '样本页上重算全部哈希的伪造', forge, (s) => [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain', 'meta'])(s),
['报告徽标不通过', s.reports[0]?.result === 'fail' && s.reports[0].parts.includes('不通过 1'), JSON.stringify(s.reports[0])],
], { seal: true })
})
// ---------------------------------------------------------------- 记录页的导出按钮
const SAMPLE_NAME = '门店周报-可验证报告.html'
// 和服务端 api/bundle.py 的 SEAL_BROKEN 一字不差(服务端单测断言那一边):提示里原样显示
const SEAL_BROKEN = '封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果'
async function preview(syn, { width = 1280, bundle } = {}) {
const ctx = await browser.newContext({ viewport: { width, height: 700 }, acceptDownloads: true })
@@ -436,41 +748,62 @@ await section('记录页的导出按钮', async () => {
}
}
{
const refusal = { status: 409, contentType: 'application/json',
body: JSON.stringify({ detail: '封存核对不一致:事件记录与封存时不一致', code: 'bundle_seal_broken' }) }
const refusal = { status: 409, contentType: 'application/json', body: JSON.stringify({ detail: SEAL_BROKEN, code: 'bundle_seal_broken' }) }
const { ctx, page } = await preview('bundle-provenance-sealed', { bundle: refusal })
try {
await page.locator('[data-bundle-export]').click()
check('服务端回 409 时提示原因', await appears(page.getByText('封存核对不一致:事件记录与封存时不一致')))
check('服务端回 409 时原样提示原因', await appears(page.getByText(SEAL_BROKEN)))
check('提示之后按钮恢复可点', await appears(page.locator('[data-bundle-export="ready"]')))
} finally {
await ctx.close()
}
}
for (const [syn, reason] of [['bundle-provenance-running', '运行结束并封存后才能导出'],
['bundle-provenance-waiting', '等待审批中'], ['bundle-provenance-unsealed', '没有封存清单'],
['bundle-provenance-again', '运行结束并封存后才能导出']]) {
{
// 失败结束的运行:导出前后都说明之后继续运行会重新封存
const { ctx, page } = await preview('bundle-provenance-failed')
try {
const button = page.locator('[data-bundle-export]')
const hover = await button.locator('xpath=..').getAttribute('title')
check('失败结束:可以导出,悬停提示写明继续运行会重新封存', await button.isEnabled() && (hover ?? '').includes('重新封存'), hover ?? '')
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), button.click()])
check('失败结束:导出后的提示写明继续运行会重新封存', !!download
&& await appears(page.getByText(`已导出「${SAMPLE_NAME}」。之后若继续运行,系统会重新封存`, { exact: false })))
} finally {
await ctx.close()
}
}
for (const [syn, reason, shown] of [['bundle-provenance-running', '运行结束并封存后才能导出', false],
['bundle-provenance-waiting', '等待审批中', false], ['bundle-provenance-unsealed', '没有封存清单', false],
['bundle-provenance-again', '运行结束并封存后才能导出', true]]) {
const { ctx, page, hits } = await preview(syn)
const label = syn.replace('bundle-provenance-', '')
try {
const button = page.locator('[data-bundle-export]')
const described = await button.getAttribute('aria-describedby')
const spoken = described ? await page.locator(`[id="${described}"]`).innerText() : ''
const hover = await page.locator('[data-bundle-export]').locator('xpath=..').getAttribute('title')
check(`${syn.replace('bundle-provenance-', '')}:按钮不可点,说明原因`, await button.isDisabled()
check(`${label}:按钮不可点,说明原因`, await button.isDisabled()
&& spoken.includes(reason) && (hover ?? '').includes(reason), spoken || '没有说明')
if (shown) {
// 左边写着「已封存」,按钮却不能点:原因要写在屏幕上,触屏看不到悬停提示
const visible = page.locator('[data-bundle-blocked="shown"]')
const box = await visible.boundingBox()
check(`${label}:左边写着「已封存」时,原因直接写在按钮旁`, await page.locator('[data-seal="sealed"]').isVisible()
&& await visible.isVisible() && !!box && box.width > 20 && (await visible.innerText()).includes(reason))
}
await button.click({ force: true }).catch(() => {})
check(`${syn.replace('bundle-provenance-', '')}:点了也不请求导出接口`, hits.length === 0)
check(`${label}:点了也不请求导出接口`, hits.length === 0)
} finally {
await ctx.close()
}
}
{
const { ctx, page } = await preview('bundle-provenance-sealed', { width: 390 })
for (const syn of ['bundle-provenance-sealed', 'bundle-provenance-again']) {
const { ctx, page } = await preview(syn, { width: 390 })
try {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
const box = await page.locator('[data-bundle-export]').boundingBox()
check('390 宽:凭证条不横向溢出,导出按钮在屏幕内', overflow <= 0 && !!box && box.x >= 0 && box.x + box.width <= 390,
`溢出 ${overflow}px`)
check(`390 宽(${syn.replace('bundle-provenance-', '')}):凭证条不横向溢出,导出按钮在屏幕内`,
overflow <= 0 && !!box && box.x >= 0 && box.x + box.width <= 390, `溢出 ${overflow}px`)
} finally {
await ctx.close()
}