feat(ql3): export redacted cluster evidence bundle

This commit is contained in:
whyour
2026-08-16 07:07:32 +08:00
parent 7955d55629
commit 4f62a86d15
17 changed files with 1308 additions and 25 deletions
@@ -112,6 +112,30 @@ next-page read only when the upstream cursor says more data exists. There is no
automatic cascade from a list to details, steps or events, so each authority
read remains visible and intentional.
## Export a redacted evidence bundle
After at least one successful read, **Export redacted bundle** creates one
UTF-8 JSON file entirely in browser memory. Export performs zero BFF or Cluster
requests and includes only the evidence already visible in the current page.
The ledger retains at most the newest sixteen entries and 8 MiB of canonical
raw facts; reaching either limit removes the oldest visible and in-memory
entry. The generated file is capped at 512 KiB.
The fixed allowlist keeps operation, local observation time, reviewed status
enums, bounded numeric/boolean facts, pagination state and typed aliases.
Project, Run, Task, Workflow, Package, Step, Artifact, request and digest values
become per-bundle aliases without an exported mapping. Free text, names,
paths/URLs, commands, inputs/outputs, environment, errors/messages, credentials,
tokens, authorization, unknown fields and Copilot model text are omitted. A
canonical SHA-256 for each omitted raw fact allows a later local comparison
without embedding that fact.
The top-level SHA-256 detects changes to the redacted JSON, but it is not a
server signature, durable audit, origin attestation or action authority. Review
the file before sharing it. Generation uses no upload, clipboard/share API,
browser storage, worker, timer or service-side temporary file. **Clear page**
removes the current in-memory ledger without sending a request.
## Run the verified image
Create a dedicated Docker network whose egress is restricted by the host
+1
View File
@@ -11,6 +11,7 @@
最新增量证据(2026-08-16):
- D-330/ADR-0422(已接受):同一 loopback-only Cluster field ledger 现可由用户显式导出纯浏览器本地的脱敏 evidence bundle。导出只消费本页已逐次读取的最近 16 条、最多 8 MiB canonical fact,不调用 upstream/BFF、补读详情/分页、轮询、上传或持久化;固定 sanitizer 只保留 operation、非权威本机观察时间、安全枚举/boolean/有界 number、结构计数/分页事实、per-bundle typed alias 与原始 fact canonical byte count/SHA-256,自由文本、名称、路径/URL/command/input/output/environment、reason/error/message、credential/token/session/authorization、未知字段及 Copilot model text 一律省略。bundle 固定为 UTF-8 `qinglong/cluster-console-redacted-evidence-bundle@v1` JSON、最大 512 KiB,顶层 self-digest 明确不是 server signature/audit/action authority;生成器作为第 4 个 digest-bound asset 留在既有 `@qinglong/cluster-admin`,不增加 package、依赖、Cluster/BFF route、数据库、对象存储、Kubernetes workload 或 Edge/Standalone closure。定向门 24/24Cluster Admin 382 pass/3 条件 skip,完整 18-package test 退出 0backend 1,224 pass/2 条件 skip/0 fail。真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot model text 验证纯文本与零泄漏;3 次显式读取后导出 3,611-byte 可复算 JSONupstream 计数仍为 3390×844 无横向溢出且 0 console error/warning。真实 arm64 Admin image `qinglong3-cluster-admin:d330-local` 为 344,543,263 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console、第 4 个 asset 与内置分发。npm pack dry-run 为 246 files、267,731-byte tarball、1,665,996-byte unpackedpackage/dependency/Edge import/Cluster deployment/image release/Console/distribution 审计零 finding。workspace 保持 18 package、`singleSourcePackages=[]``shallowSourcePackages=[]`1,199 个源码中 1,181 个位于职责目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 保持 4,493,043/4,493,175 bytesMCP 保持 7,315,930/7,316,038 bytes。PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2`,报告 SHA-256 `c9feb83c98ad2269c7649bd0869921d9dee7cfd00c9bc1a8a7879d81630d37c7`,证据审计与 Docker 残留均为零;本 Gate 没有 schema、migration、SQL、role、Pool 或连接拓扑变化。下一独立 Gate 应交付公开 release digest 的外部工作站 ceremony,或提供独立、离线、无 authority 的 evidence bundle verifier;不得为导出增加服务端聚合、稳定跨包标识、自动抓取或上传能力。
- D-329/ADR-0421(已接受):同一 loopback-only Console/BFF 已扩展为 Cluster field ledger,固定提供 Copilot `inspect|output`、Run list/detail/events/steps、Task list/detail、Workflow list 与 Workflow Run list/detail/events/steps 共 13 个显式只读 operationbrowser 仍不能提交 upstream URL/method/header/credential。服务端 exact contract 负责 ID/cursor/limit 校验和 path/query 生成,并复用既有 owner-private `ql3c_`、TLS 1.3、request-ID、2 MiB response 与低敏错误 transport;通用 Project read grammar 只接受审核过的 Run/Task/Workflow GET,拒绝 mutation、absolute URL 与 path traversal。UI 采用仅存内存的 evidence ledger,每次按钮只执行一次读取,分页只在 `hasMore|truncated` 携带 cursor 时由用户显式触发,没有自动 detail cascade、poller、WebSocket/SSE、retry、queue、cache 或后台 timer。实现继续留在 `@qinglong/cluster-admin`workspace 维持 18 package,部署 credential 推荐只授予 `run.read|task.read|artifact.read`;不回接 2.x Web/session、不新增 Cluster route/schema/SQL/Pool/Kubernetes resident service,也不进入 Edge/Standalone closure。13-operation contract、Console/CLI/TLS 定向门 23/23Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0backend 1,223 pass/2 条件 skip/0 fail。真实浏览器完成 Run/Task/Workflow 读取、显式下一页、恶意 HTML 纯文本、390×844 与零 console error/warning,并发现、修正 `[hidden]` 被 panel layout 覆盖的问题;真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpackedpackage/dependency/Cluster deployment/image release/Console/distribution 审计零 findingworkspace 为 18 package 且无 single-source/shallow package。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 保持 4,493,043/4,493,175 bytesMCP 保持 7,315,930/7,316,038 bytes。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应把现场 evidence 升级为可下载的显式脱敏诊断包,或补公开 release digest 的外部工作站 ceremony;不得增加浏览器代理权、自动全量抓取或把 Console 变为 Kubernetes 常驻服务。
- D-328/ADR-0420(已接受):Cluster Copilot Console 的 operator-workstation 分发已冻结为既有 `qinglong3-cluster-admin@sha256:…` 多架构 OCI,而不是再发布 Node archive、安装器镜像或第 19 个 workspace package。Admin release workflow 已有 amd64/arm64 原生构建、production dependency audit、BuildKit provenance/SBOM、OS vulnerability scan、扫描后 OCI merge、keyless Cosign signature,以及绑定 repository/workflow/source commit/source tag 的 GitHub provenance、CycloneDX 和 OS-vulnerability attestations;新增 `verify-release.sh` 要求 image digest、40-hex revision 与 `refs/tags/v3.*`,独立复验 exact workflow certificate identity、GitHub OIDC issuer、三类 predicate、OCI bundle 并拒绝 mutable tag、branch ref 与 self-hosted builder。已签名 image filesystem 现在携带 `0555` launcher/verifier 和 `0444` 文档/配置模板,因此 signature 同时覆盖宿主启动路径,不存在第二套依赖树。原生 Console 默认继续只监听 `127.0.0.1` ephemeral port;只有显式 `--container-published-loopback` 加固定高端口才允许容器 network namespace 内监听 `0.0.0.0`,并仍对外报告 `publishedHostAddress=127.0.0.1`。reviewed launcher 只接受 immutable digest、canonical private root、显式命名网络和 `compact|standard` 两档资源,拒绝 `bridge|default|host|none``check` 不 publish`serve` 唯一映射为 `127.0.0.1:<port>:<port>/tcp`,同时固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 authority mount、`--pull never`、3 秒 stopcompact 为 192 MiB/0.25 CPU/32 PIDsstandard 为 512 MiB/1 CPU/64 PIDs。启动器/验签器/分发审计与 Console 定向门 30/30release/OCI/SBOM 定向门 75/75package/dependency/Edge/Cluster deployment/Console distribution 审计全部零 finding。Cluster Admin 376 pass/3 条件 skip,完整 18-package clean build/test 退出 0backend 1,223 pass/2 条件 skip/0 fail。npm pack dry-run 保持 245 files、258,591-byte tarball、1,616,582-byte unpacked。真实 arm64 image `qinglong3-cluster-admin:d328-local` 为 344,492,529 bytes,验证 10 个产品命令、镜像内五项分发文件及 mode、原生 loopback Console;新增 named-network 现场门选择一个空闲高端口并以 exact `127.0.0.1:P:P` 发布,宿主真实读取页面,同时复验 read-only/non-root/no-capability/no-new-privileges 边界与容器/网络清理。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 仍为 4,493,043/4,493,175 bytesMCP 仍为 7,315,930/7,316,038 bytes,证明工作站分发没有进入低配路由设备闭包。workspace 保持 18 package、`singleSourcePackages=[]``shallowSourcePackages=[]`Cluster Admin 120 个源码中 119 个位于嵌套职责目录。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应在同一 Console/BFF ownership 下增加只读 Run/Task/Workflow 观察面,或补公开 release tag 的外部工作站 ceremony;不得把容器内部 listener 误当宿主 LAN authority、引入第二分发依赖树或把 Console 变成 Kubernetes 常驻服务。
- D-327/ADR-0419(已接受):QingLong 3.0 首个 Cluster 浏览器产品面已冻结为独立的 operator-workstation、loopback-only、只读 Copilot Console,而不是继续扩展 2.x Umi `src/pages`、legacy session 与 `/api` proxy。实现内聚在既有 `@qinglong/cluster-admin/copilot-console`workspace 仍为 18 个 package;统一产品 façade 增加第十个静态命令 `copilot-console`。BFF 只监听 `127.0.0.1` ephemeral port,启动前复验包内 HTML/CSS/JS 的路径、realpath、类型、UTF-8、大小与固定 SHA-256;三项资源合计 24,150 bytes,无外部 asset/font/CDN。Cluster `ql3c_` credential 始终留在服务端 owner-private `0600` 文件且每次上游调用重新读取;浏览器只使用另一份 exact 256-bit session key,服务端只保存 domain-separated digest,页面只保存在内存,不进入 cookie、URL、argv、environment、local/session storage。Browser BFF 仅接受 exact `inspect|output`,复用 D-324 共享 TypeScript client,不执行 CLI 子进程、不直连数据库/application capability,并明确没有 diagnose/cancel、poller、WebSocket/SSE、ServiceWorker、queue/retry/cache 或后台 timer。Host、Origin、单 Authorization、route/operation 和 JSON framing 必须 exact;第三个并发 read 立即 `429`,固定 4 KiB request、约 2 MiB response、2 in-flight、16 connections 和 2 秒 shutdown ceiling。响应全为 `no-store` 且使用 default-deny CSP;模型文本只通过 `textContent` 显示并持续标记为 untrusted/no-action-authority。部署手册固定受信运维工作站生命周期,禁止 Kubernetes workload、Ingress、sidecar、共享 LAN 和容器 `0.0.0.0`Edge/Standalone、Local MCP、Cluster Control/AI closure 均不导入 Console。npm pack dry-run 确认 245 files、258,012-byte tarball、1,614,503-byte unpacked,包含三项静态资源与全部 BFF/CLI 编译产物;独立审计还发现并修正真实 Admin Dockerfile 原先遗漏 assets 的发布缺陷,并把生产 files 白名单精确收窄到 `assets/copilot-console/*`。真实 Playwright 现场门覆盖 session 解锁、status read、显式 output reveal、390px 响应式布局和键盘路径;含 `<script>` 的模型输出保持纯文本,最终 0 error/0 warning,并修正了现代 HTML `/v` pattern 对未转义 `-` 的兼容问题。Console contract/CLI 12/12、定向产品入口 25/25、Cluster Admin 374 pass/3 条件 skip、完整 18-package clean build/test 退出 0、backend 1,215 pass/2 条件 skip/0 failpackage/dependency/Edge import/Cluster deployment/Console 审计零 findingOCI/release 64/64、SBOM 11/11。真实 arm64 Admin image `qinglong3-cluster-admin:d327-local` 为 344,479,739 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 10 个产品命令,并在同一受限容器内真实启动 Console、读取 digest-bound 页面与干净关闭。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytes,证明 Cluster UI 没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应交付可独立验签的 operator-workstation Admin/Console 分发物,或在同一 3.0 Console ownership 下增加受同一 BFF 约束的只读 Run/Task/Workflow 观察面;不得回接 2.x session、把浏览器变成 Cluster credential holder,或把 Console 变为常驻 Kubernetes 服务。
@@ -0,0 +1,47 @@
# ADR-0422:浏览器本地生成的脱敏 Cluster Evidence Bundle
- 状态:Accepted
- 日期:2026-08-16
- 关联 RFCQL-RFC-0001 D-330、Phase 2
- 扩展:ADR-0421
## 背景
ADR-0421 已让值班人员通过同一个 loopback-only Console 显式读取 Run、Task、Workflow 和 Copilot 事实,但证据仍只存在于页面 DOM。截图不利于机器复核,复制原始 JSON 又容易把 Project/Run/Task/Artifact 标识符、用户命名和 Copilot 模型文本直接带出工作站。若为“导出”新增 Cluster API、通用 proxy、服务端 ZIP 组装、对象存储或后台采集器,会扩大 credential、网络、持久化与资源边界,也会把一次人工观察错误升级为新的集群产品能力。
## 决策
1. D-330 在既有 `@qinglong/cluster-admin/copilot-console` 中增加第 4 个 digest-bound 静态资产 `evidence-bundle.js`。它同时是浏览器脚本和可由 Node 单测加载的纯生成器;不新增 workspace package、npm 生产依赖、Cluster route、BFF POST route、数据库、文件写服务、Kubernetes workload 或第二分发物。
2. 导出只由用户点击“导出脱敏包”触发,只消费当前页面内已经完成的显式读取。它不得调用 `fetch`、打开 socket、补读详情/分页、轮询、重试、排队、上传、调用 clipboard/share API 或读取 session/Cluster credential。
3. 页面账本硬限制为最近 16 条且原始 canonical JSON 合计不超过 8 MiB;新证据到达时在 DOM 与内存中同步淘汰最旧记录,并明确显示保留数量。单条仍受 BFF 约 2 MiB response ceiling 约束。bundle 本身不得超过 512 KiB,所有数组最多保留 64 项。
4. `qinglong/cluster-console-redacted-evidence-bundle@v1` 只包含 operation、非权威本机观察时间、固定安全枚举、boolean/有界 number、结构计数、分页事实、identifier alias 和每条原始事实的 canonical byte count/SHA-256。自由文本、名称、路径、URL、command、input/output、environment、reason/error/message、credential/token/authorization、未知 key/value 一律不进入 bundleCopilot output 文本无条件省略。
5. Project、Run、Task、Workflow、Package、Step、Artifact、request 与 digest 等标识符按类型在每个 bundle 内稳定映射为顺序 alias,例如 `run-001`。原始到 alias 的映射不写入文件;相同原始标识在同一 bundle 内可关联,不同 bundle 之间不能依赖 alias 稳定性。
6. 生成器对每条原始 fact 做键排序 canonical JSON 后计算 SHA-256,再对不含顶层 `contentDigest` 的完整脱敏 bundle 做第二次 SHA-256。该 digest 只用于完整性/复核,不是服务端签名、durable audit、来源证明或行动授权;文件明确声明 `attestation=none``actionAuthority=none``generatedBy=browser_local`
7. 下载格式固定为 UTF-8 JSON 加末尾换行,文件名不含任何 Project/Run/Task 标识。浏览器只使用 `crypto.subtle``TextEncoder``Blob` 与临时 object URL;点击后立即移除 anchor 并 revoke URL,不使用 timer、ServiceWorker、IndexedDB、local/session storage 或缓存。
8. Console 的 13 个 upstream read operation、`run.read|task.read|artifact.read` 推荐权限、Host/Origin/session、TLS 1.3、并发和 response ceiling 保持不变。该 bundle 不进入 Edge/Standalone 或 Cluster Control/AI closure;路由设备默认制品必须逐字节保持既有边界。
## 不选择
- **导出原始账本再提示用户自行脱敏**:把最困难且最容易遗漏的安全步骤交给值班人员,模型文本和用户命名会直接泄漏。
- **BFF/Cluster 服务端生成 ZIP**:需要更大 request body、临时文件、压缩库和新的 route,且会让浏览器提交的数据看起来像服务端证明。
- **下载前自动补齐所有分页和详情**:形成隐藏的读取放大器,破坏 ADR-0421 的“一次点击、一次读取”。
- **用 session key 签名 bundle**session 是浏览器访问能力,不是签名身份;HMAC 会错误暗示集群来源证明并增加密钥滥用面。
- **长期稳定哈希标识符**:即使不包含原文,也会让不同导出物可关联;per-bundle alias 更符合最小披露。
## 验收
1. 生成器单测覆盖 exact input、16-entry/8 MiB/512 KiB/64-item ceiling、canonical digest、同包 alias 关联、跨包 alias 重置、原始/未知/自由文本和 credential/session/model output 永不出现,以及循环/非 JSON/非法时间/operation 失败关闭。
2. Console server/asset 单测证明第 4 个 asset 经过 realpath/type/UTF-8/size/SHA-256 复验,只从 same-origin 固定 GET path 返回;CSP、Host、Origin、session 和 upstream route vocabulary 不扩大。
3. 真实浏览器证明导出按钮初始禁用、读取后可用、下载文件可解析且 digest 可复算、恶意 HTML/credential-like 值和 Copilot output 不存在、alias 可关联;导出期间 upstream request 计数不增加,390px 无横向溢出且 0 console error/warning。
4. Console/distribution/package/dependency/Cluster deployment 审计零 finding,真实 Admin image 和 host-published Console 能取得第 4 个 asset18-package、Cluster Admin、backend 与 14 档 Local artifact 全部通过后,本 ADR 才转为 Accepted 并进行 D-330 阶段提交。
## 验收结果
2026-08-16D-330 完成以下发布门并接受本决策:
- 生成器、Console server、产品镜像契约与负面架构审计定向测试 24/24;`@qinglong/cluster-admin` 完整测试 382 pass、3 条件 skip、0 fail18-package clean build/test 退出 0backend 1,224 pass、2 条件 skip、0 fail。
- 真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot 模型文本验证纯文本渲染与固定白名单脱敏;3 次显式读取前后,导出没有增加第 4 次 upstream 请求。下载的 3,611-byte JSON 可复算顶层 digest,同一 Project/Run alias 可关联,敏感样本零命中;390×844 无横向溢出,0 console error/warning,清空后内存账本与导出能力同步归零。
- 真实 arm64 `qinglong3-cluster-admin:d330-local` 为 344,543,263 bytes;在 UID/GID 10001、只读根、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB 与 32 PIDs 下通过 10 个产品命令、原生/host-published Console、第 4 个 asset 和内置分发物契约。
- npm pack dry-run 为 246 files、267,731-byte tarball、1,665,996-byte unpackedpackage/dependency/Edge import/Cluster deployment/image release/Console/distribution 审计全部零 finding。workspace 保持 18 package、`singleSourcePackages=[]``shallowSourcePackages=[]`1,199 个源码中 1,181 个位于包内职责目录,Cluster Admin 为 120 个源码、119 个嵌套源码。
- 14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 保持 4,493,043/4,493,175 bytesMCP 保持 7,315,930/7,316,038 bytes,证明证据包未进入路由设备闭包。
- PostgreSQL 18.6 arm64 physical HA 142/142timeline `1→2`,私有报告 SHA-256 为 `c9feb83c98ad2269c7649bd0869921d9dee7cfd00c9bc1a8a7879d81630d37c7`;离线证据审计零 finding,容器、网络和卷零残留。D-330 本身没有 schema、migration、SQL、role、Pool 或连接拓扑变化。
+1
View File
@@ -425,6 +425,7 @@
| [ADR-0419](./ADR-0419-loopback-read-only-cluster-copilot-console.md) | Loopback-only Cluster Copilot 只读 Console | Accepted |
| [ADR-0420](./ADR-0420-signed-admin-oci-console-workstation-distribution.md) | 以签名 Admin OCI 分发 Cluster Copilot 工作站 Console | Accepted |
| [ADR-0421](./ADR-0421-explicit-cluster-run-task-workflow-observation-console.md) | 显式 Cluster Run、Task 与 Workflow 观察台 | Accepted |
| [ADR-0422](./ADR-0422-browser-local-redacted-cluster-evidence-bundle.md) | 浏览器本地生成的脱敏 Cluster Evidence Bundle | Accepted |
## 规则
+9
View File
@@ -18,6 +18,15 @@ responses and a closed CSP, renders all returned data only via `textContent`,
and keeps start/diagnose/cancel, polling, cache, WebSocket, ServiceWorker and
legacy session authority absent.
The fourth digest-bound Console asset builds an explicit redacted evidence
bundle from only the current page's completed reads. The in-memory ledger is
bounded to the newest 16 entries and 8 MiB; export makes no network request and
produces at most 512 KiB of UTF-8 JSON. Identifiers are correlated only through
per-bundle typed aliases, while free text, names, paths, commands, inputs,
outputs, environment, errors, credentials, unknown fields and Copilot model
text are omitted. Per-fact and top-level SHA-256 values provide self-integrity,
not a server signature, durable audit or action authority.
The reviewed operator-workstation setup, private-file ceremony, release
verification, preflight and session lifecycle are documented in
`deploy/console/ql3-cluster-copilot/README.md`. Native execution binds host
@@ -315,6 +315,25 @@ h3 {
padding-bottom: 1rem;
border-bottom: 1px solid var(--line);
}
.evidence-actions {
display: grid;
justify-items: end;
gap: 0.55rem;
}
.ledger-meta {
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
font-size: 0.64rem;
color: var(--muted);
}
.evidence-buttons {
display: grid;
grid-template-columns: repeat(2, minmax(0, auto));
gap: 0.45rem;
}
.evidence-buttons button {
padding: 0.52rem 0.65rem;
font-size: 0.72rem;
}
.status-chip {
border: 1px solid currentColor;
border-radius: 999px;
@@ -483,6 +502,17 @@ footer {
.evidence-panel {
padding: 1.1rem;
}
.evidence-header {
flex-direction: column;
}
.evidence-actions {
width: 100%;
justify-items: start;
}
.evidence-buttons {
width: 100%;
grid-template-columns: 1fr 1fr;
}
.ledger-entry {
padding-left: 2.8rem;
}
@@ -32,6 +32,7 @@
workflow_event_list: 'Workflow Run Events',
workflow_step_list: 'Workflow Run Steps',
});
const bundleApi = globalThis.QingLongEvidenceBundle;
const sessionForm = document.getElementById('session-form');
const sessionInput = document.getElementById('session-token');
const controls = document.getElementById('console-controls');
@@ -39,8 +40,14 @@
const emptyState = document.getElementById('empty-state');
const message = document.getElementById('message');
const statusChip = document.getElementById('status-chip');
const ledgerMeta = document.getElementById('ledger-meta');
const exportButton = document.getElementById('export-evidence');
const clearButton = document.getElementById('clear-evidence');
const evidenceRecords = [];
let sessionToken = '';
let busy = false;
let exporting = false;
let evidenceBytes = 0;
const value = function (id) {
return document.getElementById(id).value.trim();
@@ -55,6 +62,21 @@
message.dataset.tone = tone || 'neutral';
};
const updateLedgerState = function () {
const count = evidenceRecords.length;
ledgerMeta.textContent =
String(count) +
'/' +
String(bundleApi.limits.maximumRecords) +
' 条 · ' +
String(Math.ceil(evidenceBytes / 1024)) +
' KiB 原始事实';
emptyState.hidden = count !== 0;
ledger.hidden = count === 0;
exportButton.disabled = busy || exporting || count === 0;
clearButton.disabled = busy || exporting || count === 0;
};
const setBusy = function (next) {
busy = next;
document.querySelectorAll('[data-read]').forEach(function (button) {
@@ -67,6 +89,7 @@
statusChip.textContent = '只读就绪';
statusChip.dataset.tone = 'success';
}
updateLedgerState();
};
const base = function (operation) {
@@ -179,6 +202,14 @@
const appendEvidence = function (operation, request, response) {
const fact = response.result.result;
const observedAtMs = Date.now();
const record = {
operation: operation,
observedAtMs: observedAtMs,
request: request,
fact: fact,
};
const recordBytes = bundleApi.measureClusterConsoleEvidenceRecord(record);
const entry = document.createElement('li');
entry.className = 'ledger-entry';
const header = document.createElement('header');
@@ -189,7 +220,7 @@
time.textContent = new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'short',
timeStyle: 'medium',
}).format(new Date());
}).format(new Date(observedAtMs));
output.tabIndex = 0;
output.textContent = JSON.stringify(fact, null, 2);
header.append(title, time);
@@ -205,8 +236,20 @@
entry.append(button);
}
ledger.prepend(entry);
emptyState.hidden = true;
ledger.hidden = false;
evidenceRecords.push({ record: record, bytes: recordBytes, entry: entry });
evidenceBytes += recordBytes;
let evicted = 0;
while (
evidenceRecords.length > bundleApi.limits.maximumRecords ||
evidenceBytes > bundleApi.limits.maximumRawBytes
) {
const oldest = evidenceRecords.shift();
evidenceBytes -= oldest.bytes;
oldest.entry.remove();
evicted += 1;
}
updateLedgerState();
return evicted;
};
const execute = async function (operation, prepared) {
@@ -236,9 +279,13 @@
: 'console_request_failed',
);
}
appendEvidence(operation, body, responseBody);
const evicted = appendEvidence(operation, body, responseBody);
setMessage(
labels[operation] + ' 已加入本页证据账本。刷新页面会清空。',
labels[operation] +
' 已加入本页证据账本。' +
(evicted === 0
? '刷新页面会清空。'
: '为保持容量上限,已淘汰最旧记录。'),
'success',
);
} catch (error) {
@@ -254,6 +301,72 @@
}
};
const clearEvidence = function () {
for (const evidence of evidenceRecords) evidence.entry.remove();
evidenceRecords.length = 0;
evidenceBytes = 0;
statusChip.textContent = '账本已清空';
statusChip.dataset.tone = 'success';
updateLedgerState();
setMessage('本页证据账本已清空;没有向服务端发送请求。', 'success');
};
const exportEvidence = async function () {
if (busy || exporting || evidenceRecords.length === 0) return;
exporting = true;
updateLedgerState();
setMessage('正在本页内存中生成脱敏证据包…');
try {
const generatedAtMs = Date.now();
const bundle = await bundleApi.createClusterConsoleEvidenceBundle(
evidenceRecords.map(function (evidence) {
return evidence.record;
}),
generatedAtMs,
);
const encoded = bundleApi.serializeClusterConsoleEvidenceBundle(bundle);
const blob = new Blob([encoded], {
type: 'application/json;charset=utf-8',
});
const objectUrl = URL.createObjectURL(blob);
try {
const anchor = document.createElement('a');
anchor.download =
'qinglong-cluster-evidence-' +
new Date(generatedAtMs).toISOString().replaceAll(':', '-') +
'.json';
anchor.href = objectUrl;
anchor.rel = 'noopener';
document.body.append(anchor);
anchor.click();
anchor.remove();
} finally {
URL.revokeObjectURL(objectUrl);
}
statusChip.textContent = '脱敏包已生成';
statusChip.dataset.tone = 'success';
setMessage(
'已下载 ' +
String(bundle.source.entryCount) +
' 条脱敏事实;未发起额外 Cluster 读取。',
'success',
);
} catch (error) {
statusChip.textContent = '导出失败';
statusChip.dataset.tone = 'failed';
setMessage(
'无法导出:' +
(error instanceof Error
? error.message
: 'cluster_evidence_bundle_failed'),
'error',
);
} finally {
exporting = false;
updateLedgerState();
}
};
sessionForm.addEventListener('submit', function (event) {
event.preventDefault();
const candidate = sessionInput.value.trim();
@@ -290,8 +403,18 @@
});
});
exportButton.addEventListener('click', function () {
void exportEvidence();
});
clearButton.addEventListener('click', clearEvidence);
window.addEventListener('pagehide', function () {
sessionToken = '';
evidenceRecords.length = 0;
evidenceBytes = 0;
ledger.textContent = '';
});
updateLedgerState();
})();
@@ -0,0 +1,641 @@
'use strict';
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module && module.exports) {
module.exports = api;
}
if (root && typeof root === 'object') {
Object.defineProperty(root, 'QingLongEvidenceBundle', {
configurable: false,
enumerable: false,
value: api,
writable: false,
});
}
})(typeof globalThis === 'object' ? globalThis : this, function () {
const schema = 'qinglong/cluster-console-redacted-evidence-bundle@v1';
const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1';
const limits = Object.freeze({
maximumArrayItems: 64,
maximumBundleBytes: 512 * 1024,
maximumDepth: 16,
maximumEntryFactBytes: 2 * 1024 * 1024 + 4 * 1024,
maximumObjectKeys: 256,
maximumRawBytes: 8 * 1024 * 1024,
maximumRecords: 16,
});
const operations = Object.freeze([
'inspect',
'output',
'run_list',
'run_read',
'run_event_list',
'run_step_list',
'task_list',
'task_read',
'workflow_list',
'workflow_run_list',
'workflow_run_read',
'workflow_event_list',
'workflow_step_list',
]);
const operationSet = new Set(operations);
const requestFields = Object.freeze({
inspect: ['projectId', 'requestId', 'sourceRunId'],
output: ['projectId', 'requestId', 'sourceRunId'],
run_list: [
'afterCreatedAtMs',
'afterRunId',
'limit',
'projectId',
'requestId',
],
run_read: ['projectId', 'requestId', 'runId'],
run_event_list: [
'afterSequence',
'limit',
'projectId',
'requestId',
'runId',
],
run_step_list: [
'afterStepKey',
'afterStepRunId',
'limit',
'projectId',
'requestId',
'runId',
],
task_list: ['afterTaskId', 'limit', 'projectId', 'requestId'],
task_read: ['projectId', 'requestId', 'taskId'],
workflow_list: ['packageName', 'projectId', 'requestId'],
workflow_run_list: [
'afterAdmittedAtMs',
'afterRunId',
'limit',
'packageName',
'projectId',
'requestId',
'workflowId',
],
workflow_run_read: [
'packageName',
'projectId',
'requestId',
'runId',
'workflowId',
],
workflow_event_list: [
'afterSequence',
'limit',
'packageName',
'projectId',
'requestId',
'runId',
'workflowId',
],
workflow_step_list: [
'afterStepKey',
'afterStepRunId',
'limit',
'packageName',
'projectId',
'requestId',
'runId',
'workflowId',
],
});
const identifierDomains = Object.freeze({
afterRunId: 'run',
afterStepKey: 'step',
afterStepRunId: 'step',
afterTaskId: 'task',
artifactId: 'artifact',
contentDigest: 'digest',
diagnosisRunId: 'run',
executionId: 'execution',
id: 'identifier',
modelId: 'model',
outputRef: 'artifact',
packageName: 'package',
projectId: 'project',
providerId: 'provider',
requestId: 'request',
runId: 'run',
sourceRunId: 'run',
stepKey: 'step',
stepRunId: 'step',
taskId: 'task',
triggerId: 'trigger',
workflowId: 'workflow',
workerId: 'worker',
});
const safeContainers = new Set([
'attempts',
'counts',
'events',
'items',
'metadata',
'next',
'reference',
'run',
'runs',
'source',
'step',
'steps',
'summary',
'target',
'task',
'tasks',
'usage',
'workflow',
'workflows',
]);
const safeBooleans = new Set([
'active',
'archived',
'available',
'cancelRequested',
'enabled',
'hasMore',
'outputAvailable',
'ready',
'replayed',
'tailComplete',
'terminal',
'truncated',
]);
const safeEnumKeys = new Set([
'finishReason',
'kind',
'operation',
'outcome',
'stage',
'status',
]);
const safeEnumValues = new Set([
'accepted',
'active',
'admission',
'available',
'blocked',
'cancelled',
'completed',
'completion',
'dispatch',
'dispatching',
'disabled',
'enabled',
'execution',
'failed',
'finalization',
'installed',
'local',
'lost',
'missing',
'model',
'not_found',
'pending',
'post_model',
'pre_model',
'prompt',
'quarantined',
'queued',
'ready',
'recovery',
'rejected',
'remote',
'retained',
'retired',
'retry_wait',
'run',
'running',
'skipped',
'staged',
'staging',
'step',
'stop',
'succeeded',
'system',
'task',
'terminal',
'timed_out',
'tool',
'trigger',
'unknown',
'unavailable',
'workflow',
]);
const sensitiveKey =
/credential|token|authorization|secret|session|password|cookie|private|keyring/iu;
const freeTextKey =
/text|content|stdout|stderr|command|input|output|environment|reason|error|message|description|name|path|url|uri|host|endpoint/iu;
const numericKey =
/^(?:schemaVersion|version|revision|sequence|attempt|priority|limit|offset|size|total|count|[A-Za-z0-9_]*(?:AtMs|TimeMs|DurationMs|Bytes|Tokens|Micros|Sequence|Version|Count|Limit|Offset|Size|Total))$/u;
const schemaValue = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
class ClusterConsoleEvidenceBundleError extends TypeError {
constructor() {
super('Cluster Console evidence bundle input is invalid');
this.name = 'ClusterConsoleEvidenceBundleError';
this.code = 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID';
}
}
const invalid = function () {
throw new ClusterConsoleEvidenceBundleError();
};
const plainObject = function (value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
const safeInteger = function (value) {
return Number.isSafeInteger(value) && value >= 0;
};
const canonicalValue = function (value, depth, stack) {
if (depth > limits.maximumDepth) return invalid();
if (value === null) return 'null';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'number') {
if (!Number.isFinite(value)) return invalid();
return JSON.stringify(Object.is(value, -0) ? 0 : value);
}
if (typeof value === 'string') return JSON.stringify(value);
if (!value || typeof value !== 'object') return invalid();
if (stack.has(value)) return invalid();
stack.add(value);
try {
if (Array.isArray(value)) {
if (value.length > limits.maximumArrayItems) return invalid();
return (
'[' +
value
.map(function (item) {
return canonicalValue(item, depth + 1, stack);
})
.join(',') +
']'
);
}
if (!plainObject(value)) return invalid();
const keys = Object.keys(value).sort();
if (keys.length > limits.maximumObjectKeys) return invalid();
return (
'{' +
keys
.map(function (key) {
return (
JSON.stringify(key) +
':' +
canonicalValue(value[key], depth + 1, stack)
);
})
.join(',') +
'}'
);
} finally {
stack.delete(value);
}
};
const canonicalize = function (value) {
return canonicalValue(value, 0, new WeakSet());
};
const utf8Bytes = function (value) {
return new TextEncoder().encode(value);
};
const sha256 = async function (value, cryptography) {
if (
!cryptography ||
!cryptography.subtle ||
typeof cryptography.subtle.digest !== 'function'
) {
return invalid();
}
const bytes = utf8Bytes(value);
const digest = await cryptography.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), function (byte) {
return byte.toString(16).padStart(2, '0');
}).join('');
};
const exactKeys = function (value, expected) {
if (!plainObject(value)) return false;
const actual = Object.keys(value).sort();
const normalized = expected.slice().sort();
return (
actual.length === normalized.length &&
actual.every(function (key, index) {
return key === normalized[index];
})
);
};
const validateRecord = function (record) {
if (
!exactKeys(record, ['fact', 'observedAtMs', 'operation', 'request']) ||
!operationSet.has(record.operation) ||
!safeInteger(record.observedAtMs) ||
!plainObject(record.fact)
) {
return invalid();
}
const fields = requestFields[record.operation];
if (
!exactKeys(record.request, ['operation', 'schema'].concat(fields)) ||
record.request.schema !== requestSchema ||
record.request.operation !== record.operation
) {
return invalid();
}
canonicalize(record.request);
const factCanonical = canonicalize(record.fact);
const factBytes = utf8Bytes(factCanonical).byteLength;
if (factBytes < 2 || factBytes > limits.maximumEntryFactBytes) {
return invalid();
}
return Object.freeze({ factBytes, factCanonical });
};
const createAliaser = function () {
const counters = new Map();
const values = new Map();
return function (domain, value) {
if (value === null) return null;
if (typeof value !== 'string' || value.length < 1 || value.length > 512) {
return invalid();
}
const identity = domain + '\0' + value;
const existing = values.get(identity);
if (existing) return existing;
const next = (counters.get(domain) || 0) + 1;
counters.set(domain, next);
const alias = domain + '-' + String(next).padStart(3, '0');
values.set(identity, alias);
return alias;
};
};
const sanitizeValue = function (value, operation, alias, state, depth) {
if (depth > limits.maximumDepth) return invalid();
if (Array.isArray(value)) {
return value.slice(0, limits.maximumArrayItems).map(function (item) {
if (plainObject(item) || Array.isArray(item)) {
return sanitizeValue(item, operation, alias, state, depth + 1);
}
state.omittedFieldCount += 1;
return null;
});
}
if (!plainObject(value)) return invalid();
const result = {};
for (const key of Object.keys(value).sort()) {
const candidate = value[key];
const domain = identifierDomains[key];
if (sensitiveKey.test(key)) {
state.omittedFieldCount += 1;
continue;
}
if (domain) {
if (candidate === null || typeof candidate === 'string') {
result[key] = alias(domain, candidate);
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (/Digest$/u.test(key)) {
if (candidate === null || typeof candidate === 'string') {
result[key] = alias('digest', candidate);
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (key === 'schema') {
if (typeof candidate === 'string' && schemaValue.test(candidate)) {
result[key] = candidate;
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (safeEnumKeys.has(key)) {
if (candidate === null) {
result[key] = null;
} else if (
typeof candidate === 'string' &&
(key === 'operation'
? operationSet.has(candidate)
: safeEnumValues.has(candidate))
) {
result[key] = candidate;
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (safeBooleans.has(key)) {
if (typeof candidate === 'boolean' || candidate === null) {
result[key] = candidate;
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (numericKey.test(key)) {
if (safeInteger(candidate) || candidate === null) {
result[key] = candidate;
} else {
state.omittedFieldCount += 1;
}
continue;
}
if (freeTextKey.test(key) || key === 'result') {
state.omittedFieldCount += 1;
continue;
}
if (safeContainers.has(key)) {
if (plainObject(candidate) || Array.isArray(candidate)) {
result[key] = sanitizeValue(
candidate,
operation,
alias,
state,
depth + 1,
);
} else if (candidate === null) {
result[key] = null;
} else {
state.omittedFieldCount += 1;
}
continue;
}
state.omittedFieldCount += 1;
}
return result;
};
const deepFreeze = function (value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
return value;
}
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
};
const measureClusterConsoleEvidenceRecord = function (record) {
return validateRecord(record).factBytes;
};
const createClusterConsoleEvidenceBundle = async function (
records,
generatedAtMs,
cryptography,
) {
if (
!Array.isArray(records) ||
records.length < 1 ||
records.length > limits.maximumRecords ||
!safeInteger(generatedAtMs)
) {
return invalid();
}
const cryptoProvider = cryptography || globalThis.crypto;
const alias = createAliaser();
let totalRawBytes = 0;
const entries = [];
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
const validated = validateRecord(record);
totalRawBytes += validated.factBytes;
if (totalRawBytes > limits.maximumRawBytes) return invalid();
const state = { omittedFieldCount: 0 };
const target = sanitizeValue(
record.request,
record.operation,
alias,
state,
0,
);
const fact = sanitizeValue(
record.fact,
record.operation,
alias,
state,
0,
);
entries.push({
sequence: index + 1,
observedAtMs: record.observedAtMs,
operation: record.operation,
target,
fact,
rawFact: {
canonicalBytes: validated.factBytes,
sha256: await sha256(validated.factCanonical, cryptoProvider),
},
sanitizer: {
omittedFieldCount: state.omittedFieldCount,
rawContentIncluded: false,
},
});
}
const unsigned = {
schema,
classification: 'low_sensitive_redacted',
generatedAtMs,
generatedBy: 'browser_local',
actionAuthority: 'none',
attestation: 'none',
source: {
surface: 'cluster_field_ledger',
collection: 'explicit_user_reads_only',
entryCount: entries.length,
totalRawCanonicalBytes: totalRawBytes,
},
redaction: {
policy: 'fixed_allowlist_v1',
identifiers: 'per_bundle_typed_alias_without_mapping',
freeTextIncluded: false,
copilotOutputIncluded: false,
unknownFieldsIncluded: false,
},
integrity: {
algorithm: 'sha256',
scope: 'canonical_bundle_without_contentDigest',
serverSignature: false,
durableAudit: false,
},
entries,
};
const contentDigest = await sha256(canonicalize(unsigned), cryptoProvider);
const bundle = { ...unsigned, contentDigest };
const encoded = JSON.stringify(bundle, null, 2) + '\n';
if (utf8Bytes(encoded).byteLength > limits.maximumBundleBytes) {
return invalid();
}
return deepFreeze(bundle);
};
const serializeClusterConsoleEvidenceBundle = function (bundle) {
if (
!plainObject(bundle) ||
bundle.schema !== schema ||
typeof bundle.contentDigest !== 'string' ||
!/^[0-9a-f]{64}$/u.test(bundle.contentDigest)
) {
return invalid();
}
const encoded = JSON.stringify(bundle, null, 2) + '\n';
if (utf8Bytes(encoded).byteLength > limits.maximumBundleBytes) {
return invalid();
}
return encoded;
};
const verifyClusterConsoleEvidenceBundle = async function (
bundle,
cryptography,
) {
if (!plainObject(bundle) || typeof bundle.contentDigest !== 'string') {
return false;
}
const unsigned = {};
for (const key of Object.keys(bundle)) {
if (key !== 'contentDigest') unsigned[key] = bundle[key];
}
try {
return (
/^[0-9a-f]{64}$/u.test(bundle.contentDigest) &&
(await sha256(
canonicalize(unsigned),
cryptography || globalThis.crypto,
)) === bundle.contentDigest
);
} catch {
return false;
}
};
return Object.freeze({
ClusterConsoleEvidenceBundleError,
createClusterConsoleEvidenceBundle,
limits,
measureClusterConsoleEvidenceRecord,
operations,
schema,
serializeClusterConsoleEvidenceBundle,
verifyClusterConsoleEvidenceBundle,
});
});
@@ -7,6 +7,7 @@
<meta name="description" content="QingLong 3.0 Cluster 只读现场记录台" />
<title>QingLong 3.0 · Cluster 现场记录台</title>
<link rel="stylesheet" href="/app.css" />
<script src="/evidence-bundle.js" defer></script>
<script src="/app.js" defer></script>
</head>
<body>
@@ -109,7 +110,7 @@
<aside class="trust-note">
<span>Authority boundary</span>
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。</p>
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。脱敏导出只处理本页已读事实,不补读或上传。</p>
</aside>
</aside>
@@ -119,12 +120,19 @@
<p class="eyebrow">Explicit read ledger</p>
<h2 id="evidence-title">本页证据账本</h2>
</div>
<div class="evidence-actions">
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
<span id="ledger-meta" class="ledger-meta">0/16 条 · 0 KiB 原始事实</span>
<div class="evidence-buttons">
<button id="export-evidence" type="button" class="primary" disabled>导出脱敏包</button>
<button id="clear-evidence" type="button" disabled>清空本页</button>
</div>
</div>
</div>
<div id="empty-state" class="empty-state">
<div class="evidence-rail" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
<h3>一次读取,一条可复核事实</h3>
<p>先解锁页面并选择坐标。返回值按读取顺序保留在当前内存,reload 后清空。</p>
<p>先解锁页面并选择坐标。最近 16 条、最多 8 MiB 原始事实只保留在当前内存,reload 后清空。</p>
</div>
<ol id="ledger" class="ledger" aria-live="polite" hidden></ol>
<div id="message" class="message" role="status" aria-live="polite"></div>
@@ -133,7 +141,7 @@
<footer>
<span>Loopback only · explicit reads · zero polling</span>
<span>QingLong 3.0 incubation / D-329</span>
<span>QingLong 3.0 incubation / D-330</span>
</footer>
</div>
</body>
@@ -6,6 +6,7 @@ import { TextDecoder } from 'node:util';
export interface ClusterCopilotConsoleAssets {
readonly html: string;
readonly css: string;
readonly evidenceBundle: string;
readonly javascript: string;
}
@@ -23,19 +24,25 @@ const ASSETS = Object.freeze([
name: 'index.html',
field: 'html',
maximumBytes: 32 * 1024,
digest: 'ed8db5c26dec23e7a5237ef1cd4f5f9c3fc9f5a04a4751b7a3e0ed22dac54c42',
digest: '5d452c947a9f1266e4920cf48e7d5116b3f5ef8f9120f681124ed61f0217f5ff',
}),
Object.freeze({
name: 'app.css',
field: 'css',
maximumBytes: 64 * 1024,
digest: '54234cbba7e110de2f68fad2abd657c334b7e3e80c5d9b4f59bda7e122b4b62f',
digest: '5cf82b0a88920d106530603a7d407f852312138e5b7af5c422b3bccee785f144',
}),
Object.freeze({
name: 'evidence-bundle.js',
field: 'evidenceBundle',
maximumBytes: 32 * 1024,
digest: '6ecb14d2f59d872b889bb42c22bf0c0d2c150c90ea708fb1662d47f17f2e2095',
}),
Object.freeze({
name: 'app.js',
field: 'javascript',
maximumBytes: 32 * 1024,
digest: '61811eac6a89b097b67823ccf49b0736af6494be7b187dbdcbecfc59adb3fce0',
digest: 'f109c5b0491ba9a473e3129e35773edf38ac745b403e1f547f8252aa2932cdff',
}),
] as const);
@@ -131,6 +138,7 @@ export function loadClusterCopilotConsoleAssets(
return Object.freeze({
html: result.html!,
css: result.css!,
evidenceBundle: result.evidenceBundle!,
javascript: result.javascript!,
});
}
@@ -321,7 +321,12 @@ export async function startClusterCopilotConsoleServer(
optionKeys.push('networkBoundary');
}
const record = exactObject(options, optionKeys);
const assets = exactObject(record.assets, ['css', 'html', 'javascript']);
const assets = exactObject(record.assets, [
'css',
'evidenceBundle',
'html',
'javascript',
]);
const networkBoundary =
record.networkBoundary === undefined
? 'host-loopback'
@@ -331,6 +336,8 @@ export async function startClusterCopilotConsoleServer(
assets.html.length < 1 ||
typeof assets.css !== 'string' ||
assets.css.length < 1 ||
typeof assets.evidenceBundle !== 'string' ||
assets.evidenceBundle.length < 1 ||
typeof assets.javascript !== 'string' ||
assets.javascript.length < 1 ||
!record.executor ||
@@ -379,6 +386,15 @@ export async function startClusterCopilotConsoleServer(
);
return;
}
if (request.url === '/evidence-bundle.js') {
send(
response,
200,
'text/javascript; charset=utf-8',
assets.evidenceBundle as string,
);
return;
}
}
const operation = targetPath(request);
@@ -374,8 +374,14 @@ test('loads only digest-bound packaged assets and rejects drift', async (t) => {
const assets = loadClusterCopilotConsoleAssets(moduleDirectory);
assert.match(assets.html, /沿着证据读,不替集群做决定/);
assert.match(assets.css, /prefers-reduced-motion/);
assert.match(
assets.evidenceBundle,
/qinglong\/cluster-console-redacted-evidence-bundle@v1/,
);
assert.match(assets.evidenceBundle, /createClusterConsoleEvidenceBundle/);
assert.match(assets.javascript, /output\.textContent = JSON\.stringify/);
assert.match(assets.javascript, /run_event_list/);
assert.match(assets.javascript, /createClusterConsoleEvidenceBundle/);
assert.doesNotMatch(
assets.javascript,
/localStorage|sessionStorage|innerHTML/,
@@ -412,9 +418,21 @@ test('serves an immutable same-origin shell with a closed browser policy', async
assert.match(html.text, /Cluster field ledger/);
const css = await request(server.origin, { path: '/app.css' });
const evidenceBundle = await request(server.origin, {
path: '/evidence-bundle.js',
});
const javascript = await request(server.origin, { path: '/app.js' });
assert.equal(css.statusCode, 200);
assert.equal(evidenceBundle.statusCode, 200);
assert.equal(javascript.statusCode, 200);
assert.equal(
evidenceBundle.headers['content-type'],
'text/javascript; charset=utf-8',
);
assert.match(
evidenceBundle.text,
/qinglong\/cluster-console-redacted-evidence-bundle@v1/,
);
assert.equal(
javascript.headers['content-type'],
'text/javascript; charset=utf-8',
@@ -0,0 +1,256 @@
'use strict';
const assert = require('node:assert/strict');
const { webcrypto } = require('node:crypto');
const test = require('node:test');
const {
ClusterConsoleEvidenceBundleError,
createClusterConsoleEvidenceBundle,
limits,
measureClusterConsoleEvidenceRecord,
schema,
serializeClusterConsoleEvidenceBundle,
verifyClusterConsoleEvidenceBundle,
} = require('../assets/copilot-console/evidence-bundle.js');
const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1';
function runRecord(overrides = {}) {
return {
operation: 'run_read',
observedAtMs: 1_700_000_000_000,
request: {
schema: requestSchema,
operation: 'run_read',
projectId: 'project-customer-production',
requestId: 'console-request-sensitive',
runId: 'run-customer-production',
},
fact: {
schema: 'qinglong/bounded-run-projection@v1',
schemaVersion: 1,
status: 'succeeded',
projectId: 'project-customer-production',
runId: 'run-customer-production',
createdAtMs: 1_700_000_000_000,
finalizedAtMs: 1_700_000_001_000,
outputAvailable: true,
name: 'customer-production-nightly',
message: 'ql3c_console_do_not_export',
path: '/private/customer/run.log',
unknownField: '<img src=x onerror=alert(1)>',
},
...overrides,
};
}
function outputRecord() {
return {
operation: 'output',
observedAtMs: 1_700_000_002_000,
request: {
schema: requestSchema,
operation: 'output',
projectId: 'project-customer-production',
requestId: 'diagnosis-request-sensitive',
sourceRunId: 'run-customer-production',
},
fact: {
schema:
'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
schemaVersion: 1,
status: 'available',
projectId: 'project-customer-production',
sourceRunId: 'run-customer-production',
diagnosisRunId: 'diagnosis-run-sensitive',
reference: {
artifactId: 'artifact-sensitive',
artifactDigest: 'a'.repeat(64),
contentDigest: 'b'.repeat(64),
outputBytes: 71,
sealedAtMs: 1_700_000_001_000,
},
result: {
text: '<script>steal(credential)</script>',
command: 'curl https://attacker.invalid',
token: 'ql3c_console_do_not_export',
finishReason: 'stop',
},
usage: {
inputTokens: 20,
outputTokens: 10,
totalTokens: 30,
costMicros: 42,
},
},
};
}
test('creates one self-verifiable redacted bundle with per-bundle correlated aliases', async () => {
const records = [runRecord(), outputRecord()];
assert.ok(measureClusterConsoleEvidenceRecord(records[0]) > 1);
const bundle = await createClusterConsoleEvidenceBundle(
records,
1_700_000_003_000,
webcrypto,
);
const encoded = serializeClusterConsoleEvidenceBundle(bundle);
const parsed = JSON.parse(encoded);
assert.equal(bundle.schema, schema);
assert.equal(bundle.generatedBy, 'browser_local');
assert.equal(bundle.actionAuthority, 'none');
assert.equal(bundle.attestation, 'none');
assert.equal(bundle.source.entryCount, 2);
assert.equal(bundle.redaction.freeTextIncluded, false);
assert.equal(bundle.redaction.copilotOutputIncluded, false);
assert.equal(bundle.entries[0].target.projectId, 'project-001');
assert.equal(bundle.entries[1].target.projectId, 'project-001');
assert.equal(bundle.entries[0].target.runId, 'run-001');
assert.equal(bundle.entries[1].target.sourceRunId, 'run-001');
assert.equal(bundle.entries[0].fact.runId, 'run-001');
assert.equal(bundle.entries[1].fact.sourceRunId, 'run-001');
assert.equal(bundle.entries[1].fact.result, undefined);
assert.equal(bundle.entries[1].fact.reference.artifactId, 'artifact-001');
assert.match(bundle.entries[1].rawFact.sha256, /^[0-9a-f]{64}$/);
assert.match(bundle.contentDigest, /^[0-9a-f]{64}$/);
assert.equal(
await verifyClusterConsoleEvidenceBundle(parsed, webcrypto),
true,
);
for (const forbidden of [
'project-customer-production',
'run-customer-production',
'diagnosis-run-sensitive',
'artifact-sensitive',
'customer-production-nightly',
'ql3c_console_do_not_export',
'/private/customer/run.log',
'<img src=x onerror=alert(1)>',
'<script>steal(credential)</script>',
'attacker.invalid',
]) {
assert.doesNotMatch(encoded, new RegExp(forbidden.replaceAll('/', '\\/')));
}
parsed.entries[0].fact.status = 'failed';
assert.equal(
await verifyClusterConsoleEvidenceBundle(parsed, webcrypto),
false,
);
});
test('resets the undisclosed alias table for every bundle', async () => {
const first = await createClusterConsoleEvidenceBundle(
[runRecord()],
1_700_000_003_000,
webcrypto,
);
const second = await createClusterConsoleEvidenceBundle(
[
runRecord({
request: {
schema: requestSchema,
operation: 'run_read',
projectId: 'another-project',
requestId: 'another-request',
runId: 'another-run',
},
}),
],
1_700_000_004_000,
webcrypto,
);
assert.equal(first.entries[0].target.runId, 'run-001');
assert.equal(second.entries[0].target.runId, 'run-001');
assert.notEqual(first.contentDigest, second.contentDigest);
assert.equal(JSON.stringify(first).includes('another-run'), false);
assert.equal(
JSON.stringify(second).includes('run-customer-production'),
false,
);
});
test('fails closed on widened records, unsafe JSON and every capacity ceiling', async () => {
const error = { code: 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID' };
assert.throws(() => measureClusterConsoleEvidenceRecord(null), error);
assert.throws(
() => measureClusterConsoleEvidenceRecord({ ...runRecord(), session: 'x' }),
error,
);
assert.throws(
() =>
measureClusterConsoleEvidenceRecord({
...runRecord(),
operation: 'cancel',
}),
error,
);
assert.throws(
() =>
measureClusterConsoleEvidenceRecord({
...runRecord(),
request: { ...runRecord().request, endpoint: 'https://example.test' },
}),
error,
);
assert.throws(
() =>
measureClusterConsoleEvidenceRecord({
...runRecord(),
fact: { items: Array.from({ length: 65 }, () => ({})) },
}),
error,
);
const cyclic = runRecord();
cyclic.fact.loop = cyclic.fact;
assert.throws(() => measureClusterConsoleEvidenceRecord(cyclic), error);
assert.throws(
() =>
measureClusterConsoleEvidenceRecord({
...runRecord(),
fact: { message: 'x'.repeat(limits.maximumEntryFactBytes + 1) },
}),
error,
);
await assert.rejects(
createClusterConsoleEvidenceBundle([], 1_700_000_003_000, webcrypto),
ClusterConsoleEvidenceBundleError,
);
await assert.rejects(
createClusterConsoleEvidenceBundle(
Array.from({ length: limits.maximumRecords + 1 }, () => runRecord()),
1_700_000_003_000,
webcrypto,
),
ClusterConsoleEvidenceBundleError,
);
const largeRecords = Array.from({ length: 5 }, (_, index) =>
runRecord({
observedAtMs: 1_700_000_000_000 + index,
fact: { message: String(index) + 'x'.repeat(2 * 1024 * 1024 - 64) },
}),
);
await assert.rejects(
createClusterConsoleEvidenceBundle(
largeRecords,
1_700_000_003_000,
webcrypto,
),
ClusterConsoleEvidenceBundleError,
);
assert.throws(
() =>
serializeClusterConsoleEvidenceBundle({
schema,
contentDigest: 'a'.repeat(64),
padding: 'x'.repeat(limits.maximumBundleBytes),
}),
error,
);
});
@@ -260,15 +260,23 @@ child.stdout.on('data', (chunk) => {
response.on('data', (chunk) => chunks.push(chunk));
response.once('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
if (response.statusCode !== 200 || !body.includes('Cluster field ledger') || !body.includes('/app.css') || !body.includes('/app.js')) { finish(45); return; }
if (response.statusCode !== 200 || !body.includes('Cluster field ledger') || !body.includes('/app.css') || !body.includes('/evidence-bundle.js') || !body.includes('/app.js')) { finish(45); return; }
get(started.origin + '/evidence-bundle.js', (assetResponse) => {
const assetChunks = [];
assetResponse.on('data', (chunk) => assetChunks.push(chunk));
assetResponse.once('end', () => {
const asset = Buffer.concat(assetChunks).toString('utf8');
if (assetResponse.statusCode !== 200 || !asset.includes('qinglong/cluster-console-redacted-evidence-bundle@v1') || !asset.includes('createClusterConsoleEvidenceBundle')) { finish(48); return; }
child.once('close', (status, signal) => {
if (status !== 0 || signal !== null) { finish(46); return; }
settled = true;
clearTimeout(timeout);
process.stdout.write(JSON.stringify({ loopback: true, assets: true, cleanShutdown: true }));
process.stdout.write(JSON.stringify({ loopback: true, assets: true, evidenceBundle: true, cleanShutdown: true }));
});
child.kill('SIGTERM');
});
}).once('error', () => finish(49));
});
}).once('error', () => finish(47));
});
`;
@@ -307,6 +315,7 @@ child.stdout.on('data', (chunk) => {
if (
result?.loopback !== true ||
result?.assets !== true ||
result?.evidenceBundle !== true ||
result?.cleanShutdown !== true
) {
fail('Console live contract drifted');
@@ -442,13 +451,17 @@ process.once('SIGINT', () => child.kill('SIGINT'));
process.execPath,
[
'-e',
"require('node:http').get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field ledger'))process.exit(2);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js')}));});}).on('error',()=>process.exit(3));",
"const h=require('node:http');h.get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field ledger')||!b.includes('/evidence-bundle.js'))process.exit(2);h.get(process.argv[1]+'/evidence-bundle.js',(a)=>{const d=[];a.on('data',(x)=>d.push(x));a.on('end',()=>{const s=Buffer.concat(d).toString('utf8');if(a.statusCode!==200||!s.includes('createClusterConsoleEvidenceBundle'))process.exit(4);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js'),evidenceBundle:true}));});}).on('error',()=>process.exit(5));});}).on('error',()=>process.exit(3));",
origin,
],
{ encoding: 'utf8', timeout: 5_000 },
);
const probeFact = JSON.parse(probe);
if (probeFact.status !== 200 || probeFact.assets !== true) {
if (
probeFact.status !== 200 ||
probeFact.assets !== true ||
probeFact.evidenceBundle !== true
) {
fail('published Console host read drifted');
}
@@ -536,6 +549,7 @@ function main() {
contextReadiness: true,
consoleLoopback: true,
consoleAssets: true,
consoleEvidenceBundle: true,
consolePublishedHostAddress: '127.0.0.1',
consoleDistributionEmbedded: true,
isolation: Object.freeze({
+54 -1
View File
@@ -15,6 +15,7 @@ const REQUIRED_FILES = Object.freeze([
CLIENT_FILE,
ASSET_ROOT + '/index.html',
ASSET_ROOT + '/app.css',
ASSET_ROOT + '/evidence-bundle.js',
ASSET_ROOT + '/app.js',
DEPLOYMENT_ROOT + '/README.md',
DEPLOYMENT_ROOT + '/client-config.example.json',
@@ -130,6 +131,8 @@ function auditClusterCopilotConsole(options = {}) {
"'/api/v1/observe/run-list': 'run_list'",
"'/api/v1/observe/task-list': 'task_list'",
"'/api/v1/observe/workflow-list': 'workflow_list'",
"request.url === '/evidence-bundle.js'",
'assets.evidenceBundle as string',
"default-src 'none'",
"frame-ancestors 'none'",
"'cache-control': 'no-store'",
@@ -168,12 +171,17 @@ function auditClusterCopilotConsole(options = {}) {
'读取 Workflow Runs',
'显式读取诊断内容',
'模型文本是不可信内容',
'导出脱敏包',
'/evidence-bundle.js',
]);
expectFragments(ASSET_ROOT + '/app.js', [
"credentials: 'omit'",
"cache: 'no-store'",
'output.textContent = JSON.stringify(fact, null, 2)',
'nextPage(operation, request, fact)',
'measureClusterConsoleEvidenceRecord(record)',
'createClusterConsoleEvidenceBundle',
'URL.revokeObjectURL(objectUrl)',
"sessionToken = ''",
]);
rejectFragments(ASSET_ROOT + '/app.js', [
@@ -188,6 +196,35 @@ function auditClusterCopilotConsole(options = {}) {
'cancel',
'http://',
'https://',
'navigator.',
'setTimeout(',
]);
expectFragments(ASSET_ROOT + '/evidence-bundle.js', [
'qinglong/cluster-console-redacted-evidence-bundle@v1',
'maximumRecords: 16',
'maximumRawBytes: 8 * 1024 * 1024',
'maximumBundleBytes: 512 * 1024',
"generatedBy: 'browser_local'",
"actionAuthority: 'none'",
"attestation: 'none'",
"policy: 'fixed_allowlist_v1'",
'freeTextIncluded: false',
'copilotOutputIncluded: false',
'createClusterConsoleEvidenceBundle',
'verifyClusterConsoleEvidenceBundle',
]);
rejectFragments(ASSET_ROOT + '/evidence-bundle.js', [
'fetch(',
'XMLHttpRequest',
'WebSocket',
'EventSource',
'navigator.',
'localStorage',
'sessionStorage',
'setTimeout(',
'setInterval(',
'http://',
'https://',
]);
expectFragments(ASSET_ROOT + '/app.css', [
'@media (max-width: 520px)',
@@ -202,6 +239,11 @@ function auditClusterCopilotConsole(options = {}) {
'--port=0',
'TLS 1.3 `GET /readyz`',
'excluded from small router Edge/Standalone artifacts',
'Export a redacted evidence bundle',
'Export performs zero BFF or Cluster',
'8 MiB',
'512 KiB',
'server signature',
]);
rejectFragments(DEPLOYMENT_ROOT + '/README.md', [
'--host=0.0.0.0',
@@ -222,6 +264,8 @@ function auditClusterCopilotConsole(options = {}) {
'runPublishedConsoleContract(image);',
'consoleLoopback: true',
'consoleAssets: true',
'consoleEvidenceBundle: true',
"started.origin + '/evidence-bundle.js'",
"consolePublishedHostAddress: '127.0.0.1'",
'consoleDistributionEmbedded: true',
]);
@@ -341,7 +385,16 @@ function auditClusterCopilotConsole(options = {}) {
]),
legacyUiCoupled: false,
kubernetesResident: false,
assetCount: 3,
assetCount: 4,
evidenceBundle: Object.freeze({
lifecycle: 'browser-local-explicit-export',
maximumRecords: 16,
maximumRawBytes: 8 * 1024 * 1024,
maximumBundleBytes: 512 * 1024,
upstreamReadsOnExport: 0,
attestation: 'none',
actionAuthority: 'none',
}),
sourceFileCount: 4,
findings: Object.freeze(findings),
compatible: findings.length === 0,
@@ -57,6 +57,8 @@ test('binds the live image gate to native and container-published loopback', ()
assert.match(source, /runConsoleContract\(image\);/);
assert.match(source, /consoleLoopback: true/);
assert.match(source, /consoleAssets: true/);
assert.match(source, /consoleEvidenceBundle: true/);
assert.match(source, /createClusterConsoleEvidenceBundle/);
assert.match(source, /function runPublishedConsoleContract\(image\)/);
assert.match(
source,
@@ -42,7 +42,16 @@ test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => {
],
legacyUiCoupled: false,
kubernetesResident: false,
assetCount: 3,
assetCount: 4,
evidenceBundle: {
lifecycle: 'browser-local-explicit-export',
maximumRecords: 16,
maximumRawBytes: 8 * 1024 * 1024,
maximumBundleBytes: 512 * 1024,
upstreamReadsOnExport: 0,
attestation: 'none',
actionAuthority: 'none',
},
sourceFileCount: 4,
findings: [],
compatible: true,
@@ -128,6 +137,29 @@ test('rejects browser persistence, dynamic rendering and product drift', () => {
);
});
test('rejects evidence export network, persistence and authority widening', () => {
for (const injected of [
'fetch(',
'navigator.share(',
'localStorage',
'setTimeout(',
]) {
const report = auditClusterCopilotConsole({
root,
readFile: intercept(
'packages/ql3-cluster-admin/assets/copilot-console/evidence-bundle.js',
(source) => source + '\n// ' + injected + '\n',
),
});
assert.equal(report.compatible, false);
assert.ok(
report.findings.some(
({ code }) => code === 'CLUSTER_COPILOT_CONSOLE_AUTHORITY_WIDENED',
),
);
}
});
test('rejects coupling into the legacy UI or Kubernetes workloads', () => {
const legacyTarget = 'src/pages/login/index.tsx';
const legacy = auditClusterCopilotConsole({