mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): export redacted cluster evidence bundle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/24,Cluster Admin 382 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,224 pass/2 条件 skip/0 fail。真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot model text 验证纯文本与零泄漏;3 次显式读取后导出 3,611-byte 可复算 JSON,upstream 计数仍为 3,390×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 unpacked;package/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 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 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 个显式只读 operation;browser 仍不能提交 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/23,Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0,backend 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 unpacked;package/dependency/Cluster deployment/image release/Console/distribution 审计零 finding,workspace 为 18 package 且无 single-source/shallow package。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 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 秒 stop,compact 为 192 MiB/0.25 CPU/32 PIDs,standard 为 512 MiB/1 CPU/64 PIDs。启动器/验签器/分发审计与 Console 定向门 30/30;release/OCI/SBOM 定向门 75/75,package/dependency/Edge/Cluster deployment/Console distribution 审计全部零 finding。Cluster Admin 376 pass/3 条件 skip,完整 18-package clean build/test 退出 0,backend 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 modules,application+AI 仍为 4,493,043/4,493,175 bytes,MCP 仍为 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 fail;package/dependency/Edge import/Cluster deployment/Console 审计零 finding,OCI/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 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 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
|
||||
- 关联 RFC:QL-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 一律不进入 bundle;Copilot 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 个 asset;18-package、Cluster Admin、backend 与 14 档 Local artifact 全部通过后,本 ADR 才转为 Accepted 并进行 D-330 阶段提交。
|
||||
|
||||
## 验收结果
|
||||
|
||||
2026-08-16,D-330 完成以下发布门并接受本决策:
|
||||
|
||||
- 生成器、Console server、产品镜像契约与负面架构审计定向测试 24/24;`@qinglong/cluster-admin` 完整测试 382 pass、3 条件 skip、0 fail,18-package clean build/test 退出 0,backend 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 unpacked;package/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 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 7,315,930/7,316,038 bytes,证明证据包未进入路由设备闭包。
|
||||
- PostgreSQL 18.6 arm64 physical HA 142/142,timeline `1→2`,私有报告 SHA-256 为 `c9feb83c98ad2269c7649bd0869921d9dee7cfd00c9bc1a8a7879d81630d37c7`;离线证据审计零 finding,容器、网络和卷零残留。D-330 本身没有 schema、migration、SQL、role、Pool 或连接拓扑变化。
|
||||
@@ -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 |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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>
|
||||
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
|
||||
<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,14 +260,22 @@ 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; }
|
||||
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 }));
|
||||
});
|
||||
child.kill('SIGTERM');
|
||||
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, 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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user