diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md index b2434927..bb5bb31a 100644 --- a/deploy/console/ql3-cluster-copilot/README.md +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -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 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 0d7a16b7..b5e343d7 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -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::/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 常驻服务。 diff --git a/docs/adr/ADR-0423-independent-offline-cluster-evidence-verifier.md b/docs/adr/ADR-0423-independent-offline-cluster-evidence-verifier.md new file mode 100644 index 00000000..76b472bb --- /dev/null +++ b/docs/adr/ADR-0423-independent-offline-cluster-evidence-verifier.md @@ -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 增加上传、签名或行动能力。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 9abdc704..3380f639 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 | ## 规则 diff --git a/packages/ql3-cluster-admin/README.md b/packages/ql3-cluster-admin/README.md index 71fad5e1..3b2c9d0f 100644 --- a/packages/ql3-cluster-admin/README.md +++ b/packages/ql3-cluster-admin/README.md @@ -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 diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index 0cf789bc..122be103 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -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", diff --git a/packages/ql3-cluster-admin/src/copilot-console/evidenceVerifier.ts b/packages/ql3-cluster-admin/src/copilot-console/evidenceVerifier.ts new file mode 100644 index 00000000..4f97b144 --- /dev/null +++ b/packages/ql3-cluster-admin/src/copilot-console/evidenceVerifier.ts @@ -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(OPERATIONS); +const REQUEST_FIELDS: Readonly> = + 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> = 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 { + 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 { + 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>): 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, +): 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; + readonly seen: Set; +} + +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 = {}; + 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>).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); + } +} diff --git a/packages/ql3-cluster-admin/src/copilot-console/evidenceVerifierCli.ts b/packages/ql3-cluster-admin/src/copilot-console/evidenceVerifierCli.ts new file mode 100644 index 00000000..c05491a2 --- /dev/null +++ b/packages/ql3-cluster-admin/src/copilot-console/evidenceVerifierCli.ts @@ -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)); diff --git a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts index da8e63d3..6f96f4f2 100644 --- a/packages/ql3-cluster-admin/src/product-cli/productCommand.ts +++ b/packages/ql3-cluster-admin/src/product-cli/productCommand.ts @@ -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', diff --git a/packages/ql3-cluster-admin/test/copilotConsoleEvidenceVerifier.test.cjs b/packages/ql3-cluster-admin/test/copilotConsoleEvidenceVerifier.test.cjs new file mode 100644 index 00000000..0eed5cdc --- /dev/null +++ b/packages/ql3-cluster-admin/test/copilotConsoleEvidenceVerifier.test.cjs @@ -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)); +}); diff --git a/packages/ql3-cluster-admin/test/productCli.test.cjs b/packages/ql3-cluster-admin/test/productCli.test.cjs index 5628bc8d..a23e805c 100644 --- a/packages/ql3-cluster-admin/test/productCli.test.cjs +++ b/packages/ql3-cluster-admin/test/productCli.test.cjs @@ -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( diff --git a/scripts/ql3-cluster-admin-product-live-contract.cjs b/scripts/ql3-cluster-admin-product-live-contract.cjs index e31b3013..991f915e 100644 --- a/scripts/ql3-cluster-admin-product-live-contract.cjs +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -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({ diff --git a/scripts/ql3-cluster-copilot-console-audit.cjs b/scripts/ql3-cluster-copilot-console-audit.cjs index 91c81231..d678191e 100644 --- a/scripts/ql3-cluster-copilot-console-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-audit.cjs @@ -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, }); diff --git a/test/back/ql3ClusterAdminProductLiveContract.test.cjs b/test/back/ql3ClusterAdminProductLiveContract.test.cjs index 94513352..ca28c49c 100644 --- a/test/back/ql3ClusterAdminProductLiveContract.test.cjs +++ b/test/back/ql3ClusterAdminProductLiveContract.test.cjs @@ -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, diff --git a/test/back/ql3ClusterCopilotConsoleAudit.test.cjs b/test/back/ql3ClusterCopilotConsoleAudit.test.cjs index 2ceea7f8..ee907cc3 100644 --- a/test/back/ql3ClusterCopilotConsoleAudit.test.cjs +++ b/test/back/ql3ClusterCopilotConsoleAudit.test.cjs @@ -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({ diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index d788e8e3..51548f7b 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -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', },