From e7c8d2a1f3d0dd600e6b884df087bba830f3ae52 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 5 Oct 2026 05:30:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=AF=E9=AA=8C=E8=AF=81=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=BF=AE=E6=AD=A3=EF=BC=88=E7=AC=AC=201=20?= =?UTF-8?q?=E8=BD=AE=EF=BC=89=EF=BC=9A=E8=BF=90=E8=A1=8C=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=8B=BF=E5=B7=B2=E6=A0=B8=E5=AF=B9=E7=9A=84=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=AF=B9=E7=85=A7=EF=BC=8C=E6=8A=A5=E5=91=8A=E5=BE=BD=E6=A0=87?= =?UTF-8?q?=E8=BF=9E=E5=90=8C=E6=95=B0=E5=AD=97=E4=B8=80=E8=B5=B7=E7=AE=97?= =?UTF-8?q?=EF=BC=8C=E9=81=AE=E7=BD=A9=E5=88=A4=E5=AE=9A=E5=AE=81=E4=B8=A5?= =?UTF-8?q?=E5=8B=BF=E5=AE=BD=EF=BC=8C=E5=90=84=E5=88=86=E6=94=AF=E5=9C=A8?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E9=87=8C=E6=9C=89=E7=A1=AE=E5=AE=9A?= =?UTF-8?q?=E7=9A=84=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 页面核对加 ⑥ 运行信息:运行结果看封存范围内最后一条结束类事件的类型,运行类别看运行开始事件记下的运行参数, 出具档位认最后一条出具判定(不再信元信息指的那一条);页面显示事件里的值,元信息写得不一样算不通过、计入总览。 工作流名、版本、起止时间、节点名称绑不进封存,单列一组写明取自运行记录,封存说明里也写明要到系统里一并对照 - 报告标题旁的徽标连同正文里的数字一起算,旁边写分项:重算全部哈希的伪造只在 ⑤ 露馅,徽标不能还写着通过。 分块条目多时不通过和无法核对的都列在外面,有无法核对的显示「部分无法核对」和分项计数;⑤ 改叫「数字与取值」并分开数, 读屏分别读作数字、取值,「显示格式由服务端渲染」只附在带出处的数字上;无出处数字的说法改成未使用引用标记 - 服务端不会给出的状态算不通过:被引用的工件不在清单里、非查询快照标着遮罩、工件既没内容也没说明;报告文档导出时 已不在服务端,总览不再是绿色的通过,写「有 N 份报告未能核对」,报告处写明是服务端已没有这份文档 - 认 json.dumps 写出的裸 NaN / Infinity,没被改过的文件不再误判不通过;哈希对得上但读不懂的照实写「无法核对」, 和哈希不一致分开说 - 宽屏打开出处面板时正文让出右边一栏,表格里右对齐的数字不再被面板盖住;补上斜体(不用后行断言,较早的 Safari 也能执行) - 封存哈希、运行 ID、工作流名直接写进页面,不执行脚本时写「尚未核对」而不是一直「正在核对」;脚本挪进 head, 执行后核一遍页面顶部写出的封存哈希和数据里核对用的一致 - 成果节点和运行结束事件的规范字符串,只在封存范围内每次数据源查询都对得上无遮罩的快照(或确实没交回数据)时才给: 升级前的运行从工具快照认出查询快照,协作团队成员的查询认不出就按带遮罩算,数据源现在设了遮罩的按工具名就挡 - 封存核对不一致的 409 改成一句固定的话,不再叠冒号、让人去本来就在的记录页;失败结束的运行在页面封存说明和导出提示里 写明之后继续运行会重新封存;记录页上左边写着「已封存」却不能导出时,原因写在屏幕上 - tests/bundle_samples.py 用虚构数据真实生成九份样本页(遮罩快照、两份报告、早期运行、失败结束、两条出具判定、 服务端缺文件、裸 NaN、别的运行的文档),check-bundle 断网打开逐项断言,在上面篡改引用链、运行信息、拿掉被引用的口径卡, 量宽屏面板,看不执行脚本的样子;把核对改成恒通过、徽标不看数字等七种改坏法都会变红。pytest 补遮罩判定的三种形状、 事后设遮罩(去掉接线即变红)、页面静态内容和样本页本身 --- backend/app/api/bundle.py | 6 +- backend/app/bundle/build.py | 85 ++++- backend/app/bundle/template.html | 38 +- backend/app/bundle/verifier.js | 468 +++++++++++++++++------ backend/tests/bundle_samples.py | 200 ++++++++++ backend/tests/test_bundle_api.py | 40 +- backend/tests/test_bundle_build.py | 151 +++++++- frontend/src/dev/fixtures/bundle.tsx | 5 +- frontend/src/lib/terms.ts | 5 +- frontend/src/pages/runs/Telemetry.tsx | 15 +- scripts/check-all.mjs | 2 +- scripts/check-bundle.mjs | 509 +++++++++++++++++++++----- 12 files changed, 1296 insertions(+), 228 deletions(-) create mode 100644 backend/tests/bundle_samples.py diff --git a/backend/app/api/bundle.py b/backend/app/api/bundle.py index ea40c4b7..8a2cfe25 100644 --- a/backend/app/api/bundle.py +++ b/backend/app/api/bundle.py @@ -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) diff --git a/backend/app/bundle/build.py b/backend/app/bundle/build.py index e8da8095..5886bd22 100644 --- a/backend/app/bundle/build.py +++ b/backend/app/bundle/build.py @@ -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) diff --git a/backend/app/bundle/template.html b/backend/app/bundle/template.html index 8f94d85c..e8200d6e 100644 --- a/backend/app/bundle/template.html +++ b/backend/app/bundle/template.html @@ -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; } } +

