From aae96390cc40e1aab95d9f64480fcc05855bf21e Mon Sep 17 00:00:00 2001 From: whyour Date: Sat, 15 Aug 2026 00:40:56 +0800 Subject: [PATCH] feat(ql3): add cluster tool result key authority --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 7 + ...ter-projected-tool-result-key-authority.md | 89 ++++++++ docs/adr/README.md | 2 + packages/ql3-cluster-control/package.json | 5 + .../remote-execution/mountedSecretProvider.ts | 118 +++-------- .../src/security/privateProjectedFile.ts | 168 +++++++++++++++ .../toolResultKeyringManifest.ts | 194 ++++++++++++++++++ .../toolResultProjectedKeyring.ts | 140 +++++++++++++ .../tool-result-projected-keyring.test.cjs | 193 +++++++++++++++++ test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 10 files changed, 829 insertions(+), 91 deletions(-) create mode 100644 docs/adr/ADR-0406-cluster-projected-tool-result-key-authority.md create mode 100644 packages/ql3-cluster-control/src/security/privateProjectedFile.ts create mode 100644 packages/ql3-cluster-control/src/trusted-tool/key-management/toolResultKeyringManifest.ts create mode 100644 packages/ql3-cluster-control/src/trusted-tool/key-management/toolResultProjectedKeyring.ts create mode 100644 packages/ql3-cluster-control/test/tool-result-projected-keyring.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index d2be5f55..88448fde 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-14): +- D-314/ADR-0406(已接受):Cluster Trusted Tool 的 encrypted completion 不再停留在 storage port。`@qinglong/cluster-control/trusted-tool-result-keyring` 新增只读 projected material authority:canonical v1 manifest 只含最多 16 个 canonical 32-byte key,不含 generation、active/state/retirement,provider 只有 `resolve(keyId)` 而没有 `active()`;因此 PostgreSQL `trusted-tool-results` catalog 仍是 active/decryptable 状态唯一 authority,completion 会以 catalog material proof 再次校验。runtime 每次调用重新执行 direct-root、in-root atomic symlink、single-link、64 KiB、只读/不可执行/other-inaccessible mode、dev/inode/size/mtime 与双 realpath fence,不持有 Kubernetes API、cache、watcher 或 timer。新增能力位于 Cluster Control 既有 `trusted-tool/key-management/`,并把 mounted Secret 与 keyring 的 projected-file/TOCTOU 逻辑收敛到 package-private `security/privateProjectedFile` 真源;公开 mounted Secret 行为不变,不新增 package、依赖、migration、连接、route 或默认 Profile importer。定向共享回归 7/7,Cluster Control 完整 234 pass/2 条条件 skip/0 fail;最终 18-package clean build/test 与 backend 1,207 pass/2 条件 skip/0 fail,package/dependency/Edge import/Cluster deployment 四项审计零 finding。workspace 仍为 18 package、无单文件或浅平 package;Cluster Control 54 个源码中仅 2 个 binary entry 位于根层,52 个处于嵌套领域目录。14 档 Local Profile artifact 全部通过,默认 Edge/Standalone 保持 2,589,812/2,589,890 bytes,AI 保持 3,121,108/3,121,198 bytes,MCP 保持 7,315,930/7,316,038 bytes。PostgreSQL 18.4 arm64 HA 125/125、timeline `1→2`,报告 SHA-256 为 `26c817647ed984d8d4627a7cae1c95de06017a5d6d32dd3dfd01414ba029e542`,证据审计与 Docker 容器/网络/卷零残留。下一 Gate 是独立 diagnosis Run 的 Tool/Model Step admission 与 Copilot encrypted model completion,不能借用终态源 Run 或 Plugin Prompt plan。 - D-313/ADR-0405(已接受):新增 `@qinglong/ai/failure-diagnosis-prompt`,把 ADR-0403 的潜在敏感日志投影收敛为固定 system instruction + canonical JSON data envelope;日志只存在于 `log.content` JSON string value,不能通过引号、换行、伪造 role/schema 或 delimiter 拼接出新 message。builder 重新校验完整 trust/redaction/profile byte 契约,拒绝伪造 `safe`、行动权、未知字段与 byte/signal drift;envelope 不带 Run/Attempt、Artifact、path、cursor 或 content digest。部署者必须通过 `qinglong/copilot-model-egress-policy@v1` 显式允许 `potentially_sensitive` 数据进入 `on_device|external` 边界并提供输入/output token 双预算,空 allowlist 与 external 未授权均在 Model Gateway/Provider I/O 前失败关闭。输出只含 content-free egress evidence,并固定要求模型 completion 继承潜在敏感、仅加密持久化、禁止明文审计且无行动权;真正 Cluster Trusted Tool/model completion 仍需后续组合门。能力以 `ql3-ai/src/copilot/failure-diagnosis/` 三个内聚文件和精确 subpath 交付,不新增 package、依赖、迁移、连接或常驻组件。定向 12/12、AI 221 pass/3 条件 skip/0 fail;最终 18-package clean build/test 与 backend 1,207 pass/2 条件 skip/0 fail,四项结构/部署审计零 finding,14 档 Local Profile artifact 全部通过。默认 Edge/Standalone 保持 2,589,812/2,589,890 bytes、315 files、56 modules,Edge/Standalone AI 保持 3,121,108/3,121,198 bytes、368 files、61 modules,MCP 保持 7,315,930/7,316,038 bytes、801 files、226 modules,证明未装配 subpath 被完全裁掉。PostgreSQL 18.4 arm64 HA 125/125、timeline `1→2`,报告 SHA-256 为 `2bbc8bdd0d90e6ec9ce82d2afcaec817679dddb82860c5d405a09d5e5458bece`,证据审计与 Docker 零残留。 - D-312/ADR-0404(已接受):`qinglong.run.log.excerpt@1.0.0` 进入显式可选的本机 `ql3-mcp` stdio 产品入口。每次调用固定经过 Owner credential authentication、exact `tool.call:qinglong.run.log.excerpt` + `artifact.read` Policy、durable audit、credential/Pepper fence confirm,再复用同一 SQLite authority 和私有 Artifact reader 完成 ADR-0403 的 Edge 4 KiB/Standalone 8 KiB 双读取安全投影。配置升级为 `qinglong/local-mcp-server@v2` 并要求显式 private `artifactRoot`,旧 v1 不猜测路径而是失败关闭。产物实证否决了 MCP 直接依赖 `local-execution` 的方案:该方案会带入 process/scheduler/croner,达到 7,469,105 bytes/816 files/228 modules;唯一 reader 实现因此归入既有 `local-command-file/artifact-read` 私有文件 authority,Execution 通过兼容 re-export 复用,workspace 仍为 18 package 且没有根层平铺。Local MCP 48/48、Local Execution 41/41、私有文件 3/3、依赖防火墙 54/54;最终 18-package clean build/test 与 backend 1,207 pass/2 条件 skip/0 fail,四项结构/部署审计零 finding,14 个 Local Profile artifact 全部通过。默认 Edge/Standalone 保持 2,589,812/2,589,890 bytes、315 files、56 modules,Edge/Standalone MCP 为 7,315,930/7,316,038 bytes、801 files、226 modules、RSS 38,420,480/39,567,360 bytes,闭包不含 `local-execution`、`local-process` 或 `croner`。PostgreSQL 18.4 arm64 HA 125/125、timeline `1→2`,报告 SHA-256 为 `29cd77d80737a3b1ab686c998d05a78c52deffd8add3b31d8035756d5dfcc433`,证据审计与 Docker 零残留。 - D-311/ADR-0403(已接受):新增 `qinglong.run.log.excerpt@1.0.0` 共享 Trusted Tool kernel。输入只接受 Run/Attempt ID,Project 来自受信 context;禁止 Artifact ID、路径、URI、offset、length 与 cursor。Tool 复用 ADR-0377 的 Local 私有文件和 Cluster S3 日志 range reader,以一次 1-byte 尾部探测和一次 profile 固定窗口读取完成有界选择,不循环、不分页:Edge 4 KiB、Standalone 8 KiB、Cluster Control 16 KiB,Worker 拒绝;日志并发增长通过 `tailComplete=false` 和 `bounded_tail_probe_then_range_read` 明示,不冒充事务快照。内容执行非致命 UTF-8、控制/bidi 归一与七类已识别 credential 确定性掩码,始终声明 `residualSensitivity=potentially_sensitive`,并无条件作为 `data_only_never_execute`、`actionAuthority=none` 的不可信执行输出;Prompt 注入信号只作提示,不能授予 Tool/命令权限。能力位于 Runtime Core 既有二级目录,只导出精确 subpath,不新增 package、依赖、migration、连接或常驻组件;MCP/HTTP/Cluster 产品入口与最终 Prompt builder 留给独立门禁。最终 18-package clean build/test 与 backend 1,206 pass/2 条件 skip/0 fail,package/dependency/Edge import/Cluster deployment 审计零 finding,14 个 Local Profile 制品门全部通过;默认 Edge 保持 2,589,812 bytes/315 files/56 modules,Edge AI 为 3,121,108 bytes/368 files/61 modules,Edge MCP 为 7,237,187 bytes/795 files/220 modules,均在门内。PostgreSQL 18.4 arm64 HA 125/125 Gate、timeline `1→2`,报告 SHA-256 为 `1a0df2518d39db22ecf4bbaf2e06c9e6893e1bbf507b4026b2e0ef055eb2fd90`。 @@ -6930,6 +6931,12 @@ envelope 将日志限制在不可混淆的 string value;部署者必须显式 关闭。该 kernel 只生成 `GenerateRequest` 和 content-free egress evidence,同时声明 completion 必须 加密、禁止明文审计且无行动权;Cluster Trusted Tool invocation、产品级 result key、模型调用与 encrypted completion 的真实组合仍是下一独立 Gate。 +ADR-0406 已关闭其中的 Cluster result-key material 缺口:只读 projected keyring 不携带 active +选择权,PostgreSQL catalog 继续独占 generation/state authority;它与 Prompt output keyring 分域, +并复用 Cluster Control 内部唯一 projected-file 安全读取原语。该 adapter 尚未开放 route,也不代表 +Copilot execution authority 已完成;下一 Gate 必须创建独立 diagnosis Run 的 Tool/Model Step admission, +再把 S3 Tool completion、ADR-0405 builder、Model Gateway 与 Copilot 专用 encrypted model completion +串成可恢复闭环。 ## 17. Tool Registry diff --git a/docs/adr/ADR-0406-cluster-projected-tool-result-key-authority.md b/docs/adr/ADR-0406-cluster-projected-tool-result-key-authority.md new file mode 100644 index 00000000..b935a44b --- /dev/null +++ b/docs/adr/ADR-0406-cluster-projected-tool-result-key-authority.md @@ -0,0 +1,89 @@ +# ADR-0406:Cluster Projected Tool Result Key Authority + +- 状态:Accepted +- 日期:2026-08-14 +- 关联 RFC:QL-RFC-0001 D-314、Phase 2 +- 关联 ADR:ADR-0163、ADR-0164、ADR-0166、ADR-0263、ADR-0403、ADR-0405 + +## 问题 + +ADR-0163~0166 已完成 Trusted Tool 成功结果的加密 Artifact、PostgreSQL catalog fence、rotation/ +rekey/retirement 协议和双方言 repository;Cluster runtime 也已经从同一 Pool 装配 completion、catalog +与 rekey storage。但产品组合只有 storage port,没有可部署的 result-key material provider,因此 +ADR-0405 的日志 Tool 还不能在 Cluster 中兑现 encrypted completion。 + +直接复用 Plugin Package Prompt output keyring 会混合两种密文域和轮换 ceremony;让投影文件携带 +`activeKeyId` 又会与 PostgreSQL `trusted-tool-results` catalog 形成双 active authority。把 key 写入环境 +变量、数据库或常驻缓存,则分别失去文件权限/轮换证明、违反数据库不保存 material 的约束,或引入撤权 +窗口。该能力也不足以形成新的部署制品或 workspace package。 + +## 决策 + +1. 在既有 `@qinglong/cluster-control` 的 `src/trusted-tool/key-management/` 中增加 Cluster Tool Result + projected keyring,并只通过精确 subpath `trusted-tool-result-keyring` 导出。它不进入 package root、 + 默认 control composition、Edge/Standalone 或普通 Worker closure。 +2. manifest 固定为 `qinglong/cluster-tool-result-projected-keyring@v1`,只含 canonical `keys` map;key ID + 沿用 Runtime Core catalog 约束,material 必须是 canonical base64url 的 32 bytes。投影最多包含 16 个 + key,与 catalog 的最大 decryptable key 数一致;文件最大 64 KiB 且必须是单行 canonical JSON 加换行。 +3. manifest **没有** generation、active key、state、retirement 或恢复字段。provider 只实现 `resolve(keyId)`, + 不实现 `active()`;当前加密 key 与历史可解密状态只能由 PostgreSQL catalog 的 generation/digest/ + material proof fence 决定。投影文件增删 key 不能自行改变 durable catalog 状态。 +4. `verify()` 只返回 key ID、Runtime Core domain-separated material proof 和整个投影的 content-free digest, + 不返回 encoded/raw material。Trusted Tool completion 仍会在每次使用时以 catalog entry 的 proof + 重新校验 material,调用完成后由既有 coordinator 清零 owned key bytes。 +5. runtime 每次 resolve 都重新打开投影,不使用 Kubernetes API、ServiceAccount、list/watch、cache、timer + 或后台进程。允许 Kubernetes atomic-writer symlink,但 resolved regular file 必须留在 direct、非 symlink + root 下;同时校验 single-link、大小、只读/不可执行/other-inaccessible mode、dev/inode/size/mtime 和 + root/target 二次 realpath,轮换竞争失败关闭。 +6. Cluster Control 内原有 mounted Secret provider 与新 keyring 共享 package-private + `security/privateProjectedFile` primitive。原公开类、错误码、文件名、大小和权限语义不变;抽取消除两套 + TOCTOU/symlink 实现,后续安全修正只有一个真源。该 primitive 不新增公开 export。 +7. 本阶段不增加环境变量、volume、route 或 AI 启动前置条件。只有后续 Cluster Copilot composition 明确 + 启用并把该 provider 与 PostgreSQL catalog、Trusted Tool coordinator 连接时,部署者才需要投影它; + adapter 存在不等于执行 authority 已开放。 + +## 低配与集群影响 + +- 默认 Edge/Standalone 和默认 Cluster Control 没有新 importer、listener、连接、timer、watcher、cache、 + migration、表或常驻内存;workspace 继续保持 18 个 package。 +- Cluster AI 后续只在一次 Tool completion 时读取最多 64 KiB 的一个 manifest,内存 key 数硬限 16; + PostgreSQL 继续是 active/decryptable state 的唯一事实源。 +- 实现使用既有 package 的嵌套领域目录,不创建单文件 package,也不把文件平铺到 `src/` 根。 + +## 被否决方案 + +1. **复用 Prompt output keyring/root**:跨加密域复用 material 与 rotation ceremony,扩大单 key 泄露半径。 +2. **manifest 自带 activeKeyId**:会让文件和 PostgreSQL catalog 同时选择 active generation。 +3. **把 raw/wrapped key 放进 PostgreSQL**:违反 ADR-0166 的 catalog/material 分离与数据库最小泄露面。 +4. **使用环境变量或进程缓存**:缺少轮换文件身份,且产生撤权窗口和长期明文驻留。 +5. **为 provider 新建 workspace package**:没有独立进程、制品、权限角色或第二个生产 consumer。 +6. **复制 mounted Secret 的安全读取代码**:会让 symlink、mode 与 TOCTOU 修复发生行为漂移。 + +## 当前验证 + +1. 新增 3 项 keyring 测试,覆盖无 `active()` authority、material proof、owned bytes、原子投影轮换、 + historical/missing key、非 canonical JSON、错误 key 长度、可写文件、逃逸 symlink、symlink root 和路径逃逸。 +2. 共享 projected-file 抽取与既有 mounted Secret 回归合计 7/7;Cluster Control 完整测试为 + 234 pass、2 条 PostgreSQL/S3 条件 skip、0 fail。 +3. `pnpm run test:packages:ql3` 完成 18-package clean build/test;完整 backend 为 1,207 pass、2 条 + 条件 skip、0 fail。package boundary、Cluster dependency、Edge import 与 Cluster deployment 四项审计 + 均为 compatible 且零 finding。 +4. workspace 仍为 18 个 package,无单文件或浅平 package;Cluster Control 共 54 个源码文件,只有 + `aiCli.ts`、`cli.ts` 两个 binary entry 位于 `src/` 根,其余 52 个均处于嵌套领域目录。 +5. 14 档 Local Profile artifact 全部通过;默认 Edge/Standalone 为 2,589,812/2,589,890 bytes, + AI 为 3,121,108/3,121,198 bytes,MCP 为 7,315,930/7,316,038 bytes。证明新增 Cluster-only + subpath 没有进入低配设备的本地闭包。 +6. PostgreSQL 18.4 arm64 HA 为 125/125 Gate、timeline `1→2`;报告 SHA-256 为 + `26c817647ed984d8d4627a7cae1c95de06017a5d6d32dd3dfd01414ba029e542`,独立证据审计零 finding, + Docker 容器、网络与卷零残留。 + +## 后续门禁 + +1. 定义独立 Copilot diagnosis Run admission,在同一诊断 Run 中创建 Tool Step 与 Model Step;不能把 Tool + Step 追加到已终态的源失败 Run,也不能借用 Plugin Package Prompt plan 冒充 Copilot plan。 +2. 组合 Project Tool snapshot、invocation Artifact key、S3 log reader、result-key provider 和统一 Trusted + Tool completion,证明 response-loss replay 不会重复执行 adapter。 +3. 将 ADR-0405 builder 与现有 Model Gateway 连接,并建立 Copilot 专用 encrypted model completion; + 禁止把 live response、普通 model completion 或 Prompt output Artifact 冒充潜在敏感诊断完成记录。 +4. 最后才开放默认关闭的认证/Policy/audit/credential-fenced Cluster route,并补多副本 HA、真实 S3、 + 外部 Provider fault injection 与 plaintext audit negative evidence。 diff --git a/docs/adr/README.md b/docs/adr/README.md index b00ed318..5b635fa2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -408,6 +408,8 @@ | [ADR-0402](./ADR-0402-bounded-latest-task-run-outcome-comparison.md) | 有界的 Task 最近成功/失败 Run 对比 | Accepted | | [ADR-0403](./ADR-0403-bounded-redacted-run-log-tail-tool.md) | 有界、脱敏且不授予行动权的 Run 日志尾部 Tool | Accepted | | [ADR-0404](./ADR-0404-optional-local-mcp-run-log-excerpt-surface.md) | 可选本机 MCP Run 日志摘录产品入口与私有文件边界 | Accepted | +| [ADR-0405](./ADR-0405-bounded-failure-diagnosis-prompt-and-model-egress-policy.md) | 有界故障诊断 Prompt 与显式模型出口策略 | Accepted | +| [ADR-0406](./ADR-0406-cluster-projected-tool-result-key-authority.md) | Cluster Projected Tool Result Key Authority | Accepted | ## 规则 diff --git a/packages/ql3-cluster-control/package.json b/packages/ql3-cluster-control/package.json index eb75455a..55643668 100644 --- a/packages/ql3-cluster-control/package.json +++ b/packages/ql3-cluster-control/package.json @@ -120,6 +120,11 @@ "require": "./dist/remote-execution/mountedSecretProvider.js", "default": "./dist/remote-execution/mountedSecretProvider.js" }, + "./trusted-tool-result-keyring": { + "types": "./dist/trusted-tool/key-management/toolResultProjectedKeyring.d.ts", + "require": "./dist/trusted-tool/key-management/toolResultProjectedKeyring.js", + "default": "./dist/trusted-tool/key-management/toolResultProjectedKeyring.js" + }, "./remote-completion": { "types": "./dist/remote-execution/remoteWorkerCompletionService.d.ts", "require": "./dist/remote-execution/remoteWorkerCompletionService.js", diff --git a/packages/ql3-cluster-control/src/remote-execution/mountedSecretProvider.ts b/packages/ql3-cluster-control/src/remote-execution/mountedSecretProvider.ts index 52862e82..30c6ebfe 100644 --- a/packages/ql3-cluster-control/src/remote-execution/mountedSecretProvider.ts +++ b/packages/ql3-cluster-control/src/remote-execution/mountedSecretProvider.ts @@ -1,7 +1,4 @@ // Remote Execution owns mounted Secret resolution for authenticated delivery. -import { constants } from 'node:fs'; -import { lstat, open, realpath } from 'node:fs/promises'; -import { isAbsolute, join, normalize, parse, relative } from 'node:path'; import { MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES, MAX_REMOTE_SECRET_VALUE_BYTES, @@ -12,7 +9,7 @@ import { } from '@qinglong/runtime-core/remote-secret-delivery'; import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection'; -const MAX_SECRET_ROOT_BYTES = 4096; +import { PrivateProjectedFileReader } from '../security/privateProjectedFile'; export interface ClusterMountedSecretProviderOptions { /** @@ -38,20 +35,6 @@ export class ClusterMountedSecretProviderError extends Error { } } -function rootDirectory(value: string): string { - if ( - typeof value !== 'string' || - !isAbsolute(value) || - parse(value).root === value || - normalize(value) !== value || - value.includes('\0') || - Buffer.byteLength(value, 'utf8') > MAX_SECRET_ROOT_BYTES - ) { - throw new ClusterMountedSecretProviderError('invalid_configuration'); - } - return value; -} - /** * Kubernetes Secret keys cannot contain a SecretRef directly. This stable, * non-reversible name also prevents Project/name input from becoming a path. @@ -66,72 +49,6 @@ export function clusterMountedSecretFileName(secretRef: string): string { } } -function remainsBelow(root: string, candidate: string): boolean { - const suffix = relative(root, candidate); - return ( - suffix.length > 0 && - !isAbsolute(suffix) && - suffix !== '..' && - !suffix.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) - ); -} - -async function resolvedRoot(path: string): Promise { - try { - const configured = await lstat(path); - if (!configured.isDirectory() || configured.isSymbolicLink()) { - throw new Error('root is not a direct directory'); - } - return await realpath(path); - } catch (error) { - throw new ClusterMountedSecretProviderError('root_unavailable', { - cause: error, - }); - } -} - -async function readMaterial(root: string, secretRef: string): Promise { - const candidate = join(root, clusterMountedSecretFileName(secretRef)); - let handle; - try { - const target = await realpath(candidate); - if (!remainsBelow(root, target)) { - throw new Error('material escaped its root'); - } - handle = await open( - target, - constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), - ); - const stat = await handle.stat(); - if ( - !stat.isFile() || - stat.nlink !== 1 || - stat.size < 0 || - stat.size > MAX_REMOTE_SECRET_VALUE_BYTES || - (stat.mode & 0o111) !== 0 || - (stat.mode & 0o027) !== 0 - ) { - throw new Error('material metadata is unsafe'); - } - const bytes = await handle.readFile(); - if ( - bytes.byteLength !== stat.size || - bytes.byteLength > MAX_REMOTE_SECRET_VALUE_BYTES || - (await realpath(candidate)) !== target - ) { - bytes.fill(0); - throw new Error('material changed while reading'); - } - return bytes; - } catch (error) { - throw new ClusterMountedSecretProviderError('material_unavailable', { - cause: error, - }); - } finally { - await handle?.close().catch(() => undefined); - } -} - function secretValue(bytes: Buffer): string { try { const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes); @@ -155,17 +72,34 @@ function secretValue(bytes: Buffer): string { export class ClusterMountedSecretProvider implements RemoteWorkerSecretValueProvider { - private readonly rootDirectory: string; + private readonly reader: PrivateProjectedFileReader; constructor(options: ClusterMountedSecretProviderOptions) { if (!options || typeof options !== 'object' || Array.isArray(options)) { throw new ClusterMountedSecretProviderError('invalid_configuration'); } - this.rootDirectory = rootDirectory(options.rootDirectory); + try { + this.reader = new PrivateProjectedFileReader({ + rootDirectory: options.rootDirectory, + minimumBytes: 0, + maximumBytes: MAX_REMOTE_SECRET_VALUE_BYTES, + access: 'private_material', + }); + } catch (error) { + throw new ClusterMountedSecretProviderError('invalid_configuration', { + cause: error, + }); + } } async verify(): Promise { - await resolvedRoot(this.rootDirectory); + try { + await this.reader.verify(); + } catch (error) { + throw new ClusterMountedSecretProviderError('root_unavailable', { + cause: error, + }); + } } async resolve( @@ -179,13 +113,19 @@ export class ClusterMountedSecretProvider cause: error, }); } - const root = await resolvedRoot(this.rootDirectory); const buffers: Buffer[] = []; try { const values = []; let totalBytes = 0; for (const secretRef of normalized.secretRefs) { - const bytes = await readMaterial(root, secretRef); + const bytes = await this.reader + .read(clusterMountedSecretFileName(secretRef)) + .catch((error) => { + throw new ClusterMountedSecretProviderError( + 'material_unavailable', + { cause: error }, + ); + }); buffers.push(bytes); totalBytes += bytes.byteLength; if (totalBytes > MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES) { diff --git a/packages/ql3-cluster-control/src/security/privateProjectedFile.ts b/packages/ql3-cluster-control/src/security/privateProjectedFile.ts new file mode 100644 index 00000000..f5d3f319 --- /dev/null +++ b/packages/ql3-cluster-control/src/security/privateProjectedFile.ts @@ -0,0 +1,168 @@ +import { Buffer } from 'node:buffer'; +import { constants } from 'node:fs'; +import { lstat, open, realpath } from 'node:fs/promises'; +import { isAbsolute, join, normalize, parse, relative } from 'node:path'; + +const MAX_ROOT_DIRECTORY_BYTES = 4096; +const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/; + +export interface PrivateProjectedFileReaderOptions { + readonly rootDirectory: string; + readonly minimumBytes: number; + readonly maximumBytes: number; + readonly access: 'private_material' | 'read_only_keyring'; +} + +export class PrivateProjectedFileError extends Error { + constructor(options?: ErrorOptions) { + super('Private projected file is unavailable', options); + this.name = 'PrivateProjectedFileError'; + } +} + +function unavailable(cause?: unknown): never { + throw new PrivateProjectedFileError({ + cause: cause instanceof Error ? cause : undefined, + }); +} + +function rootDirectory(value: unknown): string { + if ( + typeof value !== 'string' || + !isAbsolute(value) || + parse(value).root === value || + normalize(value) !== value || + value.includes('\0') || + Buffer.byteLength(value, 'utf8') > MAX_ROOT_DIRECTORY_BYTES + ) { + return unavailable(); + } + return value; +} + +function boundedInteger(value: unknown, minimum: number): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < minimum + ) { + return unavailable(); + } + return value; +} + +function fileName(value: unknown): string { + if (typeof value !== 'string' || !FILE_NAME_PATTERN.test(value)) { + return unavailable(); + } + return value; +} + +function remainsBelow(root: string, candidate: string): boolean { + const suffix = relative(root, candidate); + return ( + suffix.length > 0 && + !isAbsolute(suffix) && + suffix !== '..' && + !suffix.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) + ); +} + +function safeMode( + mode: number, + access: PrivateProjectedFileReaderOptions['access'], +): boolean { + if ((mode & 0o111) !== 0 || (mode & 0o007) !== 0) return false; + return access === 'private_material' + ? (mode & 0o027) === 0 + : (mode & 0o222) === 0 && (mode & 0o440) !== 0; +} + +/** Internal, no-cache reader for Kubernetes atomic-writer style projections. */ +export class PrivateProjectedFileReader { + readonly #rootDirectory: string; + readonly #minimumBytes: number; + readonly #maximumBytes: number; + readonly #access: PrivateProjectedFileReaderOptions['access']; + + constructor(options: PrivateProjectedFileReaderOptions) { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + unavailable(); + } + const normalizedRoot = rootDirectory(options.rootDirectory); + const minimumBytes = boundedInteger(options.minimumBytes, 0); + const maximumBytes = boundedInteger(options.maximumBytes, 1); + if ( + maximumBytes < minimumBytes || + (options.access !== 'private_material' && + options.access !== 'read_only_keyring') + ) { + unavailable(); + } + this.#rootDirectory = normalizedRoot; + this.#minimumBytes = minimumBytes; + this.#maximumBytes = maximumBytes; + this.#access = options.access; + } + + async #resolvedRoot(): Promise { + try { + const stat = await lstat(this.#rootDirectory); + if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailable(); + return await realpath(this.#rootDirectory); + } catch (cause) { + return unavailable(cause); + } + } + + async verify(): Promise { + await this.#resolvedRoot(); + } + + async read(name: string): Promise { + const normalizedName = fileName(name); + let handle: Awaited> | undefined; + let bytes: Buffer | undefined; + try { + const root = await this.#resolvedRoot(); + const candidate = join(root, normalizedName); + const target = await realpath(candidate); + if (!remainsBelow(root, target)) return unavailable(); + handle = await open( + target, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0), + ); + const before = await handle.stat(); + if ( + !before.isFile() || + before.nlink !== 1 || + before.size < this.#minimumBytes || + before.size > this.#maximumBytes || + !safeMode(before.mode, this.#access) + ) { + return unavailable(); + } + bytes = await handle.readFile(); + const after = await handle.stat(); + if ( + bytes.byteLength !== before.size || + after.dev !== before.dev || + after.ino !== before.ino || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs || + (await realpath(candidate)) !== target || + (await realpath(this.#rootDirectory)) !== root + ) { + return unavailable(); + } + const owned = bytes; + bytes = undefined; + return owned; + } catch (cause) { + return unavailable(cause); + } finally { + bytes?.fill(0); + await handle?.close().catch(() => undefined); + } + } +} diff --git a/packages/ql3-cluster-control/src/trusted-tool/key-management/toolResultKeyringManifest.ts b/packages/ql3-cluster-control/src/trusted-tool/key-management/toolResultKeyringManifest.ts new file mode 100644 index 00000000..7c66739c --- /dev/null +++ b/packages/ql3-cluster-control/src/trusted-tool/key-management/toolResultKeyringManifest.ts @@ -0,0 +1,194 @@ +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; + +import { toolResultKeyMaterialProof } from '@qinglong/runtime-core/tool-result-key-catalog'; + +export const CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA = + 'qinglong/cluster-tool-result-projected-keyring@v1' as const; +export const MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES = 64 * 1024; +export const MAX_CLUSTER_TOOL_RESULT_PROJECTED_KEYS = 16; + +const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; +const PROJECTION_DIGEST_DOMAIN = Buffer.from( + 'qinglong/cluster-tool-result-projected-keyring-digest@v1\0', + 'utf8', +); + +export interface ClusterToolResultKeyringManifest { + readonly schema: typeof CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA; + readonly keys: Readonly>; +} + +export interface ClusterToolResultKeyringSummary { + readonly schemaVersion: 1; + readonly keyIds: readonly string[]; + readonly materialProofs: Readonly>; + readonly projectionDigest: string; +} + +export class InvalidClusterToolResultKeyringManifestError extends TypeError { + readonly code = 'QL3_CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_INVALID'; + + constructor() { + super('Cluster Tool result keyring manifest is invalid'); + this.name = 'InvalidClusterToolResultKeyringManifestError'; + } +} + +function invalid(): never { + throw new InvalidClusterToolResultKeyringManifestError(); +} + +function exactKeys(value: object, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const canonical = [...expected].sort(); + return ( + actual.length === canonical.length && + actual.every((key, index) => key === canonical[index]) + ); +} + +function dataRecord(value: unknown): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + return invalid(); + } + return value as Record; +} + +function normalizedEntries( + value: unknown, +): readonly (readonly [string, string])[] { + const keys = dataRecord(value); + const entries = Object.entries(keys).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ); + if ( + entries.length < 1 || + entries.length > MAX_CLUSTER_TOOL_RESULT_PROJECTED_KEYS + ) { + return invalid(); + } + const normalized: (readonly [string, string])[] = []; + for (const [keyId, encoded] of entries) { + let material: Buffer | undefined; + try { + if ( + !KEY_ID_PATTERN.test(keyId) || + typeof encoded !== 'string' || + !BASE64URL_PATTERN.test(encoded) + ) { + return invalid(); + } + material = Buffer.from(encoded, 'base64url'); + if ( + material.byteLength !== 32 || + material.toString('base64url') !== encoded + ) { + return invalid(); + } + normalized.push(Object.freeze([keyId, encoded] as const)); + } finally { + material?.fill(0); + } + } + return Object.freeze(normalized); +} + +export function normalizeClusterToolResultKeyringManifest( + value: unknown, +): Readonly { + const manifest = dataRecord(value); + if ( + !exactKeys(manifest, ['keys', 'schema']) || + manifest.schema !== CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA + ) { + return invalid(); + } + return Object.freeze({ + schema: CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA, + keys: Object.freeze(Object.fromEntries(normalizedEntries(manifest.keys))), + }); +} + +export function parseClusterToolResultKeyringManifest( + bytes: Buffer, +): Readonly { + try { + if ( + !Buffer.isBuffer(bytes) || + bytes.byteLength < 1 || + bytes.byteLength > MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES + ) { + return invalid(); + } + return normalizeClusterToolResultKeyringManifest( + JSON.parse(bytes.toString('utf8')), + ); + } catch (error) { + if (error instanceof InvalidClusterToolResultKeyringManifestError) { + throw error; + } + return invalid(); + } +} + +export function canonicalClusterToolResultKeyringManifest( + value: ClusterToolResultKeyringManifest, +): Buffer { + const manifest = normalizeClusterToolResultKeyringManifest(value); + return Buffer.from(`${JSON.stringify(manifest)}\n`, 'utf8'); +} + +export function resolveClusterToolResultKeyringMaterial( + value: ClusterToolResultKeyringManifest, + keyId: string, +): Readonly<{ keyId: string; key: Uint8Array }> | null { + if (typeof keyId !== 'string' || !KEY_ID_PATTERN.test(keyId)) + return invalid(); + const manifest = normalizeClusterToolResultKeyringManifest(value); + const encoded = manifest.keys[keyId]; + if (encoded === undefined) return null; + const key = Buffer.from(encoded, 'base64url'); + if (key.byteLength !== 32 || key.toString('base64url') !== encoded) { + key.fill(0); + return invalid(); + } + return Object.freeze({ keyId, key }); +} + +export function summarizeClusterToolResultKeyringManifest( + value: ClusterToolResultKeyringManifest, +): Readonly { + const manifest = normalizeClusterToolResultKeyringManifest(value); + const keyIds = Object.keys(manifest.keys).sort(); + const materialProofs: Record = Object.create(null); + for (const keyId of keyIds) { + const material = resolveClusterToolResultKeyringMaterial(manifest, keyId)!; + try { + materialProofs[keyId] = toolResultKeyMaterialProof(keyId, material.key); + } finally { + material.key.fill(0); + } + } + const canonicalProofs = Object.freeze({ ...materialProofs }); + return Object.freeze({ + schemaVersion: 1 as const, + keyIds: Object.freeze(keyIds), + materialProofs: canonicalProofs, + projectionDigest: createHash('sha256') + .update(PROJECTION_DIGEST_DOMAIN) + .update( + JSON.stringify({ + schema: manifest.schema, + materialProofs: canonicalProofs, + }), + ) + .digest('hex'), + }); +} diff --git a/packages/ql3-cluster-control/src/trusted-tool/key-management/toolResultProjectedKeyring.ts b/packages/ql3-cluster-control/src/trusted-tool/key-management/toolResultProjectedKeyring.ts new file mode 100644 index 00000000..6248fa64 --- /dev/null +++ b/packages/ql3-cluster-control/src/trusted-tool/key-management/toolResultProjectedKeyring.ts @@ -0,0 +1,140 @@ +import { Buffer } from 'node:buffer'; + +import type { ToolInvocationArtifactKeyProvider } from '@qinglong/runtime-core/tool-invocation-artifact'; + +import { PrivateProjectedFileReader } from '../../security/privateProjectedFile'; + +import { + MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES, + canonicalClusterToolResultKeyringManifest, + parseClusterToolResultKeyringManifest, + resolveClusterToolResultKeyringMaterial, + summarizeClusterToolResultKeyringManifest, + type ClusterToolResultKeyringManifest, + type ClusterToolResultKeyringSummary, +} from './toolResultKeyringManifest'; + +const DATA_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/; + +export { + CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA, + MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES, + MAX_CLUSTER_TOOL_RESULT_PROJECTED_KEYS, + InvalidClusterToolResultKeyringManifestError, + canonicalClusterToolResultKeyringManifest, + normalizeClusterToolResultKeyringManifest, + parseClusterToolResultKeyringManifest, + resolveClusterToolResultKeyringMaterial, + summarizeClusterToolResultKeyringManifest, + type ClusterToolResultKeyringManifest, + type ClusterToolResultKeyringSummary, +} from './toolResultKeyringManifest'; + +export interface ClusterToolResultProjectedKeyringOptions { + readonly rootDirectory: string; + readonly dataFileName?: string; +} + +export class ClusterToolResultProjectedKeyringUnavailableError extends Error { + readonly code = 'QL3_CLUSTER_TOOL_RESULT_PROJECTED_KEYRING_UNAVAILABLE'; + + constructor(options?: ErrorOptions) { + super('Projected Cluster Tool result keyring is unavailable', options); + this.name = 'ClusterToolResultProjectedKeyringUnavailableError'; + } +} + +function unavailable( + cause?: unknown, +): ClusterToolResultProjectedKeyringUnavailableError { + return new ClusterToolResultProjectedKeyringUnavailableError({ + cause: cause instanceof Error ? cause : undefined, + }); +} + +function dataFileName(value: unknown): string { + if (typeof value !== 'string' || !DATA_FILE_NAME.test(value)) { + throw unavailable(); + } + return value; +} + +async function readManifest( + reader: PrivateProjectedFileReader, + fileName: string, +): Promise> { + let bytes: Buffer | undefined; + let canonical: Buffer | undefined; + try { + bytes = await reader.read(fileName); + const manifest = parseClusterToolResultKeyringManifest(bytes); + canonical = canonicalClusterToolResultKeyringManifest(manifest); + if (!canonical.equals(bytes)) throw unavailable(); + return manifest; + } catch (cause) { + throw cause instanceof ClusterToolResultProjectedKeyringUnavailableError + ? cause + : unavailable(cause); + } finally { + bytes?.fill(0); + canonical?.fill(0); + } +} + +/** + * Read-only result-key material authority for Kubernetes/CSI projections. + * PostgreSQL remains the sole active/decryptable state authority: this class + * only resolves key material and deliberately has no active() method. + */ +export class ClusterToolResultProjectedKeyring + implements Pick +{ + readonly #reader: PrivateProjectedFileReader; + readonly #dataFileName: string; + + constructor(options: ClusterToolResultProjectedKeyringOptions) { + if (!options || typeof options !== 'object' || Array.isArray(options)) { + throw unavailable(); + } + try { + this.#reader = new PrivateProjectedFileReader({ + rootDirectory: options.rootDirectory, + minimumBytes: 1, + maximumBytes: MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES, + access: 'read_only_keyring', + }); + this.#dataFileName = dataFileName(options.dataFileName ?? 'keyring.json'); + } catch (cause) { + throw unavailable(cause); + } + } + + async verify(): Promise> { + return summarizeClusterToolResultKeyringManifest( + await readManifest(this.#reader, this.#dataFileName), + ); + } + + async resolve( + keyId: string, + ): ReturnType['resolve']> { + try { + return resolveClusterToolResultKeyringMaterial( + await readManifest(this.#reader, this.#dataFileName), + keyId, + ); + } catch (cause) { + throw cause instanceof ClusterToolResultProjectedKeyringUnavailableError + ? cause + : unavailable(cause); + } + } +} + +export async function createClusterToolResultProjectedKeyring( + options: ClusterToolResultProjectedKeyringOptions, +): Promise> { + const provider = new ClusterToolResultProjectedKeyring(options); + await provider.verify(); + return provider; +} diff --git a/packages/ql3-cluster-control/test/tool-result-projected-keyring.test.cjs b/packages/ql3-cluster-control/test/tool-result-projected-keyring.test.cjs new file mode 100644 index 00000000..455e6027 --- /dev/null +++ b/packages/ql3-cluster-control/test/tool-result-projected-keyring.test.cjs @@ -0,0 +1,193 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { afterEach, test } = require('node:test'); + +const { + CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA, + ClusterToolResultProjectedKeyring, + ClusterToolResultProjectedKeyringUnavailableError, + canonicalClusterToolResultKeyringManifest, + createClusterToolResultProjectedKeyring, +} = require('../dist/trusted-tool/key-management/toolResultProjectedKeyring.js'); +const { + toolResultKeyMaterialProof, +} = require('@qinglong/runtime-core/tool-result-key-catalog'); + +const roots = []; + +afterEach(async () => { + await Promise.all( + roots + .splice(0) + .map((root) => fs.rm(root, { recursive: true, force: true })), + ); +}); + +function manifest(keys) { + return Object.freeze({ + schema: CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA, + keys: Object.freeze(keys), + }); +} + +async function tempRoot() { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'ql3-tool-result-keyring-'), + ); + roots.push(root); + return root; +} + +async function publish(root, generationName, bytes, mode = 0o440) { + const generation = path.join(root, generationName); + await fs.mkdir(generation, { mode: 0o750 }); + const target = path.join(generation, 'keyring.json'); + await fs.writeFile(target, bytes, { mode }); + await fs.chmod(target, mode); + const next = path.join(root, '..data-next'); + await fs.symlink(generationName, next); + await fs.rename(next, path.join(root, '..data')); + try { + await fs.symlink('..data/keyring.json', path.join(root, 'keyring.json')); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } +} + +test('projected Tool result keyring follows atomic rotation without choosing the active catalog key', async () => { + const root = await tempRoot(); + const keyOneBytes = Buffer.alloc(32, 0x11); + const keyTwoBytes = Buffer.alloc(32, 0x22); + const keyOne = keyOneBytes.toString('base64url'); + const keyTwo = keyTwoBytes.toString('base64url'); + await publish( + root, + '..2026_08_14_01', + canonicalClusterToolResultKeyringManifest( + manifest({ 'result-key-one': keyOne }), + ), + ); + + const provider = await createClusterToolResultProjectedKeyring({ + rootDirectory: root, + }); + assert.equal('active' in provider, false); + const firstSummary = await provider.verify(); + assert.deepEqual(firstSummary.keyIds, ['result-key-one']); + assert.equal( + firstSummary.materialProofs['result-key-one'], + toolResultKeyMaterialProof('result-key-one', keyOneBytes), + ); + const first = await provider.resolve('result-key-one'); + assert.ok(first); + assert.deepEqual(Buffer.from(first.key), keyOneBytes); + first.key.fill(0); + + await publish( + root, + '..2026_08_14_02', + canonicalClusterToolResultKeyringManifest( + manifest({ + 'result-key-two': keyTwo, + 'result-key-one': keyOne, + }), + ), + ); + const second = await provider.resolve('result-key-two'); + assert.ok(second); + assert.deepEqual(Buffer.from(second.key), keyTwoBytes); + second.key.fill(0); + assert.equal(await provider.resolve('result-key-missing'), null); +}); + +test('projected Tool result keyring rejects noncanonical, writable and escaped material', async () => { + const encoded = Buffer.alloc(32, 0x33).toString('base64url'); + const value = manifest({ 'result-key-one': encoded }); + + const noncanonicalRoot = await tempRoot(); + await publish( + noncanonicalRoot, + '..2026_08_14_01', + Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8'), + ); + await assert.rejects( + () => + createClusterToolResultProjectedKeyring({ + rootDirectory: noncanonicalRoot, + }), + ClusterToolResultProjectedKeyringUnavailableError, + ); + + const writableRoot = await tempRoot(); + await publish( + writableRoot, + '..2026_08_14_01', + canonicalClusterToolResultKeyringManifest(value), + 0o640, + ); + await assert.rejects( + () => + new ClusterToolResultProjectedKeyring({ + rootDirectory: writableRoot, + }).verify(), + ClusterToolResultProjectedKeyringUnavailableError, + ); + + const escapedRoot = await tempRoot(); + const externalRoot = await tempRoot(); + const external = path.join(externalRoot, 'keyring.json'); + await fs.writeFile( + external, + canonicalClusterToolResultKeyringManifest(value), + { mode: 0o440 }, + ); + await fs.symlink(external, path.join(escapedRoot, 'keyring.json')); + await assert.rejects( + () => + new ClusterToolResultProjectedKeyring({ + rootDirectory: escapedRoot, + }).verify(), + ClusterToolResultProjectedKeyringUnavailableError, + ); +}); + +test('projected Tool result keyring rejects invalid material and ambiguous paths', async () => { + const invalidRoot = await tempRoot(); + await publish( + invalidRoot, + '..2026_08_14_01', + Buffer.from( + `${JSON.stringify( + manifest({ 'result-key-one': Buffer.alloc(31).toString('base64url') }), + )}\n`, + 'utf8', + ), + ); + await assert.rejects( + () => + createClusterToolResultProjectedKeyring({ rootDirectory: invalidRoot }), + ClusterToolResultProjectedKeyringUnavailableError, + ); + + const root = await tempRoot(); + const rootLink = `${root}-link`; + roots.push(rootLink); + await fs.symlink(root, rootLink); + await assert.rejects( + () => + new ClusterToolResultProjectedKeyring({ + rootDirectory: rootLink, + }).verify(), + ClusterToolResultProjectedKeyringUnavailableError, + ); + assert.throws( + () => + new ClusterToolResultProjectedKeyring({ + rootDirectory: root, + dataFileName: '../keyring.json', + }), + ClusterToolResultProjectedKeyringUnavailableError, + ); +}); diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 80528714..cee9f270 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -385,10 +385,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterControl.rootSourceFileRoles, }, { - sourceFiles: 51, + sourceFiles: 54, rootSourceFiles: 2, rootSourceLines: 195, - nestedSourceFiles: 49, + nestedSourceFiles: 52, rootSourceFileRoles: { 'aiCli.ts': 'binary_entry', 'cli.ts': 'binary_entry',