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**
|
||||
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
|
||||
|
||||
@@ -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/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-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 常驻服务。
|
||||
|
||||
@@ -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-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 |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user