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