mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): verify cluster evidence offline
This commit is contained in:
@@ -136,6 +136,20 @@ the file before sharing it. Generation uses no upload, clipboard/share API,
|
|||||||
browser storage, worker, timer or service-side temporary file. **Clear page**
|
browser storage, worker, timer or service-side temporary file. **Clear page**
|
||||||
removes the current in-memory ledger without sending a request.
|
removes the current in-memory ledger without sending a request.
|
||||||
|
|
||||||
|
Verify the downloaded file independently with the same reviewed Admin release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ql3-cluster-admin evidence-verify \
|
||||||
|
--bundle=/absolute/qinglong-cluster-evidence.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The verifier performs one offline read-only file read, rejects noncanonical
|
||||||
|
JSON and structural/redaction/typed-alias drift, and independently recomputes
|
||||||
|
the bundle digest. Its output deliberately says that raw fact digests were not
|
||||||
|
recomputed because those sensitive facts are absent; it does not claim a
|
||||||
|
server signature, attestation, durable audit or action authority. The command
|
||||||
|
does not upload, mutate or write the bundle.
|
||||||
|
|
||||||
## Run the verified image
|
## Run the verified image
|
||||||
|
|
||||||
Create a dedicated Docker network whose egress is restricted by the host
|
Create a dedicated Docker network whose egress is restricted by the host
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-16):
|
最新增量证据(2026-08-16):
|
||||||
|
|
||||||
|
- D-331/ADR-0423(已接受):`@qinglong/cluster-admin` 在既有 `copilot-console/` 职责目录增加独立 TypeScript evidence verifier,并以第 11 个静态产品命令 `ql3-cluster-admin evidence-verify --bundle=/absolute/evidence.json` 交付。它只通过 no-follow/stable descriptor 读取一个最大 512 KiB 的 canonical absolute UTF-8 JSON,拒绝 BOM、CRLF、minified、duplicate-key、symlink、relative path 与读取中漂移;独立固定检查 exact bundle/request shape、13 operations、16-entry/8 MiB/64-item/depth/key ceiling、安全字段白名单和顺序 typed alias,再重算不含 `contentDigest` 的 canonical SHA-256。结果明确只证明 `bundleDigest=verified`;没有原始 fact 时逐条 digest 为 `not_recomputed_without_raw_facts`,server signature/attestation/durable audit 均未验证且 action authority 为 none。实现不读 stdin/environment/context,不联网、不写文件、不新增 package、依赖、route、listener、数据库、Kubernetes workload 或 Edge/Standalone closure。定向门 18/18,Cluster Admin 387 pass/3 条件 skip,18-package clean build/test 退出 0,backend 1,225 pass/2 条件 skip/0 fail。真实 arm64 Admin image `qinglong3-cluster-admin:d331-local` 为 344,567,527 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 11 个命令、有效 bundle、tamper rejection 与零 verifier file write。npm pack dry-run 为 250 files、271,238-byte tarball、1,690,196-byte unpacked;结构/依赖/部署/发布/Console 审计零 finding,workspace 保持 18 package、无 single/shallow package,Cluster Admin 122 个源码中 121 个位于领域目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes。因本门没有 schema/migration/SQL/role/Pool/连接拓扑变化,不重复冒充执行 HA,复用紧邻 D-330 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一门应完成公开 release digest 的外部工作站 ceremony,不得给 verifier 增加上传、签名或行动能力。
|
||||||
- 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-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-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-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 常驻服务。
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# ADR-0423:独立、离线且无 Authority 的 Cluster Evidence Verifier
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-16
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-331、Phase 2
|
||||||
|
- 扩展:ADR-0422
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
ADR-0422 的浏览器生成器能计算 evidence bundle 顶层 SHA-256,但同一段浏览器代码自验只能发现传输后的普通改写,不能独立证明固定结构、脱敏字段和 typed alias 约束没有被放宽。把 bundle 上传到 Cluster、第三方 SaaS 或新的服务端 verifier 会重新引入网络、credential、持久化和来源混淆;为一个文件校验器再拆 workspace package 也会扩大低配设备元数据与供应链审计面。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. 在既有 `@qinglong/cluster-admin` 的 `copilot-console/` 职责目录实现独立 TypeScript verifier,通过 `ql3-cluster-admin evidence-verify --bundle=/absolute/evidence.json` 和同包 direct binary `ql3-copilot-evidence-verify` 交付。它是第 11 个静态产品命令,不新增 package、生产依赖、route、listener、数据库、对象存储、Kubernetes workload 或常驻进程。
|
||||||
|
2. verifier 只读取一个用户显式给出的 canonical absolute regular file。它使用 read-only、no-follow descriptor,限制路径为 4 KiB、文件为 512 KiB,并在读取前后复验 device/inode/mode/owner/size/time;不读取 stdin、environment 或 operator context,不建立网络连接,不写文件。
|
||||||
|
3. 输入必须是 fatal UTF-8、无 BOM、固定 two-space pretty JSON 且带单个末尾换行。JSON round-trip 必须逐字节相等,从而拒绝 duplicate key、CRLF、minified/alternate whitespace 和非规范序列化。
|
||||||
|
4. verifier 独立重写并固定校验 `qinglong/cluster-console-redacted-evidence-bundle@v1` 的 exact shape、13 个 operation/request field 集合、1..16 entries、8 MiB raw canonical 总量、64-item array、16-depth/256-key 上限、安全 enum/boolean/number/container allowlist、顺序 typed alias 与固定无 authority 声明。未知字段、自由文本、错误/message、路径、credential、model output 或虚假 signature/attestation 均失败关闭。
|
||||||
|
5. verifier 独立 canonicalize 不含 `contentDigest` 的 bundle 并重算 SHA-256,返回 `qinglong/cluster-console-evidence-verification@v1`。结果只声明 `bundleDigest=verified`;由于 bundle 刻意不携带原始 fact,逐条 `rawFact.sha256` 明确为 `not_recomputed_without_raw_facts`。`serverSignature|attestation|durableAudit` 均为 `not_verified`,`actionAuthority=none`。
|
||||||
|
6. 成功输出只含 schema、digest、entry/byte count 与上述 limitation;失败输出只含稳定 code 和通用消息,不回显文件路径、原始标识或输入内容。usage/invalid 分别退出 64/65。
|
||||||
|
7. verifier 只随 Cluster Admin npm/OCI 分发,继续不进入 Edge、Standalone、AI 或 MCP Local artifact closure。它不把浏览器观察升级为集群来源证明,也不替代 release signature/provenance 或人工审阅。
|
||||||
|
|
||||||
|
## 不选择
|
||||||
|
|
||||||
|
- **复用浏览器生成器作为 CLI verifier**:会让生成和验证共享同一实现错误,缺少真正的交叉实现证据。
|
||||||
|
- **把原始 fact 一并放进 bundle 以复算逐条 digest**:会重新泄漏本门刻意剔除的用户命名、路径、错误和模型文本。
|
||||||
|
- **服务端上传验证**:需要新 route、网络和保存策略,并容易被误解为服务端背书。
|
||||||
|
- **单独拆 `@qinglong/evidence-verifier` package**:没有独立部署或依赖边界,反而违反 18-package 硬上限和包内领域目录策略。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
1. 浏览器生成器输出必须由独立 verifier 接受;digest 改写、即使重新签顶层 digest 的结构扩宽、alias gap、虚假证明、总量/sequence 漂移必须拒绝。
|
||||||
|
2. BOM、CRLF、minified、duplicate-key、relative path、symlink 和超限文件必须拒绝;成功/失败 CLI 都不得回显输入路径和私有样本。
|
||||||
|
3. Console 架构审计必须拒绝 verifier 的 network、write、stdin 和 environment authority;package boundary 必须保持 18 packages、零 single/shallow package。
|
||||||
|
4. 真实 Admin image 必须在 non-root、read-only root、network none、drop ALL、no-new-privileges、128 MiB、0.25 CPU、32 PIDs 下完成有效验证、tamper rejection 和零 verifier file write。
|
||||||
|
5. Cluster Admin、18-package、backend、npm pack、dependency/deployment/release 与全部 Local artifact 门通过后才接受本决策。
|
||||||
|
|
||||||
|
## 验收结果
|
||||||
|
|
||||||
|
2026-08-16,D-331 完成以下发布门并接受本决策:
|
||||||
|
|
||||||
|
- 生成器/独立 verifier 与产品/架构审计定向门 18/18;Cluster Admin 完整测试 387 pass、3 条件 skip、0 fail,18-package clean build/test 退出 0,backend 1,225 pass、2 条件 skip、0 fail。
|
||||||
|
- 有效浏览器 bundle 的结构、typed alias、顶层 digest 与 limitation 由独立 TypeScript 实现验证;tamper、重新计算顶层 digest 后的自由文本扩宽、alias gap、虚假 server signature、raw byte/sequence 漂移,以及 BOM/CRLF/minified/duplicate-key/symlink/relative/oversize 输入全部失败关闭。成功和失败输出均未泄漏私有文件名、路径或原始标识。
|
||||||
|
- 真实 arm64 `qinglong3-cluster-admin:d331-local` 为 344,567,527 bytes;在 UID/GID 10001、只读根、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB 与 32 PIDs 下通过 11 个产品命令、原生/host-published Console、有效验证、tamper rejection 和 verifier 零额外文件写。
|
||||||
|
- npm pack dry-run 为 250 files、271,238-byte tarball、1,690,196-byte unpacked。package/dependency/Edge import/Cluster deployment/image release/Console/distribution 审计零 finding;workspace 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,1,201 个源码中 1,183 个位于职责目录,Cluster Admin 为 122/121。
|
||||||
|
- 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,证明 verifier 没有进入路由设备闭包。
|
||||||
|
- 本门没有 schema、migration、SQL、role、Pool 或连接拓扑变化,因此不重复冒充执行 PostgreSQL HA;复用紧邻 D-330 已完成的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 物理 HA 基线。下一独立门仍是公开 release digest 的外部工作站验证 ceremony,而不是给 verifier 增加上传、签名或行动能力。
|
||||||
@@ -426,6 +426,7 @@
|
|||||||
| [ADR-0420](./ADR-0420-signed-admin-oci-console-workstation-distribution.md) | 以签名 Admin OCI 分发 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-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 |
|
| [ADR-0422](./ADR-0422-browser-local-redacted-cluster-evidence-bundle.md) | 浏览器本地生成的脱敏 Cluster Evidence Bundle | Accepted |
|
||||||
|
| [ADR-0423](./ADR-0423-independent-offline-cluster-evidence-verifier.md) | 独立、离线且无 Authority 的 Cluster Evidence Verifier | Accepted |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ outputs, environment, errors, credentials, unknown fields and Copilot model
|
|||||||
text are omitted. Per-fact and top-level SHA-256 values provide self-integrity,
|
text are omitted. Per-fact and top-level SHA-256 values provide self-integrity,
|
||||||
not a server signature, durable audit or action authority.
|
not a server signature, durable audit or action authority.
|
||||||
|
|
||||||
|
The same package provides an independently implemented offline verifier:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ql3-cluster-admin evidence-verify \
|
||||||
|
--bundle=/absolute/qinglong-cluster-evidence.json
|
||||||
|
```
|
||||||
|
|
||||||
|
It performs one read-only, no-follow file read, revalidates the complete fixed
|
||||||
|
schema/redaction/alias contract and recomputes the top-level digest without a
|
||||||
|
network request or file write. A verified result does not prove the server
|
||||||
|
origin, attestation or durable audit, and cannot recompute the per-fact hashes
|
||||||
|
because the deliberately omitted raw facts are not present.
|
||||||
|
|
||||||
The reviewed operator-workstation setup, private-file ceremony, release
|
The reviewed operator-workstation setup, private-file ceremony, release
|
||||||
verification, preflight and session lifecycle are documented in
|
verification, preflight and session lifecycle are documented in
|
||||||
`deploy/console/ql3-cluster-copilot/README.md`. Native execution binds host
|
`deploy/console/ql3-cluster-copilot/README.md`. Native execution binds host
|
||||||
|
|||||||
@@ -407,6 +407,7 @@
|
|||||||
"ql3-copilot-client": "dist/copilot-client/cli.js",
|
"ql3-copilot-client": "dist/copilot-client/cli.js",
|
||||||
"ql3-copilot-mcp": "dist/copilot-mcp/cli.js",
|
"ql3-copilot-mcp": "dist/copilot-mcp/cli.js",
|
||||||
"ql3-copilot-console": "dist/copilot-console/cli.js",
|
"ql3-copilot-console": "dist/copilot-console/cli.js",
|
||||||
|
"ql3-copilot-evidence-verify": "dist/copilot-console/evidenceVerifierCli.js",
|
||||||
"ql3-plugin-package-recover": "dist/plugin-package/recovery/pluginPackageRecoveryCli.js",
|
"ql3-plugin-package-recover": "dist/plugin-package/recovery/pluginPackageRecoveryCli.js",
|
||||||
"ql3-plugin-package-manage": "dist/plugin-package/management/pluginPackageManagementCli.js",
|
"ql3-plugin-package-manage": "dist/plugin-package/management/pluginPackageManagementCli.js",
|
||||||
"ql3-plugin-package-client": "dist/plugin-package/management/pluginPackageManagementClientCli.js",
|
"ql3-plugin-package-client": "dist/plugin-package/management/pluginPackageManagementClientCli.js",
|
||||||
|
|||||||
@@ -0,0 +1,747 @@
|
|||||||
|
/** Offline verifier for browser-local redacted Cluster Console evidence. */
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
closeSync,
|
||||||
|
constants,
|
||||||
|
fstatSync,
|
||||||
|
openSync,
|
||||||
|
readSync,
|
||||||
|
realpathSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { isAbsolute, normalize, parse } from 'node:path';
|
||||||
|
import { TextDecoder } from 'node:util';
|
||||||
|
|
||||||
|
export const CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA =
|
||||||
|
'qinglong/cluster-console-redacted-evidence-bundle@v1';
|
||||||
|
export const CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA =
|
||||||
|
'qinglong/cluster-console-evidence-verification@v1';
|
||||||
|
|
||||||
|
const REQUEST_SCHEMA = 'qinglong/cluster-copilot-console-read-request@v1';
|
||||||
|
const MAXIMUM_PATH_BYTES = 4 * 1024;
|
||||||
|
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',
|
||||||
|
] as const);
|
||||||
|
type EvidenceOperation = (typeof OPERATIONS)[number];
|
||||||
|
const OPERATION_SET = new Set<string>(OPERATIONS);
|
||||||
|
const REQUEST_FIELDS: Readonly<Record<EvidenceOperation, readonly string[]>> =
|
||||||
|
Object.freeze({
|
||||||
|
inspect: Object.freeze(['projectId', 'requestId', 'sourceRunId']),
|
||||||
|
output: Object.freeze(['projectId', 'requestId', 'sourceRunId']),
|
||||||
|
run_list: Object.freeze([
|
||||||
|
'afterCreatedAtMs',
|
||||||
|
'afterRunId',
|
||||||
|
'limit',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
]),
|
||||||
|
run_read: Object.freeze(['projectId', 'requestId', 'runId']),
|
||||||
|
run_event_list: Object.freeze([
|
||||||
|
'afterSequence',
|
||||||
|
'limit',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
'runId',
|
||||||
|
]),
|
||||||
|
run_step_list: Object.freeze([
|
||||||
|
'afterStepKey',
|
||||||
|
'afterStepRunId',
|
||||||
|
'limit',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
'runId',
|
||||||
|
]),
|
||||||
|
task_list: Object.freeze([
|
||||||
|
'afterTaskId',
|
||||||
|
'limit',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
]),
|
||||||
|
task_read: Object.freeze(['projectId', 'requestId', 'taskId']),
|
||||||
|
workflow_list: Object.freeze(['packageName', 'projectId', 'requestId']),
|
||||||
|
workflow_run_list: Object.freeze([
|
||||||
|
'afterAdmittedAtMs',
|
||||||
|
'afterRunId',
|
||||||
|
'limit',
|
||||||
|
'packageName',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
'workflowId',
|
||||||
|
]),
|
||||||
|
workflow_run_read: Object.freeze([
|
||||||
|
'packageName',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
'runId',
|
||||||
|
'workflowId',
|
||||||
|
]),
|
||||||
|
workflow_event_list: Object.freeze([
|
||||||
|
'afterSequence',
|
||||||
|
'limit',
|
||||||
|
'packageName',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
'runId',
|
||||||
|
'workflowId',
|
||||||
|
]),
|
||||||
|
workflow_step_list: Object.freeze([
|
||||||
|
'afterStepKey',
|
||||||
|
'afterStepRunId',
|
||||||
|
'limit',
|
||||||
|
'packageName',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
'runId',
|
||||||
|
'workflowId',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const IDENTIFIER_DOMAINS: Readonly<Record<string, string>> = 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 SAFE_CONTAINERS = new Set([
|
||||||
|
'attempts',
|
||||||
|
'counts',
|
||||||
|
'events',
|
||||||
|
'items',
|
||||||
|
'metadata',
|
||||||
|
'next',
|
||||||
|
'reference',
|
||||||
|
'run',
|
||||||
|
'runs',
|
||||||
|
'source',
|
||||||
|
'step',
|
||||||
|
'steps',
|
||||||
|
'summary',
|
||||||
|
'target',
|
||||||
|
'task',
|
||||||
|
'tasks',
|
||||||
|
'usage',
|
||||||
|
'workflow',
|
||||||
|
'workflows',
|
||||||
|
]);
|
||||||
|
const SAFE_BOOLEANS = new Set([
|
||||||
|
'active',
|
||||||
|
'archived',
|
||||||
|
'available',
|
||||||
|
'cancelRequested',
|
||||||
|
'enabled',
|
||||||
|
'hasMore',
|
||||||
|
'outputAvailable',
|
||||||
|
'ready',
|
||||||
|
'replayed',
|
||||||
|
'tailComplete',
|
||||||
|
'terminal',
|
||||||
|
'truncated',
|
||||||
|
]);
|
||||||
|
const SAFE_ENUM_KEYS = new Set([
|
||||||
|
'finishReason',
|
||||||
|
'kind',
|
||||||
|
'operation',
|
||||||
|
'outcome',
|
||||||
|
'stage',
|
||||||
|
'status',
|
||||||
|
]);
|
||||||
|
const SAFE_ENUM_VALUES = 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 NUMERIC_KEY =
|
||||||
|
/^(?: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 SCHEMA_VALUE = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
|
||||||
|
const SHA256 = /^[0-9a-f]{64}$/u;
|
||||||
|
const CONTROL = /[\0-\x1f\x7f]/u;
|
||||||
|
|
||||||
|
export interface ClusterConsoleEvidenceVerification {
|
||||||
|
readonly schema: typeof CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA;
|
||||||
|
readonly status: 'verified';
|
||||||
|
readonly bundle: Readonly<{
|
||||||
|
schema: typeof CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA;
|
||||||
|
contentDigest: string;
|
||||||
|
entryCount: number;
|
||||||
|
totalRawCanonicalBytes: number;
|
||||||
|
}>;
|
||||||
|
readonly integrity: Readonly<{
|
||||||
|
bundleDigest: 'verified';
|
||||||
|
rawFactDigests: 'not_recomputed_without_raw_facts';
|
||||||
|
}>;
|
||||||
|
readonly claims: Readonly<{
|
||||||
|
serverSignature: 'not_verified';
|
||||||
|
attestation: 'not_verified';
|
||||||
|
durableAudit: 'not_verified';
|
||||||
|
actionAuthority: 'none';
|
||||||
|
}>;
|
||||||
|
readonly execution: Readonly<{
|
||||||
|
networkAccess: false;
|
||||||
|
mutation: false;
|
||||||
|
fileWrites: false;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClusterConsoleEvidenceVerificationError extends TypeError {
|
||||||
|
readonly code = 'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('Cluster Console evidence bundle verification failed');
|
||||||
|
this.name = 'ClusterConsoleEvidenceVerificationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(): never {
|
||||||
|
throw new ClusterConsoleEvidenceVerificationError();
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const prototype = Object.getPrototypeOf(value);
|
||||||
|
return prototype === Object.prototype || prototype === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(
|
||||||
|
value: unknown,
|
||||||
|
expected: readonly string[],
|
||||||
|
): value is Record<string, unknown> {
|
||||||
|
if (!plainObject(value)) return false;
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const canonical = [...expected].sort();
|
||||||
|
return (
|
||||||
|
actual.length === canonical.length &&
|
||||||
|
actual.every((key, index) => key === canonical[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedKeys(value: Readonly<Record<string, unknown>>): boolean {
|
||||||
|
const actual = Object.keys(value);
|
||||||
|
const sorted = [...actual].sort();
|
||||||
|
return actual.every((key, index) => key === sorted[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(value: unknown): value is number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalValue(
|
||||||
|
value: unknown,
|
||||||
|
depth: number,
|
||||||
|
stack: WeakSet<object>,
|
||||||
|
): string {
|
||||||
|
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 === null || 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((item) => 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(
|
||||||
|
(key) =>
|
||||||
|
`${JSON.stringify(key)}:${canonicalValue(
|
||||||
|
value[key],
|
||||||
|
depth + 1,
|
||||||
|
stack,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
.join(',')}}`;
|
||||||
|
} finally {
|
||||||
|
stack.delete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalize(value: unknown): string {
|
||||||
|
return canonicalValue(value, 0, new WeakSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AliasState {
|
||||||
|
readonly nextByDomain: Map<string, number>;
|
||||||
|
readonly seen: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAlias(
|
||||||
|
domain: string,
|
||||||
|
value: unknown,
|
||||||
|
aliases: AliasState,
|
||||||
|
): void {
|
||||||
|
if (value === null) return;
|
||||||
|
if (typeof value !== 'string') return invalid();
|
||||||
|
const prefix = `${domain}-`;
|
||||||
|
if (!value.startsWith(prefix)) return invalid();
|
||||||
|
const suffix = value.slice(prefix.length);
|
||||||
|
if (!/^[0-9]{3,}$/u.test(suffix)) return invalid();
|
||||||
|
const numeric = Number(suffix);
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(numeric) ||
|
||||||
|
numeric < 1 ||
|
||||||
|
String(numeric).padStart(3, '0') !== suffix
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
const identity = `${domain}\0${suffix}`;
|
||||||
|
if (aliases.seen.has(identity)) return;
|
||||||
|
const expected = aliases.nextByDomain.get(domain) ?? 1;
|
||||||
|
if (numeric !== expected) return invalid();
|
||||||
|
aliases.seen.add(identity);
|
||||||
|
aliases.nextByDomain.set(domain, expected + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSafeValue(
|
||||||
|
value: unknown,
|
||||||
|
aliases: AliasState,
|
||||||
|
depth: number,
|
||||||
|
): void {
|
||||||
|
if (depth > LIMITS.maximumDepth) return invalid();
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > LIMITS.maximumArrayItems) return invalid();
|
||||||
|
for (const item of value) {
|
||||||
|
if (item === null) continue;
|
||||||
|
if (!plainObject(item) && !Array.isArray(item)) return invalid();
|
||||||
|
validateSafeValue(item, aliases, depth + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!plainObject(value) || !sortedKeys(value)) return invalid();
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
if (keys.length > LIMITS.maximumObjectKeys) return invalid();
|
||||||
|
for (const key of keys) {
|
||||||
|
const candidate = value[key];
|
||||||
|
const domain = IDENTIFIER_DOMAINS[key];
|
||||||
|
if (domain !== undefined) {
|
||||||
|
validateAlias(domain, candidate, aliases);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/Digest$/u.test(key)) {
|
||||||
|
validateAlias('digest', candidate, aliases);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === 'schema') {
|
||||||
|
if (typeof candidate !== 'string' || !SCHEMA_VALUE.test(candidate)) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SAFE_ENUM_KEYS.has(key)) {
|
||||||
|
if (
|
||||||
|
candidate !== null &&
|
||||||
|
(typeof candidate !== 'string' ||
|
||||||
|
(key === 'operation'
|
||||||
|
? !OPERATION_SET.has(candidate)
|
||||||
|
: !SAFE_ENUM_VALUES.has(candidate)))
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SAFE_BOOLEANS.has(key)) {
|
||||||
|
if (candidate !== null && typeof candidate !== 'boolean') {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (NUMERIC_KEY.test(key)) {
|
||||||
|
if (candidate !== null && !safeInteger(candidate)) return invalid();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SAFE_CONTAINERS.has(key)) {
|
||||||
|
if (candidate === null) continue;
|
||||||
|
if (!plainObject(candidate) && !Array.isArray(candidate))
|
||||||
|
return invalid();
|
||||||
|
validateSafeValue(candidate, aliases, depth + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTarget(
|
||||||
|
value: unknown,
|
||||||
|
operation: EvidenceOperation,
|
||||||
|
aliases: AliasState,
|
||||||
|
): void {
|
||||||
|
const fields = REQUEST_FIELDS[operation];
|
||||||
|
if (!exactKeys(value, ['operation', 'schema', ...fields])) return invalid();
|
||||||
|
if (!sortedKeys(value)) return invalid();
|
||||||
|
if (value.schema !== REQUEST_SCHEMA || value.operation !== operation) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
for (const field of fields) {
|
||||||
|
const domain = IDENTIFIER_DOMAINS[field];
|
||||||
|
if (domain !== undefined) {
|
||||||
|
validateAlias(domain, value[field], aliases);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!NUMERIC_KEY.test(field)) return invalid();
|
||||||
|
const candidate = value[field];
|
||||||
|
if (candidate !== null && !safeInteger(candidate)) return invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBundle(value: unknown): ClusterConsoleEvidenceVerification {
|
||||||
|
if (
|
||||||
|
!exactKeys(value, [
|
||||||
|
'actionAuthority',
|
||||||
|
'attestation',
|
||||||
|
'classification',
|
||||||
|
'contentDigest',
|
||||||
|
'entries',
|
||||||
|
'generatedAtMs',
|
||||||
|
'generatedBy',
|
||||||
|
'integrity',
|
||||||
|
'redaction',
|
||||||
|
'schema',
|
||||||
|
'source',
|
||||||
|
]) ||
|
||||||
|
value.schema !== CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA ||
|
||||||
|
value.classification !== 'low_sensitive_redacted' ||
|
||||||
|
!safeInteger(value.generatedAtMs) ||
|
||||||
|
value.generatedBy !== 'browser_local' ||
|
||||||
|
value.actionAuthority !== 'none' ||
|
||||||
|
value.attestation !== 'none' ||
|
||||||
|
typeof value.contentDigest !== 'string' ||
|
||||||
|
!SHA256.test(value.contentDigest)
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!exactKeys(value.source, [
|
||||||
|
'collection',
|
||||||
|
'entryCount',
|
||||||
|
'surface',
|
||||||
|
'totalRawCanonicalBytes',
|
||||||
|
]) ||
|
||||||
|
value.source.surface !== 'cluster_field_ledger' ||
|
||||||
|
value.source.collection !== 'explicit_user_reads_only' ||
|
||||||
|
!safeInteger(value.source.entryCount) ||
|
||||||
|
!safeInteger(value.source.totalRawCanonicalBytes)
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!exactKeys(value.redaction, [
|
||||||
|
'copilotOutputIncluded',
|
||||||
|
'freeTextIncluded',
|
||||||
|
'identifiers',
|
||||||
|
'policy',
|
||||||
|
'unknownFieldsIncluded',
|
||||||
|
]) ||
|
||||||
|
value.redaction.policy !== 'fixed_allowlist_v1' ||
|
||||||
|
value.redaction.identifiers !== 'per_bundle_typed_alias_without_mapping' ||
|
||||||
|
value.redaction.freeTextIncluded !== false ||
|
||||||
|
value.redaction.copilotOutputIncluded !== false ||
|
||||||
|
value.redaction.unknownFieldsIncluded !== false
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!exactKeys(value.integrity, [
|
||||||
|
'algorithm',
|
||||||
|
'durableAudit',
|
||||||
|
'scope',
|
||||||
|
'serverSignature',
|
||||||
|
]) ||
|
||||||
|
value.integrity.algorithm !== 'sha256' ||
|
||||||
|
value.integrity.scope !== 'canonical_bundle_without_contentDigest' ||
|
||||||
|
value.integrity.serverSignature !== false ||
|
||||||
|
value.integrity.durableAudit !== false
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(value.entries) ||
|
||||||
|
value.entries.length < 1 ||
|
||||||
|
value.entries.length > LIMITS.maximumRecords ||
|
||||||
|
value.source.entryCount !== value.entries.length
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliases: AliasState = {
|
||||||
|
nextByDomain: new Map(),
|
||||||
|
seen: new Set(),
|
||||||
|
};
|
||||||
|
let totalRawCanonicalBytes = 0;
|
||||||
|
for (let index = 0; index < value.entries.length; index += 1) {
|
||||||
|
const entry = value.entries[index];
|
||||||
|
if (
|
||||||
|
!exactKeys(entry, [
|
||||||
|
'fact',
|
||||||
|
'observedAtMs',
|
||||||
|
'operation',
|
||||||
|
'rawFact',
|
||||||
|
'sanitizer',
|
||||||
|
'sequence',
|
||||||
|
'target',
|
||||||
|
]) ||
|
||||||
|
entry.sequence !== index + 1 ||
|
||||||
|
!safeInteger(entry.observedAtMs) ||
|
||||||
|
typeof entry.operation !== 'string' ||
|
||||||
|
!OPERATION_SET.has(entry.operation)
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
const operation = entry.operation as EvidenceOperation;
|
||||||
|
validateTarget(entry.target, operation, aliases);
|
||||||
|
validateSafeValue(entry.fact, aliases, 0);
|
||||||
|
if (
|
||||||
|
!exactKeys(entry.rawFact, ['canonicalBytes', 'sha256']) ||
|
||||||
|
!safeInteger(entry.rawFact.canonicalBytes) ||
|
||||||
|
entry.rawFact.canonicalBytes < 2 ||
|
||||||
|
entry.rawFact.canonicalBytes > LIMITS.maximumEntryFactBytes ||
|
||||||
|
typeof entry.rawFact.sha256 !== 'string' ||
|
||||||
|
!SHA256.test(entry.rawFact.sha256)
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
totalRawCanonicalBytes += entry.rawFact.canonicalBytes;
|
||||||
|
if (totalRawCanonicalBytes > LIMITS.maximumRawBytes) return invalid();
|
||||||
|
if (
|
||||||
|
!exactKeys(entry.sanitizer, [
|
||||||
|
'omittedFieldCount',
|
||||||
|
'rawContentIncluded',
|
||||||
|
]) ||
|
||||||
|
!safeInteger(entry.sanitizer.omittedFieldCount) ||
|
||||||
|
entry.sanitizer.rawContentIncluded !== false
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.source.totalRawCanonicalBytes !== totalRawCanonicalBytes) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (key !== 'contentDigest') unsigned[key] = value[key];
|
||||||
|
}
|
||||||
|
const computed = createHash('sha256')
|
||||||
|
.update(canonicalize(unsigned), 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
if (computed !== value.contentDigest) return invalid();
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
schema: CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA,
|
||||||
|
status: 'verified',
|
||||||
|
bundle: Object.freeze({
|
||||||
|
schema: CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA,
|
||||||
|
contentDigest: value.contentDigest,
|
||||||
|
entryCount: value.entries.length,
|
||||||
|
totalRawCanonicalBytes,
|
||||||
|
}),
|
||||||
|
integrity: Object.freeze({
|
||||||
|
bundleDigest: 'verified',
|
||||||
|
rawFactDigests: 'not_recomputed_without_raw_facts',
|
||||||
|
}),
|
||||||
|
claims: Object.freeze({
|
||||||
|
serverSignature: 'not_verified',
|
||||||
|
attestation: 'not_verified',
|
||||||
|
durableAudit: 'not_verified',
|
||||||
|
actionAuthority: 'none',
|
||||||
|
}),
|
||||||
|
execution: Object.freeze({
|
||||||
|
networkAccess: false,
|
||||||
|
mutation: false,
|
||||||
|
fileWrites: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStableBundle(filePath: string): Buffer {
|
||||||
|
if (
|
||||||
|
!isAbsolute(filePath) ||
|
||||||
|
normalize(filePath) !== filePath ||
|
||||||
|
parse(filePath).root === filePath ||
|
||||||
|
Buffer.byteLength(filePath, 'utf8') > MAXIMUM_PATH_BYTES ||
|
||||||
|
CONTROL.test(filePath)
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
let descriptor = -1;
|
||||||
|
let bytes: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
if (realpathSync(filePath) !== filePath) return invalid();
|
||||||
|
descriptor = openSync(
|
||||||
|
filePath,
|
||||||
|
constants.O_RDONLY |
|
||||||
|
((constants as unknown as Readonly<Record<string, number>>).O_CLOEXEC ??
|
||||||
|
0) |
|
||||||
|
(constants.O_NOFOLLOW ?? 0),
|
||||||
|
);
|
||||||
|
const before = fstatSync(descriptor);
|
||||||
|
if (
|
||||||
|
!before.isFile() ||
|
||||||
|
before.size < 2 ||
|
||||||
|
before.size > LIMITS.maximumBundleBytes
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
bytes = Buffer.alloc(before.size);
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const read = readSync(
|
||||||
|
descriptor,
|
||||||
|
bytes,
|
||||||
|
offset,
|
||||||
|
bytes.length - offset,
|
||||||
|
offset,
|
||||||
|
);
|
||||||
|
if (read < 1) return invalid();
|
||||||
|
offset += read;
|
||||||
|
}
|
||||||
|
const after = fstatSync(descriptor);
|
||||||
|
if (
|
||||||
|
after.dev !== before.dev ||
|
||||||
|
after.ino !== before.ino ||
|
||||||
|
after.mode !== before.mode ||
|
||||||
|
after.uid !== before.uid ||
|
||||||
|
after.gid !== before.gid ||
|
||||||
|
after.size !== before.size ||
|
||||||
|
after.mtimeMs !== before.mtimeMs ||
|
||||||
|
after.ctimeMs !== before.ctimeMs
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
} catch (error) {
|
||||||
|
bytes?.fill(0);
|
||||||
|
if (error instanceof ClusterConsoleEvidenceVerificationError) throw error;
|
||||||
|
return invalid();
|
||||||
|
} finally {
|
||||||
|
if (descriptor >= 0) closeSync(descriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyClusterConsoleEvidenceBundleFile(
|
||||||
|
filePath: string,
|
||||||
|
): ClusterConsoleEvidenceVerification {
|
||||||
|
const bytes = readStableBundle(filePath);
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
bytes.length >= 3 &&
|
||||||
|
bytes[0] === 0xef &&
|
||||||
|
bytes[1] === 0xbb &&
|
||||||
|
bytes[2] === 0xbf
|
||||||
|
) {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
return invalid();
|
||||||
|
}
|
||||||
|
if (`${JSON.stringify(parsed, null, 2)}\n` !== text) return invalid();
|
||||||
|
return validateBundle(parsed);
|
||||||
|
} finally {
|
||||||
|
bytes.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/** Offline, read-only Cluster Console evidence verification entrypoint. */
|
||||||
|
import {
|
||||||
|
ClusterConsoleEvidenceVerificationError,
|
||||||
|
verifyClusterConsoleEvidenceBundleFile,
|
||||||
|
} from './evidenceVerifier';
|
||||||
|
|
||||||
|
const USAGE =
|
||||||
|
'Usage: ql3-copilot-evidence-verify --bundle=/absolute/evidence.json';
|
||||||
|
|
||||||
|
function failure(code: string, message: string): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
component: 'qinglong3-cluster-console-evidence-verifier',
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(argv: readonly string[]): void {
|
||||||
|
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
|
||||||
|
process.stdout.write(`${USAGE}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
argv.length !== 1 ||
|
||||||
|
!argv[0]?.startsWith('--bundle=') ||
|
||||||
|
argv[0] === '--bundle='
|
||||||
|
) {
|
||||||
|
process.stderr.write(
|
||||||
|
`${failure(
|
||||||
|
'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFIER_USAGE_INVALID',
|
||||||
|
USAGE,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 64;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = verifyClusterConsoleEvidenceBundleFile(
|
||||||
|
argv[0].slice('--bundle='.length),
|
||||||
|
);
|
||||||
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
const code =
|
||||||
|
error instanceof ClusterConsoleEvidenceVerificationError
|
||||||
|
? error.code
|
||||||
|
: 'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID';
|
||||||
|
process.stderr.write(
|
||||||
|
`${failure(
|
||||||
|
code,
|
||||||
|
'Cluster Console evidence bundle verification failed',
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 65;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main(process.argv.slice(2));
|
||||||
@@ -52,6 +52,12 @@ export const QINGLONG3_CLUSTER_PRODUCT_COMMANDS: readonly QingLong3ClusterProduc
|
|||||||
target: 'copilot-console/cli.js',
|
target: 'copilot-console/cli.js',
|
||||||
description: 'open the loopback-only read-only Copilot Console',
|
description: 'open the loopback-only read-only Copilot Console',
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
name: 'evidence-verify',
|
||||||
|
binary: 'ql3-copilot-evidence-verify',
|
||||||
|
target: 'copilot-console/evidenceVerifierCli.js',
|
||||||
|
description: 'verify one redacted Console evidence bundle offline',
|
||||||
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
name: 'package',
|
name: 'package',
|
||||||
binary: 'ql3-plugin-package-client',
|
binary: 'ql3-plugin-package-client',
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const { createHash, webcrypto } = require('node:crypto');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
createClusterConsoleEvidenceBundle,
|
||||||
|
serializeClusterConsoleEvidenceBundle,
|
||||||
|
} = require('../assets/copilot-console/evidence-bundle.js');
|
||||||
|
const {
|
||||||
|
CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA,
|
||||||
|
CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA,
|
||||||
|
verifyClusterConsoleEvidenceBundleFile,
|
||||||
|
} = require('../dist/copilot-console/evidenceVerifier.js');
|
||||||
|
|
||||||
|
const cliPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../dist/copilot-console/evidenceVerifierCli.js',
|
||||||
|
);
|
||||||
|
const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1';
|
||||||
|
|
||||||
|
function canonicalize(value) {
|
||||||
|
if (value === null || typeof value !== 'object') {
|
||||||
|
return JSON.stringify(Object.is(value, -0) ? 0 : value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.map(canonicalize).join(',')}]`;
|
||||||
|
}
|
||||||
|
return `{${Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`)
|
||||||
|
.join(',')}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resign(bundle) {
|
||||||
|
const unsigned = {};
|
||||||
|
for (const key of Object.keys(bundle)) {
|
||||||
|
if (key !== 'contentDigest') unsigned[key] = bundle[key];
|
||||||
|
}
|
||||||
|
bundle.contentDigest = createHash('sha256')
|
||||||
|
.update(canonicalize(unsigned), 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
return bundle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validBundle() {
|
||||||
|
return createClusterConsoleEvidenceBundle(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: 'run_read',
|
||||||
|
observedAtMs: 1_700_000_000_000,
|
||||||
|
request: {
|
||||||
|
schema: requestSchema,
|
||||||
|
operation: 'run_read',
|
||||||
|
projectId: 'private-project',
|
||||||
|
requestId: 'private-request',
|
||||||
|
runId: 'private-run',
|
||||||
|
},
|
||||||
|
fact: {
|
||||||
|
schema: 'qinglong/bounded-run-projection@v1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
status: 'succeeded',
|
||||||
|
projectId: 'private-project',
|
||||||
|
runId: 'private-run',
|
||||||
|
createdAtMs: 1_700_000_000_000,
|
||||||
|
outputAvailable: true,
|
||||||
|
message: 'must-never-survive-redaction',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
1_700_000_001_000,
|
||||||
|
webcrypto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t, encoded, name = 'evidence.json') {
|
||||||
|
const directory = fs.realpathSync(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-evidence-verifier-')),
|
||||||
|
);
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const filePath = path.join(directory, name);
|
||||||
|
fs.writeFileSync(filePath, encoded, { mode: 0o644 });
|
||||||
|
return { directory, filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('independently verifies the browser generator output without authority claims', async (t) => {
|
||||||
|
const bundle = await validBundle();
|
||||||
|
const encoded = serializeClusterConsoleEvidenceBundle(bundle);
|
||||||
|
const { filePath } = fixture(t, encoded);
|
||||||
|
|
||||||
|
const result = verifyClusterConsoleEvidenceBundleFile(filePath);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
schema: CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_SCHEMA,
|
||||||
|
status: 'verified',
|
||||||
|
bundle: {
|
||||||
|
schema: CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA,
|
||||||
|
contentDigest: bundle.contentDigest,
|
||||||
|
entryCount: 1,
|
||||||
|
totalRawCanonicalBytes: bundle.source.totalRawCanonicalBytes,
|
||||||
|
},
|
||||||
|
integrity: {
|
||||||
|
bundleDigest: 'verified',
|
||||||
|
rawFactDigests: 'not_recomputed_without_raw_facts',
|
||||||
|
},
|
||||||
|
claims: {
|
||||||
|
serverSignature: 'not_verified',
|
||||||
|
attestation: 'not_verified',
|
||||||
|
durableAudit: 'not_verified',
|
||||||
|
actionAuthority: 'none',
|
||||||
|
},
|
||||||
|
execution: { networkAccess: false, mutation: false, fileWrites: false },
|
||||||
|
});
|
||||||
|
assert.equal(Object.isFrozen(result), true);
|
||||||
|
assert.equal(encoded.includes('must-never-survive-redaction'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-verifies every fixed Console read operation', async (t) => {
|
||||||
|
const requests = {
|
||||||
|
inspect: { projectId: 'p-1', requestId: 'q-1', sourceRunId: 'r-1' },
|
||||||
|
output: { projectId: 'p-2', requestId: 'q-2', sourceRunId: 'r-2' },
|
||||||
|
run_list: {
|
||||||
|
afterCreatedAtMs: null,
|
||||||
|
afterRunId: null,
|
||||||
|
limit: 32,
|
||||||
|
projectId: 'p-3',
|
||||||
|
requestId: 'q-3',
|
||||||
|
},
|
||||||
|
run_read: { projectId: 'p-4', requestId: 'q-4', runId: 'r-4' },
|
||||||
|
run_event_list: {
|
||||||
|
afterSequence: null,
|
||||||
|
limit: 32,
|
||||||
|
projectId: 'p-5',
|
||||||
|
requestId: 'q-5',
|
||||||
|
runId: 'r-5',
|
||||||
|
},
|
||||||
|
run_step_list: {
|
||||||
|
afterStepKey: null,
|
||||||
|
afterStepRunId: null,
|
||||||
|
limit: 32,
|
||||||
|
projectId: 'p-6',
|
||||||
|
requestId: 'q-6',
|
||||||
|
runId: 'r-6',
|
||||||
|
},
|
||||||
|
task_list: {
|
||||||
|
afterTaskId: null,
|
||||||
|
limit: 32,
|
||||||
|
projectId: 'p-7',
|
||||||
|
requestId: 'q-7',
|
||||||
|
},
|
||||||
|
task_read: { projectId: 'p-8', requestId: 'q-8', taskId: 't-8' },
|
||||||
|
workflow_list: {
|
||||||
|
packageName: 'pkg-9',
|
||||||
|
projectId: 'p-9',
|
||||||
|
requestId: 'q-9',
|
||||||
|
},
|
||||||
|
workflow_run_list: {
|
||||||
|
afterAdmittedAtMs: null,
|
||||||
|
afterRunId: null,
|
||||||
|
limit: 32,
|
||||||
|
packageName: 'pkg-10',
|
||||||
|
projectId: 'p-10',
|
||||||
|
requestId: 'q-10',
|
||||||
|
workflowId: 'w-10',
|
||||||
|
},
|
||||||
|
workflow_run_read: {
|
||||||
|
packageName: 'pkg-11',
|
||||||
|
projectId: 'p-11',
|
||||||
|
requestId: 'q-11',
|
||||||
|
runId: 'r-11',
|
||||||
|
workflowId: 'w-11',
|
||||||
|
},
|
||||||
|
workflow_event_list: {
|
||||||
|
afterSequence: null,
|
||||||
|
limit: 32,
|
||||||
|
packageName: 'pkg-12',
|
||||||
|
projectId: 'p-12',
|
||||||
|
requestId: 'q-12',
|
||||||
|
runId: 'r-12',
|
||||||
|
workflowId: 'w-12',
|
||||||
|
},
|
||||||
|
workflow_step_list: {
|
||||||
|
afterStepKey: null,
|
||||||
|
afterStepRunId: null,
|
||||||
|
limit: 32,
|
||||||
|
packageName: 'pkg-13',
|
||||||
|
projectId: 'p-13',
|
||||||
|
requestId: 'q-13',
|
||||||
|
runId: 'r-13',
|
||||||
|
workflowId: 'w-13',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const records = Object.entries(requests).map(
|
||||||
|
([operation, request], index) => ({
|
||||||
|
operation,
|
||||||
|
observedAtMs: 1_700_000_000_000 + index,
|
||||||
|
request: { schema: requestSchema, operation, ...request },
|
||||||
|
fact: {
|
||||||
|
schema: 'qinglong/test-fact@v1',
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation,
|
||||||
|
status: 'succeeded',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const bundle = await createClusterConsoleEvidenceBundle(
|
||||||
|
records,
|
||||||
|
1_700_000_001_000,
|
||||||
|
webcrypto,
|
||||||
|
);
|
||||||
|
const { filePath } = fixture(
|
||||||
|
t,
|
||||||
|
serializeClusterConsoleEvidenceBundle(bundle),
|
||||||
|
);
|
||||||
|
const result = verifyClusterConsoleEvidenceBundleFile(filePath);
|
||||||
|
assert.equal(result.status, 'verified');
|
||||||
|
assert.equal(result.bundle.entryCount, 13);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CLI is secret-free on success, invalid input and usage errors', async (t) => {
|
||||||
|
const bundle = await validBundle();
|
||||||
|
const { filePath } = fixture(
|
||||||
|
t,
|
||||||
|
serializeClusterConsoleEvidenceBundle(bundle),
|
||||||
|
'private-customer-run.json',
|
||||||
|
);
|
||||||
|
const success = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[cliPath, `--bundle=${filePath}`],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(success.status, 0);
|
||||||
|
assert.equal(JSON.parse(success.stdout).status, 'verified');
|
||||||
|
assert.equal(success.stderr, '');
|
||||||
|
assert.equal(success.stdout.includes(filePath), false);
|
||||||
|
assert.equal(success.stdout.includes('private-customer-run'), false);
|
||||||
|
|
||||||
|
const tampered = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
|
tampered.entries[0].fact.status = 'failed';
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(tampered, null, 2)}\n`);
|
||||||
|
const invalid = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[cliPath, `--bundle=${filePath}`],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(invalid.status, 65);
|
||||||
|
assert.equal(invalid.stdout, '');
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(invalid.stderr).code,
|
||||||
|
'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID',
|
||||||
|
);
|
||||||
|
assert.equal(invalid.stderr.includes(filePath), false);
|
||||||
|
|
||||||
|
const usage = spawnSync(process.execPath, [cliPath], { encoding: 'utf8' });
|
||||||
|
assert.equal(usage.status, 64);
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(usage.stderr).code,
|
||||||
|
'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFIER_USAGE_INVALID',
|
||||||
|
);
|
||||||
|
const help = spawnSync(process.execPath, [cliPath, '--help'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
assert.equal(help.status, 0);
|
||||||
|
assert.match(help.stdout, /^Usage: ql3-copilot-evidence-verify/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects re-signed structural widening, alias gaps and false proof claims', async (t) => {
|
||||||
|
const baseline = JSON.parse(
|
||||||
|
serializeClusterConsoleEvidenceBundle(await validBundle()),
|
||||||
|
);
|
||||||
|
const mutations = [
|
||||||
|
(bundle) => {
|
||||||
|
bundle.entries[0].fact.message = 'unsafe free text';
|
||||||
|
},
|
||||||
|
(bundle) => {
|
||||||
|
bundle.entries[0].target.runId = 'run-002';
|
||||||
|
},
|
||||||
|
(bundle) => {
|
||||||
|
bundle.integrity.serverSignature = true;
|
||||||
|
},
|
||||||
|
(bundle) => {
|
||||||
|
bundle.source.totalRawCanonicalBytes += 1;
|
||||||
|
},
|
||||||
|
(bundle) => {
|
||||||
|
bundle.entries[0].sequence = 2;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const [index, mutate] of mutations.entries()) {
|
||||||
|
const candidate = structuredClone(baseline);
|
||||||
|
mutate(candidate);
|
||||||
|
resign(candidate);
|
||||||
|
const { filePath } = fixture(
|
||||||
|
t,
|
||||||
|
`${JSON.stringify(candidate, null, 2)}\n`,
|
||||||
|
`invalid-${index}.json`,
|
||||||
|
);
|
||||||
|
assert.throws(() => verifyClusterConsoleEvidenceBundleFile(filePath), {
|
||||||
|
code: 'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-canonical JSON, links, relative paths and oversized files', async (t) => {
|
||||||
|
const encoded = serializeClusterConsoleEvidenceBundle(await validBundle());
|
||||||
|
for (const [name, contents] of [
|
||||||
|
['minified.json', JSON.stringify(JSON.parse(encoded))],
|
||||||
|
['bom.json', `\ufeff${encoded}`],
|
||||||
|
['crlf.json', encoded.replaceAll('\n', '\r\n')],
|
||||||
|
[
|
||||||
|
'duplicate.json',
|
||||||
|
encoded.replace(
|
||||||
|
'{\n',
|
||||||
|
`{\n "schema": "${CLUSTER_CONSOLE_EVIDENCE_BUNDLE_SCHEMA}",\n`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const { filePath } = fixture(t, contents, name);
|
||||||
|
assert.throws(
|
||||||
|
() => verifyClusterConsoleEvidenceBundleFile(filePath),
|
||||||
|
undefined,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { directory, filePath } = fixture(t, encoded, 'source.json');
|
||||||
|
const linkPath = path.join(directory, 'link.json');
|
||||||
|
fs.symlinkSync(filePath, linkPath);
|
||||||
|
assert.throws(() => verifyClusterConsoleEvidenceBundleFile(linkPath));
|
||||||
|
assert.throws(() => verifyClusterConsoleEvidenceBundleFile('source.json'));
|
||||||
|
|
||||||
|
const oversized = path.join(directory, 'oversized.json');
|
||||||
|
fs.writeFileSync(oversized, 'x'.repeat(512 * 1024 + 1));
|
||||||
|
assert.throws(() => verifyClusterConsoleEvidenceBundleFile(oversized));
|
||||||
|
});
|
||||||
@@ -117,9 +117,7 @@ async function startReadinessServer(status) {
|
|||||||
port: server.address().port,
|
port: server.address().port,
|
||||||
close: () =>
|
close: () =>
|
||||||
new Promise((resolvePromise, reject) => {
|
new Promise((resolvePromise, reject) => {
|
||||||
server.close((error) =>
|
server.close((error) => (error ? reject(error) : resolvePromise()));
|
||||||
error ? reject(error) : resolvePromise(),
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -152,9 +150,7 @@ async function startCopilotReadinessServer(status) {
|
|||||||
port: server.address().port,
|
port: server.address().port,
|
||||||
close: () =>
|
close: () =>
|
||||||
new Promise((resolvePromise, reject) => {
|
new Promise((resolvePromise, reject) => {
|
||||||
server.close((error) =>
|
server.close((error) => (error ? reject(error) : resolvePromise()));
|
||||||
error ? reject(error) : resolvePromise(),
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -333,7 +329,7 @@ function validContextFixture(t) {
|
|||||||
|
|
||||||
test('catalog exposes only reviewed product entrypoints from the same package', () => {
|
test('catalog exposes only reviewed product entrypoints from the same package', () => {
|
||||||
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
|
assert.equal(manifest.bin['ql3-cluster-admin'], 'dist/product-cli/cli.js');
|
||||||
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 10);
|
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 11);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
|
||||||
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
|
||||||
@@ -352,7 +348,8 @@ test('catalog exposes only reviewed product entrypoints from the same package',
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
command.binary.includes('-client') ||
|
command.binary.includes('-client') ||
|
||||||
command.binary === 'ql3-copilot-mcp' ||
|
command.binary === 'ql3-copilot-mcp' ||
|
||||||
command.binary === 'ql3-copilot-console',
|
command.binary === 'ql3-copilot-console' ||
|
||||||
|
command.binary === 'ql3-copilot-evidence-verify',
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -382,6 +379,10 @@ test('help and version are bounded installation-derived product facts', () => {
|
|||||||
assert.match(help, /\n copilot\s+diagnose, inspect, read or cancel Runs/);
|
assert.match(help, /\n copilot\s+diagnose, inspect, read or cancel Runs/);
|
||||||
assert.match(help, /\n copilot-mcp\s+serve the bounded Cluster Copilot MCP/);
|
assert.match(help, /\n copilot-mcp\s+serve the bounded Cluster Copilot MCP/);
|
||||||
assert.match(help, /\n copilot-console\s+open the loopback-only read-only/);
|
assert.match(help, /\n copilot-console\s+open the loopback-only read-only/);
|
||||||
|
assert.match(
|
||||||
|
help,
|
||||||
|
/\n evidence-verify\s+verify one redacted Console evidence/,
|
||||||
|
);
|
||||||
assert.match(help, /Server, migration, recovery, executor and key-custody/);
|
assert.match(help, /Server, migration, recovery, executor and key-custody/);
|
||||||
assert.equal(help.includes('plugin-package-manage'), false);
|
assert.equal(help.includes('plugin-package-manage'), false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const COMMANDS = Object.freeze([
|
|||||||
name: 'copilot-console',
|
name: 'copilot-console',
|
||||||
usage: 'Usage:\n ql3-copilot-console --config ',
|
usage: 'Usage:\n ql3-copilot-console --config ',
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
name: 'evidence-verify',
|
||||||
|
usage: 'Usage: ql3-copilot-evidence-verify --bundle=',
|
||||||
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
name: 'package',
|
name: 'package',
|
||||||
usage: 'Usage: ql3-plugin-package-client ',
|
usage: 'Usage: ql3-plugin-package-client ',
|
||||||
@@ -322,6 +326,79 @@ child.stdout.on('data', (chunk) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runEvidenceVerifierContract(image) {
|
||||||
|
const source = String.raw`
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const { webcrypto } = require('node:crypto');
|
||||||
|
const { readdirSync, readFileSync, writeFileSync } = require('node:fs');
|
||||||
|
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
|
||||||
|
const bundleApi = require('/opt/qinglong/node_modules/@qinglong/cluster-admin/assets/copilot-console/evidence-bundle.js');
|
||||||
|
const bundleFile = '/tmp/evidence.json';
|
||||||
|
(async () => {
|
||||||
|
const bundle = await bundleApi.createClusterConsoleEvidenceBundle([{
|
||||||
|
operation: 'run_read',
|
||||||
|
observedAtMs: 1700000000000,
|
||||||
|
request: { schema: 'qinglong/cluster-copilot-console-read-request@v1', operation: 'run_read', projectId: 'private-project', requestId: 'private-request', runId: 'private-run' },
|
||||||
|
fact: { schema: 'qinglong/bounded-run-projection@v1', schemaVersion: 1, status: 'succeeded', projectId: 'private-project', runId: 'private-run', message: 'must-not-survive' },
|
||||||
|
}], 1700000001000, webcrypto);
|
||||||
|
writeFileSync(bundleFile, bundleApi.serializeClusterConsoleEvidenceBundle(bundle), { mode: 0o600 });
|
||||||
|
const before = readdirSync('/tmp').sort();
|
||||||
|
const verified = spawnSync(process.execPath, [facade, 'evidence-verify', '--bundle=' + bundleFile], { encoding: 'utf8' });
|
||||||
|
const after = readdirSync('/tmp').sort();
|
||||||
|
let fact;
|
||||||
|
try { fact = JSON.parse(verified.stdout); } catch { process.exit(71); }
|
||||||
|
if (verified.status !== 0 || verified.stderr !== '' || fact.status !== 'verified' || fact.integrity.bundleDigest !== 'verified' || fact.integrity.rawFactDigests !== 'not_recomputed_without_raw_facts' || fact.claims.serverSignature !== 'not_verified' || fact.claims.actionAuthority !== 'none' || fact.execution.networkAccess !== false || fact.execution.mutation !== false || fact.execution.fileWrites !== false || JSON.stringify(before) !== JSON.stringify(after) || verified.stdout.includes('private-project') || verified.stdout.includes(bundleFile)) process.exit(72);
|
||||||
|
const tampered = JSON.parse(readFileSync(bundleFile, 'utf8'));
|
||||||
|
tampered.entries[0].fact.status = 'failed';
|
||||||
|
writeFileSync(bundleFile, JSON.stringify(tampered, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
const rejected = spawnSync(process.execPath, [facade, 'evidence-verify', '--bundle=' + bundleFile], { encoding: 'utf8' });
|
||||||
|
let failure;
|
||||||
|
try { failure = JSON.parse(rejected.stderr); } catch { process.exit(73); }
|
||||||
|
if (rejected.status !== 65 || rejected.stdout !== '' || failure.code !== 'QL3_CLUSTER_CONSOLE_EVIDENCE_VERIFICATION_INVALID' || rejected.stderr.includes(bundleFile) || rejected.stderr.includes('private-project')) process.exit(74);
|
||||||
|
process.stdout.write(JSON.stringify({ verified: true, tamperRejected: true, noVerifierWrites: true }));
|
||||||
|
})().catch(() => process.exit(75));
|
||||||
|
`;
|
||||||
|
const output = docker([
|
||||||
|
'run',
|
||||||
|
'--rm',
|
||||||
|
'--read-only',
|
||||||
|
'--network',
|
||||||
|
'none',
|
||||||
|
'--cap-drop',
|
||||||
|
'ALL',
|
||||||
|
'--security-opt',
|
||||||
|
'no-new-privileges',
|
||||||
|
'--user',
|
||||||
|
'10001:10001',
|
||||||
|
'--pids-limit',
|
||||||
|
'32',
|
||||||
|
'--memory',
|
||||||
|
'128m',
|
||||||
|
'--cpus',
|
||||||
|
'0.25',
|
||||||
|
'--tmpfs',
|
||||||
|
'/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001',
|
||||||
|
'--entrypoint',
|
||||||
|
'node',
|
||||||
|
image,
|
||||||
|
'-e',
|
||||||
|
source,
|
||||||
|
]);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = JSON.parse(output);
|
||||||
|
} catch {
|
||||||
|
fail('offline evidence verifier result is invalid');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result?.verified !== true ||
|
||||||
|
result?.tamperRejected !== true ||
|
||||||
|
result?.noVerifierWrites !== true
|
||||||
|
) {
|
||||||
|
fail('offline evidence verifier contract drifted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function runPublishedConsoleContract(image) {
|
function runPublishedConsoleContract(image) {
|
||||||
const suffix = `${process.pid}-${Date.now()}`;
|
const suffix = `${process.pid}-${Date.now()}`;
|
||||||
const network = `ql3-console-live-${suffix}`;
|
const network = `ql3-console-live-${suffix}`;
|
||||||
@@ -534,6 +611,7 @@ function main() {
|
|||||||
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
|
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
|
||||||
runOperatorContextContract(image);
|
runOperatorContextContract(image);
|
||||||
runConsoleContract(image);
|
runConsoleContract(image);
|
||||||
|
runEvidenceVerifierContract(image);
|
||||||
runPublishedConsoleContract(image);
|
runPublishedConsoleContract(image);
|
||||||
|
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
@@ -550,6 +628,7 @@ function main() {
|
|||||||
consoleLoopback: true,
|
consoleLoopback: true,
|
||||||
consoleAssets: true,
|
consoleAssets: true,
|
||||||
consoleEvidenceBundle: true,
|
consoleEvidenceBundle: true,
|
||||||
|
evidenceVerifier: true,
|
||||||
consolePublishedHostAddress: '127.0.0.1',
|
consolePublishedHostAddress: '127.0.0.1',
|
||||||
consoleDistributionEmbedded: true,
|
consoleDistributionEmbedded: true,
|
||||||
isolation: Object.freeze({
|
isolation: Object.freeze({
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const REQUIRED_FILES = Object.freeze([
|
|||||||
CONSOLE_ROOT + '/assets.ts',
|
CONSOLE_ROOT + '/assets.ts',
|
||||||
CONSOLE_ROOT + '/cli.ts',
|
CONSOLE_ROOT + '/cli.ts',
|
||||||
CONSOLE_ROOT + '/contracts.ts',
|
CONSOLE_ROOT + '/contracts.ts',
|
||||||
|
CONSOLE_ROOT + '/evidenceVerifier.ts',
|
||||||
|
CONSOLE_ROOT + '/evidenceVerifierCli.ts',
|
||||||
CONSOLE_ROOT + '/server.ts',
|
CONSOLE_ROOT + '/server.ts',
|
||||||
CLIENT_FILE,
|
CLIENT_FILE,
|
||||||
ASSET_ROOT + '/index.html',
|
ASSET_ROOT + '/index.html',
|
||||||
@@ -164,6 +166,48 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'diagnose',
|
'diagnose',
|
||||||
'cancel',
|
'cancel',
|
||||||
]);
|
]);
|
||||||
|
expectFragments(CONSOLE_ROOT + '/evidenceVerifier.ts', [
|
||||||
|
'qinglong/cluster-console-evidence-verification@v1',
|
||||||
|
'maximumBundleBytes: 512 * 1024',
|
||||||
|
'maximumRawBytes: 8 * 1024 * 1024',
|
||||||
|
"rawFactDigests: 'not_recomputed_without_raw_facts'",
|
||||||
|
"serverSignature: 'not_verified'",
|
||||||
|
"attestation: 'not_verified'",
|
||||||
|
"durableAudit: 'not_verified'",
|
||||||
|
"actionAuthority: 'none'",
|
||||||
|
'networkAccess: false',
|
||||||
|
'mutation: false',
|
||||||
|
'fileWrites: false',
|
||||||
|
'constants.O_RDONLY',
|
||||||
|
'constants.O_NOFOLLOW',
|
||||||
|
"createHash('sha256')",
|
||||||
|
]);
|
||||||
|
rejectFragments(CONSOLE_ROOT + '/evidenceVerifier.ts', [
|
||||||
|
'node:http',
|
||||||
|
'node:https',
|
||||||
|
'node:net',
|
||||||
|
'node:child_process',
|
||||||
|
'writeFile',
|
||||||
|
'appendFile',
|
||||||
|
'createWriteStream',
|
||||||
|
'process.env',
|
||||||
|
'process.stdin',
|
||||||
|
'fetch(',
|
||||||
|
]);
|
||||||
|
expectFragments(CONSOLE_ROOT + '/evidenceVerifierCli.ts', [
|
||||||
|
'ql3-copilot-evidence-verify --bundle=/absolute/evidence.json',
|
||||||
|
'verifyClusterConsoleEvidenceBundleFile',
|
||||||
|
'process.exitCode = 64',
|
||||||
|
'process.exitCode = 65',
|
||||||
|
'Cluster Console evidence bundle verification failed',
|
||||||
|
]);
|
||||||
|
rejectFragments(CONSOLE_ROOT + '/evidenceVerifierCli.ts', [
|
||||||
|
'process.env',
|
||||||
|
'process.stdin',
|
||||||
|
'node:http',
|
||||||
|
'node:https',
|
||||||
|
'writeFile',
|
||||||
|
]);
|
||||||
expectFragments(ASSET_ROOT + '/index.html', [
|
expectFragments(ASSET_ROOT + '/index.html', [
|
||||||
'沿着证据读,不替集群做决定。',
|
'沿着证据读,不替集群做决定。',
|
||||||
'本机只读 BFF',
|
'本机只读 BFF',
|
||||||
@@ -265,6 +309,9 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'consoleLoopback: true',
|
'consoleLoopback: true',
|
||||||
'consoleAssets: true',
|
'consoleAssets: true',
|
||||||
'consoleEvidenceBundle: true',
|
'consoleEvidenceBundle: true',
|
||||||
|
'evidenceVerifier: true',
|
||||||
|
'function runEvidenceVerifierContract(image)',
|
||||||
|
"[facade, 'evidence-verify', '--bundle=' + bundleFile]",
|
||||||
"started.origin + '/evidence-bundle.js'",
|
"started.origin + '/evidence-bundle.js'",
|
||||||
"consolePublishedHostAddress: '127.0.0.1'",
|
"consolePublishedHostAddress: '127.0.0.1'",
|
||||||
'consoleDistributionEmbedded: true',
|
'consoleDistributionEmbedded: true',
|
||||||
@@ -284,6 +331,8 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
manifest?.bin?.['ql3-copilot-console'] !== 'dist/copilot-console/cli.js' ||
|
manifest?.bin?.['ql3-copilot-console'] !== 'dist/copilot-console/cli.js' ||
|
||||||
|
manifest?.bin?.['ql3-copilot-evidence-verify'] !==
|
||||||
|
'dist/copilot-console/evidenceVerifierCli.js' ||
|
||||||
manifest?.exports?.['./copilot-console']?.require !==
|
manifest?.exports?.['./copilot-console']?.require !==
|
||||||
'./dist/copilot-console/server.js' ||
|
'./dist/copilot-console/server.js' ||
|
||||||
!Array.isArray(manifest?.files) ||
|
!Array.isArray(manifest?.files) ||
|
||||||
@@ -307,7 +356,10 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
if (
|
if (
|
||||||
!productCommand.includes("name: 'copilot-console'") ||
|
!productCommand.includes("name: 'copilot-console'") ||
|
||||||
!productCommand.includes("binary: 'ql3-copilot-console'") ||
|
!productCommand.includes("binary: 'ql3-copilot-console'") ||
|
||||||
!productCommand.includes("target: 'copilot-console/cli.js'")
|
!productCommand.includes("target: 'copilot-console/cli.js'") ||
|
||||||
|
!productCommand.includes("name: 'evidence-verify'") ||
|
||||||
|
!productCommand.includes("binary: 'ql3-copilot-evidence-verify'") ||
|
||||||
|
!productCommand.includes("target: 'copilot-console/evidenceVerifierCli.js'")
|
||||||
) {
|
) {
|
||||||
findings.push(
|
findings.push(
|
||||||
finding(
|
finding(
|
||||||
@@ -395,7 +447,16 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
attestation: 'none',
|
attestation: 'none',
|
||||||
actionAuthority: 'none',
|
actionAuthority: 'none',
|
||||||
}),
|
}),
|
||||||
sourceFileCount: 4,
|
offlineVerifier: Object.freeze({
|
||||||
|
lifecycle: 'operator-local-explicit-file-read',
|
||||||
|
bundleDigest: 'recomputed',
|
||||||
|
rawFactDigests: 'not_recomputed_without_raw_facts',
|
||||||
|
serverSignature: 'not_verified',
|
||||||
|
mutation: false,
|
||||||
|
networkAccess: false,
|
||||||
|
fileWrites: false,
|
||||||
|
}),
|
||||||
|
sourceFileCount: 6,
|
||||||
findings: Object.freeze(findings),
|
findings: Object.freeze(findings),
|
||||||
compatible: findings.length === 0,
|
compatible: findings.length === 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ test('binds the live image gate to native and container-published loopback', ()
|
|||||||
assert.match(source, /consoleAssets: true/);
|
assert.match(source, /consoleAssets: true/);
|
||||||
assert.match(source, /consoleEvidenceBundle: true/);
|
assert.match(source, /consoleEvidenceBundle: true/);
|
||||||
assert.match(source, /createClusterConsoleEvidenceBundle/);
|
assert.match(source, /createClusterConsoleEvidenceBundle/);
|
||||||
|
assert.match(source, /function runEvidenceVerifierContract\(image\)/);
|
||||||
|
assert.match(source, /\[facade, 'evidence-verify'/);
|
||||||
|
assert.match(source, /tamperRejected: true/);
|
||||||
|
assert.match(source, /evidenceVerifier: true/);
|
||||||
assert.match(source, /function runPublishedConsoleContract\(image\)/);
|
assert.match(source, /function runPublishedConsoleContract\(image\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -52,7 +52,16 @@ test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => {
|
|||||||
attestation: 'none',
|
attestation: 'none',
|
||||||
actionAuthority: 'none',
|
actionAuthority: 'none',
|
||||||
},
|
},
|
||||||
sourceFileCount: 4,
|
offlineVerifier: {
|
||||||
|
lifecycle: 'operator-local-explicit-file-read',
|
||||||
|
bundleDigest: 'recomputed',
|
||||||
|
rawFactDigests: 'not_recomputed_without_raw_facts',
|
||||||
|
serverSignature: 'not_verified',
|
||||||
|
mutation: false,
|
||||||
|
networkAccess: false,
|
||||||
|
fileWrites: false,
|
||||||
|
},
|
||||||
|
sourceFileCount: 6,
|
||||||
findings: [],
|
findings: [],
|
||||||
compatible: true,
|
compatible: true,
|
||||||
});
|
});
|
||||||
@@ -160,6 +169,30 @@ test('rejects evidence export network, persistence and authority widening', () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects offline verifier network, write and ambient authority widening', () => {
|
||||||
|
for (const injected of [
|
||||||
|
"require('node:https')",
|
||||||
|
'writeFileSync(',
|
||||||
|
'process.env',
|
||||||
|
'process.stdin',
|
||||||
|
'fetch(',
|
||||||
|
]) {
|
||||||
|
const report = auditClusterCopilotConsole({
|
||||||
|
root,
|
||||||
|
readFile: intercept(
|
||||||
|
'packages/ql3-cluster-admin/src/copilot-console/evidenceVerifier.ts',
|
||||||
|
(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', () => {
|
test('rejects coupling into the legacy UI or Kubernetes workloads', () => {
|
||||||
const legacyTarget = 'src/pages/login/index.tsx';
|
const legacyTarget = 'src/pages/login/index.tsx';
|
||||||
const legacy = auditClusterCopilotConsole({
|
const legacy = auditClusterCopilotConsole({
|
||||||
|
|||||||
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 120,
|
sourceFiles: 122,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 61,
|
rootSourceLines: 61,
|
||||||
nestedSourceFiles: 119,
|
nestedSourceFiles: 121,
|
||||||
rootSourceFileRoles: {
|
rootSourceFileRoles: {
|
||||||
'modelInvocationMigrationCli.ts': 'binary_entry',
|
'modelInvocationMigrationCli.ts': 'binary_entry',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user