可验证报告

-

可验证报告

+

__BUNDLE_NAME__

- 正在核对… + 正在核对…尚未核对:需要在浏览器中打开本文件,并允许执行其中的核对脚本

封存清单哈希

- -

+ __BUNDLE_SEAL__ +

这个文件只证明内部一致:报告里的数字能追到口径卡或查询快照,文档、快照和运行记录彼此对得上。它不能单独证明这些记录出自系统、未被整体替换。核对真伪:在系统中打开这次运行(运行 ID __BUNDLE_RUN__),确认「凭证详情」里的清单哈希与上面一致,并点「核对」确认事件记录与封存时一致(接口 GET /api/runs/__BUNDLE_RUN__/verify)。工作流名、版本、起止时间和节点名称取自运行记录,不在封存范围内,需要在系统中一并对照。

+

这次运行以失败结束:之后若在系统中继续运行,系统会重新封存,本文件的封存哈希将无法再与系统对照。

+ +
-
+

报告

核对明细

-
+
- - diff --git a/backend/app/bundle/verifier.js b/backend/app/bundle/verifier.js index afde59f2..a2bb6e42 100644 --- a/backend/app/bundle/verifier.js +++ b/backend/app/bundle/verifier.js @@ -1,20 +1,24 @@ /* * 可验证报告的浏览器端核对:原生 JS,无依赖,不连网。 * - * 数据在 流水 [[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): diff --git a/backend/tests/test_bundle_build.py b/backend/tests/test_bundle_build.py index 18ba0f30..1cb16179 100644 --- a/backend/tests/test_bundle_build.py +++ b/backend/tests/test_bundle_build.py @@ -5,10 +5,12 @@ - 每条封存范围内的事件都带 data_sha256,按浏览器的拼法(seq|type|node_id|data_sha256 按 seq 排、换行连接) 重算出来的清单哈希等于 runner 记下的那个; - 只有白名单里的事件带 canonical(逐字等于 canonical_json(data)),其余只给哈希;运行里有带遮罩的快照时, - 成果节点和运行结束事件的预览可能带出查询结果,也只给哈希; + 成果节点和运行结束事件的预览可能带出查询结果,也只给哈希。认不出快照的查询(升级前的运行从工具快照里认, + 协作团队成员的查询认不出)按带遮罩算,查询失败的原话不算; - 报告文档、它引用到的口径卡和查询快照按原字节嵌入;带遮罩的快照只给 id;没被引用的工件不嵌; - 封存之后追加的事件单列,不进清单; -- 页面零外链,数据里的 截不断数据块。 +- 页面零外链,数据里的 截不断数据块;封存哈希、运行 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"", html, re.S).group(1) assert " 里、排在数据块前面:它的注释里也不能出现「") < html.index('