feat(ql3): verify cluster evidence offline

This commit is contained in:
whyour
2026-08-16 07:40:55 +08:00
parent 4f62a86d15
commit 33b9eb2f5e
16 changed files with 1422 additions and 13 deletions
@@ -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**
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
Create a dedicated Docker network whose egress is restricted by the host
+1
View File
@@ -11,6 +11,7 @@
最新增量证据(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/18Cluster Admin 387 pass/3 条件 skip18-package clean build/test 退出 0backend 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 审计零 findingworkspace 保持 18 package、无 single/shallow packageCluster 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/24Cluster Admin 382 pass/3 条件 skip,完整 18-package test 退出 0backend 1,224 pass/2 条件 skip/0 fail。真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot model text 验证纯文本与零泄漏;3 次显式读取后导出 3,611-byte 可复算 JSONupstream 计数仍为 3390×844 无横向溢出且 0 console error/warning。真实 arm64 Admin image `qinglong3-cluster-admin:d330-local` 为 344,543,263 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console、第 4 个 asset 与内置分发。npm pack dry-run 为 246 files、267,731-byte tarball、1,665,996-byte unpackedpackage/dependency/Edge import/Cluster deployment/image release/Console/distribution 审计零 finding。workspace 保持 18 package、`singleSourcePackages=[]``shallowSourcePackages=[]`1,199 个源码中 1,181 个位于职责目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 保持 4,493,043/4,493,175 bytesMCP 保持 7,315,930/7,316,038 bytes。PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2`,报告 SHA-256 `c9feb83c98ad2269c7649bd0869921d9dee7cfd00c9bc1a8a7879d81630d37c7`,证据审计与 Docker 残留均为零;本 Gate 没有 schema、migration、SQL、role、Pool 或连接拓扑变化。下一独立 Gate 应交付公开 release digest 的外部工作站 ceremony,或提供独立、离线、无 authority 的 evidence bundle verifier;不得为导出增加服务端聚合、稳定跨包标识、自动抓取或上传能力。
- D-329/ADR-0421(已接受):同一 loopback-only Console/BFF 已扩展为 Cluster field ledger,固定提供 Copilot `inspect|output`、Run list/detail/events/steps、Task list/detail、Workflow list 与 Workflow Run list/detail/events/steps 共 13 个显式只读 operationbrowser 仍不能提交 upstream URL/method/header/credential。服务端 exact contract 负责 ID/cursor/limit 校验和 path/query 生成,并复用既有 owner-private `ql3c_`、TLS 1.3、request-ID、2 MiB response 与低敏错误 transport;通用 Project read grammar 只接受审核过的 Run/Task/Workflow GET,拒绝 mutation、absolute URL 与 path traversal。UI 采用仅存内存的 evidence ledger,每次按钮只执行一次读取,分页只在 `hasMore|truncated` 携带 cursor 时由用户显式触发,没有自动 detail cascade、poller、WebSocket/SSE、retry、queue、cache 或后台 timer。实现继续留在 `@qinglong/cluster-admin`workspace 维持 18 package,部署 credential 推荐只授予 `run.read|task.read|artifact.read`;不回接 2.x Web/session、不新增 Cluster route/schema/SQL/Pool/Kubernetes resident service,也不进入 Edge/Standalone closure。13-operation contract、Console/CLI/TLS 定向门 23/23Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0backend 1,223 pass/2 条件 skip/0 fail。真实浏览器完成 Run/Task/Workflow 读取、显式下一页、恶意 HTML 纯文本、390×844 与零 console error/warning,并发现、修正 `[hidden]` 被 panel layout 覆盖的问题;真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpackedpackage/dependency/Cluster deployment/image release/Console/distribution 审计零 findingworkspace 为 18 package 且无 single-source/shallow package。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 保持 4,493,043/4,493,175 bytesMCP 保持 7,315,930/7,316,038 bytes。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应把现场 evidence 升级为可下载的显式脱敏诊断包,或补公开 release digest 的外部工作站 ceremony;不得增加浏览器代理权、自动全量抓取或把 Console 变为 Kubernetes 常驻服务。
- D-328/ADR-0420(已接受):Cluster Copilot Console 的 operator-workstation 分发已冻结为既有 `qinglong3-cluster-admin@sha256:…` 多架构 OCI,而不是再发布 Node archive、安装器镜像或第 19 个 workspace package。Admin release workflow 已有 amd64/arm64 原生构建、production dependency audit、BuildKit provenance/SBOM、OS vulnerability scan、扫描后 OCI merge、keyless Cosign signature,以及绑定 repository/workflow/source commit/source tag 的 GitHub provenance、CycloneDX 和 OS-vulnerability attestations;新增 `verify-release.sh` 要求 image digest、40-hex revision 与 `refs/tags/v3.*`,独立复验 exact workflow certificate identity、GitHub OIDC issuer、三类 predicate、OCI bundle 并拒绝 mutable tag、branch ref 与 self-hosted builder。已签名 image filesystem 现在携带 `0555` launcher/verifier 和 `0444` 文档/配置模板,因此 signature 同时覆盖宿主启动路径,不存在第二套依赖树。原生 Console 默认继续只监听 `127.0.0.1` ephemeral port;只有显式 `--container-published-loopback` 加固定高端口才允许容器 network namespace 内监听 `0.0.0.0`,并仍对外报告 `publishedHostAddress=127.0.0.1`。reviewed launcher 只接受 immutable digest、canonical private root、显式命名网络和 `compact|standard` 两档资源,拒绝 `bridge|default|host|none``check` 不 publish`serve` 唯一映射为 `127.0.0.1:<port>:<port>/tcp`,同时固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 authority mount、`--pull never`、3 秒 stopcompact 为 192 MiB/0.25 CPU/32 PIDsstandard 为 512 MiB/1 CPU/64 PIDs。启动器/验签器/分发审计与 Console 定向门 30/30release/OCI/SBOM 定向门 75/75package/dependency/Edge/Cluster deployment/Console distribution 审计全部零 finding。Cluster Admin 376 pass/3 条件 skip,完整 18-package clean build/test 退出 0backend 1,223 pass/2 条件 skip/0 fail。npm pack dry-run 保持 245 files、258,591-byte tarball、1,616,582-byte unpacked。真实 arm64 image `qinglong3-cluster-admin:d328-local` 为 344,492,529 bytes,验证 10 个产品命令、镜像内五项分发文件及 mode、原生 loopback Console;新增 named-network 现场门选择一个空闲高端口并以 exact `127.0.0.1:P:P` 发布,宿主真实读取页面,同时复验 read-only/non-root/no-capability/no-new-privileges 边界与容器/网络清理。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modulesapplication+AI 仍为 4,493,043/4,493,175 bytesMCP 仍为 7,315,930/7,316,038 bytes,证明工作站分发没有进入低配路由设备闭包。workspace 保持 18 package、`singleSourcePackages=[]``shallowSourcePackages=[]`Cluster Admin 120 个源码中 119 个位于嵌套职责目录。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应在同一 Console/BFF ownership 下增加只读 Run/Task/Workflow 观察面,或补公开 release tag 的外部工作站 ceremony;不得把容器内部 listener 误当宿主 LAN authority、引入第二分发依赖树或把 Console 变成 Kubernetes 常驻服务。
@@ -0,0 +1,46 @@
# ADR-0423:独立、离线且无 Authority 的 Cluster Evidence Verifier
- 状态:Accepted
- 日期:2026-08-16
- 关联 RFCQL-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 authoritypackage 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-16D-331 完成以下发布门并接受本决策:
- 生成器/独立 verifier 与产品/架构审计定向门 18/18Cluster Admin 完整测试 387 pass、3 条件 skip、0 fail18-package clean build/test 退出 0backend 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 审计零 findingworkspace 保持 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 modulesapplication+AI 保持 4,493,043/4,493,175 bytesMCP 保持 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 增加上传、签名或行动能力。
+1
View File
@@ -426,6 +426,7 @@
| [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 |
| [ADR-0423](./ADR-0423-independent-offline-cluster-evidence-verifier.md) | 独立、离线且无 Authority 的 Cluster Evidence Verifier | Accepted |
## 规则
+13
View File
@@ -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,
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
verification, preflight and session lifecycle are documented in
`deploy/console/ql3-cluster-copilot/README.md`. Native execution binds host
+1
View File
@@ -407,6 +407,7 @@
"ql3-copilot-client": "dist/copilot-client/cli.js",
"ql3-copilot-mcp": "dist/copilot-mcp/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-manage": "dist/plugin-package/management/pluginPackageManagementCli.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',
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({
name: 'package',
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,
close: () =>
new Promise((resolvePromise, reject) => {
server.close((error) =>
error ? reject(error) : resolvePromise(),
);
server.close((error) => (error ? reject(error) : resolvePromise()));
}),
};
}
@@ -152,9 +150,7 @@ async function startCopilotReadinessServer(status) {
port: server.address().port,
close: () =>
new Promise((resolvePromise, reject) => {
server.close((error) =>
error ? reject(error) : resolvePromise(),
);
server.close((error) => (error ? reject(error) : resolvePromise()));
}),
};
}
@@ -333,7 +329,7 @@ function validContextFixture(t) {
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(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 10);
assert.equal(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length, 11);
assert.equal(
new Set(QINGLONG3_CLUSTER_PRODUCT_COMMANDS.map(({ name }) => name)).size,
QINGLONG3_CLUSTER_PRODUCT_COMMANDS.length,
@@ -352,7 +348,8 @@ test('catalog exposes only reviewed product entrypoints from the same package',
assert.equal(
command.binary.includes('-client') ||
command.binary === 'ql3-copilot-mcp' ||
command.binary === 'ql3-copilot-console',
command.binary === 'ql3-copilot-console' ||
command.binary === 'ql3-copilot-evidence-verify',
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-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 evidence-verify\s+verify one redacted Console evidence/,
);
assert.match(help, /Server, migration, recovery, executor and key-custody/);
assert.equal(help.includes('plugin-package-manage'), false);
assert.equal(
@@ -21,6 +21,10 @@ const COMMANDS = Object.freeze([
name: 'copilot-console',
usage: 'Usage:\n ql3-copilot-console --config ',
}),
Object.freeze({
name: 'evidence-verify',
usage: 'Usage: ql3-copilot-evidence-verify --bundle=',
}),
Object.freeze({
name: 'package',
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) {
const suffix = `${process.pid}-${Date.now()}`;
const network = `ql3-console-live-${suffix}`;
@@ -534,6 +611,7 @@ function main() {
if (version !== '3.0.0-alpha.0') fail('product version contract drifted');
runOperatorContextContract(image);
runConsoleContract(image);
runEvidenceVerifierContract(image);
runPublishedConsoleContract(image);
process.stdout.write(
@@ -550,6 +628,7 @@ function main() {
consoleLoopback: true,
consoleAssets: true,
consoleEvidenceBundle: true,
evidenceVerifier: true,
consolePublishedHostAddress: '127.0.0.1',
consoleDistributionEmbedded: true,
isolation: Object.freeze({
+63 -2
View File
@@ -11,6 +11,8 @@ const REQUIRED_FILES = Object.freeze([
CONSOLE_ROOT + '/assets.ts',
CONSOLE_ROOT + '/cli.ts',
CONSOLE_ROOT + '/contracts.ts',
CONSOLE_ROOT + '/evidenceVerifier.ts',
CONSOLE_ROOT + '/evidenceVerifierCli.ts',
CONSOLE_ROOT + '/server.ts',
CLIENT_FILE,
ASSET_ROOT + '/index.html',
@@ -164,6 +166,48 @@ function auditClusterCopilotConsole(options = {}) {
'diagnose',
'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', [
'沿着证据读,不替集群做决定。',
'本机只读 BFF',
@@ -265,6 +309,9 @@ function auditClusterCopilotConsole(options = {}) {
'consoleLoopback: true',
'consoleAssets: true',
'consoleEvidenceBundle: true',
'evidenceVerifier: true',
'function runEvidenceVerifierContract(image)',
"[facade, 'evidence-verify', '--bundle=' + bundleFile]",
"started.origin + '/evidence-bundle.js'",
"consolePublishedHostAddress: '127.0.0.1'",
'consoleDistributionEmbedded: true',
@@ -284,6 +331,8 @@ function auditClusterCopilotConsole(options = {}) {
}
if (
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 !==
'./dist/copilot-console/server.js' ||
!Array.isArray(manifest?.files) ||
@@ -307,7 +356,10 @@ function auditClusterCopilotConsole(options = {}) {
if (
!productCommand.includes("name: '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(
finding(
@@ -395,7 +447,16 @@ function auditClusterCopilotConsole(options = {}) {
attestation: '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),
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, /consoleEvidenceBundle: true/);
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,
@@ -52,7 +52,16 @@ test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => {
attestation: '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: [],
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', () => {
const legacyTarget = 'src/pages/login/index.tsx';
const legacy = auditClusterCopilotConsole({
+2 -2
View File
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
},
{
sourceFiles: 120,
sourceFiles: 122,
rootSourceFiles: 1,
rootSourceLines: 61,
nestedSourceFiles: 119,
nestedSourceFiles: 121,
rootSourceFileRoles: {
'modelInvocationMigrationCli.ts': 'binary_entry',
},