diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa3607b..ba2d3b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Measure repeated Interactive Program loads (diagnostic, Ubuntu Node 24) if: ${{ matrix.os == 'ubuntu-24.04' && matrix.node == '24.22.1' }} run: node scripts/bench-interactive-load.mjs + - name: Measure Interactive ledger writes and verify reopened receipt history + if: ${{ matrix.os == 'ubuntu-24.04' && matrix.node == '24.22.1' }} + run: node scripts/bench-interactive-ledger.mjs - name: Consume candidate package through all documentation examples env: FLOWKIT_DOCS_EVIDENCE: .tmp/docs/candidate.json @@ -85,11 +88,29 @@ jobs: run: npm ci --ignore-scripts --no-audit --no-fund - name: Prove cross-UID model worker cannot read Host key or SQLite DB run: node tests/security/host-isolation.mjs + - name: Verify the disposable Linux Host profile + if: ${{ matrix.node == '24.22.1' }} + run: sudo -n "$(command -v node)" tests/security/secure-host-linux.mjs - name: Prove M6 Host and isolated worker integration env: FLOWKIT_M6_ISOLATION: '1' run: node --test tests/integration/m6-e2e.test.mjs + host-security-windows-profile: + name: Host OS-user isolation / Windows + runs-on: windows-2025 + timeout-minutes: 8 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.22.1' + - name: Verify the disposable Windows Host profile + shell: powershell + run: .\tests\security\secure-host-windows.ps1 -Node (Get-Command node.exe).Source -DisposableRunner + website: runs-on: ubuntu-24.04 timeout-minutes: 15 diff --git a/docs/evidence/p0a-admin-codex-20261113/README.md b/docs/evidence/p0a-admin-codex-20261113/README.md new file mode 100644 index 0000000..4536eb0 --- /dev/null +++ b/docs/evidence/p0a-admin-codex-20261113/README.md @@ -0,0 +1,39 @@ +# P0-A 管理终止和 Codex 实测 + +Draft [#22](https://github.com/devcodex-labs/flowkit/pull/22) 的实施基线为 `e1cd5c7c4f89397fbdee50e1a430074c4c610e9d`,基线 [CI](https://github.com/devcodex-labs/flowkit/actions/runs/43331776187) 8/8 通过。 + +## R5 管理终止 + +SQLite 使用显式 V2 迁移。维护窗口内先停写入方,再由可信 Host 调用: + +```js +const persistence = createSqliteInteractivePersistence(file, { + namespace, migrationBackupPath: newProtectedBackupFile, +}); +``` + +备份路径必须是可安全新建的 Host 私有文件。SQLite 生成一致性备份,并执行完整性检查。事务锁定写入后重新核对 schema 和 data version,再创建专用审计表、安装写入保护并提交 V2。新 writer 可在迁移后正常写入,已部署旧二进制不含 V2 writer 函数,旧 SQL 写入会被触发器拒绝。该保护针对旧软件兼容性,私有数据库的 OS 访问仍依赖 Host 账户隔离。需要回滚时,在停写窗口将原备份恢复到独立位置并校验 Run/Journal。切换到迁移前快照会丢掉迁移之后的写入,需要先对账。 + +管理控制器仅接受由可信 Host 在进程内创建的独立管理能力,核对 namespace、管理主体和 `run:admin-terminate`。在一个 SQLite 事务写入 `cancelled` Checkpoint、转移账本及 `fk_i_admin_events`,审计只保存操作主体、reason code、时间和转移身份。管理操作无需加载 Program。原状态已终结时直接返回其终态,审计写入失败回滚整个事务。重复操作不会追加审计,迟到回执拒绝,已持久接纳记录仍可从 Store 读出。管理操作与 P0-B 业务撤销分开。 + +本地 Windows / Node 24.22.2 的 `npm run verify` 通过 405 项及打包消费者。S12-A 新增测试验证迁移、备份重开、旧写入方拒绝、跨租户与假能力拒绝、专用审计回滚、Program 字节不可用、重复终止、迟到回执,以及两个 SQLite 连接上的管理终止和业务终态竞争。现有生产数据库没有执行迁移。 + +## Codex App Server 实际执行 + +Codex CLI 0.145.0 通过 stdio 启动 App Server,使用账号模型目录默认项 `gpt-5.6-sol`。同一 Windows SID 下真实生成一个 Agent 结果。FlowKit 实际编译、发布 Pending、观察原生动态工具开始和回调、校验同一调用 ID 及规范参数摘要,再签名接纳。Run 返回 succeeded,接纳 revision 4,终态 revision 9,Journal 10 条。所有工具的响应都是实际程序输出。 + +| 样本 | 结果 | 证据限制 | +|---|---|---| +| 65,536 B 完整内联 JSON | PASS | 开始事件、工具回调及预期规范字节完全相同,回合成功完成。 | +| 262,144 B 完整内联 JSON | BLOCKED | 模型结束回合,未发出该动态工具调用。 | +| 2,093,056 B 完整内联 JSON | BLOCKED | 模型结束回合,未发出该动态工具调用。 | + +三档都使用确定性 ASCII 样本,大小计算包含完整探针 JSON 对象。64 KiB 属实际传输实测。另两档只能证明当前模型和提示没有交付所需调用,无法推断底层硬上限或 2 MiB FlowKit 内联回执已通过。未启用文件回退。 + +**Windows/Linux 双账户等价验收 BLOCKED。** Windows 本机非提升账户,WSL 在有界探测中未启动。仓库没有配置凭据或注册自托管 runner。此前的双账户 CI 使用受限脚本并已实测 OS 权限,仍缺当前版本 Codex App Server 在独立身份下的模型调用、进程和句柄证据。探针可接收环境提供的受信隔离启动器。Linux 包含同 PID 的 exec 身份检查。Windows 需要受信启动器从实际非管理员 Host/Model Token 记录两个 SID,并只传递指定 stdio。启动器和真实环境还需联合验证。等价验收、真实多会话、签后崩溃恢复以及 NAT-14/15 保持 BLOCKED。 + +## 文件回退候选,待确认 + +建议对超过已验证内联范围的结果采用由 Host 固定的不可变内容。模型工具提交的仅是当前 Pending 的有界引用,Host 从预设的隔离入口获取内容,把同一份读取快照写入 Host 私有存储,再校验长度、UTF-8、JSON、schema 和 SHA-256。摘要、长度、命名空间、Run、Request、Attempt、thread/turn/call 及过期时间与当前受保护的动态调用绑定。模型自报路径或摘要只用于定位或比对,授权与摘要以 Host 的实际观测为准。 + +验收要求:256 KiB 和接近上限可由 Host 固定并复核。内容变化、超限、截断、过期、错 Run/tenant、重复消费以及未绑定调用均拒绝且无签名或 CAS。外部业务产物和完整内联回执按各自限额处理。成本是增加私有临时存储、清理和对账流程,收益是完整字节可以独立验证。等待用户确认后才实施。 diff --git a/docs/evidence/p0a-codex-file-fallback-20261113/README.md b/docs/evidence/p0a-codex-file-fallback-20261113/README.md new file mode 100644 index 0000000..4413704 --- /dev/null +++ b/docs/evidence/p0a-codex-file-fallback-20261113/README.md @@ -0,0 +1,22 @@ +# Codex 文件回退验收 + +Draft [#22](https://github.com/devcodex-labs/flowkit/pull/22) 的基线 `eb5823094cdee0f5d8fe8a558a9824e1c712db4d` 在 [CI](https://github.com/devcodex-labs/flowkit/actions/runs/43343789989) 8/8 通过。本批只修改 Host 示例、Codex 探针和测试。FlowKit Core、Receipt V1、Checkpoint 格式、内联容量和包导出沿用原契约。 + +Host 在预设入口用受信来源登记字节数、SHA-256、Pending 和会话。工具收到的是 Host 颁发的随机引用和 requestId。入口文件只打开一次并有界读取,读取时检查普通文件、身份、长度和变化,随后校验实际原始字节的摘要、严格 UTF-8、规范 JSON 和应用提供的 schema。原始字节以 BLOB 写入 Host 私有 SQLite,使用 WAL/FULL。入口文件此后可删掉。模型工具不接收文件路径,也没有登记摘要和读取私有 BLOB 的能力。 + +提交时,Host 比较自己观察到的动态工具开始事件和回调,核对 thread/turn/call、相同规范参数、当前 namespace/Run/Request/Node/Attempt、Program/Policy 摘要和时效。再次校验私有快照字节数和 SHA-256,原子领取一次,然后才调用可信签名与接纳。已签收的 Agent 结果只包含快照 ID、长度和 Host 摘要。调用结束时记录接纳 revision。失去签收结果的已领取引用保持锁定,等待人工对账,重开后也不会重签。过期且未领取的快照可清除。已领取及已接纳快照的持久恢复和保留由部署方决定,自动 Outbox 恢复仍属独立验收。 + +| 样本 | Codex App Server 同账户实测 | +|---|---| +| 256 KiB(262,144 B) | Codex 读取真实 Pending,提交有界引用。Host 从已保存原始字节核对摘要,签名并接纳,再返回 succeeded。 | +| 近 2 MiB(2,093,056 B) | 同路径,快照字节数和摘要与登记一致,仍只把引用写进 FlowKit State。 | + +两个样本由受信合成生产者生成。模型实际调用了 App Server 的动态工具并提交引用。Host 在签收前删除入口文件。日志分别记录原生开始事件、回调、快照 SHA-256、签名、接纳和终态。在该同账户验收阶段,Windows/Linux 双账户原生宿主、生产文件入口的权限部署、真实签后崩溃恢复及 NAT-14/15 均待验证。此次文件回退实测的对象是 Host 合成输入和模型提交的真实引用。 + +定向用例覆盖入口文件在登记前被替换、截断、超限、非法 UTF-8/重复键/schema、错误 Pending 或会话、缺少开始事件、重复或并发消费、过期、已领取后重开。登记成功后替换或删除入口文件,签收使用已固定的原始快照。 + +## Windows Node 22 文件身份回归 + +基线 `7acdfdd0137bc34cd4407ee9ffb8c2fa1e5a18a7` 的 [Windows Node 22.13.0 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43345740815/job/131624564355) 将普通入口文件误判为 `FK_FILE_SOURCE`。在 Windows 实测,Node 22.13.0 对同一文件的 `lstat` 返回设备号 0,句柄 `stat` 返回非零设备号,两者的文件 ID 一致。Windows 路径查询缺失设备号时仍要求打开前后路径与句柄的非零文件 ID 一致,并要求两次路径查询的设备号相同。可用的非零设备号仍须匹配。校验同时保留普通文件、单链接、长度、时间戳和原始字节 SHA-256 检查。 + +P 级回归用真实文件和私有 SQLite 测试。新增硬链接和文件符号链接拒绝用例,Windows 无文件符号链接权限时实测目录联接点。两项拒绝后,同一请求仍能登记合法文件。后续两个平台均通过[独立账户下的三个真实样本](../p0a-codex-isolated-20261113/live-samples.md),生产部署和完整原生安全门禁仍待验收。 diff --git a/docs/evidence/p0a-codex-isolated-20261113/README.md b/docs/evidence/p0a-codex-isolated-20261113/README.md new file mode 100644 index 0000000..572a72c --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/README.md @@ -0,0 +1,42 @@ +# Codex App Server 双账户验收 + +适用范围是 Draft #22 的隔离验收。Host 与 Model 各用普通系统账户,Host 持有 FlowKit 私有目录、Store 和签名能力,Model 持有自己的 Codex 登录。探针使用合成任务和字节样本。以下布置用于指定的隔离测试机或 VM,系统账户、SSH 配置和 Codex 登录由该机器的操作者管理。 + +## 共同布置 + +1. 将 Draft #22 拉到测试机,记录 `git rev-parse HEAD`、OS、Node 和 `codex --version`。管理员保护 Node、Codex、启动器和探针源码,Model 无写权限。以 Host 账户运行探针,Host 的临时目录和输出目录仅 Host 可写、可读。Model 的工作目录和 Codex 登录目录仅 Model 可写,登录目录也仅 Model 可读。 +2. 在测试机上用专用 Model 账户完成 `codex login --device-auth`,再运行 `codex login status`。使用该账户自己的配置目录。保存登录状态与版本,设备码和登录缓存留在登录流程内。Host 无需登录 Codex。[Codex CLI 登录文档](https://learn.chatgpt.com/docs/auth?surface=cli) +3. 若用 SSH,使用仅监听回环地址的测试实例,预先核验服务端主机公钥。Host 专用密钥仅 Host 可读。服务器把该密钥绑定到 Model 账户与固定命令,禁用代理转发、端口转发、PTY 和用户启动脚本。客户端配置 `Host flowkit-model`、Model 的 `User`、`Port`、专用 `IdentityFile` 和 Host 私有的 `UserKnownHostsFile`。记录有效配置的无敏感信息摘要。关闭调试登录输出,以免污染 JSONL。 + +## Windows + +在指定 Windows 测试机上准备两个普通本地账户。编译受信启动器,在账户可读、仅管理员可修改的目录放置结果。 + +```powershell +& "$env:SystemRoot\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /warnaserror /target:exe ` + /out:C:\FlowKitLab\codex-model-stdio.exe (Resolve-Path .\tests\security\codex-model-stdio-windows.cs).Path +``` + +SSH 固定命令使用此启动器,唯一参数为 Model 可运行的绝对 `codex.exe` 路径。Model 启动器从实际 Codex 进程令牌读取 SID 并拒绝提升及受 UAC 过滤的管理员令牌,使用 Windows 句柄列表限定继承的三个标准流。Host 在本地通过同一程序的 `--host-identity` 核对自己的 SID。[Windows 句柄继承文档](https://learn.microsoft.com/windows/win32/procthread/creating-processes) + +在 Host 的 PowerShell 设置 `FLOWKIT_CODEX_WINDOWS_IDENTITY` 为该启动器的绝对路径,`FLOWKIT_HOST_SID` 和 `FLOWKIT_MODEL_SID` 为实际账户 SID。SSH 模式下将 `FLOWKIT_CODEX_LAUNCHER` 设为绝对 `ssh.exe` 路径,将 `FLOWKIT_CODEX_SSH_CONFIG` 设为 Host 专用配置的绝对路径。 + +## Linux + +使用两个不同非零 UID 和 GID 的账户。使用 glibc 2.34+、Linux 5.9+ 的测试机,编译并部署非特权的 exec shim。 + +```sh +cc -O2 -Wall -Wextra -Werror tests/security/codex-model-exec-linux.c -o /path/to/flowkit-model-exec +``` + +当前探针支持预先配置的本机受信启动器,也支持回环 SSH。SSH 的固定命令以 Model 身份执行 `setpriv --no-new-privs`,再用绝对 Node 路径执行 `tests/security/codex-model-identity.mjs`。参数依次为 Model UID、GID、exec shim、Codex 绝对路径及 `app-server --listen stdio:// -c 'web_search="disabled"'`。Model 账户应无额外组成员关系,命令只使用 Model 的 HOME、私有临时目录和受控 PATH。身份 helper 核对有效 UID/GID、组、能力和 `NoNewPrivs`,同 PID 交接给 shim。shim 关闭标准流外的描述符、恢复阻塞标准流,再同 PID 执行 Codex。固定命令及 Model 的 shell 启动环境由测试机操作者保护。 + +在 Host 进程设置 `FLOWKIT_HOST_UID`、`FLOWKIT_HOST_GID`、`FLOWKIT_MODEL_UID`、`FLOWKIT_MODEL_GID`。SSH 模式同时设置 `FLOWKIT_CODEX_LAUNCHER=/usr/bin/ssh` 和 `FLOWKIT_CODEX_SSH_CONFIG`。本机受信启动器模式设置 `FLOWKIT_CODEX_LAUNCHER` 和 `FLOWKIT_CODEX_LINUX_EXEC`,探针会传入 Node、身份 helper、Model UID/GID、exec shim、Codex 和固定 App Server 参数。操作者为启动器设置有界退出和 Model 侧清理。Host 只关闭标准输入,不能凭自身权限可靠终止其他 UID 的进程。 + +## 实际验收与留档 + +在 Host 下分别运行 `node tests/security/codex-flowkit-probe.mjs <绝对Codex路径> <新证据目录绝对路径> <样本> isolated`。样本为 `loop`、`file-262144` 和 `file-2093056`,每个样本使用不同输出目录。SSH 模式由固定命令决定 Codex 路径,应与记录中的版本和路径一致。对命令、退出码、三个协议原始日志、`observer.ndjson` 和增量 Diff 保存 SHA-256。预期证据包含实际 Host/Model 身份、Model PID、原生开始与回调、同一个 Pending、签收 revision 和最终 `succeeded`。文件样本另核对固定字节数与摘要。 + +Windows CI 在一次性普通 Model 账户内验证启动器的标准流往返与实际进程 SID。Linux CI 以一次性 Model UID/GID 验证受限身份、同 PID exec 和标准流往返。两者均为 P 级 OS/启动器证据。Windows/Linux 各三个认证后的真实样本见[双账户实测记录](./live-samples.md)。 + +Linux 云任务在基线 `9584184d7098a402a15f3edd8bfb11ea95f7ed49` 的准备阶段使用专用容器实测 UID/GID 41001/41002。47 项 OS 权限负例通过,真实 App Server 完成同 PID 初始化。独立 Model 登录前,三个样本均返回 401,工具调用、文件登记、签名和接纳均为零。该轮报告归档 SHA-256 为 `136db4c02521fafaf76d8486d3cfd8cb597f81b39e0d0eceaecac9e61c5c0360`。 diff --git a/docs/evidence/p0a-codex-isolated-20261113/live-samples.md b/docs/evidence/p0a-codex-isolated-20261113/live-samples.md new file mode 100644 index 0000000..2d35966 --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/live-samples.md @@ -0,0 +1,26 @@ +# Codex App Server 双账户三样本实测 + +2026-11-13 在 Draft #22 的 `d9dcc0067b5d24e8a91bbe459256e99d9f285428` 上运行 `tests/security/codex-flowkit-probe.mjs` 的 `isolated` 模式。用户在两个平台各自的 Model 系统账户下完成 Codex 设备登录,Host 账户只运行 FlowKit 探针。 + +| 平台 | 实测环境 | 普通闭环 | 256 KiB 文件回退 | 近 2 MiB 文件回退 | +|---|---|---|---|---| +| Windows | 普通 Host 和 Model 的实际 SID 不同,受信启动器通过本机回环 SSH 启动 Codex CLI 0.145.0,模型 `gpt-5.6-sol` | PASS | PASS,262,144 B | PASS,2,093,056 B | +| Linux | 专用容器内 Host 41001、Model 41002,Model 零能力且 `NoNewPrivs=1`,Codex CLI 0.167.0-alpha.4,模型 `gpt-6.1-sol` | PASS | PASS,262,144 B | PASS,2,093,056 B | + +六次实测各记录三组原生动态工具开始和回调,thread、turn 和 call ID 对应。Host 对真实 Pending 签收,接纳 revision 为 4,终态为 `succeeded`(revision 9,Journal 10 条)。两个文件样本的正文来自受信合成生产者。真实模型读取 PublicTask 后提交 Host 固定的文件引用,两端登记和接纳的字节数与摘要一致。 + +| 字节数 | 两端 SHA-256 | +|---|---| +| 262,144 | `a89ec2764412be3284c3b2334a8ba4a484bfb7f6df2a8e274acc782a4e3231bb` | +| 2,093,056 | `716f7d91c27f9e89e87fa13d2e2d47ba3b4dba942cb42f371610eac0ed83fd87` | + +Linux 登录后的首轮因缺少配套 Code Mode 组件,模型完成回合但没有提交工具调用。FlowKit 任务从同一 Codex 安装补齐 `codex-code-mode-host`、`codex-exec-server` 和 `bwrap` 后重跑通过,首轮原始记录留档。Linux 测试容器仍需为长期运行配置 init/reaper。本轮 Windows 回环 SSH 使用签名校验通过的便携版,监听 `127.0.0.1:41762`。系统 OpenSSH 服务保持停止,默认入站规则已禁用。 + +P、M、T 级证据覆盖这三个样本的实际程序运行、真实模型回合和独立系统身份下的原生工具通道。完整 256 KiB 和近 2 MiB 的内联传输、完整 NAT-14/15 与 C12/S13 仍为 BLOCKED。此批的验收记录保存 Host 验签接纳及声明摘要。后续[双会话竞争与 Outbox 实测](./outbox-live.md)覆盖另两个场景,并保存了可离线验签的证据。 + +| 原始证据包 | ZIP SHA-256 | +|---|---| +| Windows 本次验收交付 `flowkit-windows-dual-account-evidence.zip` | `5442cd27e74674451a1f16ea88c24f84f01ca7a74fab7f6d0fd8fc036dca4d76` | +| Linux FlowKit 云任务交付 `FlowKit-Linux-Live-Three-Samples-20261113.zip` | `b18af80ac30c49694bdff4b4d1aeb4c0c5e835bcd849a09152b057af4474cb50` | + +两个归档包含实际执行记录、原始协议日志、Host 观察日志和逐文件哈希。设备码、密码和令牌留在登录边界内。 diff --git a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md new file mode 100644 index 0000000..0cc2105 --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md @@ -0,0 +1,75 @@ +# Codex 双会话竞争与 Outbox 重启恢复 + +在 Host 普通系统账户、已登录的独立 Model 账户下运行下列验收。证据目录必须是 Host 私有的全新目录,启动器沿用[双账户布置](./README.md)。测试使用合成 Pending 和受限的动态工具。 + +```text +node tests/security/codex-outbox-live.mjs <全新Host私有证据目录> isolated +``` + +- 双会话测试启动两个独立 App Server 和 Host 子进程,对同一 Pending 提交不同的合法答案。监督器核对两个原生工具回调、不同的 thread/turn/call 和两个已持久签名的回执,再放行竞争。验收要求唯一 `ACKED`、另一条 `CONFLICT`,Store 只有一个接纳索引。 +- 强杀测试在实际模型回调已持久写入 `SIGNED`、Store 仍为 waiting 且尚无接纳索引时,由独立监督进程终止 Host 子进程。Windows 使用 Node 的强制进程终止,Linux 使用 SIGKILL。监督器确认子进程退出后重开数据库并独立验签,检查签名、回执摘要和 Store revision 均未变化。 +- 新 Host 从持久 Outbox 自动恢复,使用原签名和相同回执,通过既有 Host 验签和 CAS。恢复子进程只读取公钥,且不启动 App Server。复跑扫描没有可重放项。未接纳且已过期的回执进入 `BLOCKED`,历史同内容回执仍通过原 Ack 规则对账。 + +Outbox 是 Host 示例适配器的独立 SQLite WAL/FULL 数据库。Core、ABI、Store schema 和 Receipt V1 沿用既有契约。初始验收使用小内联答案。实验目录含一次性私钥,证据包排除私钥和登录缓存。 + +## 实测结果 + +Windows 与 Linux 均验证实现提交 `81a4c6e4400f95ca5dc0b771342411d0f587a464`。Windows 使用当前机器的两个普通账户和回环启动入口。Linux 由 FlowKit 云任务先拉取同一提交,再用 UID/GID 41001 和 41002 运行。 + +| 场景 | Windows | Linux | +|---|---|---| +| 两个真实会话争同一 Pending | PASS,两个不同回执先落盘,唯一 `ACKED`,另一条 `CONFLICT` | PASS,两个不同回执先落盘,唯一 `ACKED`,另一条 `CONFLICT` | +| 持久签名后、CAS 前强杀 | PASS,独立监督器强制终止 Host,重启后重放原签名 | PASS,独立监督器 SIGKILL Host,重启后重放原签名 | + +两个平台在强杀前后均为 waiting revision 3,接纳索引为空,回执、声明和签名摘要一致。新进程使用公钥和原有 Outbox 恢复,revision 4 接纳,revision 9 达到 `succeeded`,Journal 10 条。竞争场景同样只产生一次接纳。每个平台导出的三份原始签名均通过独立验签。证据覆盖指定样本的程序、真实模型和原生 Host 观察(P/M/T)。 + +## CAS 后强杀和文件引用恢复 + +```text +node tests/security/codex-outbox-live.mjs <全新Host私有证据目录> isolated followup +``` + +- CAS 后强杀由监督器等待 Store 接纳索引已可读、Outbox 仍为 `SIGNED` 后触发。恢复用原回执走历史 Ack,仅补记 `ACKED`,并核对 Checkpoint、Journal 摘要、条数和接纳索引完全相同。 +- 256 KiB 和近 2 MiB 的真实动态工具调用将文件引用绑定当前 Pending、thread、turn 和 call。入口原件在签名前已删除。监督器在私有快照为 `claimed`、Outbox 为 `SIGNED`、Store 尚未接纳时强杀。恢复先核对原快照、引用、绑定及 SHA-256,使用原签名完成 CAS,最后将快照和 Outbox 分别记为 `acked`、`ACKED`。再次恢复无待投递项。若已写快照 Ack 而尚未写 Outbox Ack,允许用相同 call 和 revision 补记。 + +两端在实现提交 `f01f2b5c008bc3f9dba32ed72d8a5ddca7b01115` 上使用不同系统账户实测了以下三个样本。 + +| 样本 | Windows | Linux | 持久结果 | +|---|---|---|---| +| CAS 后、Outbox Ack 前强杀 | PASS | PASS | 接纳 revision 4,终态 revision 9。重启仅补 `ACKED`,Checkpoint、Journal 与接纳索引保持一致 | +| 262,144 字节文件引用,签后 CAS 前强杀 | PASS | PASS | 原快照 SHA-256 `a89ec2764412be3284c3b2334a8ba4a484bfb7f6df2a8e274acc782a4e3231bb`,用原签名恢复至 `succeeded` | +| 2,093,056 字节文件引用,签后 CAS 前强杀 | PASS | PASS | 原快照 SHA-256 `716f7d91c27f9e89e87fa13d2e2d47ba3b4dba942cb42f371610eac0ed83fd87`,用原签名恢复至 `succeeded` | + +两端各三份原签名离线验签通过。Linux 另在容器外核验两份快照原字节和三条 Journal 哈希链。Windows 归档 SHA-256 为 `2cd77acf9c571be7d4a0d3f431d4efddac8aae136930ea5e044dd68cb2a1548c`,Linux 为 `779ee3113930cb85fc28dff44d64ca912f53b11a8cbf2b6628e2bdeee71d83eb`。该批产生指定样本的 P/M/T 证据。 + +| 归档 | SHA-256 | +|---|---| +| Windows `flowkit-windows-outbox-live.zip` | `a66e85c0b746538af2f3f93c8a978e963bcc2434af76af9ba50a8fc4d1a2b7a9` | +| Linux `FlowKit-Linux-Outbox-Live-20261113.zip` | `00ab2b37629076f3ca9eb97b1e3cdd471ebece832274864bc5d14ee3bda3257e` | + +归档另含验签所需的声明、签名和回执原始字节,私钥和登录缓存留在各自环境内。快照字节损坏、快照 Ack 后但 Outbox Ack 前的对账有定向回归。生产保留策略以及完整 NAT-14/15、C12/S13 仍为 BLOCKED。 + +## 签名前强杀、过期和轮换 + +```text +node tests/security/codex-outbox-live.mjs <全新Host私有证据目录> isolated signing +``` + +- 签名前强杀在真实工具事件和候选字节已写入 `OBSERVED` 后触发。重启扫描保留原观察,不签署或接纳该记录。另一个真实会话对同一 Pending 重新提交,产生自己的观察和签名。 +- 过期拒绝使用独立测试 Run 的 3 秒证明有效期和真实系统时钟。监督器在 CAS 前强杀,等到期限和 Host 默认时钟容差都过去才重启。Outbox 记录 `BLOCKED`,Store、Journal、接纳索引保持原状。 +- 轮换用两个预先批准的公钥保持 Run 的固定信任策略。旧钥完成一个真实 Run 后,新的真实 Run 由新钥签名。随后用原始已过期的旧签名重放,要求返回原 Ack,两个 Run 的持久状态都保持原样。撤销旧公钥及迁移既有 Run 的策略哈希属于独立场景。 + +Windows 与 Linux 均在 `ddf8c688abaa6450c615b81e3bd0daaceaed53b8` 使用独立 Host/Model 账户实测,三项各通过一次。 + +| 场景 | 两端结果 | +|---|---| +| 签名前强杀 | 原记录停在 `OBSERVED`,声明和签名均为空,重启后仍是 waiting revision 3。新会话用新的 thread、turn、call 和 proof ID 重试,在 revision 4 接纳,revision 9 成功终结。 | +| 未接纳回执过期 | 实际时钟超过期限及 5 秒容差后转为 `BLOCKED`,无接纳索引,Checkpoint 和 Journal 保持不变。 | +| 签名轮换和旧 Ack | 旧钥与新钥分别签署并接纳各自的真实 Run。旧签名过期后重放返回原接纳 revision 4,两个 Run 的 Checkpoint、Journal 和接纳索引均保持不变。 | + +两端保存实际工具事件、监督与重启日志,以及用于独立复核的原始签名和公钥。证据为指定样本的 P/M/T。[移除旧公钥后的拒绝与 Outbox 保留基线](./revocation-retention.md) 已单独验证。生产撤销和保留策略,以及完整 NAT-14/15、C12/S13 和供应商可验证证明仍缺对应证据。 + +| 原始证据归档 | SHA-256 | +|---|---| +| Windows `flowkit-windows-outbox-signing.zip` | `9e4767867b338c26f3656cd42aa8e077b34bfc506b4b39affd19c6dd46cc6e72` | +| Linux `FlowKit-Linux-Signing-Lifecycle-20261113.zip` | `5d24066d7af6cd868afe1ea53b218651ebd908b827db7f5e148cf4cfef6eb9df` | diff --git a/docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md b/docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md new file mode 100644 index 0000000..3b15e90 --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md @@ -0,0 +1,41 @@ +# 旧公钥撤销与 Outbox 保留基线 + +当前 Run 的 `policyHash` 包含已批准公钥的指纹。改变公钥集合会改变 Host 策略摘要。既有 Run 的加载、恢复与回执接纳均核对原摘要。当前 Store 也将 `policyHash` 视为 Run 不可变字段。 + +在现有隔离环境中运行: + +```text +node tests/security/codex-outbox-live.mjs <全新Host私有证据目录> isolated revocation +``` + +先用同一批准清单创建两个 Run。旧钥签名后,一个 Run 完成接纳,另一个停在 CAS 前。用只包含新公钥的 Host 重启,在未接纳证明仍有效时尝试读取、恢复和接纳。测试核对拒绝码以及 Checkpoint、Journal、接纳索引和 Outbox 记录不变。另用只批准新公钥的 Host 创建全新 Run 并走真实工具闭环。 + +随后用真实签名前强杀与过期用例记录当前保留行为。重启扫描保留未签名的 `OBSERVED` 行;过期未接纳的签名进入 `BLOCKED`,原始签名和回执仍在 Outbox。已接纳行维持 `ACKED`。当前 Outbox 没有自动清理任务,文件入口的 `purgeExpired()` 仅清理已过期且仍为 `staged` 的文件行。 + +## 实测结果 + +验收候选为 `ab931afe0699ca89d9f5f29aea8263caf1031cba`,本机 `npm run verify` 412/412 通过,[候选 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43369908354) 8/8 通过。Windows 使用当前机器的两个普通系统账户和受保护的回环启动器。Linux 由 FlowKit 任务拉取同一提交,在 UID/GID 41001 与 41002 下运行。每端完整运行分别使用六个真实 App Server 会话。 + +| 场景 | Windows 与 Linux 结果 | +|---|---| +| 移除旧公钥后尝试接纳旧回执 | 未过期且尚未接纳的旧证明,以及已接纳的原字节重放,均返回 `FK_I_PROVENANCE_REJECTED`。 | +| 既有 Run 在新策略下加载与恢复 | 两个旧 Run 均返回 `FK_I_HOST_DENIED`。历史接纳索引仍是 revision 4。在途 Run 仍是 waiting revision 3,接纳索引为空。两个 Run 的 Checkpoint、Journal 和 Outbox 记录在尝试前后相同。 | +| 新策略创建 Run | 仅批准新公钥,使用新钥签署真实工具回执,在 revision 4 接纳并成功终结。 | +| Outbox 当前保留 | 原 `OBSERVED` 在重启与成功重试后仍在。过期未接纳的原字节转 `BLOCKED` 并保留。撤销场景中未接纳的 `SIGNED` 与原 `ACKED` 都保留。 | + +每端三组通过,各五份签名使用对应公钥独立验签。Linux 另核对了五条 Journal 哈希链,首轮完成。Windows 首轮的撤销和签名前场景通过,过期场景的 App Server 初始化 SQLite 运行状态失败。用全新 Run 和证据目录重跑后三组均通过。Windows 归档含两次运行的相关原始日志。 + +| 原始证据归档 | SHA-256 | +|---|---| +| Windows `flowkit-windows-outbox-revocation.zip` | `ac39fb3a50ba1f24ea3227bfadf81d2c8952c6232786a5114dac72147373be6a` | +| Linux `FlowKit-Linux-Revocation-Retention-20261113.zip` | `27717a224c154132a7c8f0b8470c59873641ce7469d7bf3f59457a8731f9c7df` | + +证据覆盖指定样本的 P/M/T。两端均验证新 Host 配置移除旧公钥后,对不匹配的 Run 策略拒绝执行。生产撤销对所有 Host 的生效机制、既有 Run 迁移、撤销后历史 Ack 对账接口和有界保留策略均为 BLOCKED。完整 N/V 级及发布结论仍待独立复审。 + +## 待确认的发布契约 + +- **撤销后历史 Ack。** 建议紧急撤销后停用仍批准旧钥的生产 Host,确保旧钥停止签发与接纳。历史 Ack 由独立受权的只读对账路径从 Store 账本返回,核对 namespace、Run、Request、节点、attempt 与规范回执摘要。该路径只读原 Ack,无 CAS 或状态推进。 +- **既有 Run 迁移。** 建议正常轮换沿用已冻结策略直到旧 Run 结束,新 Run 使用新策略。紧急撤销后,旧 Run 保持阻断,由受权操作员对账。若发布要求原地继续既有 Run,应另审带版本、事务与审计的迁移协议,并规定在途 Pending、历史签名和 Journal 的处理方式。 +- **保留窗口。** 建议由部署配置规定审计期限和容量上限。待投递的 `SIGNED` 和未解决的 `OBSERVED`、`BLOCKED`、`CONFLICT` 保留到对账或审计释放,容量不足时停止接收新的可落盘工作并告警。终态且对账完毕的 Run 在审计窗口后才清理 Outbox 正文与可回收快照。仍被有效结果引用的快照、合规保留标记和 Store 接纳索引继续保留。 + +上述发布行为待确认。当前验收只覆盖现有契约下的拒绝、账本保留和新策略下新 Run 的运行。 diff --git a/docs/evidence/p0a-compat-codex-20261113/README.md b/docs/evidence/p0a-compat-codex-20261113/README.md new file mode 100644 index 0000000..85d3730 --- /dev/null +++ b/docs/evidence/p0a-compat-codex-20261113/README.md @@ -0,0 +1,31 @@ +# P0-A 兼容实现与 Codex 宿主候选 + +实施基线为 Draft [#22](https://github.com/devcodex-labs/flowkit/pull/22) 的 `70143668394fc5f4e4d1d26f62788c14a66e17e3`。[该提交 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43327790322) 已完成,8/8 成功。 + +## 已批准的兼容实现 + +现有 Standard 根入口和同步 Interactive factory 继续供旧调用方使用。新版调用方可从 `@devcodex-labs/flowkit/interactive` 使用 `start`、`complete`、`resume`,获得版本化公开帧。公开 Pending 必须通过 Host 的独立披露判断。顶层执行入口要求 Host 在受信代码中创建的授权对象,JSON 拷贝不能充当该能力。新版授权对象的 `readTerminalResult` 单独检查 `result:read`、命名空间、策略和 revision。 + +新版调用方可接入 `CheckpointStoreV1` 的五个异步操作,以及独立的 `TrustedProgramRegistryV1`。SQLite 参考入口 `createSqliteInteractivePersistence` 返回两个接口,共用现有事务后端。Genesis、Program 引用、Checkpoint、Journal 和首次回执沿用原事务边界。CAS 结果不确定时,用持久化历史和线性化读取确认。缺证据返回 `FK_I_STORE_UNCERTAIN`。同步 Store 和现有四张 SQLite 表继续兼容,异步接口携带当前 P0-A Checkpoint 数据结构。 + +旧 Run 保留原转移 ID 和回执摘要。新版入口创建的 Run 固定 `transitionIdVersion:1`,对 R5 的 namespace、run、fromRevision、fromBlockId、事件、Program 和提案摘要计算 ID。Genesis 的前序 revision 和 Block 为 null。现有版本的旧 factory 拒绝推进新版 Run。部署应先升级所有可能打开同一数据库的写入方,再启用新版入口。旧二进制回滚及外部持久化格式迁移需单独安排。 + +范围保留:R5 嵌套 Checkpoint 全量 ABI、跨语言 JCS 互操作、P0-B 扩展绑定和生产宿主来源继续独立验收。外部 Store 实现必须自行证明强一致、原子创建和原子 CAS,参考 SQLite 的测试只覆盖本实现。 + +Windows / Node 24.22.2 的 `npm run verify` 通过 403 项及现有 TypeScript、包检查和实际 tarball 消费。新增回归覆盖旧 Run 续跑和历史 Ack、R5 Genesis/转移摘要、跨租户读取、独立结果授权、CAS 丢响应及缺持久证据时拒绝继续。打包消费者直接调用新版顶层入口。 + +## Codex App Server 实测 + +Windows 上的 Codex CLI `0.145.0` 可启动 stdio App Server。首次真实回合因默认模型不支持当前 ChatGPT 账号而失败。随后从 `model/list` 取得当前默认模型 `gpt-5.6-sol`,发起新的真实回合。合成动态工具取得原生 `item/started` 及 `item/tool/call`,两者的 thread、turn、call ID 和小内联参数匹配。客户端观察开始事件约 3 ms 后收到工具请求,核对参数后才回复,回合成功完成。证据目录保存调用摘要和原始 JSONL。探针运行在同一 Windows 主体,使用合成工具数据。真实 FlowKit Pending、跨 OS 主体、签名、CAS、原生大入参及故障恢复均待验证。G07–G10 和 NAT-14/15 仍为 BLOCKED。 + +官方说明描述了动态工具的开始、客户端调用、回复和完成事件。选择实验性动态工具接口作为接入候选时,应固定 App Server 版本和 schema,升级前重跑能力与时序测试。[Codex App Server 文档](https://learn.chatgpt.com/docs/app-server) + +## 请确认的三个方案 + +| 待决项 | 推荐方案 | 取舍 | +|---|---|---| +| 宿主和等价验收 | 批准 Codex App Server 为首个候选,走 Host 自己启动并持有的 stdio 通道。先验证真实 FlowKit start → PublicTask → 模型结果 → submit → continue → Terminal,同一 Run 绑定 thread/turn/call、Pending、tenant、Program/Policy 和签前规范入参。用 Windows/Linux 独立账户重复。 | 能直接观察客户端工具回调,减少另起 MCP Server 的需要。动态工具协议有版本成本。NAT-01–12、14–15 逐项映射到 Codex,验证非法提交无写入、双会话竞争、失配、断线、真实强杀、签后对账。NAT-13 仍单独检查供应商可验证签名,账号登录与工具事件本身不足以通过。Grok 目标在正式确认前保持原样。 | +| 大入参 | 优先从受保护 App Server 的动态调用取得完整 JSON,使用相同规范字节摘要串联开始、回调和回执。实测 64 KiB、256 KiB 和接近 **完整回执** 上限的向量,包括 Unicode、截断、超限和不回复超时。完整回执按 2 MiB 上限与 Checkpoint 余量分别判断。文件回退仅在内联受限且另获批准时启用。 | 内联协议简单,但受模型输出和 transport 上限约束。文件回退需 Host 复制到私有存储,校验实际捕获的字节,固定长度、摘要、生命周期和 Run/Pending 身份,拒绝变化、重复及过期引用。业务产物引用保留为独立数据协议,不能扩大 FlowKit 内联结果限额。 | +| CF-01 管理终止 | 按 R5 新增独立的管理能力与版本化迁移。由同一事务写入 `cancelled` tombstone、历史 transition 和专用管理审计记录。确认迁移前备份、版本读取策略和回滚演练。 | 专用审计更便于最小权限、审计索引和独立保留,但会增加一次迁移。Journal 内同事务记录是较轻备选,需要另批等价性。验收包括 Program 丢失、越权、跨租户、并发终态唯一赢家、迟到回执及旧 SDK 拒绝未知版本。 | + +确认宿主方案后才能进入真实适配器和正式等价验收。确认管理方案后才能变更管理终态与持久化。 diff --git a/docs/evidence/p0a-g01-20261112/README.md b/docs/evidence/p0a-g01-20261112/README.md new file mode 100644 index 0000000..ae34f2a --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/README.md @@ -0,0 +1,49 @@ +# G01 — P0-A 正式契约审查 + +**审查已执行;符合性未通过,待独立复审。** 已完整读取三份确认原件,逐项核对 [追踪表](./traceability.md) 中的 34 T / 22 R / 14 S / 31 计划任务及下文 §§1–18。结论是发现明确缺口、契约差异和未证明的场景。此阶段只交证据,没有修改 Core、ABI、Store、SDK 或 Standard,也没有进入 G04。 + +审查基线:`21b02e785cacf703c359d7d4f853b8196aa17b19`,树 `3beb2ef618d1061b184ddb774512c4d827783db7`,与合并 [G02 PR #21](https://github.com/devcodex-labs/flowkit/pull/21) 后的 main `5a82a88160e01b0e9e2e457dea6af1e3db2c54e0` 同树。[Draft PR #22](https://github.com/devcodex-labs/flowkit/pull/22) 是唯一交付 PR。[基线 Actions 43233955173](https://github.com/devcodex-labs/flowkit/actions/runs/43233955173) 七项成功;本机 Windows / Node 24.22.2,`npm ci` 与 `npm run verify` 均退出 0,395/395、0 跳过。CF-08 另行复现,结果见下。独立复审包有原始 stdout/stderr、真实命令、SHA-256 和精确增量 diff;新证据提交的 CI 状态在独立清单中记录。 + +| 冻结原件(实际读完) | 字节 | SHA-256 | 取证来源 | +|---|---:|---|---| +| Requirements V0.3 | 56883 | `3a0adc3edb41869a6309bae29ec5496df00970c815f8dc16330a90cbf26e703e` | 本地 Grok 交接 ZIP 原始条目,与冻结登记一致 | +| Technical Design V0.6-R5 | 114621 | `09f3a5698e238d10b80efb8e26d1c41e51b663ca620efa70815dd7b52f2106ba` | 本地 R5 确认交接 ZIP 原始条目,与冻结登记一致 | +| P0-A Plan V0.1 | 42988 | `0ac8719f204aae274e64abc30d02caa637fc55a5f1751a6884341f05117e02c0` | ChatGPT Library 原始下载,与冻结登记一致 | + +校验采用原始文件字节(未重编码)。范围 V0.3 全 710 行、R5 全 1128 行、计划全 319 行。R5 §18 将技术冻结与实施授权区分;计划的确认页和已确认交接决定给出 V0.1 实施范围。计划 §14 尾句留有“待用户确认”的旧措辞,保留原样;此处依据确认页、后续交接与用户本轮指示执行 G01/G03。R1/R2 仅作历史问题索引,审查结论直接取自原件和固定代码。下表的 D 是 `types/interactive.d.ts`。 + +| 发现 | 严重度 / 裁决 | 原件 → 当前行为 → 最小验证/修复候选(未执行) | +|---|---|---| +| CF-01 | P1,确认 P0-A 缺口 | R5 §§1.1/3.6/4–5/16、计划 A-M5-02/04/S12-A 要求独立受权 `adminTerminate`、无可用 Program 时仍能管理、原子墓碑和审计。公开入口、Coordinator/Trusted Host、类型中均无此能力;`src/interactive/index.mjs:2`、`coordinator.mjs:220/659`、`types/interactive.d.ts:183/254`。候选:独立管理凭据与单事务 CAS;验无 Program、普通模型输入拒绝、业务终态竞态及旧回执不能恢复运行。P0-B 的业务 cancel 不替代它。 | +| CF-02 | P1,DESIGN-QUESTION(确认差异,待裁决) | V0.3 §7.1 是示意;R5 §4 指向 §5 唯一规范签名、计划 A-M4-05 要求顶层 `start/complete/resume`。现为 `createInteractiveCoordinator`/`createTrustedInteractiveHost` 实例方法,签名也不同(入口第 2 行、D:161/183/254)。实际能力存在,未查得已批准用 factory 替代冻结顶层接口。先确认获批记录或向后兼容路径,再以发布包 ESM/TS 消费者验证;不直接加别名或撤旧接口。 | +| CF-05 | P1,确认 P0-A 投影缺口 | R5 §§4–5/7/11/16 与 A-M5-01 要求公开 Frame 用 `resultRef` 等有限信息,完整 State 走单独 `result:read` 可信读取。当前终态 `asPublicFrame` 包含 State(`coordinator.mjs:144`、D:83),受信 Host start/resume/load 转发(`trusted-host.mjs:147/159/163`);`getPublicAgentTask` 有授权和字段限制(:175、测试 :99/117)。证实 SDK 角色未分离,未证真实模型泄漏。候选:拆分公开投影和受权读取;分别验证私密数据不会随模型/stdout 发送、跨租户和错误角色拒绝、合法读取成功。 | +| CF-06 | P1,DESIGN-QUESTION(确认差异,待裁决) | R5 §§5/8.1 冻结五个 Promise 操作 `createIfAbsent/load/compareAndSwap/loadTransition/loadReceipt` 与独立 Registry;§13.2 允许 SQLite 内部 DatabaseSync。当前 D:106、`coordinator.mjs:220/247/332`、`sqlite-store.mjs:290/340` 是同步 Store+Registry,冻结五操作单独提供时不能接入。现有 SQLite 原子测试有效。先找授权的差异或评审适配方案;适配必须保住原子 CAS、Receipt/Journal、历史查询和丢 ACK 后线性化判断,另验非线性化读不会导致派发。 | +| CF-08 | P2,基线实测复现 | R5 R04、计划 A-M1-02/A-M2-02 的稳定快照/阶段门禁。 `compile.mjs:37–44` 首次 await 后重新读取原 `options.featureStage`。`probe-cf08.mjs`:稳定 P0-A 编译 7,634 B;调用后改原选项到 P0-B,产物 0 B,`FK_I_UNSUPPORTED_GRAPH`。探针退出 0 表示成功复现问题;没有绕过阶段门禁。候选:统一使用已校验快照,再用同一负例保证产物与对照一致。 | +| ABI-01 | DESIGN-QUESTION,静态差异 | R5 §§3/5/8 的 transitionId 使用规范化事件/Program/Proposal;`coordinator.mjs:29` 实际以 namespace/run/revision/block/kind 构造带前缀 ID。:54/92 检查当前 Block 与部分 Proposal 字段,并不能据测试证明冻结判别联合所有未知键/非法字段都拒绝。D:83/93 的 Frame/Pending 形态也不同。先独立确认已持久历史、支持版本及升级风险;不改计算、字段或 schema。 | + +CF-07(与 CF-08 分开)由 `606d055d5f02451fea0b30294889d740ef370d49` 修复,基线 `coordinator.test.mjs:407/421` 两项通过。CF-03 仍缺原生 Grok 可验证来源与真实 Host/Model 隔离;G03 仅能力 PoC。CF-04 维持已批准范围:Action/Human/Loop/业务 cancel 属 P0-B、Parallel all_succeeded 属 P1、any 留待 P2 专项裁决。 + +## V0.6-R5 §§1–18 正文审查 + +| § / 原件起始行 | 对照和裁决 | +|---|---| +| 1 / 27 | 产品链路仍为用户意图→可执行 JS→程序控制 Agent。G02 已合并有实际 JS/Pending,根入口 `src/index.mjs` 及包契约不扩张;本次不重跑 G02。Standard 与 Interactive 分层,无必需 FlowKit 服务。 | +| 2 / 60 | `compile.mjs:37`、`src/compiler/interactive/{lower,validate-ir,emit}.mjs` 与 `load.mjs:54` 落地 Standard 前端复用、真实 JS、同字节加载;程序决策和宿主持久化分离。ABI-01 留审。 | +| 3 / 142 | `coordinator.mjs:332/451/567/659`、crash 测试证明主要阶段/接纳屏障和无空 waiting;全故障矩阵 T29 部分,独立管理终止 CF-01。 | +| 4 / 226 | 实际子路径与公开 SDK/权限/可信读取对应 CF-01/02/05;R5 §5 是唯一规范声明,不能以 V0.3 示意代替。 | +| 5 / 264 | `types/interactive.d.ts:83/93/106/161/183/254` 和 `coordinator.mjs:29/92`:可执行 A 子集、显式形态差异 CF-02/05/06、ABI-01;未批准改 ABI。 | +| 6 / 509 | `sqlite-store.mjs:139/150/163/178` 的不可变字节、引用 FK、`coordinator.mjs:247` 固定旧 Program;m6:340 和 cache 测试成立,跨 SDK/CP 升级未实测。 | +| 7 / 547 | `trusted-host.mjs:175/221` 最小任务、可信回执可测;CF-05,Human 通道延期;实际模型和 Host 权限隔离未证。 | +| 8 / 592 | `sqlite-store.mjs:290/340` Genesis/CAS 和历史回执;R03/R11 仍缺、CF-06 的外部五操作未符合。 | +| 9 / 628 | 当前 SQL `sqlite-store.mjs:75/92–130/196/212/290/340` 对比 §9:WAL/FULL/外键、本地事务和 Journal/Receipt 索引有实测;实际四张 `fk_i_*` 表以 run→artifact FK 保留 Program,与参考 retain 结构不同;admin/outbox/墓碑与更广保留策略未证明。物理名字差异不单独判失败;不做迁移。 | +| 10 / 710 | 已落地 Condition/Terminal/基础 Agent;`feature-gate.mjs` 阻止提前扩张,P0-B/P1/P2 依原件。 | +| 11 / 787 | `canonical.mjs:34/111/115`、`trusted-host.mjs:175/221` 和 SQLite namespace,P 级测试通过;跨语言 JCS/完整文本重复键边界、目标 Grok 账户/ACL 及原生来源未证明。 | +| 12 / 820 | 基础错误已实测(FK_R002/FK_R004/容量/权限),运行期 `FK_I_INVALID_CHECKPOINT` 等实际分类与冻结词表须逐项核销;不扩大固定 Standard 61 码。 | +| 13 / 866 | 容量、预算、历史回执有界缓存已测,R12 长期样本缺;JS 不可用时真实拒绝记录缺(T11),G03 文件入口不能等同 FlowKit 2 MiB 安全验收。 | +| 14 / 901 | m6:171/250/293/365 证明 A 子集、状态与预算的对照。JSON 边界/缺失与 null 区别是已登记差异;B/P1/P2 不推断。 | +| 15 / 924 | 追踪表覆盖全部 T/R/S;原件的「未执行」是设计时状态,本轮证据逐项分级。 | +| 16 / 1030 | P0-A 与后续阶段区分;管理终止、可信读取、宿主能力是缺口或阻断,不能提前宣称 M5/M6 合格。 | +| 17 / 1084 | R5-01–10 是设计裁决,测试仅覆盖具体断言;远端 Store/升级/运维/宿主来源按剩余门禁处理。 | +| 18 / 1118 | 冻结授权与实施授权独立;依据本轮授权只做 G01/G03、同一个 Draft PR,停于独立复审。 | + +**退出状态:提交审查记录,冻结契约符合性未通过。** G04 依赖独立复审与对争议契约的裁决;T11、T27/S13 原生可信宿主、R03/R11/R12、跨版本、部分故障与完整 ABI/诊断仍待对应证据。证据等级:P 是现有流程运行,M 是真实模型实际消费 FlowKit Pending,T 是受保护观察链,N 是原生端到端可信验收,V 是可独立验证的 provider 证明;本次 G01 不产生新的 M/T/N/V。 diff --git a/docs/evidence/p0a-g01-20261112/commands.md b/docs/evidence/p0a-g01-20261112/commands.md new file mode 100644 index 0000000..1f0e9b1 --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/commands.md @@ -0,0 +1,21 @@ +# G01 执行与复现 + +PowerShell;命令从本次工作区运行,日志随本文件分发。审查用 detached worktree 对象为 `21b02e785cacf703c359d7d4f853b8196aa17b19`。 + +```powershell +git ls-remote https://github.com/devcodex-labs/flowkit.git refs/heads/main refs/heads/feat/p0a-interactive-20261112 +git -C D:/Worker/flowkit -c core.longpaths=true worktree add --detach 21b02e785cacf703c359d7d4f853b8196aa17b19 +Get-ChildItem work/frozen-originals -File | Get-FileHash -Algorithm SHA256 +Get-Content -LiteralPath -Encoding UTF8 +``` + +进入审查 worktree,实际依次运行 `npm ci` 与 `npm run verify`,各自重定向 stdout/stderr 并记录 `$LASTEXITCODE`。两条命令输出分别见 `npm-ci.*` 和 `npm-verify.*`;探针调用如下: + +```powershell +$audit = Resolve-Path outputs/flowkit-p0a-review/G01 +$repo = Resolve-Path work/flowkit-pr22 +node (Join-Path $audit 'probe-cf08.mjs') $repo 1> (Join-Path $audit 'cf08.stdout') 2> (Join-Path $audit 'cf08.stderr') +$LASTEXITCODE | Set-Content (Join-Path $audit 'cf08.exit') +``` + +正式原件包身份:`FlowKit-Interactive-Development-Handoff-For-Grok-2026-11-10.zip` = `8a2f679a796234383b5108d58db16c1016a35c6110301bc4eb7e091ee2ed5826`;`FlowKit-Interactive-V0.6-R5-Confirmed-Handoff.zip` = `2deb5b3fa60ed3b7c2d29f028e33835c47aaa0a976e947de717128caf7326549`。计划见 [Library 原始条目](https://chatgpt.com/space/file/libfile_de7a4fcb51c881919e942f9348ccd935)。读取各自原始文件的 SHA-256 和长度见 `verified-inputs.txt`。ZIP 原始条目解压后校验的是条目字节,未保存为重编码版本。 diff --git a/docs/evidence/p0a-g01-20261112/probe-cf08.mjs b/docs/evidence/p0a-g01-20261112/probe-cf08.mjs new file mode 100644 index 0000000..279ad89 --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/probe-cf08.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import {mkdtemp, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; + +const repo = resolve(process.argv[2]); +const {compileInteractive} = await import(pathToFileURL(join(repo,'src/interactive/compile.mjs'))); +const projectRoot = await mkdtemp(join(tmpdir(), 'flowkit-g01-cf08-')); +const file = join(projectRoot, 'cf08.workflow.js'); +await writeFile(file, "import {defineWorkflow,terminal} from '@devcodex-labs/flowkit/dsl';\n" + + "export default defineWorkflow({id:'cf08',version:'1.0.0',name:'CF08',nodes:[terminal('done',{name:'Done',status:'succeeded'})]});\n"); +const opts = () => ({output:'memory',featureStage:'P0-A',projectRoot}); +const control = await compileInteractive(file, opts()); +const options = opts(); +const pending = compileInteractive(file, options); +options.featureStage = 'P0-B'; +const raced = await pending; +console.log(JSON.stringify({node:process.version, file, controlBytes:Buffer.byteLength(control.code), racedBytes:Buffer.byteLength(raced.code), diagnostics:raced.diagnostics}, null, 2)); +assert.ok(control.code.length); +assert.equal(raced.code, ''); +assert.ok(raced.diagnostics.some(d=>d.message.includes('P0-B'))); diff --git a/docs/evidence/p0a-g01-20261112/sha256.txt b/docs/evidence/p0a-g01-20261112/sha256.txt new file mode 100644 index 0000000..82b1c74 --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/sha256.txt @@ -0,0 +1,14 @@ +13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354 cf08.exit +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cf08.stderr +29b364604f36f6d29d8ff7685867f86e389ab943c9afc00151c316c8c0700f02 cf08.stdout +0ed9115fdd627b14aa67b3e87706aa47f87d4aeee32e7abf5aecb3117d3afa51 commands.md +13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354 npm-ci.exit +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 npm-ci.stderr +ebbc3b8201ebd2d3248fb714b906556f5447e3c046672586c902d0dba9599dfa npm-ci.stdout +13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354 npm-verify.exit +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 npm-verify.stderr +328458367d798b68574509c7e928405475ca2eda13b8235bcb1ed65427e6dafb npm-verify.stdout +383d49b1b3d5da71276cee80b779d394029168c262d9a84137fb69f7802b2561 probe-cf08.mjs +059985a7d17927148ffd85069a671f202f215d5718ddb7b7e1290c47d4bff986 README.md +f17fd767459098b431d8d79a308b88b6a25ea2b9df44fa040703d93d8256d9f3 traceability.md +e016df07605e9b03444794754851ebabe3619b2e09d429f3607641b240c5c033 verified-inputs.txt diff --git a/docs/evidence/p0a-g01-20261112/traceability.md b/docs/evidence/p0a-g01-20261112/traceability.md new file mode 100644 index 0000000..fa04881 --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/traceability.md @@ -0,0 +1,134 @@ +# G01 — 冻结原件追踪表 + +审查对象 `21b02e785cacf703c359d7d4f853b8196aa17b19`。原件位置见下表;测试位置以审查对象为准。下面的路径缩写:**I/** = `src/interactive/`,**CI/** = `src/compiler/interactive/`,**t/** = `tests/interactive/`(测试名默认后缀 `.test.mjs`),**m6** = `tests/integration/m6-e2e.test.mjs`,**D** = `types/interactive.d.ts`。冒号后是起始行。 + +**P** = 基线实际运行的程序/Store/脚本 Host 覆盖所述子例;**P 部分** = 有运行证据但缺完整断言、切点、阶段或适配器;**静态** = 源码/设计证据;**缺口** = P0-A 应交付但当前不存在或不符合;**待裁决** = 存在接口或语义冲突,未找到获批替代契约;**延期** = 冻结范围明确排除本阶段;**BLOCKED** = 缺真实运行、前置权限或专项决策。所有测试引用结合 `npm-verify.stdout` 使用;相邻的设计断言未因此变成 PASS。 + +## T01–T34 + +需求 V0.3 §16 第 598–631 行,技术 V0.6-R5 §15.1 第 932–965 行;两列不完全等价的,以各原件的断言为准。 + +| ID | V0.3 / R5 行 | 结果与证据 | +|---|---|---| +| T01 | 598 / 932 | P:生成 JS 路由、各阶段持久化、无 Agent 终点及缺布尔路径,t/emitter:51、t/coordinator:75/90/115/122、m6:250/293/365。 | +| T02 | 599 / 933 | P:Pending 持久和 Agent 指令、Schema、缺失及 attempt 子例,I/coordinator.mjs:153/391、t/coordinator:90、t/trusted-host:314、m6:201。 | +| T03 | 600 / 934 | P 部分:发布后重启并接纳、接纳后不重新派发,t/crash:89/118、m6:201/234;全切点见 T29。 | +| T04 | 601 / 935 | P:非法输出/异常 validator/缺绑定不提交候选,t/coordinator:170/197/283/353/365;非纯外部验证延期。 | +| T05 | 602 / 936 | P:错误 node/attempt/requestId、跨租户 runId 拒绝;startKey 冲突和命名空间隔离,t/coordinator:151/231/313、t/store:107、t/trusted-host:357。 | +| T06 | 603 / 937 | P:旧同回执保留首次接受 revision、最新 Frame、不同内容拒绝,t/coordinator:137/151/297/376、t/crash:152。 | +| T07 | 604 / 938 | P 部分:身份/字节/策略/非法边子例,I/load.mjs:54、t/emitter:103、t/ir:62、t/coordinator:244、m6:279/306/340;跨 SDK 版本未实测。 | +| T08 | 605 / 939 | P:非法值/循环/accessor/Proxy/上限拒绝、own-key 数据与输入快照,t/store:40/50、t/coordinator:324/407/421、t/s07-capacity:89/419。 | +| T09 | 606 / 940 | P 部分:真实 SQLite 双进程和丢 ACK,t/store:196、t/crash:152、t/coordinator:376;未覆盖不合规 Store 的陈旧读(R03/R11)。 | +| T10 | 607 / 941 | P:Standard 合法而 A 禁用的节点和绑定拒绝;纯流程可运行,t/compiler-front:38/57/67/79/89、t/coordinator:283;另有 CF-08。 | +| T11 | 608 / 942 | BLOCKED:没有无 JS 平台上的实际 Host 拒绝记录;文档要求不冒充可执行。 | +| T12 | 609 / 943 | P:`npm run verify` 的 Standard/AOT、类型、打包和固定契约;package.json、tests/contracts.test.mjs、基线 Actions 43233955173。 | +| T13 | 610 / 944 | P:Agent attempt、reasons、State 不变,t/coordinator:170;高级验证 P0-B。 | +| T14 | 611 / 945 | P 部分:合法失败和耗尽 FK_R004,t/coordinator:170/213;控制信号与失败并发仍是 R17。 | +| T15 | 612 / 946 | 延期 P0-B:高级 Agent 验证顺序与候选 State;t/compiler-front:57、t/coordinator:283 证明 A 不会静默忽略。 | +| T16 | 613 / 947 | 延期 P0-B:Action 写集合、提交和外部效果;t/compiler-front:38。 | +| T17 | 614 / 948 | 延期 P0-B:Action 对账;R5 §10.3。 | +| T18 | 615 / 949 | 延期 P0-B:Action 错误与跳过 Verification;R5 §10.3/14。 | +| T19 | 616 / 950 | 延期 P0-B:独立/Loop Verification;t/compiler-front:38。 | +| T20 | 617 / 951 | 延期 P0-B:可信 Human/修订;R5 §10.5。 | +| T21 | 618 / 952 | 延期 P0-B:Loop 边界;R5 §10.6。 | +| T22 | 619 / 953 | 完整 Skill 失败/重试矩阵延期 P0-B;P0-A 旧 Pending 与 Skill/Program 身份子例,m6:201/340。 | +| T23 | 620 / 954 | 业务 cancel、补偿和控制信号延期 P0-B;计划 T23-A 管理终止是独立 P0-A 缺口(CF-01)。 | +| T24 | 621 / 955 | 延期 P1:Parallel all_succeeded;R5 §10.7。 | +| T25 | 622 / 956 | BLOCKED(P2 专项决策):any 与迟到结果未冻结;V0.3 §15、R5 §10.7/16。 | +| T26 | 623 / 957 | 延期 P1:多 Pending/写集合并发冲突;R5 §10.7。 | +| T27 | 624 / 958 | P:m6:171/328;M:已合并 G02 的 ChatGPT Pending 证据。原生 Grok T/N/V BLOCKED;同 PR G03 只证实合成 MCP 能力。 | +| T28 | 625 / 959 | P 部分:五导出和包/类型/文档回归、此矩阵;CF-02/05/06 说明版本/接口并未全面对齐。 | +| T29 | 626 / 960 | P 部分:t/crash:89/118/152、m6:201/234,覆盖已列的 Agent 前后切点;未证 SQLite 内核/掉电、任意切点或 P0-B。 | +| T30 | 627 / 961 | P 部分:程序/Checkpoint/签名/非法边负例 t/emitter:103、t/ir:62、t/store:226、t/trusted-host:147/357;完整运行时 Proposal 判别及 OS 隔离未证。 | +| T31 | 628 / 962 | P 部分:纯 validator 失败、双进程仅接纳一次 t/coordinator:197/365、t/crash:152;不会宣称可检测任意副作用或 exactly-once。 | +| T32 | 629 / 963 | 延期 P0-B:不支持幂等的外部 Action 的未知状态;R5 §10.3。 | +| T33 | 630 / 964 | P 部分:Standard/Interactive A 子集对照 m6:171/293/365、t/emitter:51;Human、Loop、Action 路线延期。 | +| T34 | 631 / 965 | P 部分:容量、暂停恢复、PublicTask 最小字段 t/s07-capacity:89/165/407/419、m6:250/328、t/trusted-host:99;长期压力、正式结果投影和原生隔离未证。 | + +## R01–R22 + +技术 V0.6-R5 §15.2 第 975–986、992–1001 行。 + +| ID | R5 行 | 结果与证据 | +|---|---:|---| +| R01 | 975 | P:接纳后、校验前 kill 并恢复候选,t/crash:118。 | +| R02 | 976 | P 部分:晚到重复保留原 acceptedRevision、返回最新 Frame,t/coordinator:137、t/s07-capacity:165/193;独立核查精确 revision+5 场景。 | +| R03 | 977 | P 部分:丢 ACK 恢复 t/coordinator:376;未证非线性化适配器返回陈旧读会被安全拦截(CF-06)。 | +| R04 | 978 | P:State/startIntent 在 await 前快照,t/coordinator:407/421;编译 options 的 CF-08 是另一个入口。 | +| R05 | 979 | P 部分:throw/undefined、稀疏非法数组,t/coordinator:197/365;数字返回同类型路径,但这两项不等于数字子例有独立记录。 | +| R06 | 980 | 静态:纯声明契约(R5 §10.1),不得推断可拦截副作用;现有纯校验拒绝 t/coordinator:197,外部验证器延期。 | +| R07 | 981 | 延期 P0-B:Action/Verification/contextError;R5 §10.3/14。 | +| R08 | 982 | P:received 恢复不重新发同一请求或空 waiting,t/crash:118、t/coordinator:258。 | +| R09 | 983 | P:同命名空间冲突、不同租户分隔,t/store:107、t/coordinator:231/313。 | +| R10 | 984 | P:相同经验证字节才执行、篡改拒绝,I/load.mjs:54、t/emitter:103、m6:306/340;不等于任意可信 Registry 管理策略已验证。 | +| R11 | 985 | P 部分:晚到回执和丢 ACK 分别实测,t/coordinator:137/376;未证与并发多个中间提交同时发生的完整组合。 | +| R12 | 986 | P 部分:21 条 receipt 后的有界 Checkpoint 和旧 Ack,t/s07-capacity:165/193;≥2000 条/WAL 写放大没有测量。 | +| R13 | 992 | P 部分:稳定 IR/缺阶段拒绝 t/ir:23/62;`await` 与 `awaiting` 精确混合负例未单列。 | +| R14 | 993 | P 部分:编译 IR 错边 t/ir:62、产物不符 t/emitter:103;I/coordinator.mjs:54/92 校验当前 Block/部分字段,完整运行时 Proposal 非法并集未证。 | +| R15 | 994 | 延期 P0-B:Verification 执行错误 vs false;R5 §10.4。 | +| R16 | 995 | 延期 P0-B:断线不是 Human 拒绝;R5 §10.5。 | +| R17 | 996 | P 部分:普通调用失败重试 t/coordinator:213;控制信号与失败同时出现的完整场景未执行,业务取消为 B。 | +| R18 | 997 | P:无 Agent 无需校验器或解析器,t/compiler-front:89、t/coordinator:75。 | +| R19 | 998 | 延期 P0-B:checkStep/verify 视图;R5 §10.1/14。 | +| R20 | 999 | 延期 P0-B:contextError 后继语义;R5 §10.3/14。 | +| R21 | 1000 | P 部分:业务漂移/同 key、预算独立最终状态 t/coordinator:231/258、m6:250/279;变日志选项的完整组合未证明。 | +| R22 | 1001 | 延期 P0-B:同 operationId 的未知/确认对账;R5 §10.3。 | + +## S01–S14 + +技术 V0.6-R5 §15.3 第 1007–1020 行。这里的 S 场景编号与 P/M/T/N/V 证据等级无关。 + +| ID | R5 行 | 结果与证据 | +|---|---:|---| +| S01 | 1007 | P:t/coordinator:75/122、m6:365,纯终点/缺输入和终态稳定。 | +| S02 | 1008 | P 部分:t/coordinator:90/170、t/crash:89/118、m6:201;未涵盖底层 SQLite 掉电。 | +| S03 | 1009 | P 部分:t/coordinator:197/283/365;暂缺绑定后再挂载的恢复协议没有完整证明。 | +| S04 | 1010 | P 部分:t/crash:152、t/coordinator:137/151/376,适用于 SQLite/现有 API;外部 Store 接缝 CF-06。 | +| S05 | 1011 | P 部分:t/store:107/226、t/ir:62、t/coordinator:313、t/trusted-host:357;完整伪造 PC/合法边错组合及真实隔离未证。 | +| S06 | 1012 | P 部分:t/store:82/241、t/stage2-program-cache:76、m6:340;跨 SDK/CP Schema 矩阵未执行。 | +| S07 | 1013 | P:t/store:40/50、t/s07-capacity:89/109/165/419、m6:250 与 tests/integration/m6-ledger-boundary.test.mjs:28;限额和预算结论限定于现有 P 测试,不推及真实模型安全边界。 | +| S08 | 1014 | 延期 P0-B:高级验证读视图/进程恢复;R5 §10.1/14。 | +| S09 | 1015 | 延期 P0-B:Action Patch 与 reconcile;R5 §10.3。 | +| S10 | 1016 | 延期 P0-B:Action 错误路线;P0-A 的可信结果读取另见 CF-05。 | +| S11 | 1017 | 延期 P0-B:Human 真审批与 Loop 最终修复边界;R5 §10.5/10.6。 | +| S12 | 1018 | P0-A 管理终止缺口(CF-01、计划 S12-A);P0-B 业务补偿延期。 | +| S13 | 1019 | P/M 已有,原生 Grok T/N/V BLOCKED;m6:171、G02 #21、同 PR G03。 | +| S14 | 1020 | P 部分:基线本地和 CI 四矩阵、包和类型;T33 完整分期及新提交 CI 分开记录。 | + +## A-M1-01–A-M6-06(31 项) + +计划 V0.1 §§4–9(原件行号如下)。计划中建议的文件名不是唯一判定标准;有同等功能时定位实际实现,缺失功能另列。 + +| 任务 | V0.1 行 | 当前文件 / 可观察证据与残余 | +|---|---:|---| +| A-M1-01 | 114 | P:I/compile.mjs:37/41 调用现有 src/compiler/analyze.mjs,t/compiler-front:17/79,真实 JS/TS 与 Standard 诊断。 | +| A-M1-02 | 115 | P 部分:CI/feature-gate.mjs、t/compiler-front:38/57/67/89;CF-08 是 options 快照差异。 | +| A-M1-03 | 116 | P:CI/lower.mjs、t/ir:23/54/91、t/coordinator:122,稳定图/源身份和缺布尔。 | +| A-M1-04 | 117 | P 部分:CI/validate-ir.mjs、t/ir:62;运行时 Proposal 全量判别另审。 | +| A-M1-05 | 118 | P:t/compiler-front:17/79、t/ir:23/62/100 及本地 Standard 回归。 | +| A-M2-01 | 130 | P:CI/emit.mjs、t/emitter:35/51/75/119,零 import、字节稳定、真实分支。 | +| A-M2-02 | 131 | P 部分:I/compile.mjs、CI/feature-gate.mjs、t/emitter:119、t/compiler-front:79;CF-08,诊断全部字段未逐项核销。 | +| A-M2-03 | 132 | P:实际是 I/load.mjs:54;t/emitter:103、t/stage2-program-cache:76、m6:306。 | +| A-M2-04 | 133 | 待裁决:I/coordinator.mjs:29/47/54/92/117、t/ir:62;完整运行时 unknown keys、transitionId 和 ABI 形态待确认。 | +| A-M2-05 | 134 | P 部分:t/emitter:35/51/75/103、t/coordinator:122;完整字面转义向量未逐项核销。 | +| A-M3-01 | 146 | P 部分:I/canonical.mjs:34/111/115、I/sqlite-store.mjs:139、t/store:40/50/82;重复 JSON 文本键/JCS 互操作和五操作外部 Store 尚未证明。 | +| A-M3-02 | 147 | P 部分:I/sqlite-store.mjs:75/92/196/290/340、t/store:130/167/196/241;R5 DDL、冻结接口 CF-06 待裁决。 | +| A-M3-03 | 148 | P 部分:I/sqlite-store.mjs:92/150/163/178/290、t/store:82、t/coordinator:244;同库 FK retain 实际有效,V1/V2 共存/迁移未证。 | +| A-M3-04 | 149 | P 部分:t/store:40/50/82/107/130/167、t/coordinator:137/376;R03/R11 的组合场景仍缺。 | +| A-M3-05 | 150 | P 部分:t/crash:89/118/152;应用进程故障和事务异常有测试,SQLite 内部掉电未测。 | +| A-M4-01 | 164 | P 部分:I/coordinator.mjs:451/534/567/659、t/coordinator:75/90/122/258;§5 运行时 shape 仍有差异。 | +| A-M4-02 | 165 | P 部分:I/coordinator.mjs:174/247/534、t/coordinator:231/283、t/stage2-integration:141/173;日志参数完整组合 R21 未执行。 | +| A-M4-03 | 166 | P:I/coordinator.mjs:153/391/419、t/coordinator:90/170/197/324、t/trusted-host:314;高级验证为 B。 | +| A-M4-04 | 167 | P 部分:I/coordinator.mjs:567、t/coordinator:137/151/376、t/crash:118;外部 Store 语义 CF-06。 | +| A-M4-05 | 168 | 待裁决(CF-02):I/index.mjs:2、D:161、package.json;根入口保持,子路径可消费,冻结顶层签名缺失。 | +| A-M4-06 | 169 | P 部分:t/coordinator:170/197/231/258/297、t/crash:89/118/152;不是所有主机/故障切点。 | +| A-M5-01 | 190 | 缺口(CF-05):I/trusted-host.mjs:175 和 t/trusted-host:99/117 证明 PublicTask;I/coordinator.mjs:144、D:83 仍把完整终态 State 放进 SDK Frame。 | +| A-M5-02 | 191 | 缺口(CF-01):I/index.mjs:2、I/coordinator.mjs:220、D:183 无独立 Admin Controller/凭据/墓碑事务。 | +| A-M5-03 | 192 | P 部分:m6:171/328、tests/integration/m6-host.mjs 短进程实验;未证生产主机完整授权或无 JS 主机。 | +| A-M5-04 | 193 | P 部分:t/trusted-host:99/117/147/291/357、t/coordinator:313;管理端和目标 Grok OS 证据仍缺。 | +| A-M6-01 | 207 | P:package.json、scripts/package-files.mjs、scripts/check-package.mjs、tests/contracts.test.mjs;verify 有契约和包证据。 | +| A-M6-02 | 208 | P 部分:tests/types/interactive.ts 在仓库内直接检查声明,tests/package/consumer.mjs 实测已安装 tarball 的 Interactive ESM;现有 TS tarball 消费者没有直接 import Interactive,仍缺该子路径的已安装 TS 最低版本正负例。CF-02/05/06 仍影响冻结声明。 | +| A-M6-03 | 209 | P:package.json、.github/workflows/ci.yml、基线 Actions 43233955173,四矩阵加安全/文档检查与本地 verify。 | +| A-M6-04 | 210 | P 部分:t/crash:89/118/152、t/coordinator:244/376、m6:201/234/306/340;完整存储故障矩阵未关闭。 | +| A-M6-05 | 211 | P 部分:docs/interactive.md、docs/interactive-host-security.md、m6:171/328;NAT-14、安全宿主及完整差异仍开放。 | +| A-M6-06 | 212 | P 部分:m6:250、t/s07-capacity:165/193 与计划容量断言;无 ≥2000 阶段/WAL 写放大、延迟分位或跨平台性能原始样本。 | diff --git a/docs/evidence/p0a-g01-20261112/verified-inputs.txt b/docs/evidence/p0a-g01-20261112/verified-inputs.txt new file mode 100644 index 0000000..76178ef --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/verified-inputs.txt @@ -0,0 +1,6 @@ +0ac8719f204aae274e64abc30d02caa637fc55a5f1751a6884341f05117e02c0 42988 01-P0A-IMPLEMENTATION-PLAN-V0.1-CONFIRMED.md +ccb61c7f2cf48aaae02551b7d4e9c89dbaca068728a124c8f7d2c40889701703 6107 02-ONE-PR-DELIVERY-PLAN.md +4cf572072f51d0cb8142af88f8c2030e0dbd15a93d8415aa771b4c551feb8496 7635 03-EXECUTION-ROADMAP.md +4ab6c2872eb1289b8a5215e90b1b8daedcf6007f5269a9144f894f5630ff67b8 4301 06-FROZEN-SOURCES-AND-LINKS.md +3a0adc3edb41869a6309bae29ec5496df00970c815f8dc16330a90cbf26e703e 56883 FlowKit-Host-Driven-Interactive-Execution-Requirements-V0.3-Confirmed.md +09f3a5698e238d10b80efb8e26d1c41e51b663ca620efa70815dd7b52f2106ba 114621 TECHNICAL-DESIGN-V0.6-R5-CONFIRMED.md diff --git a/docs/evidence/p0a-g03-20261112/README.md b/docs/evidence/p0a-g03-20261112/README.md new file mode 100644 index 0000000..21588e4 --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/README.md @@ -0,0 +1,34 @@ +# G03 — Grok Build 原生工具能力 PoC + +**已执行,结果 PARTIAL;G07 的安全前置条件 BLOCKED。** 真正的 Grok Build 能注册、发现并调用本地 MCP,且小型内联入参有独立可观察的开始事件。文件入参可到 MCP,但开始事件只含文件路径;PoC 未取得在要求范围内可独立验证的完整开始入参,也没有可信签名、持久收据或受保护的观察链。不能据此通过 NAT-14、S13、T27 或宣称原生安全宿主可用。 + +基线 `21b02e785cacf703c359d7d4f853b8196aa17b19`,无 FlowKit 产品代码改动。Windows,Node `v24.22.2`,真实 `grok 1.0.83 (adcd31156f45)`,Grok EXE SHA-256 `b589678bb2597796068b7d2add337a7182f93ef76522267fa2db28a894f50557`,成功回合的模型为 `grok-4.7-build`。初查 `grok models` 报未认证,随后真实模型回合成功;未提供或读取凭据,也未为其他目录扩大权限。只对临时项目 MCP 目录使用 `--trust`,以 `dontAsk` 和 `MCPTool(flowkitg03__probe)` 运行,未启用无条件审批、shell、web search 或子 agent。 + +## 真实观察 + +`run.mjs` 启动真实 CLI,逐字节保存 stdout/stderr;另外解析流里的 `tool_call`/`tool_call_update`。临时 stdio `probe.mjs` 是合成工具,不依赖 FlowKit 或任何签名/Store。观察器只在拿到同一小型内联参数的 SHA-256 和 `toolCallId` 后写临时 gate;MCP 端独立由收到的参数计算摘要、核对 gate,最多等 4 秒。注册命令、MCP Doctor 实际握手和发现、CLI 实际事件在每轮原始日志中保存(详见 `commands.md` 和哈希清单)。 + +| 样本 / 日志目录时间(UTC) | CLI 开始事件 | MCP 实收 / 响应 | 裁决 | +|---|---|---|---| +| 小内联,14:32:48 与 14:41:49 | `use_tool`,`pending`,`rawInput.tool_input` 为完整 82 B 规范样本;同 ID 后续 `completed` | 两次独立摘要相符,gate 放行 | 真实能力成立;只能证明观察早于工具成功返回 | +| 64 KiB,14:34:56 | 有 ID,仅 `rawInput.tool_input_file` | 65,536 B,与文件 SHA 相符;门保持关闭,调用 failed | 传输到 MCP 成立,开始事件未给字节 | +| 256 KiB,14:35:18 | 同上 | 262,144 B,与文件 SHA 相符;门保持关闭,调用 failed | 同上 | +| 2 MiB − 4 KiB,14:35:44 | 同上 | 2,093,056 B,与文件 SHA 相符;门保持关闭,调用 failed | 同上;仅为选定的近上限样本,非声明 CLI 或 FlowKit 精确上限 | +| 不放行,14:36:14 | 有真实开始、ID/小入参均相符 | 人为扣住 gate;MCP 4,022 ms 后返回失败,随后同调用 ID failed | 该 PoC 有界失败,不会因缺 gate 永久等待 | +| 错 nonce,本地负例 | 无真实 CLI / 无模型 | 独立调用合成 endpoint,`isError:true`、无 gate,未释放 | 只证 fixture 拒绝无匹配;不能证明抵抗伪造 stdout、写 gate、文件替换或跨用户攻击 | + +文件传输的三组 fixture 和 MCP 摘要分别是: + +- `e582bd96184cfa8782f824471e756f0e1303e114eb0f6d358559f2cb62837a37` +- `30f1255d6ed2eedec663b7cd6ecf4133dcc9f932e9f7be11cfb8049d9ebe9393` +- `b077081ef9635e26d2a1049d3644770e14d822ecbafe1a256c7636c454eaee71` + +小内联复测 `run-2026-11-12T14-41-49-005Z`,调用 ID `call-a577f2f9-8591-481e-bc3d-176a4414176c-1`,摘要 `01560b2722fa7cad8483fbd839b54b3f22b6ae2a860f49a4bf6444b2052a17a9`。同机 epoch 毫秒:MCP ingress `1794494524116` → 观察开始 `1794494524122` → gate `1794494524123` → MCP response `1794494524155` → CLI completed `1794494524157`。首次正例也为 MCP ingress 先发生(领先 4 ms);没有观察到可保证先于工具进程接收请求的 hook。两进程的 wall clock 用于对齐,观察器另记单调时间;这些时间不证明 API 一般性顺序保证。这里没有签名步骤;所谓“签名前”最多能在合成门中模拟,不是签名前安全验收。 + +两次失败的安装/授权探索也保留:14:30:41 项目工具因目录未信任而拒绝,14:32:29 使用的旧 permission 语法无效。某些 CLI 回合退出码为 0 但工具事件是 failed,因此以工具事件和 MCP 实收为准。文件路径只作定位,不能把读同一路径当作“同一字节”的来源证明。本轮未测大入参直接内联的模型生成/流截断,不能推出 CLI 所有模式均无法观察大参数。 + +## 证据等级与复审门槛 + +G03 是真实 CLI PoC。**无新增 FlowKit P/M/T/N/V**:未执行 FlowKit JS 或 Pending,M 要求真实模型处理真实 FlowKit Pending;观察器与模型端使用同一 OS 用户,不能作为受保护 T;没有生产 Host、签名/CAS、端到端 N 或可独立验证的 V。原始流中的不透明 usage 字段不作为 provider 签名证据。 + +继续 BLOCKED:完整大入参的独立绑定;真实 Host/Pending/tenant 的预签绑定;受保护 OS 通道和伪造负例;NAT-14。供复审的备选:验证是否有能提供同一份规范字节的原生流,或单独裁决一种由受保护 Host 生成并固定不可变内容摘要的传递机制;后者须验证路径替换、独立身份、顺序与失配拒绝,不能信任模型自报摘要。没有实施备选,也未启动 G05/G07/H3-02。文档参照:[Grok Build CLI](https://docs.x.ai/build/cli/reference);本报告运行事实来自原始记录。 diff --git a/docs/evidence/p0a-g03-20261112/commands.md b/docs/evidence/p0a-g03-20261112/commands.md new file mode 100644 index 0000000..a7acc4a --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/commands.md @@ -0,0 +1,25 @@ +# G03 执行与复现 + +每次真实执行的绝对命令、参数、工作目录、PID、退出码、时间以各 `run-*/observer.ndjson` 的 spawn/exit 行为准。CLI 原始 stdout/stderr 文件按 01 版本、02 项目注册、03 发现、04 doctor、05 实际模型回合编号。MCP 自身的参数摘要和响应记录另见 `mcp-rpc.ndjson`。最初两次错误和首次成功使用迭代中的脚本;它们的实际命令与原始输出均保留。下列调用使用包中当前脚本,覆盖四组边界和 14:41:49 的小参数复测。 + +```powershell +$g = Resolve-Path outputs/flowkit-p0a-review/G03 +$p = Resolve-Path work/g03-cli +$grok = Join-Path $env:USERPROFILE '.grok/bin/grok.exe' +$node = (Get-Command node).Source +Get-FileHash -LiteralPath $grok -Algorithm SHA256 +node (Join-Path $g 'run.mjs') $grok $p $node trusted 65536 +node (Join-Path $g 'run.mjs') $grok $p $node trusted 262144 +node (Join-Path $g 'run.mjs') $grok $p $node trusted 2093056 +node (Join-Path $g 'run.mjs') $grok $p $node withhold 0 +node (Join-Path $g 'run.mjs') $grok $p $node trusted 0 +``` + +复跑时创建自己控制的空临时项目目录(`$p`),并使用已可用的 Grok Build。脚本对该项目目录使用 CLI 的项目 MCP 注册和信任;不要指向真实项目。生成的三个固定大小样本另存于 `fixtures/`。小内联样本 82 B 是 `JSON.stringify({nonce,payload})` 的 UTF-8;观察器和 MCP 各自计算,不作为 JCS 互操作证明。 + +错 nonce 的非原生负例(只用本地合成 MCP): + +```powershell +$n = Join-Path $g 'negative-wrong-nonce' +Get-Content (Join-Path $n 'input.ndjson') | node (Join-Path $g 'probe.mjs') (Join-Path $n 'mcp-rpc.ndjson') (Join-Path $n 'absent.gate') expected-fixture +``` diff --git a/docs/evidence/p0a-g03-20261112/probe.mjs b/docs/evidence/p0a-g03-20261112/probe.mjs new file mode 100644 index 0000000..33a50c4 --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/probe.mjs @@ -0,0 +1,33 @@ +// Synthetic stdio MCP endpoint. No FlowKit, credentials, receipt signer or network. +import { createHash } from 'node:crypto'; +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { setTimeout } from 'node:timers/promises'; +const [, , log, gate, nonce] = process.argv; +const record = (kind, fields = {}) => appendFileSync(log, JSON.stringify({ kind, at: Date.now(), pid: process.pid, ...fields }) + '\n'); +const send = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); +const lines = createInterface({ input: process.stdin }); +for await (const line of lines) { + const request = JSON.parse(line); + if (request.id === undefined) continue; + if (request.method === 'initialize') { + record('initialize', { protocolVersion: request.params?.protocolVersion }); + send(request.id, { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'flowkitg03', version: '0.1.0' } }); + } else if (request.method === 'tools/list') { + record('tools/list'); + send(request.id, { tools: [{ name: 'probe', description: 'Submit synthetic test data; waits for a separate observer to acknowledge seeing the CLI start event.', inputSchema: { type: 'object', properties: { nonce: { type: 'string' }, payload: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] } }, required: ['nonce','payload'], additionalProperties: false } }] }); + } else if (request.method === 'tools/call' && request.params?.name === 'probe') { + const args = request.params.arguments; + const good = args?.nonce === nonce && Object.keys(args).length === 2 && (typeof args.payload === 'string' || (Array.isArray(args.payload) && args.payload.every(x=>typeof x==='string'))); + const canonical = good ? JSON.stringify({ nonce, payload: args.payload }) : ''; + const digest = good ? createHash('sha256').update(canonical).digest('hex') : null; + record('tools/call', { rpcId: request.id, bytes: Buffer.byteLength(canonical), digest }); + const until = Date.now() + 4000; + while (good && !existsSync(gate) && Date.now() < until) await setTimeout(20); + const released = good && existsSync(gate) && readFileSync(gate,'utf8').trim() === digest; + record('respond', { rpcId: request.id, released, digest }); + send(request.id, { isError: !released, content: [{ type: 'text', text: released ? `observed ${digest}` : 'probe gate closed; no native start match' }] }); + } else { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }) + '\n'); + } +} diff --git a/docs/evidence/p0a-g03-20261112/run.mjs b/docs/evidence/p0a-g03-20261112/run.mjs new file mode 100644 index 0000000..b058552 --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/run.mjs @@ -0,0 +1,81 @@ +// Capture raw streams from the actual Grok Build executable, with a bounded timeout. +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const [exe, cwd, node = process.execPath, mode = 'untrusted', targetBytesText = '0'] = process.argv.slice(2); +const here = fileURLToPath(new URL('.', import.meta.url)); +const dir = join(here, `run-${new Date().toISOString().replace(/[:.]/g, '-')}`); +mkdirSync(dir); +mkdirSync(cwd, { recursive: true }); +const nonce = randomUUID(), rpc = join(dir,'mcp-rpc.ndjson'), gate = join(dir,'observed.gate'); +const timeline = join(dir, 'observer.ndjson'); +const event = (kind, fields = {}) => appendFileSync(timeline, JSON.stringify({ kind, at: Date.now(), monoNs: String(process.hrtime.bigint()), ...fields }) + '\n'); +function invoke(label, args, observe) { + return new Promise((resolveRun, reject) => { + const stdout = join(dir,`${label}.stdout`), stderr = join(dir,`${label}.stderr`); + writeFileSync(stdout,''); writeFileSync(stderr,''); + const cp = spawn(exe,args,{ cwd, windowsHide: true, stdio:['ignore','pipe','pipe'] }); + event('spawn',{label, executable: exe, args, cwd, pid: cp.pid }); + let tail = ''; + // Pipe chunks are Buffers. Save the original bytes and decode a separate + // copy incrementally for observation. + const decoder = new TextDecoder(); + cp.stdout.on('data',chunk=>{ + appendFileSync(stdout, chunk); + if (!observe) return; + tail += decoder.decode(chunk, { stream: true }); + let cut; + while ((cut=tail.indexOf('\n'))>=0) { + const line=tail.slice(0,cut); tail=tail.slice(cut+1); + try { observe(JSON.parse(line)); } catch (e) { event('observer-exception',{message:e.message}); } + } + }); + cp.stderr.on('data',chunk=>appendFileSync(stderr,chunk)); + const timer = setTimeout(()=>{event('timeout',{label,pid:cp.pid}); cp.kill();},35000); + cp.on('error',e=>{clearTimeout(timer); reject(e);}); + cp.on('close',(code,signal)=>{clearTimeout(timer); event('exit',{label,code,signal}); resolveRun(code);}); + }); +} +await invoke('01-version',['--version']); +await invoke('02-register',['mcp','add','--scope','project','flowkitg03','--',node,resolve(here,'probe.mjs'),rpc,gate,nonce]); +await invoke('03-discover',['mcp','list','--json']); +const trust = mode === 'untrusted' ? [] : ['--trust']; +await invoke('04-doctor',[...trust,'mcp','doctor','flowkitg03']); +const expected = { nonce, payload: 'flowkit-g03-synthetic' }; +const targetBytes = Number(targetBytesText); +let file; +if (targetBytes) { + const payload = []; + for (let left=targetBytes-JSON.stringify({nonce,payload:[]}).length;left>3;) { + const length = Math.min(65536,left-(payload.length ? 3 : 2)); + left -= length+(payload.length ? 3 : 2); + payload.push(String.fromCharCode(65+payload.length%26).repeat(length)); + } + expected.payload=payload; + file = join(cwd,`arguments-${nonce}.json`); + writeFileSync(file,JSON.stringify(expected)); +} +const digest = createHash('sha256').update(JSON.stringify(expected)).digest('hex'); +event('fixture',{mode, targetBytes, bytes:Buffer.byteLength(JSON.stringify(expected)), digest, file}); +let startId = null; +const target = mode === 'untrusted' ? 'flowkitg03_probe' : 'flowkitg03__probe'; +const allow = mode === 'untrusted' ? ['--allow','mcp__flowkitg03__probe'] : ['--allow',`MCPTool(${target})`]; +const invocation = file ? `Set tool_input_file to this exact absolute path: ${JSON.stringify(file)}. Do not use tool_input, read or retype the file.` : `tool_input must be ${JSON.stringify(expected)}.`; +await invoke('05-native', [...trust,'--no-subagents','--no-plan','--disable-web-search','--tools','list_dir','--permission-mode','dontAsk',...allow,'--max-turns','4','--output-format','streaming-json','-p',`This is a synthetic local tool capability test. Do not inspect files or use shell or other servers. Call ${target} with the native use_tool exactly once. ${invocation} Report the tool result.`], e=>{ + if (e.type === 'tool_call') { + const wrapped = e.toolName === 'use_tool' && e.rawInput?.tool_name === target; + const raw = wrapped ? e.rawInput.tool_input : undefined; + const input = raw?.nonce===nonce && Object.keys(raw).length===2 ? JSON.stringify({nonce,payload:raw.payload}) : null; + const observedDigest = input ? createHash('sha256').update(input).digest('hex') : null; + const match = !!e.toolCallId && observedDigest===digest; + event('tool-start',{ toolCallId:e.toolCallId, toolName:e.toolName, status:e.status, match, bytes:input?Buffer.byteLength(input):null, fileReference:!!e.rawInput?.tool_input_file, digest:observedDigest }); + if (match && !startId && mode!=='withhold') { + startId=e.toolCallId; + writeFileSync(gate,digest+'\n',{flag:'wx'}); + event('released',{ toolCallId:startId, digest }); + } + } else if (e.type === 'tool_call_update') event('tool-update',{ toolCallId:e.toolCallId, status:e.status }); +}); +console.log(dir); diff --git a/docs/evidence/p0a-g03-20261112/sha256.txt b/docs/evidence/p0a-g03-20261112/sha256.txt new file mode 100644 index 0000000..9b632e7 --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/sha256.txt @@ -0,0 +1,109 @@ +60b1754c0713af182bd75811ceec1e60425a3379ecea72fb1f70b32b3f9a4a97 commands.md +e582bd96184cfa8782f824471e756f0e1303e114eb0f6d358559f2cb62837a37 fixtures/arguments-5bc106dc-6730-4177-86a2-fed0138e27bf.json +30f1255d6ed2eedec663b7cd6ecf4133dcc9f932e9f7be11cfb8049d9ebe9393 fixtures/arguments-65fc8dcb-8e70-4251-9ee1-0d85e8ca55f7.json +b077081ef9635e26d2a1049d3644770e14d822ecbafe1a256c7636c454eaee71 fixtures/arguments-c4b2aabf-3814-4533-a9ab-d26273fea372.json +13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354 negative-wrong-nonce/exit +cdc930025d3859d0c802b1723043a98dcdf3b2dc46bb58c5564d6912c8f8cf8a negative-wrong-nonce/input.ndjson +61d11e10c4b195c2e550d083a9cdb1d6198f1cef7fe2ec4b57a20a86eaf7f916 negative-wrong-nonce/mcp-rpc.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 negative-wrong-nonce/stderr +f0b6ffd24ab212110f229cd33dbd43b2516bf3d02362a52376122a537ff88fa6 negative-wrong-nonce/stdout +a0b56e6c6a2b7e61c4ff2da94e6588abcde8463fb3e5343647641516c85c432f probe.mjs +1335883b8266b6d90d583cb321674ab6f98714ba9b2a7fadad1f583f3f9bf2d1 README.md +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-30-41-950Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-30-41-950Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-30-41-950Z/02-register.stderr +e625e1357f7251685c75d0ea392d375b1bde5c4a5bd6aa686e12bc8960d6b07e run-2026-11-12T14-30-41-950Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-30-41-950Z/03-discover.stderr +e5b3e7325b3c222727904f9f01c08ef1fa140a4bbb4e217b347004bcf1ae10a9 run-2026-11-12T14-30-41-950Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-30-41-950Z/04-doctor.stderr +00bf33b92cffc5d3289479d3da9d3347a2cdd9d3ea6486d35ed007bc6dcc6e8e run-2026-11-12T14-30-41-950Z/04-doctor.stdout +1127ae1c7dd1b79d4b96a29a396796d3519fb3231e5d93db2413cfcc4cf698e5 run-2026-11-12T14-30-41-950Z/05-native.stderr +1126dff43eeedc6068af01f21aaedfbe41e68cee82018ea5f09e902fea3630d7 run-2026-11-12T14-30-41-950Z/05-native.stdout +b49743a461b5b1fe7d940f32157b5a995e8e4bf6a57fa9dba69cda3fd71efe1a run-2026-11-12T14-30-41-950Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-32-29-735Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/02-register.stderr +c5c76a8f5f9f232693fe144983039694abb8e27a16840f8d9aecad1215d49945 run-2026-11-12T14-32-29-735Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/03-discover.stderr +3dc6b64efaba316a8aec7590865e4df833c8f9dfc5c62a74947df169e5b8fd78 run-2026-11-12T14-32-29-735Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/04-doctor.stderr +4962fe66324490b128e63d3246fd5ced8d3126e4e116910dcdae0c2ae93ce036 run-2026-11-12T14-32-29-735Z/04-doctor.stdout +ba3918adebe8e486ae225969dd01b8b084f728a224a7fce4864805413611d293 run-2026-11-12T14-32-29-735Z/05-native.stderr +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/05-native.stdout +b518e46c994bbac60c53e64e35bd12b89e12c39c86f764744761d458aecde539 run-2026-11-12T14-32-29-735Z/mcp-rpc.ndjson +1877b39ca8834171963877d720793df814667c1e5069358a68773c7874ebc4e5 run-2026-11-12T14-32-29-735Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-48-678Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-32-48-678Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-48-678Z/02-register.stderr +1715d03974643532c1d41fe9abec301b1c4577c897a4fe367b5e632462518bfa run-2026-11-12T14-32-48-678Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-48-678Z/03-discover.stderr +1e18bf89f07c5c7de6a9b55ea1e7387a7a972d67844239d38b5eb0df76c13960 run-2026-11-12T14-32-48-678Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-48-678Z/04-doctor.stderr +961d3dbc6ba3303da182df629a153f9f513bf55ead281040dbb106173d37c738 run-2026-11-12T14-32-48-678Z/04-doctor.stdout +ecb67ffded5d12de90f02d26952912145bd84ba1509db17a2663e752db3ea421 run-2026-11-12T14-32-48-678Z/05-native.stderr +da30ca80a5b02659c08c0586db64564f31d1793cf0e09119c9b26634eb03b805 run-2026-11-12T14-32-48-678Z/05-native.stdout +fdd3336c01a0d5b893cc85f945f7351cdff5e56e4e5376508e74550251eab82d run-2026-11-12T14-32-48-678Z/mcp-rpc.ndjson +b02677709a305724dd4ed8dadf93e5f14deef6d2f3792705da5e1ff997341862 run-2026-11-12T14-32-48-678Z/observed.gate +0031e02132946d7b98a8158ff59aa071f68caf27615eec8b1c7b663ce4835842 run-2026-11-12T14-32-48-678Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-34-56-346Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-34-56-346Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-34-56-346Z/02-register.stderr +32c40483f7039216b6eff3faf1221327415af28191e4ef43cdc6e34e65028dcd run-2026-11-12T14-34-56-346Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-34-56-346Z/03-discover.stderr +3829d35cc07abd53010b2bbfbaee9ef66d86fbdac7f49920b1a013cecfe3f682 run-2026-11-12T14-34-56-346Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-34-56-346Z/04-doctor.stderr +e5b450e3c7f25a9dceef60da38ae5412789199d6db6f422c7c6fd423b6442741 run-2026-11-12T14-34-56-346Z/04-doctor.stdout +40090e738e89cd5cb58cb9ec4abdb984b72d36f460f719c9fc1ef185bd393492 run-2026-11-12T14-34-56-346Z/05-native.stderr +b601c75a6a24442c551aaa3122135796297a80cfe1b183c51fc732c29ad9382b run-2026-11-12T14-34-56-346Z/05-native.stdout +7edfa99f7b47fe7173a5e1404c9471073d2748a9e50a01883002450f9bda8888 run-2026-11-12T14-34-56-346Z/mcp-rpc.ndjson +3ffee1c35fe4a0d53bd162e108289516d6b197ace131f4ada03419611bc9280b run-2026-11-12T14-34-56-346Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-18-036Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-35-18-036Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-18-036Z/02-register.stderr +e81fad5e9ba96e999c555cfe309a142af4cf8d7ad73c4c9d956db471cb5f0fc0 run-2026-11-12T14-35-18-036Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-18-036Z/03-discover.stderr +206a15bf044257384d0bc05e482245a7c28aa804fcf158ebb4628a1182ccec1f run-2026-11-12T14-35-18-036Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-18-036Z/04-doctor.stderr +7274d4e87afe14bce2a0aa0e756ee3d1bd740b6a71b7c74cee079c83ec8af4cd run-2026-11-12T14-35-18-036Z/04-doctor.stdout +1b8f80ea0f6fee2412ef0af9418596462fb3e4a3cd2263fdad3e87de78d713d5 run-2026-11-12T14-35-18-036Z/05-native.stderr +a54ba7fe7bd39641bcab5967c97b65f8f3918260c8220d29c194ac6e2900f2ec run-2026-11-12T14-35-18-036Z/05-native.stdout +1ffae65208188beb30963898e3cc92366c59e76ff9d66bfda6c88da4b4923496 run-2026-11-12T14-35-18-036Z/mcp-rpc.ndjson +59dd683a8b4d54b1dd6bef43afd33e54053b3c8ae2439227d49152c7677ce08c run-2026-11-12T14-35-18-036Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-44-974Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-35-44-974Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-44-974Z/02-register.stderr +7a31f91074d2d24e1a413ee625d7949050f65367416ca5e1f703fbee11f30669 run-2026-11-12T14-35-44-974Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-44-974Z/03-discover.stderr +f8c25635106be2e8472aa82c4157ec4fcff69aa3e86d34c83184b2fae978a2f7 run-2026-11-12T14-35-44-974Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-44-974Z/04-doctor.stderr +d01e7edb31bcef0b49aa15956eb1efb103d6fff97eedb2e53389c588e0f77e7a run-2026-11-12T14-35-44-974Z/04-doctor.stdout +81e4cd23d2e3b4032c44d3cce9f3c10995c780873a138bf7af1f44a7304f4916 run-2026-11-12T14-35-44-974Z/05-native.stderr +1fc254d81db9d1276e100fe76c1a677f82d378c9c49054406907a2675f6a4812 run-2026-11-12T14-35-44-974Z/05-native.stdout +ef7832214b8457734ab1f82060aa108dacb1d9e5c2a6541e9108f8c397094345 run-2026-11-12T14-35-44-974Z/mcp-rpc.ndjson +6d0f893f7e8f38b2fdea3472b7a6205b38ec0c454a6c47eef819450daa47969b run-2026-11-12T14-35-44-974Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-36-14-288Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-36-14-288Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-36-14-288Z/02-register.stderr +ecf1f12aa2270ab7a0c9f1577fa396e373ec0aebade49112dc95f6d515d04043 run-2026-11-12T14-36-14-288Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-36-14-288Z/03-discover.stderr +fb698b28accd22586eac6f6125b728033989a539d5464f1b78135ca4329b2585 run-2026-11-12T14-36-14-288Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-36-14-288Z/04-doctor.stderr +1d2c5f350b0d2335514778eaac95d96ab9580c1cc511d16f808968e712329f7f run-2026-11-12T14-36-14-288Z/04-doctor.stdout +0af56ca73ef828d67a575376928a0060241ac1731d7519e497b30a5f279b71f4 run-2026-11-12T14-36-14-288Z/05-native.stderr +fe34c55831ad06ebf7001f42ea99ae8c1020dce0042bed1c5d97d553d25f73fb run-2026-11-12T14-36-14-288Z/05-native.stdout +3971df5994707bf6381b2c3881e48bb9e395826ea8a6c03f8975e03d7ab4c06a run-2026-11-12T14-36-14-288Z/mcp-rpc.ndjson +b2e9529f0a489bc7c9b4d961a5a7761ae047ecb361c58621acfe2f6f976dac79 run-2026-11-12T14-36-14-288Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-41-49-005Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/02-register.stderr +ec78834cb31ef91bfd5aa83db7aaaeb4f6d6bd6077b0c697c18bac76c84ae8f4 run-2026-11-12T14-41-49-005Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/03-discover.stderr +1e1d6594688c15125bc214086b9e38fca5d0ae08b4497ae25e530bb0a573fe21 run-2026-11-12T14-41-49-005Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/04-doctor.stderr +c81c7ac28f0de10fdfb2c6e9cd0c05bb298ea9189a6471ed39c07d18843c290d run-2026-11-12T14-41-49-005Z/04-doctor.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/05-native.stderr +9a2cf31ed61d01cdaa83d338e21ee7d0eb456d7499db521f67eb7172590807fe run-2026-11-12T14-41-49-005Z/05-native.stdout +014192b3c089000dc6e1b7fa401c2269b5e0244c9c9012756a86290ffb8fad75 run-2026-11-12T14-41-49-005Z/mcp-rpc.ndjson +b3a4ebf0137095b0c465b5e21573d54759347ce4954e5d947a9bbc4af40b8bd3 run-2026-11-12T14-41-49-005Z/observed.gate +4b1f0b7d5f94db7dc9f80124978e62b68eed165b950bcb9f415c14e1f9a7a399 run-2026-11-12T14-41-49-005Z/observer.ndjson +3be09a32fc2708bd16962f2ac954f14f7b70051d2e7374684b38189fc169c4d1 run.mjs diff --git a/docs/evidence/p0a-g04-20261112/README.md b/docs/evidence/p0a-g04-20261112/README.md new file mode 100644 index 0000000..3f78839 --- /dev/null +++ b/docs/evidence/p0a-g04-20261112/README.md @@ -0,0 +1,23 @@ +# G04 批次交付与契约裁决 + +Draft PR #22 的 G04 基线是 `d17a7da7a0efbd6b0ab4e468586d132f648b2e23`。已完成当前可做的 CF-08 和 CF-05 终态读取部分。三份确认原件、版本和哈希在 [G01 审查](../p0a-g01-20261112/README.md)。 + +| 条目 | 改动与证据 | +|---|---| +| CF-08 | 修复异步编译在 lowering 阶段读取原始可变选项的问题。定向回归修复前失败、修复后通过。提交 `6989190412817d3ee8740531cf48c3297b619934`。见 [CF-08 记录](../p0a-g04-cf08-20261112/README.md)。 | +| CF-05 | R5 §§4–5 和计划 A-M5-01。可信 Host 增加 `getPublicTerminalFrame` 和 `readTrustedTerminalResult`。`run:read` 返回状态、Checkpoint 引用和版本化结果引用,`result:read` 独立控制完整 State。两条路径从受信 Store 读取同 namespace 的终态,再核对 revision。缺少授权回调时两条路径均拒绝。 | +| 兼容范围 | 现有 factory 的执行帧保留供可信 Host 调用方使用。新公开终态帧与完整结果是分开的类型。通用 PublicFrame 及旧入口迁移由下表 CF-02/05 决定。现有 Agent 任务继续走已授权的 `getPublicAgentTask`。 | +| 证据边界 | 验证为 Windows / Node 24.22.2 的真实 JS、SQLite 和打包消费者测试,属于 P 级证据。原生宿主 G07 及 NAT-14 保持 BLOCKED。 | + +CF-05 定向命令为 `node --test --test-name-pattern=CF-05 tests/interactive/trusted-host.test.mjs`。最初加入权限回归时因缺失方法而失败,最终两个用例通过。覆盖双向权限分离、跨租户、旧 revision、无授权回调、活跃 Run 拒绝和错误终态脱敏。测试比较 SQLite Checkpoint 和 Journal 前后字节。打包 ESM 消费者验证两条路径,声明测试验证公开终态没有 State。全量 `npm run verify` 通过 398 项,0 失败、0 跳过。最终的类型兼容调整另跑 `npm run build`、`npm run test:types` 和 `npm run check:package`,均退出 0。 + +## 需要用户决定的冻结契约 + +| 项目 | 决策与影响 | +|---|---| +| CF-01 管理终止 | R5 §§3.6/4/5/9 的 `cancelled` 与独立管理审计超出现有 Store 的可读写集合。选择按 R5 做兼容迁移,或批准现有 Journal 内保存等价管理审计。两种方案都需先处理旧版读取 `cancelled` 的行为,并验证旧 Program 缺失、业务终态竞争和迟到回执。 | +| CF-02 / CF-05 SDK 与公开帧 | 选择新增 R5 顶层 `start/complete/resume` 与通用安全帧并保留现有 factory 为可信 Host 兼容入口,或安排旧调用方迁移后移除原执行帧的 State。第一种允许已安装消费者逐步迁移。当前已交付终态的分权读取。 | +| CF-06 Store | 选择并行提供 R5 五操作异步 Store 与独立 Registry 接缝并兼容现有同步 factory,或对公共 Store 入口做统一的版本迁移。两种都需要证明原子 CAS、历史查询和丢 ACK 后的线性化判断。 | +| ABI-01 转移身份 | 旧 Journal 使用现行 transition ID 公式,R5 公式还绑定事件、Program 与提案摘要。选择为新 Run 启用版本化公式并保留旧 Run 的公式,或先批准历史迁移及历史 Ack 对账策略。 | + +G04 的本批可执行改动已交付。冻结裁决仍开放。第二批 G05–G06 的 OS 隔离准备和验证可以独立进行。原始命令、输出、增量 diff、确切提交 SHA 与文件 SHA-256 随本地证据保存。 diff --git a/docs/evidence/p0a-g04-cf08-20261112/README.md b/docs/evidence/p0a-g04-cf08-20261112/README.md new file mode 100644 index 0000000..1ecf799 --- /dev/null +++ b/docs/evidence/p0a-g04-cf08-20261112/README.md @@ -0,0 +1,26 @@ +# G04 · CF-08 编译选项快照 + +基线为 [`d17a7da7a0efbd6b0ab4e468586d132f648b2e23`](https://github.com/devcodex-labs/flowkit/commit/d17a7da7a0efbd6b0ab4e468586d132f648b2e23),交付仍在 [Draft PR #22](https://github.com/devcodex-labs/flowkit/pull/22)。本次由 Codex 直接实施。三份冻结原件的字节身份已记录在 [G01](../p0a-g01-20261112/README.md)。 + +R5 §4 要求编译器按经校验的静态阶段编译,§15.2 的 R04 记录同类异步输入突变。G01 的 CF-08 定位到 `compileInteractive()` 在校验后又从调用方对象读取 `featureStage`。现在 lowering 使用编译入口已经校验的 `snapshotOptions.featureStage`。实际修复是 `src/interactive/compile.mjs:44` 的一次读取替换。 + +回归用同一份合法 DSL 比较稳定调用和立即修改原选项的调用。修复前,第二次调用错误返回 `FK_I_UNSUPPORTED_GRAPH`。修复后,两个调用的诊断、Program 标识和 JS 完全一致。原有 P0-B 拒绝用例随全量验证一起运行。 + +| Windows / Node 24.22.2 | 命令 | 结果 | +|---|---|---| +| 只加入回归测试 | `node --test --test-name-pattern=CF-08 tests/interactive/stage2-integration.test.mjs` | 退出 1,1 项失败,见 `before.stdout` | +| 加入修复 | 同上 | 退出 0,1 项通过,见 `after.stdout` | +| 加入修复 | `npm run verify` | 退出 0,396 项通过,0 失败、0 跳过,见 `verify.stdout` | + +上述是本地 P 级证据。日志原始字节、测试单独 diff、增量 diff、基线与修复提交身份和新提交的 CI 结果在本地证据包。 + +## G04 其余裁决 + +| 项目 | 已核对的约束与下一步 | +|---|---| +| CF-01 管理终止 | R5 §§3.6、4、5、9 要求独立管理权限、无需加载 Program、原子墓碑和审计。当前 Store `sqlite-store.mjs:8/44/340` 拒绝 `cancelled` Checkpoint,现有 SQL 也没有管理事件。需要裁决部署中的 Checkpoint 兼容方式、管理审计的存储位置,以及旧读取方如何处理新终态。验收须覆盖旧 Program 缺失、迟到回执和与业务终态的 CAS 竞争。 | +| CF-05 结果投影 | R5 §§4–5 要求公开 Frame 只给 `resultRef`,完整结果由 Host 的 `result:read` 独立授权。现有 `InteractiveRunFrame` 与打包消费者明确读取终态 `state`(`types/interactive.d.ts:83/258`、`tests/package/consumer.mjs:55/98`)。需裁决旧 factory 是否作为仅限 Host 的兼容入口保留,以及版本化 PublicFrame 的接入方式。现有 `getPublicAgentTask` 的字段限制继续是当前模型通道的证据。 | +| CF-02 / CF-06 | 顶层 SDK 和五操作异步 Store 差异见 G01。需要先裁决对当前消费者及自定义 Store 的兼容承诺,再评审接口或适配器。 | +| G07 / NAT-14 | G03 的完整入参独立绑定条件仍未满足。此次 G04 的验证对象为编译器,证据等级为 P。 | + +CF-08 是本提交的已验证修复。G04 其余发现及依赖步骤按各自裁决和复审条件保留。 diff --git a/docs/evidence/p0a-g05-g06-20261112/README.md b/docs/evidence/p0a-g05-g06-20261112/README.md new file mode 100644 index 0000000..b75b516 --- /dev/null +++ b/docs/evidence/p0a-g05-g06-20261112/README.md @@ -0,0 +1,36 @@ +# G05–G06 双账户 Host 隔离 + +本批验证 Windows 和 Linux 上的两个独立非管理员主体。测试使用一次性目录、测试签名密钥、真实 FlowKit SQLite Store 和可公开的任务样本。启动器把 Host 和受限测试进程作为不同身份启动。每个进程报告实际 SID 或 UID、PID 和父 PID。Windows 同时记录文件与目录的实际 SDDL,Linux 记录权限位和有效能力。启动器以白名单构造环境,测试进程验证 Host 的环境标记未传入。 + +这是一套可复跑的隔离测试配置。已有的 Linux M5/M6 隔离测试仍在 CI 中运行。新增测试不需要服务、监听端点、模型账户或外部凭据。真实模型接入的启动及句柄继承验收仍属于后续部署测试。 + +## 运行方法 + +从仓库根目录运行,使用 Node 24.22.1。 + +```sh +sudo -n "$(command -v node)" tests/security/secure-host-linux.mjs +``` + +Linux 启动器要求一次性环境,且 UID/GID 41001 和 41002 未占用。只有引导进程使用 root。两个实际工作进程通过 `setpriv` 清空附加组及能力,并启用 `no_new_privs`。Host 私有目录为 0700,密钥为 0600。 + +```powershell +.\tests\security\secure-host-windows.ps1 -Node (Get-Command node.exe).Source -DisposableRunner +``` + +Windows 使用一次性 Windows Server 2025 运行机、提升权限的 64 位 Windows PowerShell 和两个随机命名的本地普通用户。引导进程创建显式文件 DACL,只有 Host 能修改私有目录,SYSTEM 和 Administrators 保留恢复权限。测试 Host 在生成密钥前收紧自己的进程和 Token DACL,之后引导进程才启动受限进程。测试用临时账户和目录在结束时清理。 + +## 验收映射 + +| 范围 | Linux | Windows | +|---|---|---| +| SEC-01 进程身份 | 核对 Host 和受限进程的实际 UID/GID、进程身份、能力和附加组。 | 核对两个进程的实际 SID、Administrators 组及调试等高权限。 | +| SEC-02 密钥、配置和产物清单 | 测试各文件的读取、复制、打开写权限、删除、移走和替换。验证 Host 签名仍可用。 | 同样的文件测试及实际文件/目录 DACL。 | +| SEC-03 SQLite、WAL、SHM 和备份 | 数据库打开期间验证六类访问被拒。Host 关闭后重新打开主库和备份做完整性检查,并核对受保护文件的字节摘要。 | 同样的文件和完整性测试。 | +| SEC-04 进程访问 | 验证 Host 进程 fd、环境和内存入口的读取被拒,并检查无副作用的信号权限探测。 | 验证内存、线程创建和句柄复制权限请求被拒,验证不可获取可复制或可分配的 Host Token。探针只请求并关闭测试句柄。最终部署还需验证模型先于 Host 运行时的启动保护。 | +| 句柄范围 | 实际枚举受限进程的 `/proc/self/fd`,校验没有 Host 私有文件,原生启动链仍待验证。 | 当前启动器分别启动两个账户,不向受限进程提供 Host 私有句柄。系统级子进程句柄表仍待在最终启动方式下验证。 | +| SEC-17 Linux 实际受限进程 | 验证跨 UID 的脚本进程和 OS 拒绝。原生模型进程与可信来源仍待 G07 以后验证。 | 不适用 Linux UID 条款。 | + +Linux 的 47 次、Windows 的 43 次文件和 Linux 进程拒绝由测试计数校验。Windows 另有 6 次进程或 Token 权限请求。两边都有公开任务可读的正例。Windows 测试没有部署 Windows Service,服务控制权限要在采用该部署方式时单独验证。 + +以当前 GitHub CI 的 `Verify the disposable Linux Host profile` 和 `Verify the disposable Windows Host profile` 实测输出为准。只做语法检查时,不能把对应平台记为通过。G05–G06 证据用于 OS 隔离,原生 N 级、NAT-14 和 G07 保留现有阻断。 diff --git a/docs/evidence/p0a-g07-g12-20261113/README.md b/docs/evidence/p0a-g07-g12-20261113/README.md new file mode 100644 index 0000000..8e351aa --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/README.md @@ -0,0 +1,34 @@ +# G07–G12 独立验收与待裁决项 + +后续 Windows/Linux Codex 实测与当前发布裁决见 [P0-A 发布验收矩阵](./release-matrix.md)。下文保留初次 G07–G12 批次的现场和裁决。 + +本批从 Draft [#22](https://github.com/devcodex-labs/flowkit/pull/22) 的 `57a39c84934071450fadbe238dcc475f50b84d30` 开始。依据 [G01 的冻结契约矩阵](../p0a-g01-20261112/traceability.md)、[G03 的真实能力记录](../p0a-g03-20261112/README.md)、[G04 的裁决清单](../p0a-g04-20261112/README.md) 和 [G05–G06 双账户证据](../p0a-g05-g06-20261112/README.md)。本批测试调用真实 FlowKit JS、Host、SQLite 及独立 Node 进程,模型调用次数为 0。历史模型结果在本批只做重放,原始证据等级不变。 + +| 阶段 | 裁决 | 已验证事实与继续条件 | +|---|---|---| +| G07 原生适配器 | BLOCKED | 历史 G03 证明小内联内容可观察,64 KiB、256 KiB 和近 2 MiB 文件调用的事件只提供路径。先选择目标宿主和入参协议,取得受保护的真实开始事件及签前字节绑定。 | +| G08 Host 观察索引和 Outbox | BLOCKED | 依赖 G07 提供实际受信事件。P 级的 SQLite 历史 Ack、R11 和崩溃恢复可复验。OBSERVED/SIGNED/ACKED 持久化与真实签后、CAS 前崩溃保持开放。 | +| G09 候选通道和签名集成 | BLOCKED | 现有 Ed25519 验证、租户和最小披露检查通过脚本验收。双账户 CI 证明测试配置中的 OS 隔离。实际候选通道、生产启动链、Windows 句柄表及适用的服务控制仍待实测。 | +| G10 NAT-01–15 | BLOCKED | NAT-01–12、14–15 缺实际宿主链,NAT-13 的供应商可独立验证证明也未取得。C12/S13/T27 的原生验收仍开放。 | +| G11 C01–C24 | 独立部分完成 | [矩阵](./matrix.md) 逐项列出可复现的 P 级结果及 M/T/N 缺口。新增 C20 的真实双 Node 进程竞争和 R11 的多阶段丢 Ack 恢复。 | +| G12 P0-A/M6 | 独立部分完成,整体 BLOCKED | 补齐 2,000 次以上的 R12 账本测量和最低版本 TypeScript 的已安装 Interactive 包验证。G04 的契约裁决、真实安全宿主、适用的正式验收和独立复审仍决定 P0-A 的退出。 | + +### 本批增量证据 + +- **R11(C06/C15)**。测试在 SQLite 提交后丢弃 CAS 响应。另一条独立连接恢复并推进到终态。重放原收据返回首次接纳的 revision 6 和最新 revision 34,Journal 仍是 35 条,Checkpoint 与 Journal 无额外写入。该回归覆盖现有 SQLite 的重放恢复路径,R03 中弱一致自定义 Store 的拒绝仍随 CF-06 待裁决。 +- **C20**。两个不同 PID 的 Node 进程经测试屏障提交不同的有效 Ed25519 签名结果。一个提交成为该 Pending 的唯一持久收据,另一进程得到 `FK_I_RECEIPT_CONFLICT`。真实生成程序继续推进到终态。 +- **R12 / A-M6-06**。同一份 300-Agent 生成 JS 在预算 1 和 128 下分别完成 5,104 和 2,102 次成功 CAS。终态 State 摘要相同。重开后全部 300 条回执仍可查询,最近回执缓存为 16,最早 Ack 可原样重放。Windows 本地样本的累计序列化 Checkpoint 为 131,096,840 / 53,999,464 B,采样 WAL 高水位约 4.19 MB,关库后 WAL 为 0。脚本记录提交次数、延迟分位、Checkpoint/Journal/Receipt 大小、数据库和 WAL 采样。逻辑序列化量和采样文件长度用于量化存储成本,设备层实际写入量与生产 SLA 仍未测量。 +- **S14 / A-M6-02**。包验证从实际安装的 tarball 使用最低 TypeScript 5.0.4 导入 `@devcodex-labs/flowkit/interactive`。正例编译真实声明,负例检查公开终态的 State 不可读、结果引用拒绝错误协议版本。Standard、ESM/CJS、CLI、类型及 docs 消费仍由原有验证覆盖。 + +### 集中裁决 + +| 待决定项 | 建议和实际影响 | +|---|---| +| 第三批原生宿主目标 | 在保留 Grok 为冻结目标、等待明确授权后做真实复验,与批准新宿主并重新冻结等价验收条款之间选择。当前两种路径都需要真实 Pending、签前输入和会话绑定、独立 OS 身份及终态证据。 | +| 大入参可信协议 | 优先选择宿主可独立观察完整规范字节的事件模式。若目标只给文件引用,可单独批准并评审由受保护 Host 固定不可变字节和摘要的协议,验证同字节、时序、超限、替换和失配。 | +| CF-01 管理终止 | 在 R5 的版本化 Store 迁移和独立管理审计,与批准现有 Journal 承载等价管理审计之间选择。两者都需处理旧版读取 `cancelled` 和竞态。 | +| CF-02/05 SDK 与公开帧 | 推荐保留旧 factory 作为可信 Host 入口,新增 R5 顶层 API 和通用安全帧。若要退役旧帧,需要明确调用方的迁移窗口。 | +| CF-06 Store | 推荐先加 R5 异步五操作与独立 Registry 接缝,兼容同步 factory。统一迁移则需明确版本和旧 Store 的退场方案。 | +| ABI-01 转移身份 | 推荐旧 Run 保留原公式,新 Run 用版本化 R5 公式。历史迁移需要另审对账与回执兼容。 | + +跨 Node/OS 搬移同一数据库仍按 C21 原定义属于增强项。正式验收及复审完成之前,PR 保持 Draft。 diff --git a/docs/evidence/p0a-g07-g12-20261113/matrix.md b/docs/evidence/p0a-g07-g12-20261113/matrix.md new file mode 100644 index 0000000..e01579f --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/matrix.md @@ -0,0 +1,34 @@ +# G11 · C01–C24 验收矩阵 + +后续真实 Codex 结果与当前发布裁决见 [P0-A 发布验收矩阵](./release-matrix.md#c01c24-与阶段收口)。 + +源场景见 [C01–C12](../../interactive-complex-acceptance-c01-c12.md) 和 [C13–C24](../../interactive-complex-acceptance-extension-c13-c24.md)。本批每行实时模型调用数均为 **0**。P 指实际执行生成 JS、Host/Store 或指定 OS 测试。历史 ChatGPT 结果重放单独列出。未实测的原生证据均保持 BLOCKED。 + +测试文件缩写:`complex` 为 `tests/interactive/complex-c01-c12.test.mjs`,`crash` 为 `tests/interactive/crash.test.mjs`,`m6` 为 `tests/integration/m6-e2e.test.mjs`。本地证据包的 `matrix.tap` 记录原始测试名、输出和数值,`verify.stdout` 记录包与类型消费者,GitHub Actions 保存 Windows/Linux OS 子项及本批 CI。 + +| 场景 | 本批实际 P 证据 | M / T / N 及再入条件 | +|---|---|---| +| C01 串行 4 Agent + 2 Conditions | PASS,complex C01。4 签名收据,35 Journal。 | 历史五条 ChatGPT 结果重放 PASS,原生会话和受保护来源仍 BLOCKED。 | +| C02 条件 false、缺输入和拒绝分支 | PASS,complex C02 和 m6 false 条件,未到达 Agent 没有派发。 | BLOCKED,真实模型拒绝分支。 | +| C03 不合格输出和恢复 | PASS,complex C03,attempt 1/2,候选不污染 State。 | 历史受控无效输出及后续结果重放 PASS,原生来源 BLOCKED。 | +| C04 重试耗尽 | PASS,complex C04,最后 FK_R004。 | BLOCKED,真实持续失败和完整工具日志。 | +| C05 Host 强制退出和恢复 | PASS,crash 真实子进程切点,m6 发布后和 Worker 返回后两条恢复。 | BLOCKED,真实宿主会话中途 kill/restart。 | +| C06 双进程、丢响应和重复回执 | PASS,crash 真实双进程与本批 R11,首次 revision 6、最新 34。 | BLOCKED,真实双模型会话竞争。 | +| C07 Program、Skill、Policy 漂移 | PASS,m6 S06、stage2-integration 绑定漂移、trusted-host。 | BLOCKED,宿主/模型升级重新授权和恢复。 | +| C08 权限、签名、泄漏 | PASS,trusted-host 的错租户、伪签、白名单及 CF-05。G05–G06 有两个独立 OS 账户的测试配置。 | BLOCKED,实际候选通道和原生会话、生产启动链。 | +| C09 Checkpoint 容量 | PASS,s07-capacity、host-oversize、m6-ledger-boundary。 | BLOCKED,真实模型的大输出和传输事件。 | +| C10 长链和持久回执 | PASS,36 Agent 全量历史查询,R12 另验证 300 Agent。 | 此场景以 P 为验收范围,宿主接入另见 C12。 | +| C11 阶段预算 1 与 128 | PASS,complex C11、m6、R12 比较终态 State。 | 此场景以 P 为验收范围。 | +| C12 原生工具闭环 | 原生验收适用 G10。脚本 Host 只记录支持证据。 | BLOCKED,真实原生会话、独立工具开始事件和签名/Store 闭环。 | +| C13 分支 × 预算 | PASS,complex C13 复跑。 | P 场景。 | +| C14 链中错误或超限回执 | PASS,complex C14,五类拒绝,Checkpoint/Journal 保持相同。 | 原生入口同类拒绝在 G09/G10 待测。 | +| C15 旧 Ack 重传 | PASS,complex C15,R11 增补提交后丢响应与后续推进。 | 原生 Outbox 恢复见 G08/G10。 | +| C16 可信 Provider 失败回执 | PASS,complex C16 的本地签名失败回执及有界重试。 | 真实 Provider 失败 BLOCKED。 | +| C17 链中 policy drift | PASS,complex C17,三个拒绝入口。 | P 场景,宿主实际升级待 G10。 | +| C18 多 Run 交错 | PASS,complex C18,重复 startKey 幂等、冲突拒绝。 | P 场景,多模型会话见 C23。 | +| C19 36 Agent 第 18 步失败 | PASS,complex C19,未分发后 18 步。 | P 场景。 | +| C20 两个不同有效签名提交 | PASS,complex C20 原测试加两个独立 Node PID 和一条 receipt CAS。 | 真实模型会话竞争 BLOCKED。 | +| C21 崩溃和跨版本恢复 | PARTIAL,crash 同环境重开 PASS。 | 跨 Node/OS 同物理文件转移未执行,按原定义保留为增强项。 | +| C22 模型自报权限 | PASS,trusted-host 授权/披露和错租户,G05–G06 测试账号隔离。 | BLOCKED,真实模型、实际候选通道及实际原生启动链。 | +| C23 真实模型异常组合 | C02/C04/C05/C06/C20 的 P 子项可分别重现。 | BLOCKED,真实失败/重试、强杀恢复和竞争提交的联合证据。 | +| C24 兼容和验收门禁 | 本地 verify、实际 tarball/CLI/最低版本 TS 和 docs 消费记录,跨平台状态见本提交 CI。 | BLOCKED,原生 C12/S13 与正式 P0-A 审查。 | diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md new file mode 100644 index 0000000..4be96a1 --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -0,0 +1,138 @@ +# P0-A 发布验收矩阵 + +[Draft #22](https://github.com/devcodex-labs/flowkit/pull/22) 的汇总基线为 `32fec01c22a90f02535456cf099e31f7c8b0d744`,[基线 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43370955427) 8/8 通过。Requirements V0.3、Technical Design V0.6-R5、P0-A V0.1 的本地原件 SHA-256 均与 [G01 冻结登记](../p0a-g01-20261112/verified-inputs.txt) 一致。适用条款见 [SEC/NAT 原矩阵](../../interactive-trusted-host-security-v2-acceptance.md)、[C01–C12](../../interactive-complex-acceptance-c01-c12.md)、[C13–C24](../../interactive-complex-acceptance-extension-c13-c24.md) 和 [T/R/S 与 31 项计划追踪表](../p0a-g01-20261112/traceability.md)。 + +本次回填既有证据。**PASS** 适用于该行注明的样本和环境。**BLOCKED** 表示完整条款尚缺实测或正式裁决,已通过的子项仍保留。**N/A** 限于本批已核对的配置或发布范围。P 为程序与 OS 测试,M 为真实模型执行,T 为受保护通道中的独立观察,N 为完整原生可信验收,V 为供应商可独立验证的证明。以下原生样本为 P/M/T。Windows 与 Linux 各自在表中记录的同一候选上运行,采用各自的 Codex 版本和独立普通账户。 + +## 原始证据索引 + +| 编号 | 已实测提交 | 场景与原始记录 | +|---|---|---| +| E1 | `d9dcc0067b5d24e8a91bbe459256e99d9f285428` | 双账户普通闭环、256 KiB 文件引用、近 2 MiB 文件引用。两端各三例,接纳 revision 4、终态 9、Journal 10 条。[命令、协议日志归档和 SHA-256](../p0a-codex-isolated-20261113/live-samples.md) | +| E2 | `81a4c6e4400f95ca5dc0b771342411d0f587a464` | 两个真实会话竞争同一 Pending,唯一接纳。签后、CAS 前强杀,恢复以原签名和回执完成接纳。[日志和归档哈希](../p0a-codex-isolated-20261113/outbox-live.md#实测结果) | +| E3 | `f01f2b5c008bc3f9dba32ed72d8a5ddca7b01115` | CAS 后、Outbox `ACKED` 前强杀。两个文件大小在入口原件删除后从私有快照恢复。[日志和归档哈希](../p0a-codex-isolated-20261113/outbox-live.md#cas-后强杀和文件引用恢复) | +| E4 | `ddf8c688abaa6450c615b81e3bd0daaceaed53b8` | 签名前强杀、未接纳回执过期、仍受信旧钥的过期回执返回原 Ack。[日志和归档哈希](../p0a-codex-isolated-20261113/outbox-live.md#签名前强杀过期和轮换) | +| E5 | `ab931afe0699ca89d9f5f29aea8263caf1031cba` | 移除旧公钥后拒绝加载、恢复和旧回执重放。Store 原 Ack 和 Outbox 记录仍在。新策略下新 Run 通过。[两端日志、验签和归档哈希](../p0a-codex-isolated-20261113/revocation-retention.md#实测结果) | +| E6 | `57a39c84934071450fadbe238dcc475f50b84d30` | Windows/Linux 一次性双账户 OS 负例,包含密钥、SQLite/WAL/SHM/备份、进程权限及完整性检查。P 级。[运行命令和平台结果](../p0a-g05-g06-20261112/README.md) | +| E7 | `eb5823094cdee0f5d8fe8a558a9824e1c712db4d`、`7acdfdd0137bc34cd4407ee9ffb8c2fa1e5a18a7`、`9584184d7098a402a15f3edd8bfb11ea95f7ed49` | 64 KiB 同账户真实内联成功。256 KiB 和近 2 MiB 的内联试验均未发出工具调用。文件替换、链接、截断、摘要和重复消费有定向 P 级回归。[内联记录](../p0a-admin-codex-20261113/README.md) · [文件入口记录](../p0a-codex-file-fallback-20261113/README.md) | + +## SEC · 生产配置与候选入口 + +| 条款 | 当前裁决 | 证据和仍需验证的内容 | +|---|---|---| +| SEC-01 | PASS,双平台测试配置 | E1 记录实际普通 Host 和 Model 的不同 SID 或 UID/GID,以及真实 App Server 身份。生产部署按最终选定配置复核。 | +| SEC-02 | BLOCKED,完整原生负例 | E6 两端保护密钥、配置和 Program 清单的路径访问,Host 签名成功。E1 有标准流继承限制。尚缺生产启动链下实际 Model 进程对密钥及可继承句柄的拒绝证据。 | +| SEC-03 | BLOCKED,完整原生负例 | E6 两端拒绝数据库、WAL、SHM、备份的路径访问和修改,完整性通过。补实际 Model 进程句柄或映射和实际配置下的保护证据。 | +| SEC-04 | BLOCKED,最终启动链 | E6 Linux 进程访问、Windows 进程和 Token 权限请求拒绝。补生产启动顺序下的进程权限负例和实际启用的服务控制。 | +| SEC-05 | BLOCKED,候选入口授权 | E1 真实 stdio 工具通道正常,E7 有错请求、错会话、缺观察的 P 级负例。补实际启用的 stdio、文件入口及其启动入口的错主体、错租户、无观察证据,断言零签名和零 CAS。 | +| SEC-06 | BLOCKED,真实入口拒绝 | 补独立 Model 账户用普通进程重放同一提交的拒绝结果,核对没有 Host 签名、接纳索引或状态推进。 | +| SEC-07 | N/A,当前配置无 TLS/mTLS IPC | 实测为受控 stdio,回环 SSH 负责启动。实际输入路径按 SEC-05 验收。 | +| SEC-08 | BLOCKED,原生身份和正文负例 | E7 有替换、截断、错 Pending 与会话的定向回归,E5 有真实策略不匹配拒绝。补实际候选入口逐项变更 Run、request、attempt、node、Program、policy 和正文,核对无写入及错误脱敏。 | +| SEC-09 | BLOCKED,真实自报身份拒绝 | E1/E2 从受保护通道取得调用 ID,由 Host 分配证明。补实际 Model 自报 proof/session 或审批字段的拒绝日志。 | +| SEC-10 | BLOCKED,完整切点矩阵 | E2/E3/E4 的签名前、签后 CAS 前和 CAS 后强杀恢复通过。发布 Pending 前后等其余适用切点按 T29 和故障矩阵核对,缺原生证据的切点保持 BLOCKED。 | +| SEC-11 | BLOCKED,完整披露矩阵 | E1 使用含 Host 私有标记的任务并走公开投影,现有 P 级回归独立检查结果读取权限。补任务、工具回复、终态和生产日志全路径标记检查。 | +| SEC-12 | BLOCKED,真实资源边界 | E1 文件入口两个大小通过。E7 内联 64 KiB 通过,两档更大内联无工具调用。补实际入口异常正文、限流/超时及内联回执上限分类。 | +| SEC-13 | BLOCKED,完整替换负例 | E7 的文件替换、硬链接、符号链接或目录联接点有 P 级证据。E5 的旧策略拒绝有双平台原生结果。补实际启动链下 Program 和所有已启用配置入口的替换负例。 | +| SEC-14 | BLOCKED,生产轮换与撤销 | E4 验证仍受信的两把钥轮换和原 Ack。E5 验证仅信任新钥时旧 Run 拒绝、旧索引保留。补实际权限变更及按部署配置适用的证书/KMS 操作、旧 Run 处置和撤销后历史对账。 | +| SEC-15 | BLOCKED,真实跨租户并发 | E2 是同租户、同 Pending 的两个会话。现有脚本测试有租户拒绝。补实际入口两个租户同 startKey 和 Run 标识的隔离证据。 | +| SEC-16 | N/A,当前 Windows 配置无 Named Pipe 候选入口 | 当前使用标准流。若发行选择 Named Pipe,按原矩阵重新裁决。 | +| SEC-17 | BLOCKED,Linux 实际 CLI 拒绝 | E6 记录受限进程的 47 项文件和进程权限拒绝,E1 记录真实 Codex 同 PID 启动、不同 UID/GID 和零能力。补真实 CLI 的相应拒绝日志。Windows 不适用。 | +| SEC-18 | PASS,双平台 Outbox 样本 | E2/E3/E4 覆盖签名前、签后 CAS 前及 CAS 后 `ACKED` 前的真实强杀、原字节验签与对账、未接纳过期拒绝和仍受信的历史 Ack。E5 单列移除公钥后的拒绝和账本保留。 | +| SEC-19 | BLOCKED,生产有界保留 | E4/E5 核对重启后原记录仍在。补期限、容量、清理、合规保留和清理后 Ack/Journal 复核,见下方待确认方案。 | + +## NAT · Codex 等价验收 + +以下裁决适用于已确认的 Codex App Server 候选。原生 Grok 的完整验收沿用 [G03 的 PARTIAL/BLOCKED](../p0a-g03-20261112/README.md)。ChatGPT 产品专项仍按 T27 独立取证。 + +| 条款 | 当前裁决 | 证据和仍需验证的内容 | +|---|---|---| +| NAT-01 | BLOCKED,未授权启动负例 | E1 的真实 `flowkit_start` 使用 Host 预配置 nonce、Program 和任务。补错误 nonce、未授权 Program 或 Run 的真实拒绝和零写入。 | +| NAT-02 | PASS,样本 Public Task | E1 真实工具取得当前 Pending,日志关联 request、任务摘要和 Program。全路径披露按 SEC-11 验收。 | +| NAT-03 | PASS,小内联模型结果 | E1 普通闭环中模型真实生成并提交答案。两档大文件正文由受信合成生产者提供,模型真实提交引用。 | +| NAT-04 | PASS,小内联与已批准文件引用样本 | E1 签前核对开始和回调的 thread/turn/call、规范参数;E2/E3 导出原签名。完整大内联、缺开始事件和失配负例分别随 NAT-10、NAT-14 和 SEC-08 裁决。 | +| NAT-05 | BLOCKED,完整真实续跑与可信读取 | E1 中程序在接纳后达到终态,真实 `flowkit_continue` 读取公开终态。补有非平凡分支或下一 Pending 的真实多阶段续跑,以及单独受权的完整结果读取。 | +| NAT-06 | BLOCKED,普通进程重放 | 对应 SEC-06。补绕过实际 Codex 通道时的拒绝、零签名、零 CAS。 | +| NAT-07 | BLOCKED,自报来源 | 对应 SEC-09。补伪造来源字段无法替代独立观察的真实入口负例。 | +| NAT-08 | PASS,强杀恢复与新会话重试样本 | E4 签名前强杀后原 `OBSERVED` 不自动接纳,新会话使用新 call ID 对同一 Pending 成功重试。E2 单独验证使用原证明的自动恢复。 | +| NAT-09 | PASS,两个真实会话争同一 Pending | E2 两个独立原生回调、不同签名,唯一 `ACKED`、另一条 `CONFLICT`,Store 只有一个接纳索引。 | +| NAT-10 | BLOCKED,真实失败与传输边界 | E7 的大内联未发出工具调用,E1 已通过文件回退。补真实断线、供应商失败、非法输出和重试耗尽,核对错误分类与有界退出。 | +| NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | +| NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | +| NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | +| NAT-14 | BLOCKED,完整时序与死锁负例 | E1/E2/E3 正常路径已观察到原生开始事件、匹配规范入参后才签收。补真实候选入口缺失、迟到或失配开始事件,以及有界超时、零签名、零 CAS 的可复验记录。 | +| NAT-15 | PASS,双平台 Codex 实测样本 | E2 用原签名、回执和同一 Run 恢复签后 CAS 前切点。E3 增补文件引用与 CAS 后 Ack 修复,E4 增补真实过期拒绝。完整 N 级随 C12/S13 的组合证据复审。 | + +## C01–C24 与阶段收口 + +[原 G11 矩阵](./matrix.md) 记录各项 P 级测试。本表回填后续真实 Codex 证据,按原条款适用范围给出当前发布裁决。 + +| 条款 | 当前裁决与证据 | +|---|---| +| C01 | BLOCKED,E1 是单 Agent 闭环。缺真实 4 Agent+2 Conditions 的原生记录。 | +| C02 | BLOCKED,条件 false 有 P 证据。缺真实模型拒绝和未派发路径的组合记录。 | +| C03 | BLOCKED,有受控无效结果重放。缺原生非法输出、按同一 Run 重试并恢复。 | +| C04 | BLOCKED,重试耗尽有 P 证据。缺真实持续失败及完整工具日志。 | +| C05 | BLOCKED,E2/E3/E4 的原生强杀和恢复样本 PASS。Pending 发布及纯校验前后的完整原生故障切点归 SEC-10/T29。 | +| C06 | PASS,E2 双会话竞争和 E3 已 CAS 但未记 Ack 的恢复样本。R11 的后续多阶段丢响应有 P 证据。 | +| C07 | BLOCKED,E5 补实策略漂移拒绝。缺真实模型/宿主升级和重新授权。 | +| C08 | BLOCKED,E1/E6 补普通账户真实启动和 P 级权限拒绝。生产路径和真实披露负例按 SEC-02–06、SEC-08/11。 | +| C09 | BLOCKED,E1 补双平台大文件引用,Checkpoint 容量有 P 证据。缺完整大内联和真实异常输出的容量分类。 | +| C10 | PASS,P 级长链与持久回执,含 300 Agent 账本测量。 | +| C11 | PASS,P 级预算 1 与 128 的终态对照。 | +| C12 | BLOCKED,E1–E5 补齐多个原生子样本,完整 SEC/NAT 和来源链待复审。 | +| C13 | PASS,P 级分支与预算。 | +| C14 | PASS,P 级链中错误与超限拒绝。原生入口负例归 SEC-08/12。 | +| C15 | PASS,P 级历史重传及 E3/E4 的原生历史 Ack。撤销后的独立对账另列。 | +| C16 | BLOCKED,P 级可信失败路径已有。缺真实供应商失败。 | +| C17 | PASS,P 级策略漂移拒绝;E5 补同类原生拒绝样本。 | +| C18 | PASS,P 级多 Run 交错。 | +| C19 | PASS,P 级长链中途失败。 | +| C20 | PASS,原 P 级双进程竞争及 E2 真实双会话竞争。 | +| C21 | 同环境重开 PASS(P)。跨 Node/OS 搬移同一物理库 BLOCKED,属于增强项。发布支持的升级另在 M6 核对。 | +| C22 | BLOCKED,E1/E6 补独立身份。缺实际入口模型自报权限、伪审批及跨租户负例。 | +| C23 | BLOCKED,E2/E3/E4 补真实竞争和崩溃恢复。缺真实拒绝、非法输出重试耗尽和供应商失败的组合证据。 | +| C24 | BLOCKED,Windows/Linux × Node 22/24、包/CLI/类型和文档消费者 CI 已通过。完整原生证据、发布支持的升级与独立复审仍待完成。 | + +| 冻结条款或计划项 | 后续证据对 G01 的更新 | +|---|---| +| T03、S02、T29 | E2–E4 补真实 Host 进程退出、同 Run 与原回执恢复。T29 的适用切点及 S02 的完整裁决仍待复审。 | +| T05、S05 | E5 补实际策略不符拒绝,E7 提供文件入口 P 级错请求/会话拒绝。真实入口的完整身份负例随 SEC-05/08 保持 BLOCKED。 | +| T06、T09、S04、R11 | E2/E3/E4 补同 Pending 并发、CAS 后丢 Ack 恢复及旧 Ack 样本。SQLite 已有后续多阶段丢响应的 P 级回归,外部 Store 仍须证明其声明的强一致和原子语义。 | +| T07、S06 | E5 补实公钥集合变更时的策略不匹配拒绝。支持的 SDK、Checkpoint 和 Store 升级组合仍待逐一裁决。 | +| T08、S07、T34 | E1/E7 补真实大文件引用、字节固定与路径负例的分层证据。生产资源上限、异常输出分类与全路径最小披露仍为 BLOCKED。 | +| T11、T27、S13 | E1–E5 补 Codex 真实 JS 调用与独立来源链。完整 C12/NAT 尚未签收。ChatGPT 产品专项和 Grok 原生项继续 BLOCKED。 | +| S12-A | 管理终止专用审计和 SQLite V2 迁移有 P 级正反例。生产升级与恢复演练待发行候选。 | +| T12、T28、T33、S14、A-M6-01–03 | 包导出、类型、CLI、Standard 和现有 Interactive 适用回归已在四矩阵通过。T28 的 Checkpoint 契约差异及正式复审仍开放。T33 按 P0-A 支持节点裁决。 | +| R03、A-M6-04 | 异步接缝有缺持久证据时返回 `FK_I_STORE_UNCERTAIN` 的回归。E2–E4 增补原生重启证据。外部 Store 的陈旧读、强一致及部署恢复边界以实际适配器取证。 | +| R12、A-M6-06 | P 级 300 Agent 两档预算完成 5,104/2,102 次 CAS,重开可查询全部回执并返回最早 Ack。保留已测的序列化与 WAL 样本范围。 | + +- **G07–G08。** Codex 候选入口、签前绑定、双账户闭环和 Outbox 崩溃恢复样本已有实测。生产有界保留和候选入口完整负例为 BLOCKED。 +- **G09–G11。** 签名接纳、唯一 CAS 与恢复已有双平台实测。完整安全矩阵、C12/S13/T27、C23 和 N 级裁决为 BLOCKED。 +- **G12/M6。** 见 [冻结追踪表](../p0a-g01-20261112/traceability.md)、[兼容实现](../p0a-compat-codex-20261113/README.md) 和 [管理终止](../p0a-admin-codex-20261113/README.md)。待核对同一发行候选上的适用 T/R/S 和计划条款、Checkpoint 契约裁决、支持的升级与回滚、发布 Host 的生产验收及独立复审。P0-B/P1/P2 条款按原计划阶段判定 N/A。 +- **G17。** 在 G12 收口后冻结发行范围与候选,验证发行包和安装,再按独立授权办理合并、Tag、发布和部署。 + +## 四项待确认契约 + +### 1. Checkpoint 格式 + +**推荐 P0-A 登记现有持久结构为明确的兼容例外。** 当前异步五操作承载现有 P0-A `RunCheckpointV1`,与 R5 §5 的嵌套 `program`、`policyIdentity`、`execution` 等字段有实质差异。发布契约应写清支持的 payload 及兼容矩阵,现有库和历史 Run 可继续使用。代价是接入方不能按 R5 全量结构直接互操作,差异需记入发行说明并留待独立复审。 + +**备选是本次发行前完成版本化对齐。** 冻结新的格式版本,明确旧读写方、双版本读取或离线迁移、历史回执与 Journal 的处理,再演练备份、升级和回滚。优点是发布契约与 R5 一致,代价是扩大 Store、类型和恢复回归范围。此项需要对格式及版本路径单独授权。 [当前类型声明](../../../types/interactive.d.ts) · [兼容实现范围](../p0a-compat-codex-20261113/README.md) + +### 2. 旧钥撤销后的历史 Ack + +**推荐撤销使旧钥停止签发和接纳,历史 Ack 走独立受权的只读对账。** 按 namespace、Run、request、node、attempt 和规范回执摘要精确匹配持久接纳记录,仅返回原接纳信息。找不到或不匹配时拒绝。发行验收须覆盖跨租户、摘要错配、未接纳旧回执、重复查询及零 CAS/Journal/Checkpoint 写入。代价是新增 Host 权限和审计面,收益是能在关闭旧信任后恢复丢失的 Ack。 + +**备选仅保留运维对账。** 由受权人员通过 Host 私有 Store 核对原接纳记录,接口面较小,调用方需要人工介入。E4 只证明旧钥仍受信时的过期重放。E5 证明移除旧钥后原接纳入口拒绝且账本仍在。 [当前撤销与对账记录](../p0a-codex-isolated-20261113/revocation-retention.md) + +### 3. 既有 Run 信任策略迁移 + +**推荐正常轮换让旧 Run 按原批准策略完成,新 Run 使用新策略。** 紧急撤销时停用仍允许旧钥的 Host,既有 Run 保持阻断并交受权人员对账。优点是已冻结的策略身份和历史可完整保留。代价是紧急撤销时在途 Run 可能无法继续。 + +**备选是本次发行支持受控原地续跑。** 先单独确认带版本、管理授权和审计的原子迁移协议,定义 Pending 重绑、旧签名处置、新钥签发、竞态、故障恢复及旧写入方拒绝。收益是保留同一 Run 的业务连续性。代价是跨策略的恢复和签名校验更复杂,需要重新审查不变量。 [Host 轮换设计](../../interactive-trusted-host-security-v2-design.md#63-错误和恢复) · [E5 现有行为](../p0a-codex-isolated-20261113/revocation-retention.md) + +### 4. Outbox 保留与容量 + +**推荐由 Host 部署配置设定审计期限和容量上限。** 保留待投递的 `SIGNED`、未对账的 `OBSERVED`/`BLOCKED`/`CONFLICT`、受合规保留或有效引用的快照,以及 Store 接纳索引。终态且已对账的数据过审计窗口后才清理可回收正文和快照。达到容量上限时停止接收需要落盘的新工作并告警。具体天数与字节上限按目标部署的合规、吞吐和磁盘预算确认,随后实测容量、清理崩溃恢复和历史对账。 + +**备选由受权人员在同一保留约束下执行清理。** 可减少首版后台清理逻辑,增加值守成本与容量告警响应时间。当前只验证记录留存及过期引用的局部行为。 [Outbox 设计 §6.4](../../interactive-trusted-host-security-v2-design.md#64-host-only-观察证据与签名回执-outbox关闭-dr-13) · [保留实测](../p0a-codex-isolated-20261113/revocation-retention.md) diff --git a/docs/interactive.md b/docs/interactive.md index 07dda2f..d281e4a 100644 --- a/docs/interactive.md +++ b/docs/interactive.md @@ -159,6 +159,14 @@ business-artifact persistence, file GC or Context module is added. public-task disclosure checks. This protects only a Host separated from model-controlled filesystem/database/credential access; see [Trusted Host security](./interactive-host-security.md). +- Keep the execution frames from `start()`, `resume()`, `load()`, and + `accept()` inside the trusted Host. To show a terminal status, call + `getPublicTerminalFrame({runId})`. It returns status and a versioned result + reference. Only a trusted caller should use + `readTrustedTerminalResult({resultRef})` for the full State. Configure + `authorizeTerminalAccess` in the Host from its authenticated session. + Grant `run:read` and `result:read` independently for the requested namespace + and Run. A result reference carries no permissions. - Store data is written with `node:sqlite` in WAL mode and `synchronous=FULL`, with every transition in one SQLite transaction. Do not place this reference Store on an unreliable/shared filesystem. diff --git a/examples/interactive/codex-file-handoff.mjs b/examples/interactive/codex-file-handoff.mjs new file mode 100644 index 0000000..610ac47 --- /dev/null +++ b/examples/interactive/codex-file-handoff.mjs @@ -0,0 +1,203 @@ +import {randomUUID} from 'node:crypto'; +import {closeSync,constants,openSync} from 'node:fs'; +import {lstat,open} from 'node:fs/promises'; +import path from 'node:path'; +import {DatabaseSync} from 'node:sqlite'; +import {canonicalBytes,sha256Bytes,assertSHA256,MAX_INLINE_AGENT_RESULT_BYTES} + from '../../src/interactive/canonical.mjs'; + +const MAX_BYTES=MAX_INLINE_AGENT_RESULT_BYTES; +const UUID=/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/; +const TOOL='flowkit_submit_file'; +const fail=code=>Object.assign(new Error(code),{code}); +const validId=value=>typeof value==='string' && UUID.test(value); +const label=value=>typeof value==='string' && value.length>0 && value.length<=256 && !/[\u0000-\u001f\u007f]/.test(value); + +function sameFile(named,opened) { + // Some Windows Node versions omit the device ID in lstat but report it on the open handle. + return named.ino!==0n && named.ino===opened.ino + && (named.dev===opened.dev || (process.platform==='win32' && named.dev===0n)); +} + +function bindingOf(value) { + const {namespace,runId,requestId,nodeId,attempt,threadId,turnId,programHash,policyHash}=value; + if (![namespace,runId,requestId,nodeId,threadId,turnId].every(label) || !Number.isSafeInteger(attempt) || attempt<1) { + throw fail('FK_FILE_BINDING'); + } + assertSHA256(programHash); + assertSHA256(policyHash); + return {namespace,runId,requestId,nodeId,attempt,threadId,turnId,programHash,policyHash}; +} + +async function capture(file,size,hash) { + const entry=await lstat(file,{bigint:true}); + if (!entry.isFile() || entry.nlink!==1n) throw fail('FK_FILE_SOURCE'); + const flags=constants.O_RDONLY | (process.platform==='win32'?0:(constants.O_NOFOLLOW??0)) + | (constants.O_NONBLOCK??0); + const handle=await open(file,flags); + try { + const before=await handle.stat({bigint:true}); + const named=await lstat(file,{bigint:true}); + if (!before.isFile() || !named.isFile() || before.nlink!==1n || named.nlink!==1n || entry.dev!==named.dev + || !sameFile(entry,before) || !sameFile(named,before)) throw fail('FK_FILE_SOURCE'); + if (before.size>BigInt(MAX_BYTES)) throw fail('FK_FILE_LIMIT'); + if (before.size!==BigInt(size)) throw fail('FK_FILE_LENGTH'); + const bytes=Buffer.alloc(size+1); + let length=0; + while(lengthlabel(started[key]) && started[key]===callback[key])) { + throw fail('FK_FILE_OBSERVATION'); + } + let args; + try { + const observed=canonicalBytes(started.arguments,2048); + if (!observed.equals(canonicalBytes(callback.arguments,2048))) throw fail('FK_FILE_OBSERVATION'); + args=JSON.parse(observed); + } catch {throw fail('FK_FILE_OBSERVATION');} + if (!args || Object.keys(args).length!==2 || !validId(args.id) || !label(args.requestId)) { + throw fail('FK_FILE_REFERENCE'); + } + return {...args,threadId:started.threadId,turnId:started.turnId,callId:started.callId}; +} + +/** Host-only adapter. The provisioner owns the private DB and the ingress directory. */ +export function createFileHandoff({database,inbox,validateArtifact,now=Date.now}) { + if (!path.isAbsolute(database) || !path.isAbsolute(inbox) || typeof validateArtifact!=='function' + || typeof now!=='function') throw new TypeError('Host file handoff configuration required'); + try {closeSync(openSync(database,'wx',0o600));} + catch(error) { + if (error.code!=='EEXIST') throw error; + // A reopened database is allowed only inside the protected Host directory. + } + const db=new DatabaseSync(database); + try { + db.exec(` + PRAGMA busy_timeout=15000; + PRAGMA journal_mode=WAL; + PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS file_handoff ( + id TEXT PRIMARY KEY, namespace TEXT NOT NULL, run_id TEXT NOT NULL, + request_id TEXT NOT NULL, binding TEXT NOT NULL, + sha256 TEXT NOT NULL, size INTEGER NOT NULL, expires_at INTEGER NOT NULL, + bytes BLOB NOT NULL, state TEXT NOT NULL CHECK(state IN ('staged','claimed','acked')), + call_id TEXT, accepted_revision INTEGER, + UNIQUE(namespace,run_id,request_id) + ) STRICT; + `); + if (db.prepare('PRAGMA journal_mode').get().journal_mode!=='wal' + || db.prepare('PRAGMA synchronous').get().synchronous!==2) throw fail('FK_FILE_STORE'); + } catch(error) {db.close();throw error;} + const insert=db.prepare(`INSERT INTO file_handoff + (id,namespace,run_id,request_id,binding,sha256,size,expires_at,bytes,state) + VALUES (?,?,?,?,?,?,?,?,?,'staged')`); + const read=db.prepare('SELECT * FROM file_handoff WHERE id=?'); + const claim=db.prepare(`UPDATE file_handoff SET state='claimed',call_id=? + WHERE id=? AND state='staged' AND expires_at>?`); + const ack=db.prepare(`UPDATE file_handoff SET state='acked',accepted_revision=? + WHERE id=? AND state='claimed' AND call_id=?`); + const purge=db.prepare("DELETE FROM file_handoff WHERE expires_at<=? AND state='staged'"); + + function checkedBytes(saved) { + const bytes=Buffer.from(saved.bytes); + if (bytes.length!==saved.size || sha256Bytes(bytes)!==saved.sha256) throw fail('FK_FILE_STORE'); + return bytes; + } + + function acknowledge(id,callId,acceptedRevision) { + if (!Number.isSafeInteger(acceptedRevision) || acceptedRevision<1) throw fail('FK_FILE_UNCERTAIN'); + if (ack.run(acceptedRevision,id,callId).changes===1) return; + const saved=read.get(id); + if (saved?.state!=='acked' || saved.call_id!==callId || saved.accepted_revision!==acceptedRevision) { + throw fail('FK_FILE_UNCERTAIN'); + } + } + + return Object.freeze({ + close:()=>db.close(), + purgeExpired:()=>purge.run(now()).changes, + readSnapshot(id) { + if (!validId(id)) throw fail('FK_FILE_REFERENCE'); + const saved=read.get(id); + if (!saved) return null; + const bytes=checkedBytes(saved); + return {bytes,sha256:saved.sha256,byteLength:saved.size,state:saved.state,acceptedRevision:saved.accepted_revision}; + }, + // Host-only Outbox reconciliation. A claim without matching durable evidence cannot be reopened. + verifyClaim(artifact,binding,callId) { + if (!artifact || !validId(artifact.id)) throw fail('FK_FILE_REFERENCE'); + const saved=read.get(artifact.id); + if (!saved || !['claimed','acked'].includes(saved.state) || saved.call_id!==callId + || saved.size!==artifact.byteLength || saved.sha256!==artifact.sha256 + || !canonicalBytes(JSON.parse(saved.binding)).equals(canonicalBytes(bindingOf(binding)))) { + throw fail('FK_FILE_BINDING'); + } + checkedBytes(saved); + }, + acknowledge, + // Only a trusted ingress producer may declare the expected original bytes. + async stage({sourceId,binding:inputBinding,byteLength,sha256,ttlMs=120000}) { + if (!validId(sourceId)) throw fail('FK_FILE_SOURCE'); + if (!Number.isSafeInteger(byteLength) || byteLength<1 || byteLength>MAX_BYTES) throw fail('FK_FILE_LIMIT'); + assertSHA256(sha256); + if (!Number.isSafeInteger(ttlMs) || ttlMs<1 || ttlMs>300000) throw fail('FK_FILE_EXPIRY'); + const binding=Object.freeze(bindingOf(inputBinding)); + const expiresAt=now()+ttlMs; + const bytes=await capture(path.join(inbox,sourceId+'.json'),byteLength,sha256); + parseArtifact(bytes,validateArtifact,binding); + if (expiresAt<=now()) throw fail('FK_FILE_EXPIRY'); + const id=randomUUID(); + insert.run(id,binding.namespace,binding.runId,binding.requestId,JSON.stringify(binding), + sha256,byteLength,expiresAt,bytes); + return {id,requestId:binding.requestId}; + }, + async consume({started,callback,loadPending,signAndAccept}) { + const call=observedCall(started,callback); + const saved=read.get(call.id); + if (!saved) throw fail('FK_FILE_REFERENCE'); + if (saved.state!=='staged') throw fail('FK_FILE_CONSUMED'); + if (saved.expires_at<=now()) throw fail('FK_FILE_EXPIRY'); + const binding=Object.freeze(JSON.parse(saved.binding)); + if (['threadId','turnId','requestId'].some(key=>call[key]!==binding[key])) throw fail('FK_FILE_BINDING'); + const current=loadPending(binding.runId); + if (!current || current.status!=='waiting' || current.namespace!==binding.namespace || current.runId!==binding.runId + || current.artifactHash!==binding.programHash || current.policyHash!==binding.policyHash + || current.pending?.requestId!==binding.requestId || current.pending.nodeId!==binding.nodeId + || current.pending.attempt!==binding.attempt) { + throw fail('FK_FILE_PENDING'); + } + checkedBytes(saved); + if (claim.run(call.callId,call.id,now()).changes!==1) throw fail('FK_FILE_CONSUMED'); + const artifact=Object.freeze({id:call.id,byteLength:saved.size,sha256:saved.sha256}); + // A persisted claim never re-signs after an ambiguous or failed attempt. + const result=await signAndAccept({binding,callId:call.callId,artifact,pending:current.pending}); + acknowledge(call.id,call.callId,result?.acceptedRevision); + return result; + }, + }); +} diff --git a/examples/interactive/codex-outbox.mjs b/examples/interactive/codex-outbox.mjs new file mode 100644 index 0000000..18b9c58 --- /dev/null +++ b/examples/interactive/codex-outbox.mjs @@ -0,0 +1,154 @@ +import {randomUUID,sign} from 'node:crypto'; +import {closeSync,openSync} from 'node:fs'; +import path from 'node:path'; +import {DatabaseSync} from 'node:sqlite'; +import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import { + createAgentReceiptStatement,encodeAgentReceiptStatement,normalizeHostAgentReceipt,MAX_AGENT_RECEIPT_BYTES, +} from '../../src/interactive/host-attestation.mjs'; + +const fail=code=>Object.assign(new Error(code),{code}); +const identifier=value=>typeof value==='string' && value.length>0 && value.length<=256 + && !/[\u0000-\u001f\u007f]/.test(value); + +function decode(bytes,limit) { + const saved=Buffer.from(bytes); + const value=JSON.parse(saved.toString('utf8')); + if (!canonicalBytes(value,limit).equals(saved)) throw fail('FK_OUTBOX_CORRUPT'); + return value; +} + +/** A dedicated SQLite file in an operator-protected Host directory. */ +export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { + if (!path.isAbsolute(database)) throw new TypeError('Outbox requires an absolute database path'); + try {closeSync(openSync(database,'wx',0o600));} + catch (error) {if (error.code!=='EEXIST') throw error;} + const db=new DatabaseSync(database); + try { + db.exec( + 'PRAGMA busy_timeout=15000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;' + + "CREATE TABLE IF NOT EXISTS delivery (proof_id TEXT PRIMARY KEY, namespace TEXT NOT NULL," + + " run_id TEXT NOT NULL, request_id TEXT NOT NULL, thread_id TEXT NOT NULL, turn_id TEXT NOT NULL," + + " call_id TEXT NOT NULL, observation BLOB NOT NULL, receipt BLOB NOT NULL, receipt_hash TEXT NOT NULL," + + " statement BLOB, signature BLOB, accepted_revision INTEGER," + + " state TEXT NOT NULL CHECK(state IN ('OBSERVED','SIGNED','ACKED','CONFLICT','BLOCKED'))," + + ' UNIQUE(thread_id,turn_id,call_id)) STRICT;' + ); + if (db.prepare('PRAGMA journal_mode').get().journal_mode!=='wal' + || db.prepare('PRAGMA synchronous').get().synchronous!==2) throw fail('FK_OUTBOX_DURABILITY'); + } catch (error) {db.close();throw error;} + const get=db.prepare('SELECT * FROM delivery WHERE proof_id=? AND namespace=?'); + const insert=db.prepare("INSERT INTO delivery VALUES (?,?,?,?,?,?,?,?,?,?,NULL,NULL,NULL,'OBSERVED')"); + const save=db.prepare("UPDATE delivery SET statement=?,signature=?,state='SIGNED' WHERE proof_id=? AND state='OBSERVED'"); + const finish=db.prepare("UPDATE delivery SET state=?,accepted_revision=? WHERE proof_id=? AND state='SIGNED'"); + const signed=db.prepare("SELECT proof_id FROM delivery WHERE namespace=? AND run_id=? AND state='SIGNED' ORDER BY rowid"); + + function checkResult(tool,args,result,binding,callId) { + if (tool==='flowkit_submit_file' && handoff && Object.keys(result??{}).length===1 + && result.artifact && Object.keys(result.artifact).length===3 && result.artifact.id===args?.id) { + handoff.verifyClaim(result.artifact,binding,callId); + return; + } + if (tool!=='flowkit_submit' || typeof args?.answer!=='string' + || Object.keys(result??{}).length!==1 || result.answer!==args.answer) throw fail('FK_OUTBOX_OBSERVATION'); + } + + async function record({task,started,callback,modelPid},receipt,key, + {keyId='proof',ttlMs=120000,afterObserve}={}) { + const normalized=normalizeHostAgentReceipt(receipt); + const cp=store.load(normalized.runId); + const {threadId,turnId,callId,tool,args}=callback; + if (![threadId,turnId,callId].every(identifier) || normalized.kind!=='agent-result' + || !Number.isSafeInteger(modelPid) || modelPid<2 + || ['threadId','turnId','callId','tool'].some(field=>started?.[field]!==callback[field]) + || !canonicalBytes(started?.args,4096).equals(canonicalBytes(args,4096)) + || args.requestId!==normalized.requestId + || cp?.status!=='waiting' || cp.namespace!==store.namespace || task.runId!==normalized.runId + || task.requestId!==normalized.requestId || cp.pending?.requestId!==normalized.requestId + || cp.pending?.nodeId!==normalized.nodeId || cp.pending?.attempt!==normalized.attempt) { + throw fail('FK_OUTBOX_OBSERVATION'); + } + checkResult(tool,args,normalized.result,{...normalized,namespace:cp.namespace,threadId,turnId, + programHash:cp.artifactHash,policyHash:cp.policyHash},callId); + const proofId=randomUUID(); + const receiptBytes=canonicalBytes(normalized,MAX_AGENT_RECEIPT_BYTES); + const receiptHash=sha256Bytes(receiptBytes); + const observation=canonicalBytes({modelPid,threadId,turnId,callId,tool,namespace:cp.namespace, + runId:normalized.runId,requestId:normalized.requestId,nodeId:normalized.nodeId,attempt:normalized.attempt, + artifactHash:cp.artifactHash,policyHash:cp.policyHash,taskDigest:sha256Bytes(canonicalBytes(task)), + toolInputDigest:sha256Bytes(canonicalBytes(args,4096)),receiptHash,source:'HOST_OBSERVED'},4096); + insert.run(proofId,cp.namespace,normalized.runId,normalized.requestId,threadId,turnId,callId, + observation,receiptBytes,receiptHash); + if (afterObserve) await afterObserve(describe(proofId)); + const issuedAt=now(); + const statement=createAgentReceiptStatement({run:cp,receipt:normalized,keyId, + proofId,issuedAt,expiresAt:issuedAt+ttlMs}); + const statementBytes=encodeAgentReceiptStatement(statement); + const signature=sign(null,statementBytes,key); + if (save.run(statementBytes,signature,proofId).changes!==1) throw fail('FK_OUTBOX_STATE'); + return proofId; + } + + function describe(proofId) { + const row=get.get(proofId,store.namespace); + if (!row) throw fail('FK_OUTBOX_STATE'); + return {proofId,state:row.state,receiptHash:row.receipt_hash,acceptedRevision:row.accepted_revision, + ...(row.statement?{statementHash:sha256Bytes(row.statement),signatureHash:sha256Bytes(row.signature), + expiresAt:decode(row.statement,4096).expiresAt}:{})}; + } + + async function deliver(proofId,{afterAccept}={}) { + const row=get.get(proofId,store.namespace); + if (!row || !['SIGNED','ACKED'].includes(row.state)) throw fail('FK_OUTBOX_STATE'); + const receipt=decode(row.receipt,MAX_AGENT_RECEIPT_BYTES); + const statement=decode(row.statement,4096); + const observation=decode(row.observation,4096); + if (statement.proofId!==proofId || statement.namespace!==store.namespace + || receipt.runId!==row.run_id || receipt.requestId!==row.request_id + || statement.receiptHash!==row.receipt_hash || sha256Bytes(row.receipt)!==row.receipt_hash + || observation.receiptHash!==row.receipt_hash || observation.callId!==row.call_id + || observation.threadId!==row.thread_id || observation.turnId!==row.turn_id) throw fail('FK_OUTBOX_CORRUPT'); + const artifact=observation.tool==='flowkit_submit_file'?receipt.result?.artifact:null; + if (observation.tool==='flowkit_submit_file') { + if (!handoff) throw fail('FK_OUTBOX_OBSERVATION'); + handoff.verifyClaim(artifact,{...receipt,namespace:statement.namespace,threadId:row.thread_id, + turnId:row.turn_id,programHash:statement.artifactHash,policyHash:statement.policyHash},row.call_id); + } + const indexed=()=>store.getReceipt(row.run_id,row.request_id); + const matching=ack=>ack?.digest===row.receipt_hash && ack.nodeId===receipt.nodeId && ack.attempt===receipt.attempt; + const existing=indexed(); + if (!matching(existing) && now()>=statement.expiresAt) { + finish.run('BLOCKED',null,proofId); + throw fail('FK_OUTBOX_EXPIRED'); + } + let frame; + try { + frame=await host.accept({receipt,attestation:{statement,signature:Buffer.from(row.signature).toString('base64url')}}); + } catch (error) { + if (error.code==='FK_I_RECEIPT_CONFLICT') finish.run('CONFLICT',null,proofId); + throw error; + } + const ack=indexed(); + if (!matching(ack) || ack.acceptedRevision!==frame.acceptedRevision) throw fail('FK_OUTBOX_ACK'); + if (afterAccept) await afterAccept(ack); + if (artifact) handoff.acknowledge(artifact.id,row.call_id,ack.acceptedRevision); + finish.run('ACKED',ack.acceptedRevision,proofId); + const final=describe(proofId); + if (final.state!=='ACKED' || final.acceptedRevision!==ack.acceptedRevision) throw fail('FK_OUTBOX_ACK'); + return frame; + } + + return Object.freeze({record,describe,deliver,close:()=>db.close(), + async recover(runId) { + const results=[]; + for (const {proof_id:proofId} of signed.all(store.namespace,runId)) { + try {await deliver(proofId);} + catch (error) { + if (!['FK_OUTBOX_EXPIRED','FK_I_RECEIPT_CONFLICT'].includes(error.code)) throw error; + } + results.push(describe(proofId)); + } + return results; + }, + }); +} diff --git a/scripts/bench-interactive-ledger.mjs b/scripts/bench-interactive-ledger.mjs new file mode 100644 index 0000000..05191fb --- /dev/null +++ b/scripts/bench-interactive-ledger.mjs @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import {mkdtemp,rm,stat,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {performance} from 'node:perf_hooks'; +import {compileInteractive,createInteractiveCoordinator,createSqliteCheckpointStore} + from '../src/interactive/index.mjs'; +import {canonicalBytes,sha256Bytes} from '../src/interactive/canonical.mjs'; + +const dir=await mkdtemp(path.join(tmpdir(),'flowkit-ledger-measure-')); +const agents=300; +const nodes=Array.from({length:agents},(_,index)=>{ + const next=index+1===agents?'done':'agent'+(index+1); + return `node('agent${index}',{name:'Agent ${index}',agent:'writer',inputs:[], + output:{type:'object',properties:{answer${index}:{type:'string'}}},retries:0,next:'${next}'})`; +}); +const source=`import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl'; +export default defineWorkflow({id:'m6-ledger',name:'Ledger measurement',version:'1.0.0',nodes:[ +${nodes.join(',\n')}, +terminal('done',{name:'Done',status:'succeeded'}) +]});`; +const runtime={ + skillResolver:async()=> 'Write one bounded answer', + validateResult:(schema,result)=>Object.keys(result).length===1 + && Object.keys(schema.properties).every(key=>typeof result[key]==='string')?[]:['Expected one string answer'], +}; + +async function fileBytes(file) { + try {return (await stat(file)).size;} + catch (error) {if (error.code==='ENOENT') return 0; throw error;} +} + +function latency(samples) { + const sorted=[...samples].sort((a,b)=>a-b); + const percentile=p=>Number(sorted[Math.ceil(p*sorted.length)-1].toFixed(3)); + return {p50:percentile(.50),p95:percentile(.95),p99:percentile(.99)}; +} + +async function measure(program,maxStagesPerCall) { + const database=path.join(dir,'ledger-'+maxStagesPerCall+'.sqlite'); + const namespace='ledger-measure'; + const runId='ledger'; + const receipts=[]; + const samples=[]; + const bytes={checkpointSerializedTotal:0,checkpointLargest:0,walHighWaterSample:0}; + let casAttempts=0,casCommitted=0,pauseCalls=0; + let finalStateHash,stats,firstReceipt; + const store=createSqliteCheckpointStore(database,{namespace}); + try { + const instrumented={ + ...store, + compareAndSwap(args) { + const size=canonicalBytes(args.nextCheckpoint).byteLength; + const result=store.compareAndSwap(args); + casAttempts++; + if (result.committed) { + casCommitted++; + bytes.checkpointSerializedTotal+=size; + bytes.checkpointLargest=Math.max(bytes.checkpointLargest,size); + } + return result; + }, + }; + const api=createInteractiveCoordinator({store:instrumented,runtime,maxStagesPerCall}); + async function settle(frame) { + while (frame.status==='paused') { + assert.equal(frame.pauseReason,'stage-budget'); + assert.ok(++pauseCalls<20000,'unexpected budget loop'); + frame=await api.resume({runId}); + } + return frame; + } + const started=performance.now(); + let frame=await settle(await api.start({ + programBytes:Buffer.from(program.code),programIdentity:program.programIdentity,runId,startKey:runId, + })); + for (let index=0;index=2000); + assert.equal(casCommitted,checkpoint.revision); + assert.equal(journal.length,checkpoint.revision+1); + assert.ok(checkpoint.receipts.length<=16); + assert.ok(!checkpoint.receipts.some(receipt=>receipt.requestId===receipts[0].requestId)); + bytes.checkpointFinal=canonicalBytes(checkpoint).byteLength; + bytes.journalExport=journal.reduce((sum,entry)=>sum+canonicalBytes(entry).byteLength,0); + const receiptIndex=receipts.map(receipt=>store.getReceipt(runId,receipt.requestId)); + bytes.receiptIndexExport=receiptIndex.reduce((sum,receipt)=>sum+canonicalBytes(receipt).byteLength,0); + bytes.databaseOpen=await fileBytes(database); + bytes.walAtTerminal=await fileBytes(database+'-wal'); + stats={elapsedMs:Number(elapsedMs.toFixed(3)),latencyMs:latency(samples),pauseCalls,casAttempts,casCommitted, + journalRows:journal.length,receiptIndexRows:receiptIndex.length,checkpointReceiptCache:checkpoint.receipts.length}; + } finally {store.close();} + bytes.databaseClosed=await fileBytes(database); + bytes.walClosed=await fileBytes(database+'-wal'); + const reopened=createSqliteCheckpointStore(database,{namespace}); + try { + for (const receipt of receipts) { + assert.equal(reopened.getReceipt(runId,receipt.requestId)?.acceptedRevision,receipt.revision); + } + const cp=reopened.load(runId); + const api=createInteractiveCoordinator({store:reopened,runtime,maxStagesPerCall}); + const repeated=await api.complete(firstReceipt); + assert.equal(repeated.duplicate,true); + assert.equal(repeated.acceptedRevision,receipts[0].revision); + assert.equal(repeated.revision,cp.revision); + assert.equal(sha256Bytes(canonicalBytes(repeated.state)),finalStateHash); + } finally {reopened.close();} + return {maxStagesPerCall,finalStateHash,bytes,...stats}; +} + +try { + const sourcePath=path.join(dir,'ledger.workflow.js'); + await writeFile(sourcePath,source); + const program=await compileInteractive(sourcePath,{output:'memory',featureStage:'P0-A',skillCheck:false,projectRoot:dir}); + assert.ok(program.code,JSON.stringify(program.diagnostics)); + const results=[]; + for (const budget of [1,128]) { + const result=await measure(program,budget); + results.push(result); + console.log('FLOWKIT_LEDGER_MEASUREMENT '+JSON.stringify({ + platform:process.platform,node:process.version,agents,modelCalls:0, + programHash:program.programIdentity.artifactHash,...result, + })); + } + assert.equal(results[0].finalStateHash,results[1].finalStateHash); +} finally {await rm(dir,{recursive:true,force:true});} diff --git a/scripts/package-files.mjs b/scripts/package-files.mjs index c02a31f..a88ab42 100644 --- a/scripts/package-files.mjs +++ b/scripts/package-files.mjs @@ -10,7 +10,7 @@ export const packageFiles = [ ...['source', 'extract', 'validate', 'skill-check', 'analyze', 'graph-algorithms', 'graph', 'dataflow', 'values', 'paths', 'mermaid', 'emit-plan', 'helpers', 'emit', 'compile', 'api', 'output'].map(name => [`src/compiler/${name}.mjs`, `dist/compiler/${name}.mjs`]), ...['feature-gate', 'lower', 'validate-ir'].map(name => [`src/compiler/interactive/${name}.mjs`, `dist/compiler/interactive/${name}.mjs`]), ['src/compiler/interactive/emit.mjs', 'dist/compiler/interactive/emit.mjs'], - ...['compile', 'identity', 'load', 'canonical', 'sqlite-store', 'coordinator', 'index', 'host-attestation', 'trusted-host'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), + ...['compile', 'identity', 'load', 'canonical', 'sqlite-store', 'coordinator', 'index', 'host-attestation', 'trusted-host', 'versioned'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), ...['parser', 'evaluate'].map(name => [`src/when/${name}.mjs`, `dist/when/${name}.mjs`]), ...['context', 'execute', 'load', 'source', 'skills', 'skill-remote', 'run', 'aot', 'dump', 'dump-channel', 'dump-hook'].map(name => [`src/runtime/${name}.mjs`, `dist/runtime/${name}.mjs`]), ...['dsl', 'index', 'compiler-api', 'interactive'].map(name => [`types/${name}.d.ts`, `dist/${name}.d.ts`]), diff --git a/src/interactive/compile.mjs b/src/interactive/compile.mjs index 43f6dfa..06f25f7 100644 --- a/src/interactive/compile.mjs +++ b/src/interactive/compile.mjs @@ -41,7 +41,7 @@ export async function compileInteractive(file, options) { const analysis = await analyzeSource(snapshot,{ ...analyzerOptions,filename,projectRoot, }); - const lowered = lowerInteractive(analysis,{featureStage:options.featureStage}); + const lowered = lowerInteractive(analysis,{featureStage:snapshotOptions.featureStage}); if (!lowered.ir) return {code:'',diagnostics:[...lowered.diagnostics]}; const emitted = emitInteractive(lowered.ir); if (emitted.diagnostics.some(d => d.severity === 'error')) { diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index 6031f40..0662850 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -2,7 +2,7 @@ import {randomUUID} from 'node:crypto'; import {MAX_INLINE_AGENT_RESULT_BYTES, canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs'; import {loadInteractiveProgram} from './load.mjs'; -const FINISHED = new Set(['succeeded','failed','errored']); +const FINISHED = new Set(['succeeded','failed','errored','cancelled']); // P0-A has at most 500 static nodes and 11 attempts per Agent. // Do not cap legitimate 500-node workflows below their worst-case 5,500 receipts. const MAX_RECEIPTS = 8192; @@ -217,7 +217,11 @@ function initialCheckpoint(namespace, runId, program, policyHash, state) { * Only the trusted Host may call start/complete/resume and provide bindings. * Host identity/provenance adapters are added in M5, NOT simulated here. */ -export function createInteractiveCoordinator({store, runtime, policy = {}, maxStagesPerCall = 128} = {}) { +export function createInteractiveCoordinator(options) { + return buildCoordinator(options, false); +} + +export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall = 128, signal} = {}, versioned) { if (!store || typeof store.load !== 'function' || typeof store.createRun !== 'function' || typeof store.compareAndSwap !== 'function' @@ -243,13 +247,17 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt // Small fixed LRU avoids a broad, implicit process-global cache. const verifiedPrograms = new Map(); const MAX_VERIFIED_PROGRAMS = 4; + function checkSignal() { signal?.throwIfAborted(); } async function loadPinned(cp) { + if (!versioned && cp.transitionIdVersion!==undefined) { + throw failure('FK_I_POLICY_MISMATCH','versioned Run requires the asynchronous SDK'); + } if (cp.namespace !== store.namespace || cp.policyHash !== policyHash) { throw failure('FK_I_POLICY_MISMATCH','Run policy and namespace must match'); } assertSHA256(cp.artifactHash); - const saved = store.getArtifact(cp.artifactHash); + const saved = versioned ? await store.getArtifact(cp.artifactHash) : store.getArtifact(cp.artifactHash); if (!saved || saved.programIdentity?.artifactHash !== cp.artifactHash || !(saved.bytes instanceof Uint8Array) || sha256Bytes(saved.bytes) !== cp.artifactHash) { @@ -284,16 +292,35 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt function advanceSafely(cp, program, event) { try { const proposal = program.advance(programView(cp),event); - return certifiedProposal(cp,program,event,proposal); + return {event,proposal:certifiedProposal(cp,program,event,proposal)}; } catch(error) { if (error?.code === 'FK_R002') { - return certifiedProposal(cp,program,internalEvent(cp,'deterministic-failure',{error:{code:'FK_R002'}}), - program.advance(programView(cp),internalEvent(cp,'deterministic-failure',{error:{code:'FK_R002'}}))); + const rejected=internalEvent(cp,'deterministic-failure',{error:{code:'FK_R002'}}); + return {event:rejected,proposal:certifiedProposal(cp,program,rejected,program.advance(programView(cp),rejected))}; } throw error; } } - function failCapacity(cp) { + async function writeTransition(cp,args,event,proposal,receiptOutcome) { + checkSignal(); + if (cp.transitionIdVersion===undefined) return store.compareAndSwap(versioned ? {...args,fromBlockId:cp.blockId} : args); + if (cp.transitionIdVersion!==1) throw failure('FK_I_INVALID_CHECKPOINT','unknown transition ID version'); + const eventDigest=event.eventDigest ?? fingerprint(event); + const proposalDigest=fingerprint(proposal); + const transitionId=fingerprint([cp.namespace,cp.runId,cp.revision,cp.blockId, + eventDigest,cp.artifactHash,proposalDigest]); + const transition={ + namespace:cp.namespace,runId:cp.runId,transitionId, + fromRevision:cp.revision,toRevision:args.nextCheckpoint.revision, + fromBlockId:cp.blockId,toBlockId:proposal.kind==='terminal'||proposal.kind==='raise-error' + || proposal.kind==='capacity-terminal' ? null : args.nextCheckpoint.blockId, + programHash:cp.artifactHash,eventDigest,proposalDigest,committedAt:new Date().toISOString(), + }; + return store.compareAndSwap({...args,transitionId,journalEntry:{ + ...args.journalEntry,transition,...(receiptOutcome ? {receiptOutcome} : {}), + }}); + } + async function failCapacity(cp) { if (FINISHED.has(cp.status)) return {committed:false,checkpoint:cp}; const nodeId=cp.blockId.slice(0,cp.blockId.lastIndexOf('::')); const next={ @@ -304,18 +331,18 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt }; // This terminal transition may use the Store's reserved 256 KiB; it // never drops State or accepted receipt identities. - return store.compareAndSwap({ + return writeTransition(cp,{ runId:cp.runId,expectedRevision:cp.revision,nextCheckpoint:next, transitionId:stableTransitionId(cp,'capacity-terminal'), journalEntry:{kind:'capacity-terminal',causeCode:'FK_I_CHECKPOINT_CAPACITY', from:cp.blockId}, - }); + },{kind:'capacity-terminal'},{kind:'capacity-terminal',causeCode:'FK_I_CHECKPOINT_CAPACITY'}); } - function casOrCapacityExit(cp, args) { - try { return store.compareAndSwap(args); } + async function casOrCapacityExit(cp, args, event, proposal, receiptOutcome) { + try { return await writeTransition(cp,args,event,proposal,receiptOutcome); } catch(error) { if (error?.code!=='FK_I_CHECKPOINT_CAPACITY') throw error; - const terminal=failCapacity(cp); + const terminal=await failCapacity(cp); return {...terminal,capacityExit:terminal.committed}; } } @@ -329,7 +356,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt } } - function stepCAS(cp, proposal, eventKind, extras={}) { + function stepCAS(cp, proposal, event, legacyEventKind, extras={}) { const next = {...cp,revision:cp.revision+1}; if (proposal.kind === 'raise-error') { next.status='errored'; @@ -377,14 +404,16 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt && proposal.stackEffect.kind === 'push') { next.activationSeq=cp.activationSeq+1; } - const transitionId=stableTransitionId(cp,eventKind + '/' + proposal.kind); + const transitionId=stableTransitionId(cp,legacyEventKind + '/' + proposal.kind); const write=casOrCapacityExit(cp,{ runId:cp.runId,expectedRevision:cp.revision,nextCheckpoint:next,transitionId, journalEntry:{ - kind:proposal.kind,event:eventKind,from:cp.blockId, + kind:proposal.kind,event:legacyEventKind,from:cp.blockId, to:proposal.toBlockId ?? null,causeCode:proposal.causeCode ?? null, }, - }); + },Object.keys(extras).length ? { + eventDigest:fingerprint([event.eventDigest,extras]), + } : event,proposal); return write; } @@ -450,16 +479,18 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt async function drive(runId) { for (let count=0; count < stageBudget; ) { - const cp=store.load(runId); + checkSignal(); + const cp=versioned ? await store.load(runId) : store.load(runId); if (!cp) throw failure('FK_I_RUN_NOT_FOUND','run is not accessible in this namespace'); + if (cp.status==='cancelled' && cp.namespace===store.namespace && cp.policyHash===policyHash) return asPublicFrame(cp); const program=await loadPinned(cp); if (FINISHED.has(cp.status) || cp.status==='waiting') return asPublicFrame(cp); if (cp.status === 'paused') { if (!['stage-budget','skill-temporary'].includes(cp.pauseReason)) return asPublicFrame(cp); const unpause={...cp,status:'running',revision:cp.revision+1,pauseReason:null}; - const committed=casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, + const committed=await casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, nextCheckpoint:unpause,transitionId:stableTransitionId(cp,'resume-budget'), - journalEntry:{kind:'resume-budget'}}); + journalEntry:{kind:'resume-budget'}},{kind:'resume-budget'},{kind:'resume-budget'}); if (!committed.committed) continue; // Budget unpause is a control action, not a Program stage. continue; @@ -468,7 +499,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt if (cp.phase === 'awaiting') { throw failure('FK_I_INVALID_CHECKPOINT','awaiting without waiting Pending'); } - let event,proposal,extras={}; + let event,extras={}; if (cp.phase === 'prepare') { let prepared; try { prepared=await makePrepared(cp,program); } @@ -476,20 +507,19 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt if (['FK_R014','FK_I_TEMPORARY_SKILL'].includes(error?.code)) { const paused={...cp,revision:cp.revision+1,status:'paused', pauseReason:'skill-temporary'}; - const result=casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, + const result=await casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, nextCheckpoint:paused,transitionId:stableTransitionId(cp,'pause-skill'), - journalEntry:{kind:'pause',reason:'skill-temporary'}}); + journalEntry:{kind:'pause',reason:'skill-temporary'}}, + {kind:'skill-temporary'},{kind:'pause',reason:'skill-temporary'}); if (!result.committed) continue; return asPublicFrame(result.checkpoint); } const causeCode=error?.code==='FK_R010' || error?.code==='FK_R011' ? error.code : 'FK_I_SKILL_CONTRACT'; event=internalEvent(cp,'deterministic-failure',{error:{code:causeCode}}); - proposal=advanceSafely(cp,program,event); } if (prepared) { event=internalEvent(cp,'internal-step'); - proposal=advanceSafely(cp,program,event); extras.prepared=prepared; } } else if (cp.phase === 'validate') { @@ -497,7 +527,6 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt event=evaluation.kind === 'deterministic-failure' ? internalEvent(cp,'deterministic-failure',{error:{code:evaluation.causeCode}}) : internalEvent(cp,evaluation.kind); - proposal=advanceSafely(cp,program,event); if (evaluation.reasons?.length) extras.retryReasons=evaluation.reasons; } else { const kind=cp.phase==='dispatch-ready' ? 'pending-prepared' : 'internal-step'; @@ -505,9 +534,9 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt && program.programTable.nodes[block.nodeId].kind==='agent' ? {activation:cp.activationSeq+1} : {}; event=internalEvent(cp,kind,extra); - proposal=advanceSafely(cp,program,event); } - const advanced=stepCAS(cp,proposal,event.kind,extras); + const certified=advanceSafely(cp,program,event); + const advanced=await stepCAS(cp,certified.proposal,certified.event,event.kind,extras); if (!advanced.committed) continue; count++; if (FINISHED.has(advanced.checkpoint.status) || advanced.checkpoint.status==='waiting') { @@ -516,22 +545,26 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt } // A stage budget is observable and resumable. Never misreport it as waiting. for (;;) { - const cp=store.load(runId); + checkSignal(); + const cp=versioned ? await store.load(runId) : store.load(runId); + if (cp.status==='cancelled' && cp.namespace===store.namespace && cp.policyHash===policyHash) return asPublicFrame(cp); const program=await loadPinned(cp); if (FINISHED.has(cp.status) || cp.status==='waiting' || cp.status==='paused') { return asPublicFrame(cp); } const paused={...cp,revision:cp.revision+1,status:'paused', pauseReason:'stage-budget'}; - const result=casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, + const result=await casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, nextCheckpoint:paused,transitionId:stableTransitionId(cp,'pause-budget'), - journalEntry:{kind:'pause',reason:'stage-budget'}}); + journalEntry:{kind:'pause',reason:'stage-budget'}}, + {kind:'budget-exhausted'},{kind:'pause',reason:'stage-budget'}); if (result.committed) return asPublicFrame(result.checkpoint); } } /** Register compiler-verified bytes before the idempotent start transaction. */ - async function start({programBytes,programIdentity,startKey,runId=randomUUID(),state={},startIntent=null}={}) { + async function start({programBytes,programIdentity,startKey,runId=randomUUID(),state={},startIntent=null, + authorizeExisting}={}) { nonempty(startKey,'startKey');nonempty(runId,'runId'); if (!(programBytes instanceof Uint8Array) || !programIdentity) { throw new TypeError('Compiler Program bytes and identity are required'); @@ -558,13 +591,22 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt } assertRuntime(program,execution); // before any mutation, reject unavailable Stage bindings const initial=initialCheckpoint(store.namespace,runId,program,policyHash,stateSnapshot); - store.putArtifact({programIdentity:declared,bytes:byteSnapshot}); - const opened=store.createRun({startKey,startIntent:intentSnapshot,checkpoint:initial}); + checkSignal(); + const registered=store.putArtifact({programIdentity:declared,bytes:byteSnapshot}); + if (versioned) { + await registered; + initial.transitionIdVersion=1; + } + checkSignal(); + const opening=store.createRun({startKey,startIntent:intentSnapshot,checkpoint:initial}); + const opened=versioned ? await opening : opening; + if (versioned && !opened.created) await authorizeExisting(opened.checkpoint.runId); return drive(opened.checkpoint.runId); } /** Only a trusted Host may call complete; M5 will bind provenance/transport. */ - async function complete({runId,requestId,nodeId,attempt,kind='agent-result',result,failure:callFailure}={}) { + async function complete({runId,requestId,nodeId,attempt,kind='agent-result',result,failure:callFailure, + submissionDigest}={}) { nonempty(runId,'runId');nonempty(requestId,'requestId');nonempty(nodeId,'nodeId'); if (!Number.isSafeInteger(attempt) || attempt < 1) throw new TypeError('Invalid Agent attempt'); if (!['agent-result','agent-call-failed'].includes(kind)) { @@ -590,16 +632,22 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt const inlineTooLarge=kind==='agent-result' && (oversizedInline(result) || oversizedInline(rawSubmission)); const submission=inlineTooLarge ? null : copy(rawSubmission); - const digest=inlineTooLarge ? null : fingerprint(submission); + if (versioned) assertSHA256(submissionDigest); + const legacyDigest=inlineTooLarge ? null : fingerprint(submission); for (;;) { - const cp=store.load(runId); + checkSignal(); + const cp=versioned ? await store.load(runId) : store.load(runId); if (!cp) throw failure('FK_I_RUN_NOT_FOUND','run is not accessible'); + if (cp.status==='cancelled') throw failure('FK_I_STALE_RECEIPT','Run has been terminated'); const program=await loadPinned(cp); + const digest=inlineTooLarge ? null : (cp.transitionIdVersion===1 ? submissionDigest : legacyDigest); const indexed=typeof store.getReceipt==='function'; - const already=(indexed ? store.getReceipt(runId,requestId) : null) + const receiptLookup=indexed ? store.getReceipt(runId,requestId) : null; + const already=(versioned ? await receiptLookup : receiptLookup) ?? cp.receipts.find(r=>r.requestId===requestId); if (already) { - if (already.digest!==digest || already.nodeId!==nodeId || already.attempt!==attempt) { + if (already.digest!==digest || (cp.transitionIdVersion===undefined + && (already.nodeId!==nodeId || already.attempt!==attempt))) { throw failure('FK_I_RECEIPT_CONFLICT','Different receipt with accepted requestId'); } const current=await drive(runId); @@ -626,7 +674,7 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt // reference. FlowKit never stores oversized inline results or invents // an external business-data provider. The durable failure is NOT an Ack. if (inlineTooLarge) { - const terminal=failCapacity(cp); + const terminal=await failCapacity(cp); if (!terminal.committed) continue; throw failure('FK_I_CHECKPOINT_CAPACITY', 'inline result exceeds the configured execution budget; Run is errored'); @@ -643,9 +691,12 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt ? [...cp.receipts.slice(-(MAX_INLINE_RECEIPT_CACHE-1)),ledgerEntry] : [...cp.receipts,ledgerEntry], }; - const persisted=casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, + const persisted=await casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, nextCheckpoint:next,transitionId:stableTransitionId(cp,'receipt:'+requestId), - journalEntry:{kind:'receipt-accepted',nodeId,attempt,requestId,digest}}); + journalEntry:{kind:'receipt-accepted',nodeId,attempt,requestId,digest}}, + {kind:'receipt-accepted',requestId,nodeId,attempt,submissionDigest:digest}, + {kind:'accept-receipt',toBlockId:accept,submissionDigest:digest}, + kind==='agent-result'?'succeeded':'failed'); if (!persisted.committed) continue; if (persisted.capacityExit) { throw failure('FK_I_CHECKPOINT_CAPACITY', @@ -659,9 +710,10 @@ export function createInteractiveCoordinator({store, runtime, policy = {}, maxSt async function resume({runId}={}) { nonempty(runId,'runId');return drive(runId); } async function load({runId}={}) { nonempty(runId,'runId'); - const cp=store.load(runId); + checkSignal(); + const cp=versioned ? await store.load(runId) : store.load(runId); if (!cp) return null; - await loadPinned(cp); + if (cp.status!=='cancelled' || cp.namespace!==store.namespace || cp.policyHash!==policyHash) await loadPinned(cp); return asPublicFrame(cp); } diff --git a/src/interactive/index.mjs b/src/interactive/index.mjs index ad23b25..1a1e5f3 100644 --- a/src/interactive/index.mjs +++ b/src/interactive/index.mjs @@ -7,3 +7,5 @@ export {createInteractiveCoordinator} from './coordinator.mjs'; // M5 Host-only provenance and disclosure facade. Does not expose a signer/private key. export {createTrustedInteractiveHost} from './trusted-host.mjs'; export {normalizeHostAgentReceipt,createAgentReceiptStatement,encodeAgentReceiptStatement} from './host-attestation.mjs'; +export {start,complete,resume,createInteractiveHostContext,createInteractiveAdminContext, + createSqliteInteractivePersistence} from './versioned.mjs'; diff --git a/src/interactive/sqlite-store.mjs b/src/interactive/sqlite-store.mjs index 0f2db54..34ebc39 100644 --- a/src/interactive/sqlite-store.mjs +++ b/src/interactive/sqlite-store.mjs @@ -1,12 +1,17 @@ import {DatabaseSync} from 'node:sqlite'; import {Buffer} from 'node:buffer'; +import {closeSync,openSync,existsSync} from 'node:fs'; +import path from 'node:path'; import { MAX_CHECKPOINT_BYTES, MAX_ACTIVE_CHECKPOINT_BYTES, MAX_JOURNAL_BYTES, assertSHA256, canonicalBytes, decodeCanonical, safeJSONSnapshot, sha256Bytes, } from './canonical.mjs'; -const TERMINAL = new Set(['succeeded', 'failed', 'errored']); +const TERMINAL = new Set(['succeeded', 'failed', 'errored', 'cancelled']); const STATUSES = new Set(['running', 'waiting', 'paused', ...TERMINAL]); +export const sqliteAdminTerminate=Symbol('SQLite admin terminate'); +const digest=value=>sha256Bytes(canonicalBytes(value)); +const guardedTables=['fk_i_artifacts','fk_i_runs','fk_i_receipt_index','fk_i_journal','fk_i_admin_events']; function boundedCheckpointBytes(input) { try { return canonicalBytes(input); } catch (error) { @@ -42,6 +47,7 @@ function assertCheckpoint(value, namespace, runId, revision, artifactHash) { throw failure('FK_I_PROGRAM_MISMATCH', 'run artifact is immutable'); } if (!STATUSES.has(value.status) + || (value.transitionIdVersion!==undefined && value.transitionIdVersion!==1) || typeof value.blockId !== 'string' || !value.blockId || typeof value.phase !== 'string' || !value.phase || !value.blockId.endsWith('::' + value.phase) @@ -55,7 +61,7 @@ function assertCheckpoint(value, namespace, runId, revision, artifactHash) { } } function immutableIdentity(previous, next) { - for (const key of ['namespace', 'runId', 'schemaVersion', 'artifactHash', 'startKey', 'startIntentHash', 'policyHash']) { + for (const key of ['namespace', 'runId', 'schemaVersion', 'artifactHash', 'startKey', 'startIntentHash', 'policyHash', 'transitionIdVersion']) { if (previous[key] !== next[key]) { throw failure('FK_I_INVALID_CHECKPOINT', 'immutable field changed: ' + key); } @@ -73,9 +79,24 @@ function journalDigest(namespace, runId, revision, transitionId, previousHash, c * is experimental on Node 22; the CI matrix tests it as an explicit boundary. */ export function createSqliteCheckpointStore(file, {namespace} = {}) { + return openStore(file,namespace); +} + +export function migrateSqliteInteractiveStore(file,{namespace,backupPath}={}) { + identifier(file,'SQLite file'); + identifier(backupPath,'SQLite backup file'); + if (path.resolve(file)===path.resolve(backupPath)) throw new TypeError('Backup must be a separate new file'); + if (!existsSync(file)) throw failure('FK_I_STORE_SCHEMA','migration requires an existing SQLite store'); + return openStore(file,namespace,backupPath); +} + +function openStore(file,namespace,backupPath) { identifier(file, 'SQLite file'); identifier(namespace, 'namespace'); const db = new DatabaseSync(file); + // Only current FlowKit writers provide the function used by the V2 triggers. + db.function('fk_i_writer_version', {deterministic:true},()=>2); + let schemaVersion; try { db.exec('PRAGMA busy_timeout = 15000'); db.exec('PRAGMA foreign_keys = ON'); @@ -88,7 +109,10 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { if (journalMode !== 'wal' || synchronous !== 2 || foreignKeys !== 1) { throw failure('FK_I_STORE_UNSAFE', 'WAL, synchronous FULL and foreign keys are required'); } - db.exec([ + const version=()=>db.prepare('PRAGMA user_version').get().user_version; + schemaVersion=version(); + if (![0,2].includes(schemaVersion)) throw failure('FK_I_STORE_SCHEMA','unsupported SQLite schema version'); + if (backupPath===undefined && schemaVersion===0) db.exec([ 'CREATE TABLE IF NOT EXISTS fk_i_artifacts (', ' namespace TEXT NOT NULL, artifact_hash TEXT NOT NULL, source_hash TEXT NOT NULL,', ' identity_json TEXT NOT NULL, program_bytes BLOB NOT NULL,', @@ -118,6 +142,50 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { ' FOREIGN KEY (namespace, run_id) REFERENCES fk_i_runs(namespace, run_id)', ') STRICT;', ].join('\n')); + if (backupPath!==undefined) { + if (schemaVersion!==0) throw failure('FK_I_STORE_SCHEMA','database is already migrated'); + const readVersion=()=>db.prepare('PRAGMA data_version').get().data_version; + const before=readVersion(); + closeSync(openSync(backupPath,'wx',0o600)); + db.prepare('VACUUM INTO ?').run(backupPath); + const backup=new DatabaseSync(backupPath,{readOnly:true}); + try { + if (backup.prepare('PRAGMA integrity_check').get().integrity_check!=='ok') { + throw failure('FK_I_STORE_CORRUPT','pre-migration backup failed integrity check'); + } + } finally {backup.close();} + db.exec('BEGIN EXCLUSIVE'); + try { + if (version()!==0 || readVersion()!==before) throw failure('FK_I_STORE_SCHEMA','concurrent write during migration backup'); + const findTable=db.prepare("SELECT 1 FROM sqlite_schema WHERE type='table' AND name=?"); + if (guardedTables.slice(0,-1).some(table=>!findTable.get(table))) { + throw failure('FK_I_STORE_SCHEMA','migration source is not an existing FlowKit store'); + } + db.exec(` + CREATE TABLE fk_i_admin_events ( + namespace TEXT NOT NULL, run_id TEXT NOT NULL, transition_id TEXT NOT NULL, + admin_subject_id TEXT NOT NULL, reason_code TEXT NOT NULL, created_at TEXT NOT NULL, + PRIMARY KEY (namespace, run_id, transition_id), + FOREIGN KEY (namespace, run_id, transition_id) + REFERENCES fk_i_journal(namespace, run_id, transition_id) + ) STRICT; + `); + for (const table of guardedTables) { + for (const operation of ['INSERT','UPDATE','DELETE']) { + db.exec(`CREATE TRIGGER ${table}_writer_${operation} BEFORE ${operation} ON ${table} + WHEN fk_i_writer_version() <> 2 BEGIN SELECT RAISE(ABORT, 'incompatible FlowKit writer'); END;`); + } + } + db.exec('PRAGMA user_version = 2; COMMIT'); + schemaVersion=2; + } catch(error) {db.exec('ROLLBACK');throw error;} + } + if (schemaVersion===2) { + const trigger=db.prepare('SELECT 1 FROM sqlite_schema WHERE type=? AND name=?'); + if (guardedTables.some(table=>['INSERT','UPDATE','DELETE'].some(op=>!trigger.get('trigger',table+'_writer_'+op)))) { + throw failure('FK_I_STORE_SCHEMA','incomplete V2 writer protection'); + } + } } catch (error) { db.close(); throw error; @@ -135,6 +203,9 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { const readReceipt = db.prepare('SELECT * FROM fk_i_receipt_index WHERE namespace = ? AND run_id = ? AND request_id = ?'); const insertReceipt = db.prepare('INSERT OR IGNORE INTO fk_i_receipt_index(namespace, run_id, request_id, node_id, attempt, digest, accepted_revision, entry_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); const journalAtRevision = db.prepare('SELECT * FROM fk_i_journal WHERE namespace = ? AND run_id = ? AND revision = ?'); + const journalById = db.prepare('SELECT * FROM fk_i_journal WHERE namespace = ? AND run_id = ? AND transition_id = ?'); + const addAdminEvent=schemaVersion===2 ? db.prepare(`INSERT INTO fk_i_admin_events + (namespace,run_id,transition_id,admin_subject_id,reason_code,created_at) VALUES (?,?,?,?,?,?)`) : null; function transaction(mode, work) { db.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN'); @@ -221,6 +292,35 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { return Object.freeze({ namespace, close() { db.close(); }, + [sqliteAdminTerminate](runId,reason,subjectId) { + identifier(runId,'runId'); + identifier(subjectId,'subjectId'); + if (!addAdminEvent) throw failure('FK_I_STORE_SCHEMA','explicit V2 migration is required'); + return transaction('write',()=>{ + const current=decodeRow(read.get(namespace,runId)); + if (!current) throw failure('FK_I_HOST_DENIED','Run is not accessible'); + if (TERMINAL.has(current.status)) return current; + if (current.revision>=Number.MAX_SAFE_INTEGER) throw failure('FK_I_REVISION_EXHAUSTED','revision range exceeded'); + const next={...current,status:'cancelled',revision:current.revision+1, + pending:null,prepared:null,candidate:null,terminal:null,pauseReason:null}; + const eventDigest=digest({kind:'admin-terminate',subjectId,reason}); + const proposalDigest=digest({kind:'admin-tombstone',status:'cancelled'}); + const transitionId=current.transitionIdVersion===1 + ? digest([namespace,runId,current.revision,current.blockId,eventDigest,current.artifactHash,proposalDigest]) + : 't:'+digest([namespace,runId,current.revision,current.blockId,'admin-terminate']).slice(7); + const committedAt=new Date().toISOString(); + const transition={namespace,runId,transitionId,fromRevision:current.revision,toRevision:next.revision, + fromBlockId:current.blockId,toBlockId:null,programHash:current.artifactHash,eventDigest,proposalDigest,committedAt}; + const bytes=boundedCheckpointBytes(next), checkpointHash=sha256Bytes(bytes); + const previous=latest.get(namespace,runId); + if (updateRun.run(next.revision,bytes.toString('utf8'),checkpointHash,namespace,runId,current.revision).changes!==1) { + throw failure('FK_I_CAS_CONFLICT','revision changed during admin transaction'); + } + appendJournal(runId,next.revision,transitionId,checkpointHash,previous.entry_hash,{kind:'admin-terminate',transition}); + addAdminEvent.run(namespace,runId,transitionId,subjectId,reason,committedAt); + return next; + }); + }, /** Preserve exact compiler-produced bytes, keyed by their content hash. */ putArtifact({programIdentity, bytes}) { @@ -287,11 +387,12 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { * Durable idempotent start: the server computes the intent fingerprint, * including tenant, artifact, initial State and caller options. */ - createRun({startKey, startIntent = null, checkpoint}) { + createRun({startKey, startIntent = null, checkpoint, genesis}) { identifier(startKey, 'startKey'); const base = boundedCheckpoint(checkpoint); identifier(base?.runId, 'checkpoint.runId'); assertCheckpoint(base, namespace, base.runId, 0); + if (base.status==='cancelled') throw failure('FK_I_INVALID_TRANSITION','cancelled requires management authorization'); if (base.startKey !== undefined || base.startIntentHash !== undefined) { throw failure('FK_I_INVALID_CHECKPOINT', 'start identity is store-generated'); } @@ -301,6 +402,9 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { policyHash:base.policyHash ?? null, intent, })); const initial = {...base, startKey, startIntentHash}; + if ((base.transitionIdVersion===1)!==Boolean(genesis)) { + throw failure('FK_I_INVALID_TRANSITION','versioned genesis metadata required'); + } const checkpointBytes = boundedCheckpointBytes(initial); // The reserve must already exist before a Run can publish its first Pending. // Read/duplicate-start of a pre-upgrade Run is not blocked by this check. @@ -325,7 +429,8 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { } insertRun.run(namespace, initial.runId, startKey, startIntentHash, 0, initial.artifactHash, checkpointBytes.toString('utf8'), checkpointHash); - appendJournal(initial.runId, 0, 'start:' + startIntentHash, checkpointHash, null, {kind:'created'}); + appendJournal(initial.runId, 0, genesis?.transitionId ?? ('start:' + startIntentHash), + checkpointHash, null, {kind:'created',...(genesis ? {transition:genesis} : {})}); return {created:true, checkpoint:JSON.parse(checkpointBytes.toString('utf8'))}; }); }, @@ -344,6 +449,7 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { throw new TypeError('expectedRevision must be a nonnegative safe integer'); } const next = boundedCheckpoint(nextCheckpoint); + if (next.status==='cancelled') throw failure('FK_I_INVALID_TRANSITION','cancelled requires management authorization'); const journal = safeJSONSnapshot(journalEntry, MAX_JOURNAL_BYTES); return transaction('write', () => { const row = read.get(namespace, runId); @@ -423,6 +529,23 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { }); }, + /** Indexed read used by the async Store facade. Old records keep their original IDs. */ + readJournalRecord(runId, {transitionId,revision}) { + identifier(runId, 'runId'); + if (transitionId!==undefined) identifier(transitionId,'transitionId'); + return transaction('read',()=>{ + const cp=decodeRow(read.get(namespace,runId)); + if (!cp) return null; + const row=transitionId===undefined ? journalAtRevision.get(namespace,runId,revision) + : journalById.get(namespace,runId,transitionId); + if (!row) return null; + const entry=JSON.parse(row.entry_json); + if (journalDigest(namespace,runId,row.revision,row.transition_id,row.previous_hash,row.checkpoint_hash,entry) + !==row.entry_hash) throw failure('FK_I_STORE_CORRUPT','invalid journal record'); + return {revision:row.revision,transitionId:row.transition_id,entry}; + }); + }, + /** Audit chain is verified on each read, independently of a live Coordinator. */ listJournal(runId) { identifier(runId, 'runId'); diff --git a/src/interactive/trusted-host.mjs b/src/interactive/trusted-host.mjs index 315e226..c479fdb 100644 --- a/src/interactive/trusted-host.mjs +++ b/src/interactive/trusted-host.mjs @@ -78,6 +78,14 @@ function numericPolicy(value,label,max) { return value; } +function terminalNode(checkpoint) { + if (checkpoint.status==='succeeded' || checkpoint.status==='failed') { + if (typeof checkpoint.terminal?.nodeId!=='string' + || checkpoint.terminal.status!==checkpoint.status) throw denied(); + return {nodeId:checkpoint.terminal.nodeId,status:checkpoint.status}; + } +} + /** * Trusted Host facade for the existing P0-A Coordinator. The Host keeps * SQLite, runtime callbacks, and signing private key outside model access. @@ -89,7 +97,7 @@ export function createTrustedInteractiveHost({ store,runtime,policy={}, trustedPublicKeys,trustedArtifacts, allowedInputKeys=[], - disclosurePolicyId,authorizeDisclosure, + disclosurePolicyId,authorizeDisclosure,authorizeTerminalAccess, maxProofAgeMs=5*60*1000,clockSkewMs=5000, now=()=>Date.now(),maxStagesPerCall=128, }={}) { @@ -100,6 +108,9 @@ export function createTrustedInteractiveHost({ if (typeof now!=='function' || typeof authorizeDisclosure!=='function') { throw new TypeError('Trusted Host clock and disclosure authorizer are required'); } + if (authorizeTerminalAccess!==undefined && typeof authorizeTerminalAccess!=='function') { + throw new TypeError('Terminal access authorizer must be a function'); + } name(disclosurePolicyId); numericPolicy(maxProofAgeMs,'maxProofAgeMs',3600000); if (maxProofAgeMs<1000) throw new TypeError('maxProofAgeMs must be >= 1000'); @@ -167,6 +178,61 @@ export function createTrustedInteractiveHost({ return coordinator.load({runId}); } + async function loadAuthorizedTerminal(runId, action, revision) { + if (!authorizeTerminalAccess || typeof runId!=='string' || runId.length<1 + || runId.length>256 || /[\u0000-\u001f\u007f]/.test(runId)) throw denied(); + const checkpoint=assertPinned(runId); + if (checkpoint.runId!==runId || !['succeeded','failed','errored','cancelled'].includes(checkpoint.status) + || (revision!==undefined && revision!==checkpoint.revision)) throw denied(); + + let decision; + try { + decision=await authorizeTerminalAccess(Object.freeze({ + action,namespace:checkpoint.namespace,runId,revision:checkpoint.revision, + })); + } catch { + throw denied(); + } + if (decision!==true) throw denied(); + + const current=assertPinned(runId); + if (current.runId!==runId || current.revision!==checkpoint.revision + || current.status!==checkpoint.status) throw denied(); + return current; + } + + async function getPublicTerminalFrame({runId}={}) { + const checkpoint=await loadAuthorizedTerminal(runId,'run:read'); + const {revision,status}=checkpoint; + const terminal=terminalNode(checkpoint); + return { + protocolVersion:1,runId,revision,status,pending:[], + checkpointRef:{runId,revision}, + ...(terminal ? {terminal} : {}), + resultRef:{runId,revision,projectionVersion:1}, + }; + } + + async function readTrustedTerminalResult(input) { + let resultRef; + try { + resultRef=safeJSONSnapshot(input,2048)?.resultRef; + } catch { + throw denied(); + } + if (!resultRef || Object.keys(resultRef).length!==3 || resultRef.projectionVersion!==1 + || !Number.isSafeInteger(resultRef.revision) || resultRef.revision<0) throw denied(); + const checkpoint=await loadAuthorizedTerminal(resultRef.runId,'result:read',resultRef.revision); + const terminal=terminalNode(checkpoint); + const causeCode=checkpoint.terminal?.causeCode; + return safeJSONSnapshot({ + projectionVersion:1,namespace:checkpoint.namespace,runId:checkpoint.runId, + status:checkpoint.status,state:checkpoint.state,contextError:null, + ...(terminal ? {terminal} : {}), + ...(typeof causeCode==='string' ? {causeCode} : {}), + },MAX_CHECKPOINT_BYTES); + } + /** * This is the ONLY task structure to send to the model. It is derived from * authoritative Store state (never a caller-supplied frame), enforces an @@ -277,7 +343,7 @@ export function createTrustedInteractiveHost({ } return Object.freeze({ - start,resume,load,getPublicAgentTask,accept, + start,resume,load,getPublicAgentTask,getPublicTerminalFrame,readTrustedTerminalResult,accept, policyHash:coordinator.policyHash, securityMode:'ed25519-agent-receipt-v1', }); diff --git a/src/interactive/versioned.mjs b/src/interactive/versioned.mjs new file mode 100644 index 0000000..2b4292b --- /dev/null +++ b/src/interactive/versioned.mjs @@ -0,0 +1,450 @@ +import {buildCoordinator} from './coordinator.mjs'; +import {compileInteractive} from './compile.mjs'; +import {createSqliteCheckpointStore,migrateSqliteInteractiveStore,sqliteAdminTerminate} from './sqlite-store.mjs'; +import {assertSHA256,canonicalBytes,safeJSONSnapshot,sha256Bytes} from './canonical.mjs'; + +const authCallbacks=new WeakMap(); +const adminCallbacks=new WeakMap(); +const hash=value=>sha256Bytes(canonicalBytes(value)); +const fail=(code,message)=>Object.assign(new Error(code+': '+message),{code}); +const uncertain=()=>fail('FK_I_STORE_UNCERTAIN','cannot establish a durable transition outcome'); + +export function createInteractiveHostContext({namespace,subjectId,authorize,authorizeTask}={}) { + if (typeof namespace!=='string'||!namespace || typeof subjectId!=='string'||!subjectId + || typeof authorize!=='function' || typeof authorizeTask!=='function') throw new TypeError('Trusted Host authorizers are required'); + const auth=Object.freeze({namespace,subjectId, + readTerminalResult:(resultRef,options)=>readTerminalResult(resultRef,{...options,auth}), + }); + authCallbacks.set(auth,{authorize,authorizeTask}); + return auth; +} + +function genesisFor(checkpoint) { + const {namespace,runId,artifactHash:programHash,blockId:toBlockId,startIntentHash}=checkpoint; + const eventDigest=hash(['genesis',startIntentHash]); + const proposalDigest=hash(['create',toBlockId]); + return {namespace,runId,programHash,fromRevision:null,toRevision:0,fromBlockId:null,toBlockId, + eventDigest,proposalDigest,transitionId:hash([namespace,runId,null,null,eventDigest,programHash,proposalDigest]), + committedAt:new Date().toISOString()}; +} + +function refFor(namespace,identity) { + return {namespace,artifactHash:identity.artifactHash,programId:identity.workflowId}; +} + +/** Two asynchronous interfaces over the same SQLite connection and transaction backend. */ +export function createInteractiveAdminContext({namespace,subjectId,authorize}={}) { + const identifier=value=>typeof value==='string' && /^[a-zA-Z0-9_.:@-]{1,128}$/.test(value); + if (!identifier(subjectId) || typeof namespace!=='string' || !namespace || typeof authorize!=='function') { + throw new TypeError('A trusted admin identity and authorizer are required'); + } + const auth=Object.freeze({namespace,subjectId}); + adminCallbacks.set(auth,authorize); + return auth; +} + +export function createSqliteInteractivePersistence(file,{namespace,migrationBackupPath}={}) { + const legacy=migrationBackupPath===undefined ? createSqliteCheckpointStore(file,{namespace}) + : migrateSqliteInteractiveStore(file,{namespace,backupPath:migrationBackupPath}); + const store=Object.freeze({ + namespace, + async createIfAbsent(startKey,initial,genesis) { + const {startKey:declaredKey,startIntentHash, ...checkpoint}=initial; + const expected=hash({namespace,startKey,artifactHash:checkpoint.artifactHash, + state:checkpoint.state,policyHash:checkpoint.policyHash,intent:null}); + const expectedGenesis=genesisFor(initial); + if (declaredKey!==startKey || startIntentHash!==expected || initial.transitionIdVersion!==1 + || genesis?.transitionId!==expectedGenesis.transitionId || genesis.fromRevision!==null + || genesis.fromBlockId!==null || genesis.toRevision!==0 || genesis.toBlockId!==initial.blockId + || genesis.namespace!==namespace || genesis.runId!==initial.runId || genesis.programHash!==initial.artifactHash + || genesis.eventDigest!==expectedGenesis.eventDigest || genesis.proposalDigest!==expectedGenesis.proposalDigest) { + throw fail('FK_I_INVALID_TRANSITION','invalid genesis for versioned Run'); + } + return legacy.createRun({startKey,checkpoint,genesis}); + }, + async load(runId) {return legacy.load(runId);}, + async compareAndSwap(runId,expectedRevision,{next,transition,receipt}) { + const current=legacy.load(runId); + if (!current) throw fail('FK_I_RUN_NOT_FOUND','run is not accessible'); + if (current.revision!==expectedRevision) return {committed:false,currentRevision:current.revision}; + if (transition.namespace!==namespace || transition.runId!==runId + || transition.fromRevision!==expectedRevision || transition.toRevision!==expectedRevision+1 + || transition.fromBlockId!==current.blockId || transition.programHash!==current.artifactHash) { + throw fail('FK_I_INVALID_TRANSITION','transition identity mismatch'); + } + if (current.transitionIdVersion===1) { + assertSHA256(transition.eventDigest); assertSHA256(transition.proposalDigest); + const id=hash([namespace,runId,expectedRevision,current.blockId,transition.eventDigest, + current.artifactHash,transition.proposalDigest]); + const to=['succeeded','failed','errored','cancelled'].includes(next.status) ? null : next.blockId; + if (id!==transition.transitionId || to!==transition.toBlockId) { + throw fail('FK_I_INVALID_TRANSITION','transition digest mismatch'); + } + } + const journalEntry={kind:receipt?'receipt-accepted':'versioned-transition',transition}; + if (receipt) { + if (receipt.namespace!==namespace || receipt.runId!==runId || receipt.kind!=='agent' + || next.candidate?.requestId!==receipt.requestId + || (receipt.nodeId!==undefined && next.candidate?.nodeId!==receipt.nodeId) + || (receipt.attempt!==undefined && next.candidate?.attempt!==receipt.attempt) + || receipt.acceptedRevision!==next.revision || receipt.transitionId!==transition.transitionId + || !['succeeded','failed'].includes(receipt.outcome) + || next.receipts.at(-1)?.digest!==receipt.submissionDigest) { + throw fail('FK_I_INVALID_RECEIPT','receipt identity mismatch'); + } + Object.assign(journalEntry,{requestId:receipt.requestId,nodeId:next.candidate?.nodeId, + attempt:next.candidate?.attempt,digest:receipt.submissionDigest,receiptOutcome:receipt.outcome}); + } else if (current.status==='waiting' && next.phase==='validate') { + throw fail('FK_I_INVALID_RECEIPT','accepting a Pending requires its atomic receipt'); + } + const result=legacy.compareAndSwap({runId,expectedRevision,nextCheckpoint:next, + transitionId:transition.transitionId,journalEntry}); + return result.committed ? result : {committed:false,currentRevision:result.checkpoint.revision}; + }, + async loadTransition(runId,transitionId) { + const entry=legacy.readJournalRecord(runId,{transitionId}); + return entry?.entry?.transition ?? null; + }, + async loadReceipt(runId,requestId) { + const receipt=legacy.getReceipt(runId,requestId); + if (!receipt) return null; + const saved=legacy.readJournalRecord(runId,{revision:receipt.acceptedRevision}); + return {namespace,runId,requestId,kind:'agent',submissionDigest:receipt.digest, + acceptedRevision:receipt.acceptedRevision,outcome:saved.entry.receiptOutcome ?? 'legacy', + transitionId:saved.transitionId,nodeId:receipt.nodeId,attempt:receipt.attempt}; + }, + }); + const programRegistry=Object.freeze({ + namespace, + async putImmutable(bytes,manifest) { + legacy.putArtifact({bytes,programIdentity:manifest}); + return refFor(namespace,manifest); + }, + async resolveByHash(artifactHash) { + const value=legacy.getArtifact(artifactHash); + if (!value) throw fail('FK_I_PROGRAM_MISMATCH','Program bytes unavailable'); + return value.bytes; + }, + async describe(artifactHash) {return legacy.getArtifact(artifactHash)?.programIdentity ?? null;}, + async retain(runId,artifactHash) { + if (legacy.load(runId)?.artifactHash!==artifactHash) { + throw fail('FK_I_PROGRAM_MISMATCH','retain must come from the atomic create transaction'); + } + }, + async release(runId,artifactHash) { + if (legacy.load(runId)?.artifactHash===artifactHash) { + throw fail('FK_I_ARTIFACT_IN_USE','durable Run still retains the Program'); + } + }, + }); + const adminController=Object.freeze({ + async adminTerminate(runId,reason,auth) { + const authorize=adminCallbacks.get(auth); + if (!authorize || auth.namespace!==namespace || typeof runId!=='string' || !runId + || typeof reason!=='string' || !/^[A-Z][A-Z0-9_.-]{0,63}$/.test(reason)) { + throw fail('FK_I_HOST_DENIED','invalid management request'); + } + let allowed; + try {allowed=await authorize(Object.freeze({action:'run:admin-terminate',namespace,runId,reasonCode:reason, + subjectId:auth.subjectId}));} catch {} + if (allowed!==true) throw fail('FK_I_HOST_DENIED','management authorization denied'); + const cp=legacy[sqliteAdminTerminate](runId,reason,auth.subjectId); + return terminalFrame(cp.runId,cp.revision,cp.status,cp.terminal); + }, + }); + return Object.freeze({store,programRegistry,adminController,close:()=>legacy.close()}); +} + +function terminalFrame(runId,revision,status,terminal) { + return {protocolVersion:1,runId,revision,status,pending:[],checkpointRef:{runId,revision}, + resultRef:{runId,revision,projectionVersion:1}, + ...(status==='succeeded'||status==='failed' ? {terminal:{nodeId:terminal.nodeId,status}} : {})}; +} + +function sameTransition(saved,expected) { + if (!saved) return false; + const {committedAt:_actual,...actual}=saved; + const {committedAt:_expected,...rest}=expected; + return canonicalBytes(actual).equals(canonicalBytes(rest)); +} + +function executionStore(store,registry) { + async function read(runId) { + const cp=await store.load(runId); + if (cp && (cp.runId!==runId || cp.namespace!==store.namespace || !Number.isSafeInteger(cp.revision) + || cp.revision<0)) throw uncertain(); + return cp; + } + return { + namespace:store.namespace, + load:read, + async getArtifact(artifactHash) { + const programIdentity=await registry.describe(artifactHash); + if (!programIdentity) return null; + const bytes=await registry.resolveByHash(artifactHash); + return {programIdentity,bytes}; + }, + async putArtifact({programIdentity,bytes}) { + const ref=await registry.putImmutable(bytes,programIdentity); + const persisted=await registry.describe(programIdentity.artifactHash); + const storedBytes=await registry.resolveByHash(programIdentity.artifactHash); + if (ref?.namespace!==store.namespace || ref.artifactHash!==programIdentity.artifactHash + || !persisted || hash(persisted)!==hash(programIdentity) + || !(storedBytes instanceof Uint8Array) || sha256Bytes(storedBytes)!==programIdentity.artifactHash) { + throw fail('FK_I_PROGRAM_MISMATCH','registered Program identity did not survive readback'); + } + }, + async createRun({startKey,checkpoint}) { + const startIntentHash=hash({namespace:store.namespace,startKey,artifactHash:checkpoint.artifactHash, + state:checkpoint.state,policyHash:checkpoint.policyHash,intent:null}); + const initial={...checkpoint,startKey,startIntentHash}; + const opened=await store.createIfAbsent(startKey,initial,genesisFor(initial)); + if (opened?.checkpoint?.namespace!==store.namespace || opened.checkpoint.startKey!==startKey + || opened.checkpoint.startIntentHash!==startIntentHash) throw uncertain(); + return opened; + }, + async getReceipt(runId,requestId) { + const receipt=await store.loadReceipt(runId,requestId); + if (!receipt) return null; + if (receipt.namespace!==store.namespace || receipt.runId!==runId || receipt.requestId!==requestId + || receipt.kind!=='agent' || !Number.isSafeInteger(receipt.acceptedRevision) || receipt.acceptedRevision<1) { + throw uncertain(); + } + return {...receipt,digest:receipt.submissionDigest}; + }, + async compareAndSwap({runId,expectedRevision,nextCheckpoint,transitionId,journalEntry,fromBlockId}) { + const transition=journalEntry?.transition ?? { + namespace:store.namespace,runId,transitionId,fromRevision:expectedRevision,toRevision:expectedRevision+1, + fromBlockId, + toBlockId:nextCheckpoint.blockId,programHash:nextCheckpoint.artifactHash, + eventDigest:hash(journalEntry ?? null),committedAt:new Date().toISOString(), + }; + const receipt=journalEntry?.kind==='receipt-accepted' ? { + namespace:store.namespace,runId,requestId:journalEntry.requestId,kind:'agent', + submissionDigest:journalEntry.digest,acceptedRevision:nextCheckpoint.revision, + outcome:journalEntry.receiptOutcome ?? (nextCheckpoint.candidate.submissionKind==='agent-result'?'succeeded':'failed'), + transitionId,nodeId:journalEntry.nodeId,attempt:journalEntry.attempt, + } : undefined; + let result; + try { + result=await store.compareAndSwap(runId,expectedRevision,{next:nextCheckpoint,transition,receipt}); + } catch (error) { + if (['FK_I_CHECKPOINT_CAPACITY','FK_I_INVALID_TRANSITION','FK_I_INVALID_RECEIPT', + 'FK_I_TERMINAL_IMMUTABLE','FK_I_REVISION_EXHAUSTED','FK_I_RUN_NOT_FOUND'].includes(error?.code)) throw error; + let saved,latest; + try { + saved=await store.loadTransition(runId,transitionId); + if (saved && receipt) { + const persisted=await store.loadReceipt(runId,receipt.requestId); + if (persisted?.transitionId!==transitionId || persisted.submissionDigest!==receipt.submissionDigest + || persisted.acceptedRevision!==receipt.acceptedRevision) throw uncertain(); + } + latest=await read(runId); + } catch {throw uncertain();} + if (sameTransition(saved,transition) && latest?.revision>=transition.toRevision) { + return {committed:true,checkpoint:latest}; + } + if (!saved && latest?.revision>expectedRevision) return {committed:false,checkpoint:latest}; + throw uncertain(); + } + if (result?.committed===true && result.checkpoint?.runId===runId + && result.checkpoint.namespace===store.namespace && result.checkpoint.revision===transition.toRevision + && hash(result.checkpoint)===hash(nextCheckpoint)) return result; + if (result?.committed===false && Number.isSafeInteger(result.currentRevision) + && result.currentRevision>expectedRevision) { + const latest=await read(runId); + if (latest?.revision>=result.currentRevision) return {committed:false,checkpoint:latest}; + } + throw uncertain(); + }, + }; +} + +async function checkAccess(options,action,resource) { + options.signal?.throwIfAborted(); + const callbacks=authCallbacks.get(options.auth); + if (!callbacks || options.auth.namespace!==options.store.namespace + || options.programRegistry.namespace!==options.store.namespace) throw fail('FK_I_HOST_DENIED','invalid Host capability'); + const request=Object.freeze({namespace:options.auth.namespace,subjectId:options.auth.subjectId,action,...resource}); + let approved; + try {approved=await callbacks.authorize(request);} catch {} + if (approved!==true) throw fail('FK_I_HOST_DENIED','Host authorization denied'); + return callbacks; +} + +async function readTerminalResult(resultRef,options) { + options=operationOptions(options); + const ref=safeJSONSnapshot(resultRef,2048); + if (!ref || Object.keys(ref).length!==3 || typeof ref.runId!=='string' + || !Number.isSafeInteger(ref.revision) || ref.revision<0 || ref.projectionVersion!==1) { + throw fail('FK_I_HOST_DENIED','invalid result reference'); + } + await checkAccess(options,'result:read',{runId:ref.runId,revision:ref.revision}); + const cp=await options.store.load(ref.runId); + if (cp?.namespace!==options.auth.namespace || cp.runId!==ref.runId || cp.revision!==ref.revision + || cp.policyHash!==hash(options.policy) || !['succeeded','failed','errored','cancelled'].includes(cp.status)) { + throw fail('FK_I_HOST_DENIED','result is not accessible'); + } + return safeJSONSnapshot({ + projectionVersion:1,namespace:cp.namespace,runId:cp.runId,status:cp.status, + state:cp.state,contextError:null, + ...(cp.status==='succeeded'||cp.status==='failed' ? {terminal:{nodeId:cp.terminal.nodeId,status:cp.status}} : {}), + ...(typeof cp.terminal?.causeCode==='string' ? {causeCode:cp.terminal.causeCode} : {}), + }); +} + +function bindingRuntime(runtime,policy) { + const bound={}; + for (const [key,mode] of [['validateResult','pure-local'],['skillResolver','trusted-read']]) { + const value=runtime[key]; + if (!value) continue; + const approved=policy.bindingManifest?.[key]; + if (!approved || typeof value.id!=='string' || typeof value.version!=='string' + || value.mode!==mode || approved.id!==value.id || approved.version!==value.version + || approved.mode!==value.mode || typeof value.implementation!=='function') { + throw fail('FK_I_POLICY_MISMATCH','unapproved Runtime binding: '+key); + } + const implementation=value.implementation; + bound[key]=key==='skillResolver' ? descriptor=>{ + const reference=descriptor.skill ?? descriptor.agent; + return implementation(reference,descriptor); + } : implementation; + } + if (runtime.checkStep) bound.checkStep=runtime.checkStep; + return bound; +} + +function operationOptions(options) { + if (!options || typeof options!=='object') throw new TypeError('Interactive options are required'); + return {...options,policy:safeJSONSnapshot(options.policy)}; +} + +function bind(runtime,options) { + const {store,programRegistry,policy,maxStagesPerCall,signal}=options; + if (!store || !programRegistry || !policy || policy.capabilityStage!=='P0-A' + || policy.canonicalizationVersion!=='jcs-rfc8785-v1' || typeof policy.id!=='string' + || !policy.id || typeof policy.version!=='string' || !policy.version + || typeof policy.disclosePolicyId!=='string' || !policy.disclosePolicyId + || !policy.bindingManifest || !runtime + || !['createIfAbsent','load','compareAndSwap','loadTransition','loadReceipt'].every(key=>typeof store[key]==='function') + || !['putImmutable','describe','resolveByHash','retain','release'].every(key=>typeof programRegistry[key]==='function')) { + throw new TypeError('P0-A policy, Runtime and asynchronous persistence are required'); + } + return buildCoordinator({store:executionStore(store,programRegistry),runtime:bindingRuntime(runtime,policy), + policy,maxStagesPerCall,signal},true); +} + +async function publicFrame(frame,options,callbacks) { + const {runId,revision,status}=frame; + const output={protocolVersion:1,runId,revision,status,pending:[],checkpointRef:{runId,revision}}; + if (frame.acceptedRevision!==undefined) { + output.acknowledgement={acceptedRevision:frame.acceptedRevision,repeated:frame.duplicate}; + } + if (status==='waiting') { + const checkpoint=await options.store.load(runId); + if (checkpoint?.namespace!==options.auth.namespace || checkpoint.revision!==revision + || checkpoint.status!=='waiting' || checkpoint.pending?.requestId!==frame.pending?.requestId) throw uncertain(); + const {requestId,nodeId,attempt,name,instruction,input,missing,resultSchema,reasons,overview}=frame.pending; + const task=safeJSONSnapshot({name,instruction,input,missing,resultSchema,reasons,...(overview?{overview}:{})}); + let approved; + try { + approved=await callbacks.authorizeTask(Object.freeze({namespace:options.auth.namespace, + subjectId:options.auth.subjectId,runId,requestId,revision,task:safeJSONSnapshot(task)})); + } catch {} + if (approved!==true) throw fail('FK_I_HOST_DENIED','Host task disclosure denied'); + const current=await options.store.load(runId); + if (current?.namespace!==options.auth.namespace || current.revision!==revision + || current.status!=='waiting' || current.pending?.requestId!==requestId) throw uncertain(); + output.pending.push({requestId,nodeId,attempt,activation:checkpoint.controlStack.at(-1).activation, + kind:'agent',intent:'execute',task}); + } else if (['succeeded','failed','errored','cancelled'].includes(status)) { + Object.assign(output,terminalFrame(runId,revision,status,frame.terminal)); + } else if (status!=='paused') throw uncertain(); + return output; +} + +export async function start(source,runtime,initialState,options) { + options=operationOptions(options); + const state=safeJSONSnapshot(initialState); + const reference=typeof source==='string' ? source : safeJSONSnapshot(source); + const {startKey}=options; + const coordinator=bind(runtime,options); + if (typeof startKey!=='string' || !startKey) throw new TypeError('A start key is required'); + const callbacks=await checkAccess(options,'run:create',{startKey, + ...(typeof reference==='string'?{source:reference}:{artifactHash:reference?.artifactHash})}); + let bytes,identity; + if (typeof reference==='string') { + const compiled=await compileInteractive(reference,{output:'memory',featureStage:'P0-A'}); + if (!compiled.programIdentity) throw Object.assign(fail('FK_I_PROGRAM_MISMATCH','source compilation failed'), + {diagnostics:compiled.diagnostics}); + bytes=Buffer.from(compiled.code); + identity=compiled.programIdentity; + } else { + if (reference?.namespace!==options.auth.namespace) throw fail('FK_I_HOST_DENIED','invalid Program reference'); + identity=await options.programRegistry.describe(reference.artifactHash); + if (!identity || identity.artifactHash!==reference.artifactHash || identity.workflowId!==reference.programId) { + throw fail('FK_I_PROGRAM_MISMATCH','invalid Program reference identity'); + } + bytes=await options.programRegistry.resolveByHash(reference.artifactHash); + } + return publicFrame(await coordinator.start({programBytes:bytes,programIdentity:identity,startKey,state, + authorizeExisting:runId=>checkAccess(options,'run:resume',{runId})}),options,callbacks); +} + +export async function complete(submission,runtime,options) { + options=operationOptions(options); + const input=safeJSONSnapshot(submission); + if (input?.kind!=='agent' || typeof input.runId!=='string' || typeof input.requestId!=='string' + || !['succeeded','failed'].includes(input.outcome) + || Object.keys(input).length!==5 + || (input.outcome==='failed' && (typeof input.error?.message!=='string' || !input.error.message + || typeof input.error.code!=='string' || !input.error.code + || (input.error.retryable!==undefined && typeof input.error.retryable!=='boolean') + || Object.keys(input.error).some(key=>!['message','code','retryable','origin'].includes(key)) + || (input.error.origin!==undefined && !['runtime','executor','validator','store','policy'].includes(input.error.origin)))) + || (input.outcome==='succeeded' && (!input.result || typeof input.result!=='object' || Array.isArray(input.result)))) { + throw fail('FK_I_INVALID_RECEIPT','a bounded Agent submission is required'); + } + const coordinator=bind(runtime,options); + const callbacks=await checkAccess(options,'run:complete',{runId:input.runId,requestId:input.requestId}); + const cp=await options.store.load(input.runId); + if (!cp || cp.namespace!==options.auth.namespace || cp.runId!==input.runId) { + throw fail('FK_I_HOST_DENIED','Run is not accessible'); + } + if (cp.status==='cancelled') throw fail('FK_I_STALE_RECEIPT','Run has been terminated'); + const historical=await options.store.loadReceipt(input.runId,input.requestId); + let task=historical ?? cp.pending; + if (historical && (typeof historical.nodeId!=='string'||!Number.isSafeInteger(historical.attempt))) { + const indexed=cp.receipts?.find(receipt=>receipt.requestId===input.requestId); + const suffix='::validate'; + if (indexed) task={...historical,nodeId:indexed.nodeId,attempt:indexed.attempt}; + else { + const transition=await options.store.loadTransition(input.runId,historical.transitionId); + if (cp.transitionIdVersion!==1 || transition?.namespace!==cp.namespace || transition.runId!==cp.runId + || transition.transitionId!==historical.transitionId || transition.toRevision!==historical.acceptedRevision + || !transition.toBlockId?.endsWith(suffix)) throw uncertain(); + // Versioned receipt equality is the canonical public submission hash. + // The attempt is used only to enter the shared historical Ack path. + task={...historical,nodeId:transition.toBlockId.slice(0,-suffix.length),attempt:1}; + } + } + if (!task || task.requestId!==input.requestId || (historical && + (historical.runId!==input.runId || historical.namespace!==options.auth.namespace))) { + throw fail('FK_I_STALE_RECEIPT','request is not current or accepted'); + } + const {runId,requestId,outcome}=input; + const result=await coordinator.complete({runId,requestId,nodeId:task.nodeId,attempt:task.attempt, + submissionDigest:hash(input), + ...(outcome==='succeeded' ? {result:input.result} + : {kind:'agent-call-failed',failure:{message:input.error.message}})}); + const frame=await publicFrame(result,options,callbacks); + frame.acknowledgement.requestId=requestId; + return frame; +} + +export async function resume(runId,runtime,options) { + options=operationOptions(options); + const coordinator=bind(runtime,options); + const callbacks=await checkAccess(options,'run:resume',{runId}); + return publicFrame(await coordinator.resume({runId}),options,callbacks); +} diff --git a/tests/interactive/codex-file-handoff.test.mjs b/tests/interactive/codex-file-handoff.test.mjs new file mode 100644 index 0000000..d1a58c4 --- /dev/null +++ b/tests/interactive/codex-file-handoff.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {link,mkdir,mkdtemp,rename,rm,symlink,truncate,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.mjs'; +import {canonicalBytes,MAX_INLINE_AGENT_RESULT_BYTES,sha256Bytes} from '../../src/interactive/canonical.mjs'; + +async function fixture(t) { + const dir=await mkdtemp(path.join(tmpdir(),'flowkit-file-')); + const inbox=path.join(dir,'inbox'); + await mkdir(inbox,{mode:0o700}); + const database=path.join(dir,'private.sqlite'); + let timestamp=1; + const options={database,inbox,now:()=>timestamp, + validateArtifact:value=>Object.keys(value).length===1 && typeof value.answer==='string'}; + const handoff=createFileHandoff(options); + t.after(async()=>{handoff.close();await rm(dir,{recursive:true,force:true});}); + const binding={namespace:'tenant-a',runId:'run-a',requestId:'req-a',nodeId:'agent',attempt:1, + threadId:'thread-a',turnId:'turn-a',programHash:'sha256:'+'1'.repeat(64),policyHash:'sha256:'+'2'.repeat(64)}; + const pending={runId:binding.runId,requestId:binding.requestId,attempt:1,nodeId:'agent'}; + let current={namespace:binding.namespace,runId:binding.runId,status:'waiting',pending, + artifactHash:binding.programHash,policyHash:binding.policyHash}; + let signs=0,accepts=0; + function call(ref,overrides={}) { + return {tool:'flowkit_submit_file',callId:'call-a',threadId:binding.threadId,turnId:binding.turnId, + arguments:ref,...overrides}; + } + const operation=(ref,overrides={})=>handoff.consume({ + started:call(ref),callback:call(ref), + loadPending:()=>current,async signAndAccept() {signs++;accepts++;return {acceptedRevision:7};}, + ...overrides, + }); + async function put(bytes) { + const sourceId=randomUUID(),source=path.join(inbox,sourceId+'.json'); + await writeFile(source,bytes,{flag:'wx'}); + return {source,request:{sourceId,binding,byteLength:bytes.length,sha256:sha256Bytes(bytes)}}; + } + return {handoff,operation,call,put,options,counts:()=>({signs,accepts}), + expire:()=>{timestamp=130000;},setPending:next=>{current={...current,pending:next};}}; +} + +test('file fallback consumes the persisted original after ingress is replaced or deleted',async t=>{ + for (const size of [256*1024,2*1024*1024-4096]) { + const f=await fixture(t); + const bytes=canonicalBytes({answer:'a'.repeat(size-13)}); + const {source,request}=await f.put(bytes); + const ref=await f.handoff.stage(request); + const replacement=source+'.replacement'; + await writeFile(replacement,Buffer.alloc(size,122)); + await rm(source); + await rename(replacement,source); + const reader=createFileHandoff(f.options); + try { + const saved=reader.readSnapshot(ref.id); + assert.deepEqual(saved.bytes,bytes); + saved.bytes[0]=0; + } finally {reader.close();} + await rm(source); + await f.operation(ref,{async signAndAccept({artifact,binding,callId,pending}) { + assert.deepEqual(artifact,{id:ref.id,byteLength:size,sha256:request.sha256}); + assert.equal(binding.requestId,pending.requestId); + assert.equal(callId,'call-a'); + return {acceptedRevision:7}; + }}); + assert.equal(f.handoff.readSnapshot(ref.id).state,'acked'); + assert.deepEqual(f.handoff.readSnapshot(ref.id).bytes,bytes); + await assert.rejects(f.operation(ref),{code:'FK_FILE_CONSUMED'}); + console.log('FLOWKIT_FILE_FALLBACK '+JSON.stringify({size,sha256:request.sha256, + originalRetained:true,sourceRemoved:true,duplicateRejected:true,acceptedRevision:7})); + } +}); + +test('file fallback rejects changed, truncated, oversize and invalid bytes before signing',async t=>{ + const f=await fixture(t); + const bytes=canonicalBytes({answer:'hello'}); + const changed=await f.put(bytes); + const replacement=changed.source+'.replacement'; + await writeFile(replacement,canonicalBytes({answer:'other'})); + await rm(changed.source); + await rename(replacement,changed.source); + await assert.rejects(f.handoff.stage(changed.request),{code:'FK_FILE_DIGEST'}); + const short=await f.put(bytes); + await truncate(short.source,short.request.byteLength-1); + await assert.rejects(f.handoff.stage(short.request),{code:'FK_FILE_LENGTH'}); + const big=await f.put(Buffer.alloc(MAX_INLINE_AGENT_RESULT_BYTES+1)); + await assert.rejects(f.handoff.stage(big.request),{code:'FK_FILE_LIMIT'}); + await assert.rejects(f.handoff.stage({...big.request,byteLength:bytes.length}),{code:'FK_FILE_LIMIT'}); + for(const candidate of [Buffer.from('{"answer":"x","answer":"y"}'),Buffer.from([0xff])]) { + const invalid=await f.put(candidate); + await assert.rejects(f.handoff.stage(invalid.request),{code:'FK_FILE_JSON'}); + } + const wrongSchema=await f.put(canonicalBytes({answer:1})); + await assert.rejects(f.handoff.stage(wrongSchema.request),{code:'FK_FILE_SCHEMA'}); + assert.deepEqual(f.counts(),{signs:0,accepts:0}); +}); + +test('file fallback rejects hard links and symbolic links before staging',async t=>{ + const f=await fixture(t); + const bytes=canonicalBytes({answer:'hello'}); + const hardLinked=await f.put(bytes); + await link(hardLinked.source,hardLinked.source+'.link'); + await assert.rejects(f.handoff.stage(hardLinked.request),{code:'FK_FILE_SOURCE'}); + + const redirected=await f.put(bytes); + const target=redirected.source+'.target'; + await rename(redirected.source,target); + try { + await symlink(target,redirected.source,'file'); + } catch (error) { + if (process.platform!=='win32' || !['EPERM','EACCES'].includes(error.code)) throw error; + // An unelevated Windows runner may only be able to create a junction. + const directory=target+'-directory'; + await mkdir(directory); + await symlink(directory,redirected.source,'junction'); + t.diagnostic('File symlinks require privileges on this Windows runner. Tested a junction.'); + } + await assert.rejects(f.handoff.stage(redirected.request),{code:'FK_FILE_SOURCE'}); + + // All inputs share a request. A regular file can still stage it after both rejections. + const regular=await f.put(bytes); + const ref=await f.handoff.stage(regular.request); + assert.deepEqual(f.handoff.readSnapshot(ref.id).bytes,bytes); +}); + +test('file fallback rejects wrong request, wrong session, missing observation and competing consumers',async t=>{ + const f=await fixture(t); + const input=await f.put(canonicalBytes({answer:'hello'})); + const ref=await f.handoff.stage(input.request); + await assert.rejects(f.operation({...ref,requestId:'wrong'}),{code:'FK_FILE_BINDING'}); + const wrongSession=f.call(ref,{turnId:'wrong'}); + await assert.rejects(f.operation(ref,{started:wrongSession,callback:wrongSession}),{code:'FK_FILE_BINDING'}); + await assert.rejects(f.operation(ref,{started:null}),{code:'FK_FILE_OBSERVATION'}); + await assert.rejects(f.operation(ref,{callback:f.call(ref,{callId:'wrong'})}),{code:'FK_FILE_OBSERVATION'}); + f.setPending({requestId:'different',attempt:2}); + await assert.rejects(f.operation(ref),{code:'FK_FILE_PENDING'}); + assert.deepEqual(f.counts(),{signs:0,accepts:0}); + f.setPending({requestId:'req-a',attempt:1,nodeId:'agent'}); + const results=await Promise.allSettled([f.operation(ref),f.operation(ref)]); + assert.equal(results.filter(result=>result.status==='fulfilled').length,1); + assert.equal(results.find(result=>result.status==='rejected').reason.code,'FK_FILE_CONSUMED'); + assert.deepEqual(f.counts(),{signs:1,accepts:1}); +}); + +test('expired references can be purged and uncertain claims cannot sign again after reopen',async t=>{ + const f=await fixture(t); + const {request}=await f.put(canonicalBytes({answer:'hello'})); + const first=await f.handoff.stage(request); + f.expire(); + await assert.rejects(f.operation(first),{code:'FK_FILE_EXPIRY'}); + assert.equal(f.handoff.purgeExpired(),1); + const second=await f.handoff.stage(request); + await assert.rejects(f.operation(second,{async signAndAccept(){throw new Error('uncertain');}}),/uncertain/); + const reopened=createFileHandoff(f.options); + try { + assert.equal(reopened.readSnapshot(second.id).state,'claimed'); + await assert.rejects(reopened.consume({started:f.call(second),callback:f.call(second), + loadPending:()=>assert.fail('must not read Pending again'),signAndAccept:()=>assert.fail('must not re-sign')}), + {code:'FK_FILE_CONSUMED'}); + } finally {reopened.close();} +}); diff --git a/tests/interactive/codex-outbox.test.mjs b/tests/interactive/codex-outbox.test.mjs new file mode 100644 index 0000000..6a15530 --- /dev/null +++ b/tests/interactive/codex-outbox.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import {generateKeyPairSync,randomUUID,verify} from 'node:crypto'; +import {mkdirSync,mkdtempSync,rmSync,writeFileSync,unlinkSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import {DatabaseSync} from 'node:sqlite'; +import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; +import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.mjs'; +import {sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {encodeAgentReceiptStatement} from '../../src/interactive/host-attestation.mjs'; + +test('Codex Outbox replays exact signed bytes, reconciles historical Ack and blocks expired work',async t=>{ + const directory=mkdtempSync(path.join(tmpdir(),'flowkit-outbox-')); + const database=path.join(directory,'outbox.sqlite'); + const keys=generateKeyPairSync('ed25519'); + const pending={runId:'run',requestId:'pending',nodeId:'agent',attempt:1}; + const task={...pending,input:{question:'Answer ready'}}; + const checkpoint={namespace:'host',status:'waiting',pending, + artifactHash:'sha256:'+'1'.repeat(64),policyHash:'sha256:'+'2'.repeat(64)}; + const receipt={...pending,kind:'agent-result',result:{answer:'ready'}}; + const call={threadId:'thread',turnId:'turn',callId:'call',tool:'flowkit_submit', + args:{requestId:'pending',answer:'ready'}}; + let ack=null,accepts=0,now=1000; + const store={namespace:'host',load:()=>checkpoint,getReceipt:()=>ack}; + const host={accept({receipt:supplied,attestation:{statement,signature}}) { + assert.deepEqual(supplied,receipt); + assert.ok(verify(null,encodeAgentReceiptStatement(statement),keys.publicKey,Buffer.from(signature,'base64url'))); + accepts++; + ack??={digest:statement.receiptHash,nodeId:receipt.nodeId,attempt:receipt.attempt,acceptedRevision:4}; + return {acceptedRevision:ack.acceptedRevision}; + }}; + const open=()=>openCodexOutbox({database,store,host,now:()=>now}); + let outbox=open(); + t.after(()=>{outbox.close();rmSync(directory,{recursive:true,force:true});}); + const observed=(callback=call,started=callback)=>({task,modelPid:100,callback,started}); + await assert.rejects(outbox.record(observed(call,{...call,turnId:'other'}),receipt,keys.privateKey), + {code:'FK_OUTBOX_OBSERVATION'}); + let unsigned; + await assert.rejects(outbox.record(observed({...call,callId:'unsigned'}),receipt,keys.privateKey,{ + afterObserve(row) {unsigned=row;throw new Error('before signing');}, + }),/before signing/); + assert.equal(unsigned.state,'OBSERVED'); + assert.equal(unsigned.statementHash,undefined); + assert.deepEqual(await outbox.recover('run'),[]); + assert.equal(accepts,0); + const id=await outbox.record(observed(),receipt,keys.privateKey); + await assert.rejects(outbox.record(observed(),receipt,keys.privateKey)); + const signed=outbox.describe(id); + assert.equal(signed.state,'SIGNED'); + assert.equal(accepts,0); + await assert.rejects(outbox.deliver(id,{afterAccept:()=>{throw new Error('after CAS');}}),/after CAS/); + assert.deepEqual(outbox.describe(id),signed); + assert.equal(ack.acceptedRevision,4); + outbox.close(); + outbox=open(); + assert.deepEqual(await outbox.recover('run'),[{...signed,state:'ACKED',acceptedRevision:4}]); + assert.deepEqual(await outbox.recover('run'),[]); + assert.equal(accepts,2); + + const retry=await outbox.record(observed({...call,callId:'retry'}),receipt,keys.privateKey); + const expired=await outbox.record(observed({...call,callId:'expired',args:{...call.args,answer:'stale'}}), + {...receipt,result:{answer:'stale'}},keys.privateKey); + now+=120001; + assert.deepEqual((await outbox.recover('run')).map(row=>[row.proofId,row.state]),[[retry,'ACKED'],[expired,'BLOCKED']]); + assert.equal(accepts,3); +}); + +test('file Outbox recovery verifies the claimed bytes and replays an already-recorded file Ack',async t=>{ + const directory=mkdtempSync(path.join(tmpdir(),'flowkit-file-outbox-')); + const inbox=path.join(directory,'inbox'); + const handoffDatabase=path.join(directory,'file.sqlite'); + const database=path.join(directory,'outbox.sqlite'); + mkdirSync(inbox); + const handoff=createFileHandoff({database:handoffDatabase,inbox,validateArtifact:()=>true}); + const keys=generateKeyPairSync('ed25519'); + const pending={runId:'run',requestId:'request',nodeId:'agent',attempt:1}; + const binding={...pending,namespace:'host',threadId:'thread',turnId:'turn', + programHash:'sha256:'+'1'.repeat(64),policyHash:'sha256:'+'2'.repeat(64)}; + const cp={...pending,namespace:'host',status:'waiting',pending,artifactHash:binding.programHash,policyHash:binding.policyHash}; + const bytes=Buffer.from('{"answer":"ready"}'); + const sourceId=randomUUID(); + const file=path.join(inbox,sourceId+'.json'); + writeFileSync(file,bytes); + let ack=null,accepts=0; + const store={namespace:'host',load:()=>cp,getReceipt:()=>ack}; + const host={accept({attestation:{statement}}) { + accepts++; + ack={digest:statement.receiptHash,nodeId:pending.nodeId,attempt:1,acceptedRevision:4}; + return {acceptedRevision:4}; + }}; + const outbox=openCodexOutbox({database,store,host,handoff}); + t.after(()=>{outbox.close();handoff.close();rmSync(directory,{recursive:true,force:true});}); + const reference=await handoff.stage({sourceId,binding,byteLength:bytes.length,sha256:sha256Bytes(bytes)}); + unlinkSync(file); + const event={threadId:binding.threadId,turnId:binding.turnId,callId:'call',tool:'flowkit_submit_file'}; + const call={...event,arguments:reference}; + let proofId; + await assert.rejects(handoff.consume({started:call,callback:call,loadPending:store.load, + async signAndAccept({artifact}) { + const observed={...event,args:reference}; + const receipt={...pending,kind:'agent-result',result:{artifact}}; + await assert.rejects(outbox.record({task:pending,started:observed,callback:observed,modelPid:100}, + {...receipt,result:{artifact:{...artifact,sha256:binding.programHash}}},keys.privateKey),{code:'FK_FILE_BINDING'}); + proofId=await outbox.record({task:pending,started:observed,callback:observed,modelPid:100},receipt,keys.privateKey); + throw new Error('after signed'); + }}),/after signed/); + const db=new DatabaseSync(handoffDatabase); + try { + const update=db.prepare('UPDATE file_handoff SET bytes=? WHERE id=?'); + update.run(Buffer.alloc(bytes.length),reference.id); + await assert.rejects(outbox.recover('run'),{code:'FK_FILE_STORE'}); + assert.equal(accepts,0); + update.run(bytes,reference.id); + } finally {db.close();} + await assert.rejects(outbox.deliver(proofId,{afterAccept:()=>{throw new Error('after CAS');}}),/after CAS/); + handoff.acknowledge(reference.id,event.callId,ack.acceptedRevision); + assert.equal((await outbox.recover('run'))[0].state,'ACKED'); + assert.deepEqual(handoff.readSnapshot(reference.id),{bytes,sha256:sha256Bytes(bytes),byteLength:bytes.length, + state:'acked',acceptedRevision:4}); + assert.deepEqual(await outbox.recover('run'),[]); +}); diff --git a/tests/interactive/complex-c01-c12.test.mjs b/tests/interactive/complex-c01-c12.test.mjs index 807e64e..ae055dd 100644 --- a/tests/interactive/complex-c01-c12.test.mjs +++ b/tests/interactive/complex-c01-c12.test.mjs @@ -7,6 +7,7 @@ import assert from 'node:assert/strict'; import {mkdtemp,rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import path from 'node:path'; +import {spawn} from 'node:child_process'; import {generateKeyPairSync,sign} from 'node:crypto'; import {analyzeSource} from '../../src/compiler/analyze.mjs'; import {lowerInteractive} from '../../src/compiler/interactive/lower.mjs'; @@ -86,8 +87,8 @@ async function fixture(t,{ skillResolver:async({nodeId})=>'Perform declared step '+nodeId, validateResult:validator, }; - const makeHost=()=>createTrustedInteractiveHost({ - store,runtime,policy:{deploymentId:'complex-c01-c12',bindingVersions:{ + const makeHost=(hostStore=store)=>createTrustedInteractiveHost({ + store:hostStore,runtime,policy:{deploymentId:'complex-c01-c12',bindingVersions:{ resolver:'v1',validator:'v1', }}, trustedPublicKeys:{'trusted-host':keys.publicKey}, @@ -461,6 +462,53 @@ test('C15 lost Ack replay after Host rebuild and after downstream Terminal is im replayAfterTerminal:true}); }); +test('R11 reconciles a lost receipt acknowledgement after a second SQLite connection advances the run',async t=>{ + const f=await fixture(t); + const frame=await start(f,{runId:'R11-lost-response'}); + const original=receiptFor(await task(f,frame),answers.requirements); + const attestation=attest(f,original); + const lostResponse=new Error('test drops the successful receipt CAS response'); + let acceptedRevision; + const droppingStore={ + ...f.store, + compareAndSwap(args) { + const result=f.store.compareAndSwap(args); + if (result.committed && args.journalEntry?.kind==='receipt-accepted') { + acceptedRevision=result.checkpoint.revision; + throw lostResponse; + } + return result; + }, + }; + const firstHost=f.makeHost(droppingStore); + await assert.rejects(firstHost.accept({receipt:original,attestation}),error=>error===lostResponse); + assert.equal(f.store.getReceipt(frame.runId,original.requestId).acceptedRevision,acceptedRevision); + + const secondStore=createSqliteCheckpointStore(path.join(f.root,'run.sqlite'),{namespace:f.store.namespace}); + t.after(()=>secondStore.close()); + const second={...f,store:secondStore,host:f.makeHost(secondStore)}; + let later=await second.host.resume({runId:frame.runId}); + for (const id of ['design','plan','review']) { + assert.equal(later.pending.nodeId,id); + later=(await respond(second,later,answers[id])).frame; + } + assert.equal(later.status,'succeeded'); + assert.ok(later.revision>acceptedRevision+5); + const before=canonicalBytes(secondStore.load(frame.runId)); + const beforeJournal=secondStore.listJournal(frame.runId); + const replay=await f.host.accept({receipt:original,attestation}); + assert.equal(replay.duplicate,true); + assert.equal(replay.acceptedRevision,acceptedRevision); + assert.equal(replay.revision,later.revision); + assert.deepEqual(replay.state,later.state); + assert.deepEqual(canonicalBytes(secondStore.load(frame.runId)),before); + assert.deepEqual(secondStore.listJournal(frame.runId),beforeJournal); + console.log('FLOWKIT_R11_RECOVERY '+JSON.stringify({ + runId:frame.runId,requestId:original.requestId,acceptedRevision,latestRevision:later.revision, + sqliteConnections:2,modelCalls:0,journalRows:beforeJournal.length, + })); +}); + test('C16 signed Host call-failed in late Agent retries safely and does not skip route',async t=>{ const f=await fixture(t); let frame=await start(f,{runId:'C16-provider-failure'}); @@ -603,3 +651,93 @@ test('C20 conflicting independently signed late-Agent answers accept only one CA evidence('C20',f,frame,{twoValidCompetingHostReceipts:true, exactlyOneCAS:true,winningDesign:frame.state.designId}); }); + +test('C20 two Node processes submit different signed answers for the same Pending',async t=>{ + const f=await fixture(t); + let frame=await start(f,{runId:'C20-process-race'}); + frame=(await respond(f,frame,answers.requirements)).frame; + const p=await task(f,frame); + assert.equal(p.nodeId,'design'); + const publicKey=f.keys.publicKey.export({type:'spki',format:'pem'}); + const script=` + import {once} from 'node:events'; + import {createSqliteCheckpointStore} from ${JSON.stringify(new URL('../../src/interactive/sqlite-store.mjs',import.meta.url).href)}; + import {createTrustedInteractiveHost} from ${JSON.stringify(new URL('../../src/interactive/trusted-host.mjs',import.meta.url).href)}; + const [file,publicKey,artifactHash,submission]=process.argv.slice(1); + const store=createSqliteCheckpointStore(file,{namespace:'complex-acceptance'}); + try { + const host=createTrustedInteractiveHost({ + store,runtime:{ + skillResolver:async({nodeId})=>'Perform declared step '+nodeId, + validateResult:(schema,result)=>Object.keys(result).length===1 && + Object.keys(schema.properties).every(key=>typeof result[key]===schema.properties[key].type)?[]:['invalid'], + }, + policy:{deploymentId:'complex-c01-c12',bindingVersions:{resolver:'v1',validator:'v1'}}, + trustedPublicKeys:{'trusted-host':publicKey},trustedArtifacts:[artifactHash], + allowedInputKeys:['request','requirementId','designId','planId'], + disclosurePolicyId:'complex-test-public',authorizeDisclosure:async()=>true,now:()=>1794400000000, + }); + const release=once(process.stdin,'data'); + process.stdout.write('ready\\n'); + await release; + try { + const result=await host.accept(JSON.parse(submission)); + console.log(JSON.stringify({pid:process.pid,acceptedRevision:result.acceptedRevision,duplicate:result.duplicate})); + } catch (error) { + if(error.code!=='FK_I_RECEIPT_CONFLICT') throw error; + console.log(JSON.stringify({pid:process.pid,code:error.code})); + } + } finally {store.close();} + `; + const submissions=['DES-A','DES-B'].map(designId=>{ + const receipt=receiptFor(p,{designId}); + return {receipt,attestation:attest(f,receipt)}; + }); + const children=submissions.map(submission=>{ + const child=spawn(process.execPath,['--input-type=module','-e',script,path.join(f.root,'run.sqlite'), + publicKey,f.compiled.programIdentity.artifactHash,JSON.stringify(submission)],{stdio:['pipe','pipe','pipe']}); + let stdout='',stderr=''; + let markReady; + const ready=new Promise(resolve=>{markReady=resolve}); + child.stdin.on('error',error=>{stderr+=error.message}); + child.stdout.on('data',bytes=>{ + stdout+=bytes; + if(stdout.startsWith('ready\n')) markReady(true); + }); + child.stderr.on('data',bytes=>{stderr+=bytes}); + const closed=new Promise(resolve=>{ + child.once('error',error=>{markReady(false);resolve({error:error.message})}); + child.once('close',code=>{markReady(false);resolve({code,stdout,stderr})}); + }); + return {child,ready,closed}; + }); + const deadline=setTimeout(()=>children.forEach(({child})=>child.kill()),15000); + let records; + try { + assert.deepEqual(await Promise.all(children.map(child=>child.ready)),[true,true]); + for(const {child} of children) child.stdin.end('commit'); + records=(await Promise.all(children.map(child=>child.closed))).map(({code,stdout,stderr,error})=>{ + assert.equal(code,0,stderr??error); + return JSON.parse(stdout.split('\n')[1]); + }); + } finally { + clearTimeout(deadline); + for(const {child} of children) if(child.exitCode===null && child.signalCode===null) child.kill(); + await Promise.all(children.map(child=>child.closed)); + } + assert.notEqual(records[0].pid,records[1].pid); + const winner=records.findIndex(record=>record.duplicate===false); + assert.ok(winner>=0); + assert.equal(records[1-winner].code,'FK_I_RECEIPT_CONFLICT'); + const stored=f.store.getReceipt(frame.runId,p.requestId); + assert.equal(stored.acceptedRevision,records[winner].acceptedRevision); + const audited=audit(f,frame.runId); + assert.equal(audited.cp.state.designId,submissions[winner].receipt.result.designId); + assert.equal(audited.journal.filter(entry=>entry.entry?.requestId===p.requestId + && entry.entry.kind==='receipt-accepted').length,1); + frame=await f.host.resume({runId:frame.runId}); + frame=(await respond(f,frame,answers.plan)).frame; + frame=(await respond(f,frame,answers.review)).frame; + assert.equal(frame.status,'succeeded'); + evidence('C20',f,frame,{twoNodeProcesses:records,winningRequestId:p.requestId}); +}); diff --git a/tests/interactive/stage2-integration.test.mjs b/tests/interactive/stage2-integration.test.mjs index cacd726..4219ab8 100644 --- a/tests/interactive/stage2-integration.test.mjs +++ b/tests/interactive/stage2-integration.test.mjs @@ -138,6 +138,22 @@ test('S2 compiler refuses unrelated Standard options and accessors before any wr assert.equal(await readFile(disk.artifactPath,'utf8'),out.code); }); +test('CF-08: Interactive lowering uses the feature stage validated at compile entry',async()=>{ + const {workflows}=await project(); + const file=path.join(workflows,'snapshot.workflow.js'); + await writeFile(file,pure); + const options={output:'memory',featureStage:'P0-A'}; + const expected=await compileInteractive(file,options); + assert.ok(expected.code.length>0); + + const compilation=compileInteractive(file,options); + options.featureStage='P0-B'; + const actual=await compilation; + assert.deepEqual(actual.diagnostics,expected.diagnostics); + assert.deepEqual(actual.programIdentity,expected.programIdentity); + assert.equal(actual.code,expected.code); +}); + test('S2 harmless shared Host checkStep does not block pure Condition/Terminal, but Agent remains fail-closed',async t=>{ const {root,workflows}=await project(); const pureFile=path.join(workflows,'pure.workflow.js'); diff --git a/tests/interactive/trusted-host.test.mjs b/tests/interactive/trusted-host.test.mjs index af0d98a..e686702 100644 --- a/tests/interactive/trusted-host.test.mjs +++ b/tests/interactive/trusted-host.test.mjs @@ -114,6 +114,68 @@ test('M5 authorized public task contains only allowlisted declared input, not St assert.equal(f.store.load(f.frame.runId).revision,f.frame.revision); }); +test('CF-05: terminal projection and trusted result require separate permissions',async t=>{ + let allowedAction='run:read'; + const calls=[]; + const f=await waiting(t,{ + authorizeTerminalAccess:async context=>{ + calls.push(context); + return context.action===allowedAction; + }, + }); + const runId=f.frame.runId; + await assert.rejects(f.host.getPublicTerminalFrame({runId}),{code:'FK_I_HOST_DENIED'}); + const receipt=agentResult(f.frame); + const completed=await f.host.accept({receipt,attestation:proof(f.store.load(runId),receipt,f.keys.privateKey)}); + const resultRef={runId,revision:completed.revision,projectionVersion:1}; + const before=canonicalBytes(f.store.load(runId)); + const journal=f.store.listJournal(runId); + + const frame=await f.host.getPublicTerminalFrame({runId}); + assert.deepEqual(frame,{ + protocolVersion:1,runId,revision:completed.revision,status:'succeeded',pending:[], + checkpointRef:{runId,revision:completed.revision},terminal:{nodeId:'done',status:'succeeded'},resultRef, + }); + await assert.rejects(f.host.readTrustedTerminalResult({resultRef}),{code:'FK_I_HOST_DENIED'}); + allowedAction='result:read'; + await assert.rejects(f.host.getPublicTerminalFrame({runId}),{code:'FK_I_HOST_DENIED'}); + const result=await f.host.readTrustedTerminalResult({resultRef}); + assert.deepEqual(result,{ + projectionVersion:1,namespace:'tenant-m5',runId,status:'succeeded', + terminal:{nodeId:'done',status:'succeeded'},state:completed.state,contextError:null, + }); + assert.ok(calls.every(context=>Object.isFrozen(context) && + context.namespace==='tenant-m5' && context.runId===runId && context.revision===completed.revision)); + await assert.rejects(f.host.readTrustedTerminalResult({ + resultRef:{...resultRef,revision:resultRef.revision-1}, + }),{code:'FK_I_HOST_DENIED'}); + + const unconfigured=createTrustedInteractiveHost({...f.hostOptions,authorizeTerminalAccess:undefined}); + await assert.rejects(unconfigured.getPublicTerminalFrame({runId}),{code:'FK_I_HOST_DENIED'}); + const otherStore=createSqliteCheckpointStore(path.join(f.dir,'run.db'),{namespace:'another-tenant'}); + t.after(()=>otherStore.close()); + const other=createTrustedInteractiveHost({...f.hostOptions,store:otherStore,authorizeTerminalAccess:()=>true}); + await assert.rejects(other.readTrustedTerminalResult({resultRef}),{code:'FK_I_HOST_DENIED'}); + assert.deepEqual(canonicalBytes(f.store.load(runId)),before); + assert.deepEqual(f.store.listJournal(runId),journal); +}); + +test('CF-05: public error terminal omits the private cause and State',async t=>{ + const f=await fixture(t,{authorizeTerminalAccess:()=>true}); + const frame=await f.host.start({ + ...f.program,startKey:'terminal-with-error',state:{secret:'Host private data'}, + }); + assert.equal(frame.status,'errored'); + const projection=await f.host.getPublicTerminalFrame({runId:frame.runId}); + assert.equal(projection.status,'errored'); + assert.equal(Object.hasOwn(projection,'terminal'),false); + assert.equal(Object.hasOwn(projection,'causeCode'),false); + assert.equal(Object.hasOwn(projection,'state'),false); + const result=await f.host.readTrustedTerminalResult({resultRef:projection.resultRef}); + assert.equal(result.causeCode,'FK_R002'); + assert.deepEqual(result.state,{secret:'Host private data'}); +}); + test('M5 strict disclosure allowlist and authorizer both fail closed with zero CAS writes',async t=>{ for(const options of [ {allowedInputKeys:[]}, diff --git a/tests/interactive/versioned.test.mjs b/tests/interactive/versioned.test.mjs new file mode 100644 index 0000000..0ce43e9 --- /dev/null +++ b/tests/interactive/versioned.test.mjs @@ -0,0 +1,267 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,writeFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {DatabaseSync} from 'node:sqlite'; +import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import { + compileInteractive,complete,createInteractiveAdminContext,createInteractiveCoordinator,createInteractiveHostContext, + createSqliteCheckpointStore,createSqliteInteractivePersistence,resume,start, +} from '../../src/interactive/index.mjs'; + +const ns='p0a-compatible'; +const runtime={ + skillResolver:{id:'skill',version:'1',mode:'trusted-read',implementation:async reference=>'Skill: '+reference}, + validateResult:{id:'schema',version:'1',mode:'pure-local',implementation:(_schema,result)=> + typeof result.answer==='string'?[]:['Expected answer']}, +}; +const policy={ + id:'compatibility-test',version:'1',disclosePolicyId:'input-allowlist',capabilityStage:'P0-A', + canonicalizationVersion:'jcs-rfc8785-v1',bindingManifest:{ + skillResolver:{id:'skill',version:'1',mode:'trusted-read'}, + validateResult:{id:'schema',version:'1',mode:'pure-local'}, + }, +}; +const source=`import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl'; +export default defineWorkflow({id:'compat',name:'Compat',version:'1.0.0',nodes:[ +node('agent',{name:'Agent',agent:'writer',inputs:['message'], +output:{type:'object',properties:{answer:{type:'string'}}},retries:0,next:'done'}), +terminal('done',{name:'Done',status:'succeeded'}) +]});`; +const bare=`import {defineWorkflow,terminal} from '@devcodex-labs/flowkit/dsl'; +export default defineWorkflow({id:'pure',name:'Pure',version:'1.0.0',nodes:[ +terminal('done',{name:'Done',status:'succeeded'}) +]});`; +function context(overrides={}) { + return createInteractiveHostContext({namespace:ns,subjectId:'test-host', + authorize:()=>true,authorizeTask:({task})=>Object.keys(task.input).every(key=>key==='message'),...overrides}); +} +async function fixture(t) { + const dir=await mkdtemp(path.join(tmpdir(),'flowkit-compat-')); + const file=path.join(dir,'cp.sqlite'); + const persistence=createSqliteInteractivePersistence(file,{namespace:ns}); + const raw=createSqliteCheckpointStore(file,{namespace:ns}); + t.after(async()=>{persistence.close();raw.close();await rm(dir,{recursive:true,force:true});}); + const script=path.join(dir,'agent.workflow.js'); + await writeFile(script,source); + const compiled=await compileInteractive(script,{output:'memory',featureStage:'P0-A',skillCheck:false}); + assert.ok(compiled.code,JSON.stringify(compiled.diagnostics)); + const program=await persistence.programRegistry.putImmutable(Buffer.from(compiled.code),compiled.programIdentity); + return {dir,file,raw,persistence,compiled,program,options:{...persistence,policy,auth:context(),startKey:'new'}}; +} +const submission=(frame,result={answer:'yes'})=>({ + runId:frame.runId,requestId:frame.pending[0].requestId,kind:'agent',outcome:'succeeded',result, +}); + +test('R5 facade uses the five async operations, separates disclosure and preserves legacy Run history',async t=>{ + const f=await fixture(t); + const legacy=createInteractiveCoordinator({store:f.raw,policy,runtime:{ + skillResolver:async()=> 'Skill: writer',validateResult:runtime.validateResult.implementation, + }}); + const old=await legacy.start({programBytes:Buffer.from(f.compiled.code),programIdentity:f.compiled.programIdentity, + startKey:'legacy',state:{message:'hello',secret:'private'}}); + const oldHistory=f.raw.listJournal(old.runId); + assert.ok(oldHistory.slice(1).every(row=>row.transitionId.startsWith('t:'))); + const oldWaiting=await resume(old.runId,runtime,f.options); + const oldDone=await complete(submission(oldWaiting),runtime,f.options); + const history=f.raw.listJournal(old.runId); + assert.equal(oldDone.status,'succeeded'); + assert.deepEqual(history.slice(0,oldHistory.length),oldHistory); + assert.ok(history.slice(1).every(row=>row.transitionId.startsWith('t:'))); + assert.equal(f.raw.load(old.runId).transitionIdVersion,undefined); + const oldAck=await complete(submission(oldWaiting),runtime,f.options); + assert.equal(oldAck.acknowledgement.acceptedRevision,oldDone.acknowledgement.acceptedRevision); + + const waiting=await start(f.program,runtime,{message:'hello',secret:'private'},f.options); + assert.equal(waiting.protocolVersion,1); + assert.equal(waiting.pending.length,1); + assert.deepEqual(waiting.pending[0].task.input,{message:'hello'}); + assert.equal(waiting.pending[0].activation,1); + assert.ok(!JSON.stringify(waiting).includes('private')); + const idem=await start(f.program,runtime,{message:'hello',secret:'private'},f.options); + assert.deepEqual(idem,waiting); + await assert.rejects(start(f.program,runtime,{message:'hello',secret:'private'}, + {...f.options,auth:context({authorize:({action})=>action==='run:create'})}),{code:'FK_I_HOST_DENIED'}); + await assert.rejects(legacy.resume({runId:waiting.runId}),{code:'FK_I_POLICY_MISMATCH'}); + await assert.rejects(start(f.program,runtime,{message:'changed'},f.options),{code:'FK_I_START_CONFLICT'}); + const denied={...f.options,auth:context({authorize:({action})=>action!=='run:complete'})}; + await assert.rejects(complete(submission(waiting),runtime,denied),{code:'FK_I_HOST_DENIED'}); + const forged={...f.options,auth:{...f.options.auth}}; + await assert.rejects(resume(waiting.runId,runtime,forged),{code:'FK_I_HOST_DENIED'}); + await assert.rejects(resume(waiting.runId,runtime,{...f.options,auth:context({authorizeTask:()=>false})}), + {code:'FK_I_HOST_DENIED'}); + const final=await complete(submission(waiting),runtime,f.options); + assert.equal(final.status,'succeeded'); + assert.deepEqual(final.pending,[]); + assert.deepEqual(final.resultRef,{runId:final.runId,revision:final.revision,projectionVersion:1}); + assert.equal(Object.hasOwn(final,'state'),false); + const privateResult=await f.options.auth.readTerminalResult(final.resultRef,f.options); + assert.equal(privateResult.state.secret,'private'); + await assert.rejects(context({authorize:({action})=>action!=='result:read'}) + .readTerminalResult(final.resultRef,f.options),{code:'FK_I_HOST_DENIED'}); + const journal=f.raw.listJournal(final.runId); + for(const row of journal) { + const transition=await f.options.store.loadTransition(final.runId,row.transitionId); + assert.equal(transition.transitionId,sha256Bytes(canonicalBytes([ + ns,final.runId,transition.fromRevision,transition.fromBlockId,transition.eventDigest, + transition.programHash,transition.proposalDigest, + ]))); + } + const receipt=await f.options.store.loadReceipt(final.runId,waiting.pending[0].requestId); + assert.equal(receipt.submissionDigest,sha256Bytes(canonicalBytes(submission(waiting)))); + assert.equal(receipt.acceptedRevision,final.acknowledgement.acceptedRevision); + assert.equal((await f.options.store.loadTransition(final.runId,receipt.transitionId)).toRevision,receipt.acceptedRevision); + const wrongTenant=createSqliteInteractivePersistence(f.file,{namespace:'other'}); + try { + assert.equal(await wrongTenant.store.load(final.runId),null); + assert.equal(await wrongTenant.store.loadReceipt(final.runId,waiting.pending[0].requestId),null); + assert.equal(await wrongTenant.store.loadTransition(final.runId,receipt.transitionId),null); + } finally {wrongTenant.close();} + await assert.rejects(f.options.programRegistry.release(final.runId,f.program.artifactHash),{code:'FK_I_ARTIFACT_IN_USE'}); +}); + +test('R5 CAS recovers from a lost response only with durable transition evidence',async t=>{ + const f=await fixture(t); + const waiting=await start(f.program,runtime,{message:'hello'},f.options); + let lose=true, recoveredId; + const dropping={...f.options.store, + async compareAndSwap(runId,revision,tx) { + const result=await f.options.store.compareAndSwap(runId,revision,tx); + if(lose && tx.receipt && result.committed) { + lose=false; + recoveredId=tx.transition.transitionId; + throw new Error('lost response'); + } + return result; + }, + }; + const result=await complete(submission(waiting),runtime,{...f.options,store:dropping}); + assert.equal(result.status,'succeeded'); + assert.equal((await f.options.store.loadTransition(result.runId,recoveredId)).toRevision, + result.acknowledgement.acceptedRevision); + const before=f.raw.listJournal(result.runId); + const replay=await complete(submission(waiting),runtime,f.options); + assert.equal(replay.acknowledgement.repeated,true); + assert.deepEqual(f.raw.listJournal(result.runId),before); + + const second=await start(f.program,runtime,{message:'again'},{...f.options,startKey:'uncertain'}); + const stale=await f.options.store.load(second.runId); + let attempted=0; + const unusable={...f.options.store, + async load(){return stale;}, + async compareAndSwap(){attempted++;throw new Error('database unavailable');}, + async loadTransition(){return null;}, + }; + await assert.rejects(complete(submission(second),runtime,{...f.options,store:unusable}), + {code:'FK_I_STORE_UNCERTAIN'}); + assert.equal(attempted,1); + assert.deepEqual(f.raw.load(second.runId),stale); +}); + +test('R5 file source works without Agent bindings and honors pre-aborted operations',async t=>{ + const f=await fixture(t); + const file=path.join(f.dir,'terminal.workflow.js'); + await writeFile(file,bare); + const opts={...f.options,policy:{...policy,bindingManifest:{}},startKey:'pure'}; + await assert.rejects(start(file,{}, {},{...opts,signal:AbortSignal.abort()}),{name:'AbortError'}); + const result=await start(file,{}, {secret:'private'},opts); + assert.equal(result.status,'succeeded'); + assert.equal(Object.hasOwn(result,'state'),false); + assert.equal(result.terminal.nodeId,'done'); +}); + +test('S12-A migrates with a recoverable backup, rejects old writers and atomically terminates without the Program',async t=>{ + const f=await fixture(t); + const waiting=await start(f.program,runtime,{message:'hello'},f.options); + const before=f.raw.load(waiting.runId); + const journal=f.raw.listJournal(waiting.runId); + const admin=createInteractiveAdminContext({namespace:ns,subjectId:'ops-1',authorize:()=>true}); + await assert.rejects(f.persistence.adminController.adminTerminate(waiting.runId,'OPERATOR',admin), + {code:'FK_I_STORE_SCHEMA'}); + const backupPath=path.join(f.dir,'pre-v2.sqlite'); + assert.throws(()=>createSqliteInteractivePersistence(path.join(f.dir,'missing.sqlite'), + {namespace:ns,migrationBackupPath:backupPath}),{code:'FK_I_STORE_SCHEMA'}); + const migrated=createSqliteInteractivePersistence(f.file,{namespace:ns,migrationBackupPath:backupPath}); + try { + const db=new DatabaseSync(f.file); + const audit=()=>db.prepare('SELECT * FROM fk_i_admin_events').all(); + try { + assert.equal(db.prepare('PRAGMA user_version').get().user_version,2); + assert.throws(()=>db.prepare('UPDATE fk_i_runs SET revision=revision').run(),/no such function: fk_i_writer_version/); + await assert.rejects(migrated.adminController.adminTerminate(waiting.runId,'OPERATOR',{...admin}), + {code:'FK_I_HOST_DENIED'}); + const other=createInteractiveAdminContext({namespace:'another',subjectId:'ops-1',authorize:()=>true}); + await assert.rejects(migrated.adminController.adminTerminate(waiting.runId,'OPERATOR',other),{code:'FK_I_HOST_DENIED'}); + assert.deepEqual(audit(),[]); + db.exec(`CREATE TRIGGER force_audit_failure BEFORE INSERT ON fk_i_admin_events + BEGIN SELECT RAISE(ABORT,'audit fixture failure'); END`); + await assert.rejects(migrated.adminController.adminTerminate(waiting.runId,'OPERATOR',admin),/audit fixture failure/); + assert.deepEqual(f.raw.load(waiting.runId),before); + assert.deepEqual(f.raw.listJournal(waiting.runId),journal); + assert.deepEqual(audit(),[]); + db.exec('DROP TRIGGER force_audit_failure'); + // A deliberately broken test fixture models an unavailable artifact. + // The management action must not open or verify Program bytes. + db.function('fk_i_writer_version',{deterministic:true},()=>2); + db.prepare('UPDATE fk_i_artifacts SET program_bytes=? WHERE namespace=? AND artifact_hash=?') + .run(Buffer.from('unavailable'),ns,before.artifactHash); + const result=await migrated.adminController.adminTerminate(waiting.runId,'OPERATOR',admin); + assert.equal(result.status,'cancelled'); + assert.equal(result.revision,before.revision+1); + assert.deepEqual(result.pending,[]); + assert.equal(Object.hasOwn(result,'state'),false); + const opts={...f.options,...migrated}; + assert.deepEqual(await resume(waiting.runId,runtime,opts),result); + await assert.rejects(complete(submission(waiting),runtime,opts),{code:'FK_I_STALE_RECEIPT'}); + assert.deepEqual(await migrated.adminController.adminTerminate(waiting.runId,'REPEATED',admin),result); + const [record]=audit(); + assert.equal(record.admin_subject_id,'ops-1'); + assert.equal(record.reason_code,'OPERATOR'); + assert.equal((await migrated.store.loadTransition(waiting.runId,record.transition_id)).toRevision,result.revision); + assert.equal(audit().length,1); + } finally {db.close();} + const restored=createSqliteCheckpointStore(backupPath,{namespace:ns}); + try { + assert.deepEqual(restored.load(waiting.runId),before); + assert.deepEqual(restored.listJournal(waiting.runId),journal); + } finally {restored.close();} + } finally {migrated.close();} +}); + +test('S12-A terminal completion and management termination have a single final transition',async t=>{ + const f=await fixture(t); + const migrated=createSqliteInteractivePersistence(f.file,{namespace:ns,migrationBackupPath:path.join(f.dir,'backup.sqlite')}); + try { + const opts={...f.options,...migrated}; + const waiting=await start(f.program,runtime,{message:'hello'},opts); + let entered; + const gate=new Promise(resolve=>{entered=resolve;}); + let release; + const hold=new Promise(resolve=>{release=resolve;}); + const controlled={...opts,store:{...opts.store,async compareAndSwap(runId,revision,tx) { + if (tx.next.status==='succeeded') { + entered(); + await hold; + } + return opts.store.compareAndSwap(runId,revision,tx); + }}}; + const completing=complete(submission(waiting),runtime,controlled); + await Promise.race([gate,completing.then(()=>assert.fail('terminal barrier not reached'))]); + try { + const admin=createInteractiveAdminContext({namespace:ns,subjectId:'ops-1',authorize:()=>true}); + const ended=await migrated.adminController.adminTerminate(waiting.runId,'OPERATOR',admin); + assert.equal(ended.status,'cancelled'); + release(); + assert.equal((await completing).status,'cancelled'); + assert.equal(f.raw.listJournal(waiting.runId).at(-1).entry.kind,'admin-terminate'); + assert.equal(f.raw.listJournal(waiting.runId).filter(row=>row.entry.kind==='admin-terminate').length,1); + const another=await start(f.program,runtime,{message:'hello'},{...opts,startKey:'completion-first'}); + const completed=await complete(submission(another),runtime,opts); + const before=f.raw.listJournal(another.runId); + assert.equal((await migrated.adminController.adminTerminate(another.runId,'OPERATOR',admin)).status,'succeeded'); + assert.equal(completed.revision,before.at(-1).revision); + assert.deepEqual(f.raw.listJournal(another.runId),before); + } finally {release(); await completing.catch(()=>{});} + } finally {migrated.close();} +}); diff --git a/tests/package/consumer-dom.ts b/tests/package/consumer-dom.ts index eed3ed4..5140470 100644 --- a/tests/package/consumer-dom.ts +++ b/tests/package/consumer-dom.ts @@ -1,5 +1,25 @@ import { compile, run, isControlSignal } from '@devcodex-labs/flowkit'; import type { FlowkitOptions, RunResult } from '@devcodex-labs/flowkit'; +import { compileInteractive, createTrustedInteractiveHost } from '@devcodex-labs/flowkit/interactive'; +import type { + InteractiveCompileOptions, InteractiveTerminalResultRef, PublicInteractiveTerminalFrame, + TrustedInteractiveHostOptions, +} from '@devcodex-labs/flowkit/interactive'; const options: FlowkitOptions = { fetch, signal: new AbortController().signal }; const result: Promise = run('sample.workflow.ts', { isControlSignal }, {}, options); void [compile('sample.workflow.ts'), result]; + +declare const hostOptions: TrustedInteractiveHostOptions; +const host=createTrustedInteractiveHost(hostOptions); +const compileOptions: InteractiveCompileOptions={output:'memory',featureStage:'P0-A'}; +void compileInteractive('sample.workflow.ts',compileOptions); +async function readTerminal() { + const frame: PublicInteractiveTerminalFrame=await host.getPublicTerminalFrame({runId:'run'}); + const ref: InteractiveTerminalResultRef=frame.resultRef; + // @ts-expect-error the public projection does not expose private State + void frame.state; + // @ts-expect-error a different projection protocol version is invalid + void host.readTrustedTerminalResult({resultRef:{...ref,projectionVersion:2}}); + return host.readTrustedTerminalResult({resultRef:ref}); +} +void readTerminal; diff --git a/tests/package/consumer.mjs b/tests/package/consumer.mjs index 036def0..a1f8b44 100644 --- a/tests/package/consumer.mjs +++ b/tests/package/consumer.mjs @@ -31,9 +31,10 @@ assert.equal(await readFile('sample.compiled.mjs', 'utf8'), output.code); assert.deepEqual(Object.keys(interactive),[ - 'compileInteractive','createAgentReceiptStatement','createInteractiveCoordinator', - 'createSqliteCheckpointStore','createTrustedInteractiveHost','encodeAgentReceiptStatement', - 'loadInteractiveProgram','normalizeHostAgentReceipt', + 'compileInteractive','complete','createAgentReceiptStatement','createInteractiveAdminContext','createInteractiveCoordinator', + 'createInteractiveHostContext','createSqliteCheckpointStore','createSqliteInteractivePersistence', + 'createTrustedInteractiveHost','encodeAgentReceiptStatement', + 'loadInteractiveProgram','normalizeHostAgentReceipt','resume','start', ]); await writeFile('simple.workflow.js', "import {defineWorkflow,terminal} from '@devcodex-labs/flowkit/dsl';\n"+ @@ -41,6 +42,21 @@ await writeFile('simple.workflow.js', const compiledInteractive=await interactive.compileInteractive('simple.workflow.js',{ output:'memory',featureStage:'P0-A', }); +const versioned=interactive.createSqliteInteractivePersistence('versioned.sqlite',{namespace:'installed'}); +try { + const auth=interactive.createInteractiveHostContext({ + namespace:'installed',subjectId:'test-host',authorize:()=>true,authorizeTask:()=>false, + }); + const frame=await interactive.start('simple.workflow.js',{}, {private:'host-only'},{ + ...versioned,auth,startKey:'installed-versioned',policy:{ + id:'consumer',version:'1',bindingManifest:{},disclosePolicyId:'consumer', + capabilityStage:'P0-A',canonicalizationVersion:'jcs-rfc8785-v1', + }, + }); + assert.equal(frame.status,'succeeded'); + assert.equal(Object.hasOwn(frame,'state'),false); + assert.equal((await versioned.store.load(frame.runId)).transitionIdVersion,1); +} finally {versioned.close();} assert.ok(compiledInteractive.programIdentity?.artifactHash); const store=interactive.createSqliteCheckpointStore('interactive-demo.db',{namespace:'consumer'}); try { @@ -75,6 +91,7 @@ try { allowedInputKeys:['message'], disclosurePolicyId:'consumer-disclosure-v1', authorizeDisclosure:async()=>true, + authorizeTerminalAccess:({action})=>action==='run:read'||action==='result:read', now:()=>now, }); const pending=await trusted.start({ @@ -96,6 +113,11 @@ try { const final=await trusted.accept({receipt,attestation:{statement,signature}}); assert.equal(final.status,'succeeded'); assert.deepEqual(final.state,{message:'shareable',hidden:'private',answer:'verified'}); + const publicTerminal=await trusted.getPublicTerminalFrame({runId:final.runId}); + assert.equal(Object.hasOwn(publicTerminal,'state'),false); + assert.deepEqual(publicTerminal.pending,[]); + const trustedResult=await trusted.readTrustedTerminalResult({resultRef:publicTerminal.resultRef}); + assert.deepEqual(trustedResult.state,final.state); assert.equal((await trusted.accept({receipt,attestation:{statement,signature}})).duplicate,true); } finally {store.close();} diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs new file mode 100644 index 0000000..f16154c --- /dev/null +++ b/tests/security/codex-flowkit-probe.mjs @@ -0,0 +1,487 @@ +import assert from 'node:assert/strict'; +import {execFileSync,spawn} from 'node:child_process'; +import {createPrivateKey,createPublicKey,generateKeyPairSync,randomUUID,sign} from 'node:crypto'; +import {appendFileSync,mkdirSync} from 'node:fs'; +import {mkdir,mkdtemp,readFile,rm,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import { + compileInteractive,createAgentReceiptStatement,createSqliteCheckpointStore, + createTrustedInteractiveHost,encodeAgentReceiptStatement, +} from '../../src/interactive/index.mjs'; +import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.mjs'; +import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; + +const [exe,cwd,out,requested='loop',profile='direct',scenarioDir,answer,cut,scenarioBytes]=process.argv.slice(2); +const scenario=['outbox-worker','outbox-recover','outbox-setup','outbox-next','outbox-replay', + 'outbox-check-revocation'].includes(requested); +const worker=requested==='outbox-worker'; +const offline=scenario&&!worker; +const rotation=['rotate-old','rotate-new','rotate-old-next'].includes(cut); +const revoked=['revoked-old','revoked-pending','revoked-new'].includes(cut); +const nextSigner=cut==='rotate-new'||cut==='revoked-new'; +const runRecord=['rotate-new','rotate-old-next','revoked-pending'].includes(cut)?'run-next.json' + :cut==='revoked-new'?'run-new.json':'run.json'; +const fallback=requested.startsWith('file-') || (scenario && scenarioBytes!==undefined); +const artifactSize=fallback?Number(scenario?scenarioBytes:requested.slice(5)):0; +const size=requested==='loop'||fallback||scenario?0:Number(requested); +assert.ok(path.isAbsolute(exe) && path.isAbsolute(cwd) && path.isAbsolute(out)); +assert.ok([0,65536,262144,2093056].includes(size)); +assert.ok(!fallback||[262144,2093056].includes(artifactSize)); +assert.ok(['direct','isolated'].includes(profile)); +mkdirSync(out,{recursive:false}); +const nonce=randomUUID(); +const bodyLength=size===0?0:size-Buffer.byteLength(JSON.stringify({nonce,payload:''})); +const expectedBytes=size===0?null:canonicalBytes({nonce,payload:'A'.repeat(bodyLength)}); +const record=(file,data)=>appendFileSync(path.join(out,file),data); +const journal=(event,details={})=>record('observer.ndjson',JSON.stringify({event,at:Date.now(),...details})+'\n'); +const objectSchema=(properties,required=Object.keys(properties))=>({type:'object',properties,required,additionalProperties:false}); +const str={type:'string'}; +const tool=(name,description,inputSchema)=>({type:'function',name,description,inputSchema}); +const specs=size ? [tool('flowkit_inline','Measure an inline synthetic payload',objectSchema({nonce:str,payload:str}))] : [ + tool('flowkit_start','Start the one provisioned synthetic FlowKit Run and return the current public task',objectSchema({nonce:str})), + fallback?tool('flowkit_submit_file','Submit the Host-issued artifact reference for the exact current FlowKit request', + objectSchema({requestId:str,id:str})) + :tool('flowkit_submit','Submit the answer for the exact current FlowKit request',objectSchema({requestId:str,answer:str})), + tool('flowkit_continue','Read the public terminal state of the provisioned Run',objectSchema({runId:str})), +]; +const fixture=scenario?scenarioDir:size?null:await mkdtemp(path.join(tmpdir(),'flowkit-codex-evidence-')); +if (scenario) assert.ok(path.isAbsolute(fixture)); +if (worker) assert.ok(process.send && ['alpha','beta','ready'].includes(answer) + && (!cut || ['after-cas','before-sign','expire','rotate-old','rotate-new','rotate-old-next','revoked-new'].includes(cut))); +let store,host,program,frame,currentTask,handoff,staged,outbox; +const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; +let command=exe,args=appArgs; +let hostSid; +if (profile==='isolated'&&!offline) { + const node=process.execPath; + // A pre-authorized, fixture-only launcher must enforce the model uid/gid and + // pass only stdio. This probe never acquires or transfers account credentials. + command=process.env.FLOWKIT_CODEX_LAUNCHER; + assert.ok(path.isAbsolute(command)); + const sshConfig=process.env.FLOWKIT_CODEX_SSH_CONFIG; + if (process.platform==='linux') { + const modelUid=Number(process.env.FLOWKIT_MODEL_UID), modelGid=Number(process.env.FLOWKIT_MODEL_GID); + const hostUid=Number(process.env.FLOWKIT_HOST_UID), hostGid=Number(process.env.FLOWKIT_HOST_GID); + assert.ok([hostUid,hostGid,modelUid,modelGid].every(id=>Number.isSafeInteger(id)&&id>0)); + assert.notEqual(hostUid,modelUid); + assert.notEqual(hostGid,modelGid); + assert.equal(process.getuid(),hostUid); + assert.equal(process.getgid(),hostGid); + const modelExec=process.env.FLOWKIT_CODEX_LINUX_EXEC; + if (!sshConfig) assert.ok(path.isAbsolute(modelExec)); + args=[node,fileURLToPath(new URL('./codex-model-identity.mjs',import.meta.url)),String(modelUid),String(modelGid), + modelExec,exe,...appArgs]; + } else { + assert.equal(process.platform,'win32'); + assert.ok(process.env.FLOWKIT_HOST_SID && process.env.FLOWKIT_MODEL_SID + && process.env.FLOWKIT_HOST_SID!==process.env.FLOWKIT_MODEL_SID); + const identityHelper=process.env.FLOWKIT_CODEX_WINDOWS_IDENTITY; + assert.ok(path.isAbsolute(identityHelper)); + hostSid=execFileSync(identityHelper,['--host-identity'],{encoding:'utf8',timeout:5000,windowsHide:true}).trim(); + assert.equal(hostSid,process.env.FLOWKIT_HOST_SID); + args=[exe,...appArgs]; + } + if (sshConfig) { + assert.ok(path.isAbsolute(sshConfig) && ['ssh','ssh.exe'].includes(path.basename(command).toLowerCase())); + args=['-F',sshConfig,'-T','-o','HostName=127.0.0.1','-o','BatchMode=yes', + '-o','StrictHostKeyChecking=yes','-o','IdentitiesOnly=yes','-o','ForwardAgent=no', + '-o','ClearAllForwardings=yes','-o','PermitLocalCommand=no','-o','ProxyCommand=none', + '-o','ProxyJump=none','-o','ControlMaster=no','-o','ControlPath=none','flowkit-model']; + } +} +const child=offline?null:spawn(command,args, + {cwd,stdio:['pipe','pipe','pipe'],windowsHide:true}); +child?.stdin.on('error',()=>{}); +const pending=new Map(),starts=new Map(),completed=new Set(),successful=[]; +let seq=0,threadId,turnId,turnStatus,failure,inlineObserved; +let finish; +const finished=new Promise(resolve=>{finish=resolve;}); +let recordIdentity; +const identityReady=new Promise(resolve=>{recordIdentity=resolve;}); +child?.on('error',error=>{failure=error.message;finish('spawn-error');}); +child?.on('close',code=>{journal('exit',{code});finish('closed');}); +child?.stdout.on('data',buffer=>record('app-server.stdout.ndjson',buffer)); +child?.stderr.on('data',buffer=>record('app-server.stderr',buffer)); +const timer=offline?null:setTimeout(()=>finish('timeout'),size?95000:65000); + +function send(message) { + record('app-server.requests.ndjson',JSON.stringify(message)+'\n'); + child.stdin.write(JSON.stringify(message)+'\n'); +} +function request(method,params) { + const id=++seq; + return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});send({id,method,params});}); +} + +async function prepareFlowKit() { + const file=path.join(fixture,'probe.workflow.js'); + if (!scenario || requested==='outbox-setup') await writeFile(file,`import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl'; +export default defineWorkflow({id:'codex-probe',version:'1.0.0',name:'Codex probe',nodes:[ +node('review',{name:'Review',agent:'reviewer',inputs:['question'], +output:{type:'object',properties:${fallback?'{artifact:{type:"object"}}':"{answer:{type:'string'}}"}},retries:0,next:'done'}), +terminal('done',{name:'Done',status:'succeeded'}) +]});`); + program=await compileInteractive(file,{output:'memory',featureStage:'P0-A',skillCheck:false}); + assert.ok(program.programIdentity,JSON.stringify(program.diagnostics)); + store=createSqliteCheckpointStore(path.join(fixture,'run.sqlite'),{namespace:'codex-proof'}); + let publicKey,privateKey; + if (!scenario || requested==='outbox-setup') { + ({publicKey,privateKey}=generateKeyPairSync('ed25519')); + if (scenario) { + await writeFile(path.join(fixture,'proof.public.pem'),publicKey.export({type:'spki',format:'pem'}),{mode:0o600,flag:'wx'}); + await writeFile(path.join(fixture,'proof.private.pem'),privateKey.export({type:'pkcs8',format:'pem'}),{mode:0o600,flag:'wx'}); + } + } else { + publicKey=createPublicKey(await readFile(path.join(fixture,'proof.public.pem'))); + if (worker) privateKey=createPrivateKey(await readFile(path.join(fixture, + nextSigner?'next.private.pem':'proof.private.pem'))); + } + const trustedPublicKeys={proof:publicKey}; + if (rotation || revoked) { + if (requested==='outbox-setup') { + const next=generateKeyPairSync('ed25519'); + await writeFile(path.join(fixture,'next.public.pem'),next.publicKey.export({type:'spki',format:'pem'}),{mode:0o600,flag:'wx'}); + await writeFile(path.join(fixture,'next.private.pem'),next.privateKey.export({type:'pkcs8',format:'pem'}),{mode:0o600,flag:'wx'}); + } + trustedPublicKeys.next=createPublicKey(await readFile(path.join(fixture,'next.public.pem'))); + if (revoked) delete trustedPublicKeys.proof; + } + if (fallback) { + const inbox=path.join(fixture,'inbox'); + await mkdir(inbox,{mode:0o700,recursive:true}); + handoff=createFileHandoff({inbox,database:path.join(fixture,'file-handoff.sqlite'), + validateArtifact:value=>Object.keys(value).length===1 && typeof value.answer==='string'}); + } + host=createTrustedInteractiveHost({ + store, runtime:{skillResolver:async()=> 'Read the public question and submit its requested answer.', + validateResult:(_schema,value)=>fallback ? validateArtifactResult(value) + : (scenario?['alpha','beta','ready'].includes(value.answer):value.answer==='ready')?[]:['Unexpected answer']}, + trustedPublicKeys,trustedArtifacts:[program.programIdentity.artifactHash], + allowedInputKeys:['question'],disclosurePolicyId:'probe-v1',authorizeDisclosure:()=>true, + authorizeTerminalAccess:({action})=>action==='run:read', + }); + if (scenario) outbox=openCodexOutbox({database:path.join(fixture,'outbox.sqlite'),store,host,handoff}); + return privateKey; +} + +let key; +function validateArtifactResult(value) { + if (!scenario) return value.artifact?.id===staged?.id?[]:['Expected staged reference']; + const artifact=value.artifact; + const snapshot=artifact && handoff.readSnapshot(artifact.id); + return snapshot && ['claimed','acked'].includes(snapshot.state) && snapshot.byteLength===artifact.byteLength + && snapshot.sha256===artifact.sha256 ? [] : ['Expected claimed snapshot']; +} + +async function acceptResult(result,identity,pending=currentTask) { + const receipt={runId:pending.runId,requestId:pending.requestId,nodeId:pending.nodeId, + attempt:pending.attempt,kind:'agent-result',result}; + const issuedAt=Date.now(); + const statement=createAgentReceiptStatement({run:store.load(receipt.runId),receipt,keyId:'proof', + proofId:identity.callId,issuedAt,expiresAt:issuedAt+30000}); + const statementBytes=encodeAgentReceiptStatement(statement); + journal('signing',{callId:identity.callId,requestId:receipt.requestId,toolInputDigest:identity.digest, + statementDigest:sha256Bytes(statementBytes),...(result.artifact?{artifact:result.artifact}:{})}); + const signature=sign(null,statementBytes,key).toString('base64url'); + frame=await host.accept({receipt,attestation:{statement,signature}}); + journal('accepted',{acceptedRevision:frame.acceptedRevision,revision:frame.revision,runId:frame.runId, + receipt:store.getReceipt(frame.runId,receipt.requestId)}); + return {runId:frame.runId,acceptedRevision:frame.acceptedRevision}; +} + +async function deliverCandidate(result,name,args,identity,started) { + const receipt={runId:currentTask.runId,requestId:currentTask.requestId,nodeId:currentTask.nodeId, + attempt:currentTask.attempt,kind:'agent-result',result}; + const details={threadId,turnId,callId:identity.callId,hostPid:process.pid, + modelPid:isolatedIdentity?.pid??child.pid,requestId:currentTask.requestId}; + const proofId=await outbox.record({task:currentTask,modelPid:details.modelPid, + started:{...started,callId:identity.callId,args:started.arguments}, + callback:{...identity,tool:name,args}},receipt,key,{ + keyId:nextSigner?'next':'proof', + ttlMs:cut==='expire'?3000:cut==='rotate-old'?15000:120000, + afterObserve:cut==='before-sign'?async observed=>{ + journal('outbox-before-sign',{...details,...observed}); + process.send({event:'observed',...details,...observed}); + await new Promise(()=>{}); + }:undefined, + }); + const signed=outbox.describe(proofId); + const detail={...details,...signed, + ...(result.artifact?{artifact:result.artifact}:{})}; + const release=new Promise(resolve=>process.once('message',resolve)); + journal('outbox-signed',{...identity,...detail}); + process.send({event:'signed',...detail}); + assert.equal(await release,'deliver'); + const afterAccept=cut==='after-cas'?async ack=>{ + journal('outbox-after-cas',{proofId,ack}); + process.send({event:'committed',proofId,ack,hostPid:process.pid}); + // Keep the exact post-CAS cut open until the independent supervisor kills this Host. + await new Promise(()=>{}); + }:undefined; + try {frame=await outbox.deliver(proofId,{afterAccept});} + catch (error) { + if (error.code!=='FK_I_RECEIPT_CONFLICT') throw error; + return {conflict:true,...outbox.describe(proofId)}; + } + return {runId:frame.runId,...outbox.describe(proofId)}; +} + +async function handleTool(name,args,identity,started) { + if (size) { + const canonical=canonicalBytes(args); + inlineObserved={expected:size,received:canonical.byteLength,matched:canonical.equals(expectedBytes), + expectedDigest:sha256Bytes(expectedBytes),receivedDigest:sha256Bytes(canonical)}; + journal('inline-comparison',inlineObserved); + assert.ok(inlineObserved.matched,'complete inline JSON differs from exact target'); + return {bytes:canonical.byteLength}; + } + if (name==='flowkit_start') { + assert.ok(!worker || fallback); + assert.equal(args.nonce,nonce); + if (worker) assert.equal(staged,undefined); + else { + assert.equal(frame,undefined); + frame=await host.start({programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, + startKey:nonce,state:{question:'Return the word ready.',privateMarker:'host-only synthetic marker'}}); + currentTask=await host.getPublicAgentTask({runId:frame.runId}); + } + journal('pending', {runId:frame.runId,requestId:currentTask.requestId,revision:frame.revision, + programHash:program.programIdentity.artifactHash,taskDigest:sha256Bytes(canonicalBytes(currentTask))}); + if (!fallback) return currentTask; + const sourceId=randomUUID(); + const bytes=canonicalBytes({answer:'A'.repeat(artifactSize-13)}); + assert.equal(bytes.length,artifactSize); + const ingress=path.join(fixture,'inbox',sourceId+'.json'); + await writeFile(ingress,bytes,{mode:0o600,flag:'wx'}); + const checkpoint=store.load(frame.runId); + staged=await handoff.stage({sourceId,byteLength:bytes.length,sha256:sha256Bytes(bytes), + binding:{namespace:store.namespace,runId:frame.runId,requestId:currentTask.requestId, + nodeId:currentTask.nodeId,attempt:currentTask.attempt,threadId:identity.threadId,turnId:identity.turnId, + programHash:checkpoint.artifactHash,policyHash:checkpoint.policyHash}}); + // The inbox is no longer needed once the private BLOB is committed. + await rm(ingress); + journal('file-staged',{...staged,byteLength:bytes.length,sha256:sha256Bytes(bytes),producer:'trusted-synthetic-fixture'}); + return {task:currentTask,fileReference:staged}; + } + if (name==='flowkit_submit') { + if (worker) { + assert.deepEqual(args,{requestId:currentTask.requestId,answer}); + return deliverCandidate({answer},name,args,identity,started); + } + assert.equal(frame?.status,'waiting'); + assert.equal(args.requestId,currentTask.requestId); + return acceptResult({answer:args.answer},identity); + } + if (name==='flowkit_submit_file') { + return handoff.consume({ + started:{...started,callId:identity.callId},callback:{...identity,tool:name,arguments:args}, + loadPending:store.load,signAndAccept:({artifact,pending})=>worker + ?deliverCandidate({artifact},name,args,identity,started):acceptResult({artifact},identity,pending), + }); + } + assert.equal(name,'flowkit_continue'); + assert.equal(args.runId,frame?.runId); + const final=await host.getPublicTerminalFrame({runId:args.runId}); + assert.equal(final.status,'succeeded'); + if (fallback) { + const snapshot=handoff.readSnapshot(staged.id); + assert.equal(snapshot.state,'acked'); + assert.equal(snapshot.byteLength,artifactSize); + assert.deepEqual(frame.state.artifact,{id:staged.id,byteLength:artifactSize,sha256:snapshot.sha256}); + journal('file-ack',{id:staged.id,byteLength:snapshot.byteLength,sha256:snapshot.sha256, + acceptedRevision:snapshot.acceptedRevision}); + } else assert.equal(frame.state.answer,'ready'); + const rows=store.listJournal(frame.runId); + assert.equal(rows.length,frame.revision+1); + journal('terminal',{status:final.status,revision:final.revision,journalRows:rows.length, + checkpointHash:rows.at(-1).checkpointHash}); + return final; +} + +const lines=child&&createInterface({input:child.stdout}); +lines?.on('line',line=>{ + let message; + try {message=JSON.parse(line);} catch {failure='invalid JSONL';finish('protocol-error');return;} + if (message.id!==undefined && !message.method) { + const waiter=pending.get(message.id); + pending.delete(message.id); + if(message.error) waiter?.reject(new Error(JSON.stringify(message.error))); + else waiter?.resolve(message.result); + return; + } + const p=message.params; + if(message.method==='item/started' && p.item.type==='dynamicToolCall') { + starts.set(p.item.id,{threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,arguments:p.item.arguments}); + journal('tool-start',{callId:p.item.id,threadId:p.threadId,turnId:p.turnId,tool:p.item.tool}); + } else if(message.method==='item/completed' && p.item.type==='dynamicToolCall') { + completed.add(p.item.id); + } else if (message.method==='item/tool/call') { + void (async()=>{ + const started=starts.get(p.callId); + try { + assert.ok((profile==='direct'||isolatedIdentity) && started && !completed.has(p.callId) && p.threadId===threadId && p.turnId===turnId + && started.threadId===threadId && started.turnId===turnId && started.tool===p.tool); + const args=canonicalBytes(p.arguments); + assert.ok(args.equals(canonicalBytes(started.arguments))); + assert.ok(specs.some(spec=>spec.name===p.tool)); + starts.delete(p.callId); + const identity={callId:p.callId,threadId,turnId,digest:sha256Bytes(args),bytes:args.byteLength}; + journal('tool-callback',identity); + const result=await handleTool(p.tool,p.arguments,identity,started); + if (worker && p.tool!=='flowkit_start') { + journal('outbox-delivered',result); + process.send({event:'delivered',...result}); + } + successful.push(p.tool); + send({id:message.id,result:{success:!result.conflict,contentItems:[{type:'inputText',text:JSON.stringify(result)}]}}); + } catch(error) { + failure=error.message; + journal('rejected',{callId:p.callId,code:error.code,detail:error.message}); + send({id:message.id,result:{success:false,contentItems:[{type:'inputText',text:'Rejected fixture submission'}]}}); + } + })(); + } else if(message.id!==undefined && message.method) { + send({id:message.id,error:{code:-32601,message:'Unexpected server request'}}); + } else if(message.method==='turn/completed') { + turnStatus=p.turn.status; + if (p.turn.error) failure=p.turn.error.message; + finish('turn-completed'); + } else if(message.method==='flowkit/profile/identity' && profile==='isolated') { + const reported=p; + try { + assert.equal(isolatedIdentity,undefined); + assert.ok(Number.isSafeInteger(reported.pid) && reported.pid>1); + if (process.platform==='linux') { + assert.equal(reported.uid,Number(process.env.FLOWKIT_MODEL_UID)); + assert.equal(reported.gid,Number(process.env.FLOWKIT_MODEL_GID)); + assert.ok(reported.groups.every(group=>group===reported.gid)); + assert.match(reported.capabilities,/^0+$/); + assert.equal(reported.noNewPrivs,'1'); + } else { + assert.equal(reported.modelSid,process.env.FLOWKIT_MODEL_SID); + assert.equal(reported.modelElevated,false); + assert.equal(reported.stdioAllowlist,true); + } + isolatedIdentity=reported; + journal('isolated-identity',{...reported,hostUid:process.getuid?.(),hostGid:process.getgid?.(),hostSid, + ...(hostSid?{hostElevated:false}:{})}); + recordIdentity(); + } catch(error) {failure=error.message;finish('identity-error');} + } +}); +let isolatedIdentity; + +async function run() { + if (profile==='isolated') await identityReady; + if (!size) key=await prepareFlowKit(); + if (worker) { + const {runId}=JSON.parse(await readFile(path.join(fixture,runRecord),'utf8')); + currentTask=await host.getPublicAgentTask({runId}); + frame=store.load(runId); + } + await request('initialize',{clientInfo:{name:'flowkit-acceptance',title:'FlowKit acceptance',version:'0.1.0'}, + capabilities:{experimentalApi:true}}); + send({method:'initialized'}); + const {data}=await request('model/list',{limit:30,includeHidden:false}); + const model=data.find(entry=>entry.isDefault)?.model ?? data[0]?.model; + const {thread}=await request('thread/start',{cwd,model,ephemeral:true,approvalPolicy:'never',sandbox:'read-only', + multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}}, + dynamicTools:worker?specs.filter(spec=>fallback?spec.name!=='flowkit_continue':spec.name==='flowkit_submit'):specs}); + threadId=thread.id; + const prompt=worker&&fallback + ? 'Call flowkit_start with nonce "'+nonce+'". Read its fileReference and call flowkit_submit_file once with its exact id and requestId. Then finish.' + : worker + ? 'Call the dynamic tool flowkit_submit exactly once with requestId "'+currentTask.requestId + +'" and answer "'+answer+'". Use the dynamic tool, then finish.' + : size + ? `Call flowkit_inline exactly once with nonce "${nonce}" and payload containing exactly ${bodyLength} uppercase ASCII A characters as one JSON string. Supply complete inline JSON, without using files, references, code, other tools or a placeholder. If impossible, say so and finish.` + : fallback + ? `Call flowkit_start with nonce "${nonce}". Take fileReference.id and fileReference.requestId from its reply, then call flowkit_submit_file with those exact two values. Call flowkit_continue with the returned runId. Use only those three tools.` + : `Only call the three FlowKit tools. Call flowkit_start with nonce "${nonce}". Read its public question and call flowkit_submit using its exact requestId and the answer. Then call flowkit_continue with the runId returned by submit and finish.`; + const {turn}=await request('turn/start',{threadId,input:[{type:'text',text:prompt}]}); + turnId=turn.id; + journal('turn',{model,threadId,turnId,requestedBytes:size,pid:isolatedIdentity?.pid??child.pid, + launcherPid:child.pid,observerPid:process.pid}); + return finished; +} +try { + if (offline) await runOutboxOffline(); + else { + const outcome=await Promise.race([run(),finished]); + const expected=worker?(fallback?['flowkit_start','flowkit_submit_file']:['flowkit_submit']) + :size?['flowkit_inline']:['flowkit_start',fallback?'flowkit_submit_file':'flowkit_submit','flowkit_continue']; + const passed=outcome==='turn-completed' && turnStatus==='completed' && !failure + && JSON.stringify(successful)===JSON.stringify(expected) && completed.size===expected.length + && (profile==='direct'||!!isolatedIdentity); + console.log(JSON.stringify({passed,outcome,turnStatus,requestedBytes:size,artifactSize,profile,successful,inlineObserved,failure})); + if(!passed)process.exitCode=1; + } +} catch(error) {console.error(error.message);process.exitCode=1;} +finally { + clearTimeout(timer); + child?.stdin.end(); + if (child&&child.exitCode===null && child.signalCode===null) { + if (profile==='isolated') { + // The external launcher owns cleanup across OS identities. + journal('launcher-cleanup',{pid:child.pid,method:'stdio-eof'}); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + } else child.kill(); + } + handoff?.close(); + outbox?.close(); + store?.close(); + if(fixture&&!scenario) await rm(fixture,{recursive:true,force:true}); + if (worker&&process.connected) process.disconnect(); +} + +async function runOutboxOffline() { + await prepareFlowKit(); + if (requested==='outbox-setup'||requested==='outbox-next') { + frame=await host.start({programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, + startKey:nonce,state:{question:'Submit the assigned answer.'}}); + const task=await host.getPublicAgentTask({runId:frame.runId}); + await writeFile(path.join(fixture,runRecord),JSON.stringify({runId:frame.runId,requestId:task.requestId}),{mode:0o600,flag:'wx'}); + console.log(JSON.stringify({runId:frame.runId,requestId:task.requestId,revision:frame.revision})); + return; + } + const {runId,requestId}=JSON.parse(await readFile(path.join(fixture,runRecord),'utf8')); + const before=store.load(runId); + const beforeReceipt=store.getReceipt(runId,requestId); + if (requested==='outbox-check-revocation') { + assert.ok(revoked && before.policyHash!==host.policyHash); + const rejected={}; + for (const [name,action,code] of [ + ['load',()=>host.load({runId}),'FK_I_HOST_DENIED'], + ['resume',()=>host.resume({runId}),'FK_I_HOST_DENIED'], + ['delivery',()=>beforeReceipt?outbox.deliver(answer):outbox.recover(runId),'FK_I_PROVENANCE_REJECTED'], + ]) { + await assert.rejects(action,error=>{ + rejected[name]=error.code; + return error.code===code; + }); + } + assert.deepEqual(store.load(runId),before); + assert.deepEqual(store.getReceipt(runId,requestId),beforeReceipt); + console.log(JSON.stringify({runId,requestId,rejected,receipt:beforeReceipt,runPolicyHash:before.policyHash, + configuredPolicyHash:host.policyHash,outbox:outbox.describe(answer)})); + return; + } + if (requested==='outbox-replay') { + const replay=await outbox.deliver(answer); + console.log(JSON.stringify({runId:replay.runId,acceptedRevision:replay.acceptedRevision, + receipt:store.getReceipt(runId,requestId),status:replay.status})); + return; + } + const recovered=await outbox.recover(runId); + assert.deepEqual(await outbox.recover(runId),[]); + const after=store.load(runId); + console.log(JSON.stringify({runId,before:{status:before.status,revision:before.revision,receipt:beforeReceipt}, + recovered,after:{status:after.status,revision:after.revision,answer:after.state.answer, + journalRows:store.listJournal(runId).length,receipt:store.getReceipt(runId,requestId)}})); +} diff --git a/tests/security/codex-model-exec-linux.c b/tests/security/codex-model-exec-linux.c new file mode 100644 index 0000000..f02ec15 --- /dev/null +++ b/tests/security/codex-model-exec-linux.c @@ -0,0 +1,27 @@ +#define _GNU_SOURCE +#include +#include +#include +#include + +int main(int argc, char **argv) { + if (argc < 2 || argv[1][0] != '/' || getuid() == 0 || getuid() != geteuid() + || getgid() != getegid() || prctl(PR_GET_NO_NEW_PRIVS, 0L, 0L, 0L, 0L) != 1) { + fputs("Expected an absolute executable under the restricted Model account\n", stderr); + return 2; + } + if (close_range(3, ~0U, 0) == -1) { + perror("Close extra descriptors"); + return 1; + } + for (int fd = 0; fd <= 2; fd++) { + int flags = fcntl(fd, F_GETFL); + if (flags == -1 || fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) == -1) { + perror("Restore blocking stdio"); + return 1; + } + } + execv(argv[1], &argv[1]); + perror("Start Model executable"); + return 1; +} diff --git a/tests/security/codex-model-identity.mjs b/tests/security/codex-model-identity.mjs new file mode 100644 index 0000000..06e2958 --- /dev/null +++ b/tests/security/codex-model-identity.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import {readFileSync,writeSync} from 'node:fs'; +import {execve} from 'node:process'; + +const [uid,gid,exe,...args]=process.argv.slice(2); +assert.equal(process.platform,'linux'); +assert.equal(typeof execve,'function','Node with process.execve is required for the Linux handoff'); +assert.equal(process.getuid(),Number(uid)); +assert.equal(process.getgid(),Number(gid)); +const groups=process.getgroups(); +assert.ok(groups.every(group=>group===Number(gid))); +const status=readFileSync('/proc/self/status','utf8'); +const capabilities=status.match(/^CapEff:\s*(\S+)/m)?.[1]; +const noNewPrivs=status.match(/^NoNewPrivs:\s*(\S+)/m)?.[1]; +assert.match(capabilities,/^0+$/); +assert.equal(noNewPrivs,'1'); +const identity=JSON.stringify({method:'flowkit/profile/identity', + params:{uid:Number(uid),gid:Number(gid),groups,capabilities,noNewPrivs,pid:process.pid}})+'\n'; +assert.equal(writeSync(1,identity),Buffer.byteLength(identity)); +execve(exe,[exe,...args],process.env); diff --git a/tests/security/codex-model-stdio-fixture.cjs b/tests/security/codex-model-stdio-fixture.cjs new file mode 100644 index 0000000..a7fa15f --- /dev/null +++ b/tests/security/codex-model-stdio-fixture.cjs @@ -0,0 +1,35 @@ +const assert = require('node:assert/strict'); +const {execFileSync, spawnSync} = require('node:child_process'); +const {writeFileSync} = require('node:fs'); +const path = require('node:path'); + +try { + const launcher = process.env.FLOWKIT_FIXTURE_STDIO_EXE; + const sid = process.env.FLOWKIT_FIXTURE_MODEL_SID; + assert.equal(execFileSync(launcher, ['--host-identity'], {encoding: 'utf8', timeout: 3000}).trim(), sid); + const child = spawnSync(launcher, [process.execPath], { + input: 'fixture stdin\n', + encoding: 'utf8', + timeout: 8000, + windowsHide: true, + }); + assert.ifError(child.error); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stderr, 'fixture stderr'); + const [record, echo] = child.stdout.trim().split(/\r?\n/); + const identity = JSON.parse(record); + assert.equal(identity.method, 'flowkit/profile/identity'); + assert.equal(identity.params.modelSid, sid); + assert.equal(identity.params.modelElevated, false); + assert.ok(Number.isSafeInteger(identity.params.pid) && identity.params.pid > 1); + assert.equal(identity.params.stdioAllowlist, true); + assert.equal(echo, 'fixture stdin'); + writeFileSync(path.join(process.env.TEMP, 'stdio-result.json'), JSON.stringify({ + event: 'codex-model-stdio-fixture', + ...identity.params, + roundTrip: true, + })); +} catch (error) { + writeFileSync(path.join(process.env.TEMP, 'stdio-error.txt'), error.stack); + process.exitCode = 1; +} diff --git a/tests/security/codex-model-stdio-windows.cs b/tests/security/codex-model-stdio-windows.cs new file mode 100644 index 0000000..4d4a65e --- /dev/null +++ b/tests/security/codex-model-stdio-windows.cs @@ -0,0 +1,171 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; + +public static class FlowKitModelStdio { + private const uint UseStdHandles = 0x100; + private const uint ExtendedStartupInfo = 0x80000; + private const uint CreateSuspended = 4; + private const uint CreateNoWindow = 0x08000000; + private const uint FileTypePipe = 3; + + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfo { + public uint size; + public IntPtr reserved, desktop, title; + public uint x, y, width, height, columns, rows, fill, flags; + public ushort showWindow, reservedSize; + public IntPtr reservedBytes, stdin, stdout, stderr; + } + [StructLayout(LayoutKind.Sequential)] + private struct StartupInfoEx { + public StartupInfo info; + public IntPtr attributes; + } + [StructLayout(LayoutKind.Sequential)] + private struct ProcessInfo { + public IntPtr process, thread; + public uint pid, tid; + } + + [DllImport("kernel32.dll", SetLastError=true)] + private static extern IntPtr GetStdHandle(int which); + [DllImport("kernel32.dll")] + private static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern uint GetFileType(IntPtr handle); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool DuplicateHandle(IntPtr sourceProcess, IntPtr source, IntPtr targetProcess, + out IntPtr target, uint access, bool inherit, uint options); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, + IntPtr value, IntPtr size, IntPtr previous, IntPtr returnedSize); + [DllImport("kernel32.dll")] + private static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode, ExactSpelling=true)] + private static extern bool CreateProcessW(string executable, StringBuilder command, IntPtr processAttributes, + IntPtr threadAttributes, bool inherit, uint flags, IntPtr environment, string directory, + ref StartupInfoEx startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool GetExitCodeProcess(IntPtr handle, out uint code); + [DllImport("kernel32.dll", SetLastError=true)] + private static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + [DllImport("advapi32.dll", SetLastError=true)] + private static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token); + [DllImport("advapi32.dll", SetLastError=true)] + private static extern bool GetTokenInformation(IntPtr token, int kind, out int value, int size, out int returned); + + private static Win32Exception Win32(string operation) { + return new Win32Exception(Marshal.GetLastWin32Error(), operation); + } + + private static IntPtr CopyPipe(int which) { + IntPtr original = GetStdHandle(which); + if (GetFileType(original) != FileTypePipe) throw new InvalidOperationException("Stdio must be piped"); + IntPtr copy; + IntPtr self = GetCurrentProcess(); + if (!DuplicateHandle(self, original, self, out copy, 0, true, 2)) throw Win32("Duplicate stdio pipe"); + return copy; + } + + private static string OrdinaryUserSid(IntPtr process) { + IntPtr token; + if (!OpenProcessToken(process, 8, out token)) throw Win32("Open model token"); + try { + using (WindowsIdentity user = new WindowsIdentity(token)) { + int elevationType, elevated, length; + if (!GetTokenInformation(token, 18, out elevationType, sizeof(int), out length)) + throw Win32("Read process token elevation type"); + if (!GetTokenInformation(token, 20, out elevated, sizeof(int), out length)) + throw Win32("Read process token elevation"); + // Read the queried token directly. IsInRole needs an impersonation token. + var administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); + if (elevationType != 1 || elevated != 0 || user.Groups.Contains(administrators) + || user.IsSystem || user.User.IsWellKnown(WellKnownSidType.AnonymousSid) + || user.User.IsWellKnown(WellKnownSidType.LocalServiceSid) + || user.User.IsWellKnown(WellKnownSidType.NetworkServiceSid)) + throw new InvalidOperationException("The process must run as an ordinary account"); + return user.User.Value; + } + } finally { CloseHandle(token); } + } + + private static int Run(string executable) { + string sid = OrdinaryUserSid(GetCurrentProcess()); + var stdio = new IntPtr[3]; + IntPtr list = IntPtr.Zero, handles = IntPtr.Zero, listSize = IntPtr.Zero; + bool initialized = false, completed = false; + var child = new ProcessInfo(); + try { + for (int i = 0; i < stdio.Length; i++) stdio[i] = CopyPipe(-10-i); + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref listSize); + if (listSize == IntPtr.Zero) throw Win32("Size startup attributes"); + list = Marshal.AllocHGlobal(listSize); + if (!InitializeProcThreadAttributeList(list, 1, 0, ref listSize)) throw Win32("Initialize startup attributes"); + initialized = true; + int bytes = IntPtr.Size * stdio.Length; + handles = Marshal.AllocHGlobal(bytes); + Marshal.Copy(stdio, 0, handles, stdio.Length); + // PROC_THREAD_ATTRIBUTE_HANDLE_LIST. No other handle is inherited. + if (!UpdateProcThreadAttribute(list, 0, new IntPtr(0x20002), handles, new IntPtr(bytes), + IntPtr.Zero, IntPtr.Zero)) throw Win32("Restrict inherited handles"); + var startup = new StartupInfoEx(); + startup.info.size = (uint)Marshal.SizeOf(typeof(StartupInfoEx)); + startup.info.flags = UseStdHandles; + startup.info.stdin = stdio[0]; + startup.info.stdout = stdio[1]; + startup.info.stderr = stdio[2]; + startup.attributes = list; + var command = new StringBuilder("\"" + executable + "\" app-server --listen stdio:// -c \"web_search=\\\"disabled\\\"\""); + if (!CreateProcessW(executable, command, IntPtr.Zero, IntPtr.Zero, true, + CreateSuspended | CreateNoWindow | ExtendedStartupInfo, IntPtr.Zero, null, ref startup, out child)) + throw Win32("Start Codex App Server"); + if (OrdinaryUserSid(child.process) != sid) throw new InvalidOperationException("Codex model SID mismatch"); + Console.Out.WriteLine("{\"method\":\"flowkit/profile/identity\",\"params\":{\"modelSid\":\"" + sid + + "\",\"modelElevated\":false,\"pid\":" + child.pid + ",\"stdioAllowlist\":true}}"); + Console.Out.Flush(); + if (ResumeThread(child.thread) == UInt32.MaxValue) throw Win32("Resume Codex App Server"); + if (WaitForSingleObject(child.process, UInt32.MaxValue) != 0) throw Win32("Wait for Codex App Server"); + completed = true; + uint code; + if (!GetExitCodeProcess(child.process, out code)) throw Win32("Read Codex exit code"); + return unchecked((int)code); + } finally { + if (child.process != IntPtr.Zero) { + if (!completed) TerminateProcess(child.process, 1); + CloseHandle(child.process); + CloseHandle(child.thread); + } + foreach (IntPtr pipe in stdio) if (pipe != IntPtr.Zero) CloseHandle(pipe); + if (initialized) DeleteProcThreadAttributeList(list); + if (list != IntPtr.Zero) Marshal.FreeHGlobal(list); + if (handles != IntPtr.Zero) Marshal.FreeHGlobal(handles); + } + } + + public static int Main(string[] args) { + if (args.Length != 1) return 2; + try { + if (args[0] == "--host-identity") { + Console.WriteLine(OrdinaryUserSid(GetCurrentProcess())); + return 0; + } + if (!Path.IsPathRooted(args[0]) || args[0].Contains("\"") || !File.Exists(args[0])) return 2; + string executable = Path.GetFullPath(args[0]); + if (!String.Equals(executable, args[0], StringComparison.OrdinalIgnoreCase)) return 2; + return Run(executable); + } + catch (Exception error) { Console.Error.WriteLine(error.ToString()); return 1; } + } +} diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs new file mode 100644 index 0000000..b2edce2 --- /dev/null +++ b/tests/security/codex-outbox-live.mjs @@ -0,0 +1,388 @@ +import assert from 'node:assert/strict'; +import {fork} from 'node:child_process'; +import {appendFileSync,mkdirSync,readFileSync,readdirSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {DatabaseSync} from 'node:sqlite'; +import {createPublicKey,verify} from 'node:crypto'; +import {setTimeout as delay} from 'node:timers/promises'; +import {createSqliteCheckpointStore} from '../../src/interactive/index.mjs'; +import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.mjs'; + +const [exe,cwd,root,profile='isolated',suite='original']=process.argv.slice(2); +assert.ok([exe,cwd,root].every(path.isAbsolute)); +assert.ok(['original','followup','signing','revocation'].includes(suite)); +mkdirSync(root,{mode:0o700}); +const probe=fileURLToPath(new URL('./codex-flowkit-probe.mjs',import.meta.url)); +const log=(event,data)=>appendFileSync(path.join(root,'supervisor.ndjson'), + JSON.stringify({event,at:Date.now(),supervisorPid:process.pid,...data})+'\n'); +const children=new Set(); + +function launch(mode,dir,label,{answer='',cut='',bytes}={}) { + const out=path.join(dir,label); + const child=fork(probe,[exe,cwd,out,mode,profile,dir,answer,cut,...(bytes?[String(bytes)]:[])], + {stdio:['ignore','pipe','pipe','ipc'],windowsHide:true}); + children.add(child); + let stdout='',stderr=''; + child.stdout.on('data',data=>{stdout+=data;appendFileSync(out+'.stdout.log',data);}); + child.stderr.on('data',data=>{stderr+=data;appendFileSync(out+'.stderr.log',data);}); + child.on('message',data=>log('worker-message',{label,...data})); + const closed=new Promise((resolve,reject)=>{ + child.once('error',reject); + child.once('close',(code,signal)=>{ + children.delete(child); + const exit={pid:child.pid,code,signal}; + log('worker-exit',{label,...exit}); + resolve({...exit,stdout,stderr}); + }); + }); + log('worker-start',{label,pid:child.pid,mode,args:child.spawnargs}); + return {child,closed}; +} + +async function waitFor(worker,event) { + const {child,closed}=worker; + let listener,timer; + try { + return await Promise.race([ + new Promise(resolve=>{ + listener=message=>{if (message.event===event) resolve(message);}; + child.on('message',listener); + }), + closed.then(result=>{throw new Error('Worker exited before '+event+': '+result.stderr+' '+result.stdout);}), + new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Timed out waiting for '+event)),70000);}), + ]); + } finally {clearTimeout(timer);child.off('message',listener);} +} + +async function offline(mode,dir,label,options) { + const result=await launch(mode,dir,label,options).closed; + assert.equal(result.code,0,result.stderr); + return JSON.parse(result.stdout); +} + +function inspect(dir,setup,proofs) { + const db=new DatabaseSync(path.join(dir,'outbox.sqlite'),{readOnly:true}); + let store; + try { + store=createSqliteCheckpointStore(path.join(dir,'run.sqlite'),{namespace:'codex-proof'}); + const cp=store.load(setup.runId); + const indexed=store.getReceipt(setup.runId,setup.requestId); + const journal=store.listJournal(setup.runId); + const get=db.prepare('SELECT * FROM delivery WHERE proof_id=?'); + const rows=proofs.map(proof=>{ + const row=get.get(proof.proofId); + assert.ok(row); + const receipt=JSON.parse(Buffer.from(row.receipt).toString('utf8')); + assert.equal(row.receipt_hash,sha256Bytes(row.receipt)); + assert.equal(row.receipt_hash,proof.receiptHash); + assert.equal(receipt.runId,setup.runId); + assert.equal(receipt.requestId,setup.requestId); + let statement; + if (proof.statementHash) { + statement=JSON.parse(Buffer.from(row.statement).toString('utf8')); + const keyFile=statement.keyId==='next'?'next.public.pem':'proof.public.pem'; + const publicKey=createPublicKey(readFileSync(path.join(dir,keyFile))); + assert.equal(sha256Bytes(row.statement),proof.statementHash); + assert.equal(sha256Bytes(row.signature),proof.signatureHash); + assert.ok(verify(null,row.statement,publicKey,row.signature)); + } else { + assert.equal(row.statement,null); + assert.equal(row.signature,null); + } + return {proofId:proof.proofId,state:row.state,receiptHash:row.receipt_hash,...receipt.result, + observationHash:sha256Bytes(row.observation),acceptedRevision:row.accepted_revision, + keyId:statement?.keyId,expiresAt:statement?.expiresAt, + statementHash:proof.statementHash,signatureHash:proof.signatureHash}; + }); + return {status:cp.status,revision:cp.revision,policyHash:cp.policyHash,answer:cp.state.answer,artifact:cp.state.artifact, + checkpointHash:journal.at(-1).checkpointHash,checkpointDigest:sha256Bytes(canonicalBytes(cp)), + journalDigest:sha256Bytes(canonicalBytes(journal)),indexed,journalRows:journal.length,rows}; + } finally {store?.close();db.close();} +} + +function pendingState(state,setup) { + assert.equal(state.status,'waiting'); + assert.equal(state.revision,setup.revision); + assert.equal(state.indexed,null); + assert.ok(state.rows.every(row=>row.state==='SIGNED')); +} + +function finishedState(state,winner) { + assert.equal(state.status,'succeeded'); + assert.equal(state.indexed.digest,winner.receiptHash); + assert.equal(state.indexed.acceptedRevision,winner.acceptedRevision); + assert.equal(state.answer,winner.answer); + assert.deepEqual(state.artifact,winner.artifact); + assert.equal(state.journalRows,state.revision+1); +} + +function assertSameStore(before,after) { + const {rows:beforeRows,...storeBefore}=before; + const {rows:afterRows,...storeAfter}=after; + assert.deepEqual(storeAfter,storeBefore); +} + +async function originalSuite() { + const raceDir=path.join(root,'race'); + mkdirSync(raceDir,{mode:0o700}); + const race=await offline('outbox-setup',raceDir,'setup'); + const contenders=['alpha','beta'].map(answer=>launch('outbox-worker',raceDir,answer,{answer})); + const proofs=await Promise.all(contenders.map(worker=>waitFor(worker,'signed'))); + for (const key of ['threadId','turnId','hostPid','modelPid','callId','proofId','receiptHash']) { + assert.notEqual(proofs[0][key],proofs[1][key],key); + } + assert.ok(proofs.every(proof=>proof.requestId===race.requestId)); + const beforeRace=inspect(raceDir,race,proofs); + pendingState(beforeRace,race); + log('race-before-release',beforeRace); + const deliveries=contenders.map(worker=>waitFor(worker,'delivered')); + contenders.forEach(({child})=>child.send('deliver')); + await Promise.all(deliveries); + const afterRace=inspect(raceDir,race,proofs); + const winners=afterRace.rows.filter(row=>row.state==='ACKED'); + assert.equal(winners.length,1); + assert.equal(afterRace.rows.filter(row=>row.state==='CONFLICT').length,1); + finishedState(afterRace,winners[0]); + assert.equal(afterRace.indexed.acceptedRevision,race.revision+1); + log('race-passed',afterRace); + await Promise.all(contenders.map(worker=>worker.closed)); + + const crashDir=path.join(root,'crash'); + mkdirSync(crashDir,{mode:0o700}); + const crash=await offline('outbox-setup',crashDir,'setup'); + const victim=launch('outbox-worker',crashDir,'victim',{answer:'ready'}); + const proof=await waitFor(victim,'signed'); + const beforeCrash=inspect(crashDir,crash,[proof]); + pendingState(beforeCrash,crash); + log('kill-before-cas',{victimPid:victim.child.pid,...beforeCrash}); + assert.ok(victim.child.kill('SIGKILL')); + const killed=await victim.closed; + assert.equal(killed.signal,'SIGKILL'); + const afterKill=inspect(crashDir,crash,[proof]); + assert.deepEqual(afterKill,beforeCrash); + log('signed-survived-kill',{killedPid:killed.pid,signal:killed.signal,...afterKill}); + const restarted=await offline('outbox-recover',crashDir,'restart'); + assert.equal(restarted.before.revision,crash.revision); + assert.equal(restarted.before.receipt,null); + assert.equal(restarted.recovered.length,1); + assert.equal(restarted.recovered[0].proofId,proof.proofId); + const afterCrash=inspect(crashDir,crash,[proof]); + assert.equal(afterCrash.rows[0].state,'ACKED'); + finishedState(afterCrash,afterCrash.rows[0]); + assert.equal(afterCrash.indexed.acceptedRevision,crash.revision+1); + log('recovery-passed',afterCrash); + return {race:afterRace,crash:afterCrash}; +} + +function inspectSnapshot(dir,artifact) { + const inbox=path.join(dir,'inbox'); + assert.deepEqual(readdirSync(inbox),[]); + const handoff=createFileHandoff({database:path.join(dir,'file-handoff.sqlite'),inbox, + validateArtifact:()=>false}); + try { + const {bytes,...snapshot}=handoff.readSnapshot(artifact.id); + assert.equal(snapshot.byteLength,artifact.byteLength); + assert.equal(snapshot.sha256,artifact.sha256); + assert.deepEqual(bytes,canonicalBytes({answer:'A'.repeat(snapshot.byteLength-13)})); + return snapshot; + } finally {handoff.close();} +} + +async function crashCase(label,{cut='',bytes}={}) { + const dir=path.join(root,label); + mkdirSync(dir,{mode:0o700}); + const setup=await offline('outbox-setup',dir,'setup',{bytes}); + const victim=launch('outbox-worker',dir,'victim',{answer:'ready',cut,bytes}); + const proof=await waitFor(victim,'signed'); + assert.equal(proof.hostPid,victim.child.pid); + assert.equal(proof.requestId,setup.requestId); + if (cut) { + const committed=waitFor(victim,'committed'); + victim.child.send('deliver'); + assert.equal((await committed).proofId,proof.proofId); + } + const before=inspect(dir,setup,[proof]); + const artifact=before.rows[0].artifact; + assert.equal(before.rows[0].state,'SIGNED'); + assert.equal(before.rows[0].acceptedRevision,null); + if (cut) { + assert.equal(before.indexed.acceptedRevision,setup.revision+1); + finishedState(before,{...before.rows[0],acceptedRevision:before.indexed.acceptedRevision}); + } else pendingState(before,setup); + const snapshot=bytes?inspectSnapshot(dir,artifact):null; + if (snapshot) assert.equal(snapshot.state,'claimed'); + log('followup-before-kill',{label,cut:cut||'before-cas',victimPid:victim.child.pid,before,snapshot}); + assert.ok(victim.child.kill('SIGKILL')); + const killed=await victim.closed; + assert.equal(killed.signal,'SIGKILL'); + assert.deepEqual(inspect(dir,setup,[proof]),before); + if (snapshot) assert.deepEqual(inspectSnapshot(dir,artifact),snapshot); + log('followup-kill-confirmed',{label,victimPid:killed.pid,signal:killed.signal,before,snapshot}); + const restarted=await offline('outbox-recover',dir,'restart',{bytes}); + assert.deepEqual(restarted.before.receipt,before.indexed); + assert.equal(restarted.before.revision,before.revision); + assert.equal(restarted.recovered.length,1); + assert.equal(restarted.recovered[0].proofId,proof.proofId); + const after=inspect(dir,setup,[proof]); + const winner=after.rows[0]; + assert.equal(winner.state,'ACKED'); + finishedState(after,winner); + assert.equal(after.indexed.acceptedRevision,setup.revision+1); + if (cut) assertSameStore(before,after); + const recoveredSnapshot=bytes?inspectSnapshot(dir,artifact):null; + if (snapshot) assert.deepEqual(recoveredSnapshot,{...snapshot,state:'acked',acceptedRevision:winner.acceptedRevision}); + log('followup-passed',{label,after,snapshot:recoveredSnapshot}); + return {after,snapshot:recoveredSnapshot}; +} + +async function acceptLive(dir,setup,label,cut,answer) { + const worker=launch('outbox-worker',dir,label,{cut,answer}); + const proof=await waitFor(worker,'signed'); + assert.equal(proof.requestId,setup.requestId); + const delivery=waitFor(worker,'delivered'); + worker.child.send('deliver'); + assert.equal((await delivery).state,'ACKED'); + assert.equal((await worker.closed).code,0); + return proof; +} + +async function beforeSignCase() { + const dir=path.join(root,'before-sign'); + mkdirSync(dir,{mode:0o700}); + const setup=await offline('outbox-setup',dir,'setup'); + const victim=launch('outbox-worker',dir,'victim',{answer:'ready',cut:'before-sign'}); + const observed=await waitFor(victim,'observed'); + assert.equal(observed.hostPid,victim.child.pid); + const before=inspect(dir,setup,[observed]); + assert.equal(before.status,'waiting'); + assert.equal(before.revision,setup.revision); + assert.equal(before.indexed,null); + assert.equal(before.rows[0].state,'OBSERVED'); + log('before-sign-cut',{observed,before}); + assert.ok(victim.child.kill('SIGKILL')); + assert.equal((await victim.closed).signal,'SIGKILL'); + assert.deepEqual(inspect(dir,setup,[observed]),before); + const restart=await offline('outbox-recover',dir,'restart'); + assert.deepEqual(restart.recovered,[]); + assert.deepEqual(inspect(dir,setup,[observed]),before); + const retry=await acceptLive(dir,setup,'retry','','ready'); + for (const field of ['proofId','threadId','turnId','callId']) assert.notEqual(retry[field],observed[field],field); + const after=inspect(dir,setup,[observed,retry]); + assert.deepEqual(after.rows[0],before.rows[0]); + finishedState(after,after.rows[1]); + assert.equal(after.indexed.acceptedRevision,setup.revision+1); + log('before-sign-passed',{observed,before,restart,retry,after}); + return {before,after}; +} + +async function waitPastHostSkew(proof) { + // Wait past both the Outbox deadline and the verifier's default 5-second grace. + const minimum=proof.expiresAt+5100; + await delay(Math.max(0,minimum-Date.now())); + assert.ok(Date.now()>=minimum); +} + +async function expiredCase() { + const dir=path.join(root,'expired'); + mkdirSync(dir,{mode:0o700}); + const setup=await offline('outbox-setup',dir,'setup'); + const worker=launch('outbox-worker',dir,'victim',{answer:'ready',cut:'expire'}); + const proof=await waitFor(worker,'signed'); + const before=inspect(dir,setup,[proof]); + pendingState(before,setup); + assert.ok(worker.child.kill('SIGKILL')); + assert.equal((await worker.closed).signal,'SIGKILL'); + await waitPastHostSkew(proof); + const restart=await offline('outbox-recover',dir,'restart'); + assert.equal(restart.recovered.length,1); + const after=inspect(dir,setup,[proof]); + assertSameStore(before,after); + assert.deepEqual(after.rows,[{...before.rows[0],state:'BLOCKED'}]); + log('expired-passed',{proof,before,restart,after}); + return {before,after}; +} + +async function rotationCase() { + const dir=path.join(root,'rotation'); + mkdirSync(dir,{mode:0o700}); + const setup=await offline('outbox-setup',dir,'setup',{cut:'rotate-old'}); + const oldProof=await acceptLive(dir,setup,'old','rotate-old','alpha'); + const old=inspect(dir,setup,[oldProof]); + finishedState(old,old.rows[0]); + assert.equal(old.rows[0].keyId,'proof'); + const next=await offline('outbox-next',dir,'next-setup',{cut:'rotate-new'}); + assert.notEqual(setup.runId,next.runId); + const nextProof=await acceptLive(dir,next,'new','rotate-new','beta'); + const newer=inspect(dir,next,[nextProof]); + finishedState(newer,newer.rows[0]); + assert.equal(newer.rows[0].keyId,'next'); + assert.equal(newer.policyHash,old.policyHash); + await waitPastHostSkew(oldProof); + const replay=await offline('outbox-replay',dir,'old-replay',{cut:'rotate-old',answer:oldProof.proofId}); + assert.equal(replay.runId,setup.runId); + assert.equal(replay.acceptedRevision,old.indexed.acceptedRevision); + assert.deepEqual(replay.receipt,old.indexed); + assert.deepEqual(inspect(dir,setup,[oldProof]),old); + assert.deepEqual(inspect(dir,next,[nextProof]),newer); + log('rotation-passed',{oldProof,nextProof,old,newer,replay}); + return {old,newer,replay}; +} + +async function revocationCase() { + const dir=path.join(root,'revocation'); + mkdirSync(dir,{mode:0o700}); + const oldRun=await offline('outbox-setup',dir,'setup',{cut:'rotate-old'}); + const oldProof=await acceptLive(dir,oldRun,'old','rotate-old','alpha'); + const accepted=inspect(dir,oldRun,[oldProof]); + finishedState(accepted,accepted.rows[0]); + assert.equal(accepted.rows[0].keyId,'proof'); + const pendingRun=await offline('outbox-next',dir,'pending-setup',{cut:'rotate-old-next'}); + const pendingHost=launch('outbox-worker',dir,'pending',{cut:'rotate-old-next',answer:'beta'}); + const pendingProof=await waitFor(pendingHost,'signed'); + assert.equal(pendingProof.requestId,pendingRun.requestId); + const pending=inspect(dir,pendingRun,[pendingProof]); + pendingState(pending,pendingRun); + assert.ok(pendingHost.child.kill('SIGKILL')); + assert.equal((await pendingHost.closed).signal,'SIGKILL'); + + const historical=await offline('outbox-check-revocation',dir,'revoked-old', + {cut:'revoked-old',answer:oldProof.proofId}); + const inFlight=await offline('outbox-check-revocation',dir,'revoked-pending', + {cut:'revoked-pending',answer:pendingProof.proofId}); + assert.ok(Date.now(){ + child.once('error',error=>resolve({error:error.message})); + child.once('close',(code,signal)=>resolve({code,signal})); + }); + return {child,finished}; +} + +async function readReport(directory,filename) { + return JSON.parse(await readFile(path.join(directory,filename),'utf8')); +} + +async function waitReady() { + for (let i=0;i<150;i++) { + try {return await readReport(hostDir,'host-ready.json');} + catch (error) {if (error.code!=='ENOENT') throw error;} + assert.equal(host.child.exitCode,null,'Host stopped during startup'); + await delay(100); + } + assert.fail('Host startup timed out'); +} + +async function finish(workerProcess,timeout) { + const timer=setTimeout(()=>workerProcess.child.kill('SIGKILL'),timeout); + try {assert.deepEqual(await workerProcess.finished,{code:0,signal:null});} + finally {clearTimeout(timer);} +} + +function verifyModelExec() { + const input='fixture stdin\n'; + const execShim=path.join(publicDir,'model-exec'); + const child=spawn('/usr/bin/setpriv',[ + '--reuid='+modelUid,'--regid='+modelUid,'--clear-groups','--inh-caps=-all', + '--bounding-set=-all','--no-new-privs',process.execPath, + path.join(publicDir,'tests/security/codex-model-identity.mjs'),String(modelUid),String(modelUid), + execShim,path.join(publicDir,'model-stdio-fixture'), + ],{cwd:publicDir,env:{PATH:'/usr/bin:/bin',LANG:'C'},stdio:['pipe','pipe','pipe','pipe']}); + let stdout='',stderr=''; + child.stdout.setEncoding('utf8').on('data',data=>{stdout+=data;}); + child.stderr.setEncoding('utf8').on('data',data=>{stderr+=data;}); + const finished=new Promise((resolve,reject)=>{ + child.once('error',reject); + child.stdin.once('error',reject); + child.once('close',(code,signal)=>resolve({code,signal})); + }); + child.stdin.end(input); + return finish({child,finished},5000).then(()=>{ + const [record,result]=stdout.trim().split('\n').map(line=>JSON.parse(line)); + assert.equal(record.method,'flowkit/profile/identity'); + const identity=record.params; + assert.equal(identity.pid,child.pid); + assert.equal(identity.uid,modelUid); + assert.equal(identity.gid,modelUid); + assert.deepEqual(result,{pid:identity.pid,uid:identity.uid,gid:identity.gid}); + assert.equal(stderr,'fixture stderr'); + console.log(JSON.stringify({event:'codex-model-exec-fixture',...identity,roundTrip:true})); + }); +} + +try { + await chmod(root,0o755); + await mkdir(publicDir,{mode:0o755}); + for (const [folder,uid] of [[hostDir,hostUid],[modelDir,modelUid]]) { + await mkdir(folder,{mode:0o700}); + await chown(folder,uid,uid); + } + for (const relative of ['tests/security/secure-host-worker.mjs','tests/security/codex-model-identity.mjs', + 'src/interactive/sqlite-store.mjs','src/interactive/canonical.mjs']) { + const destination=path.join(publicDir,relative); + await mkdir(path.dirname(destination),{recursive:true,mode:0o755}); + await copyFile(path.join(repo,relative),destination); + await chmod(destination,0o644); + } + const compiler='/usr/bin/cc'; + const shim=spawnSync(compiler,['-Wall','-Wextra','-Werror','-O2', + path.join(repo,'tests/security/codex-model-exec-linux.c'),'-o',path.join(publicDir,'model-exec')],{stdio:'inherit'}); + assert.equal(shim.status,0,'Compile the Model exec shim'); + const fixtureSource=path.join(publicDir,'model-stdio-fixture.c'); + await writeFile(fixtureSource,` +#include +#include +#include +#include +int main(void) { + for (int fd = 0; fd <= 2; fd++) { + int flags = fcntl(fd, F_GETFL); + if (flags == -1 || (flags & O_NONBLOCK)) return 1; + } + if (fcntl(3, F_GETFD) != -1) return 2; + char input[64]; + if (!fgets(input, sizeof(input), stdin) || strcmp(input, "fixture stdin\\n")) return 3; + printf("{\\"pid\\":%ld,\\"uid\\":%lu,\\"gid\\":%lu}\\n", (long)getpid(), + (unsigned long)getuid(), (unsigned long)getgid()); + fputs("fixture stderr", stderr); + return 0; +} +`,{mode:0o644}); + const fixtureBuild=spawnSync(compiler,['-Wall','-Wextra','-Werror','-O2',fixtureSource, + '-o',path.join(publicDir,'model-stdio-fixture')],{stdio:'inherit'}); + assert.equal(fixtureBuild.status,0,'Compile the stdio check'); + await verifyModelExec(); + await writeFile(path.join(publicDir,'task.json'),'flowkit-public-task\n',{mode:0o644}); + host=launch(hostUid,'host'); + const ready=await waitReady(); + assert.equal(ready.pid,host.child.pid); + assert.equal(ready.identity.uid,hostUid); + console.log(JSON.stringify({event:'host-ready',...ready})); + + model=launch(modelUid,'model',[String(ready.pid)]); + await finish(model,30000); + const result=await readReport(modelDir,'model-result.json'); + assert.equal(result.pid,model.child.pid); + assert.equal(result.identity.uid,modelUid); + assert.ok(result.publicTaskReadable && result.checks.length===47); + console.log(JSON.stringify({event:'model-result',...result})); + + await writeFile(path.join(hostDir,'stop'),'stop\n',{mode:0o600}); + await finish(host,15000); + const closed=await readReport(hostDir,'host-result.json'); + assert.ok(closed.protectedBytesUnchanged && closed.keyUsable); + console.log(JSON.stringify({event:'host-result',...closed})); +} catch (error) { + for (const [folder,filename] of [[hostDir,'host-error.json'],[modelDir,'model-error.json']]) { + try {console.error(JSON.stringify(await readReport(folder,filename)));} + catch (reportError) {if (reportError.code!=='ENOENT') console.error(reportError.message);} + } + throw error; +} finally { + for (const process of [model,host]) { + if (!process) continue; + if (process.child.exitCode===null && process.child.signalCode===null) process.child.kill('SIGKILL'); + await process.finished; + } + await rm(root,{recursive:true,force:true}); +} diff --git a/tests/security/secure-host-win-process.cs b/tests/security/secure-host-win-process.cs new file mode 100644 index 0000000..602c1cc --- /dev/null +++ b/tests/security/secure-host-win-process.cs @@ -0,0 +1,107 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; + +public static class FlowKitProcessAccess { + private const uint QueryLimitedInformation = 0x1000; + private const uint WriteDacl = 0x00040000; + private const uint TokenQuery = 0x0008; + private const uint DaclSecurityInformation = 0x00000004; + [DllImport("kernel32.dll", SetLastError=true)] + private static extern IntPtr OpenProcess(uint rights, bool inherit, int pid); + [DllImport("advapi32.dll", SetLastError=true)] + private static extern bool OpenProcessToken(IntPtr process, uint rights, out IntPtr token); + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll")] + private static extern int GetCurrentProcessId(); + [DllImport("advapi32.dll", SetLastError=true)] + private static extern bool SetKernelObjectSecurity(IntPtr handle, uint information, byte[] descriptor); + + // Ask for access and immediately close any returned handle. + private static int CheckProcess(int pid, uint rights) { + IntPtr handle = OpenProcess(rights, false, pid); + if (handle == IntPtr.Zero) return Marshal.GetLastWin32Error(); + CloseHandle(handle); + return 0; + } + + private static int CheckToken(int pid, uint rights) { + IntPtr process = OpenProcess(QueryLimitedInformation, false, pid); + if (process == IntPtr.Zero) return Marshal.GetLastWin32Error(); + try { + IntPtr token; + if (!OpenProcessToken(process, rights, out token)) return Marshal.GetLastWin32Error(); + CloseHandle(token); + return 0; + } finally { + CloseHandle(process); + } + } + + private static string Check(string name, int positiveControl, int result) { + if (positiveControl != 0) throw new InvalidOperationException("Positive control failed: " + name); + return "{\"operation\":\"" + name + "\",\"denied\":" + (result == 5 ? "true" : "false") + + ",\"win32Error\":" + result.ToString(CultureInfo.InvariantCulture) + "}"; + } + + // The Host invokes this in the disposable profile before creating private data. + // The helper must run with the same account as the target Host process. + private static void Restrict(int pid) { + IntPtr process = OpenProcess(WriteDacl | QueryLimitedInformation, false, pid); + if (process == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "Open Host process"); + try { + IntPtr token; + if (!OpenProcessToken(process, WriteDacl | TokenQuery, out token)) { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Open Host token"); + } + try { + string sid; + using (WindowsIdentity current = WindowsIdentity.GetCurrent()) + using (WindowsIdentity target = new WindowsIdentity(token)) { + sid = current.User.Value; + if (sid != target.User.Value) throw new InvalidOperationException("Host identity mismatch"); + } + RawSecurityDescriptor security = new RawSecurityDescriptor("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;" + sid + ")"); + byte[] descriptor = new byte[security.BinaryLength]; + security.GetBinaryForm(descriptor, 0); + if (!SetKernelObjectSecurity(token, DaclSecurityInformation, descriptor)) { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Restrict Host token DACL"); + } + if (!SetKernelObjectSecurity(process, DaclSecurityInformation, descriptor)) { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Restrict Host process DACL"); + } + Console.WriteLine("{\"sid\":\"" + sid + "\",\"processDaclApplied\":true,\"tokenDaclApplied\":true}"); + } finally { + CloseHandle(token); + } + } finally { + CloseHandle(process); + } + } + + public static int Main(string[] args) { + int host; + int self = GetCurrentProcessId(); + bool restrict = args.Length == 2 && args[0] == "restrict"; + if ((!restrict && args.Length != 1) || !Int32.TryParse(args[restrict ? 1 : 0], out host) + || host <= 1 || host == self) return 2; + if (restrict) { + Restrict(host); + return 0; + } + string[] results = { + Check("process-vm-read", CheckProcess(self, 0x0010), CheckProcess(host, 0x0010)), + Check("process-vm-write", CheckProcess(self, 0x0020), CheckProcess(host, 0x0020)), + Check("process-duplicate-handle", CheckProcess(self, 0x0040), CheckProcess(host, 0x0040)), + Check("process-create-thread", CheckProcess(self, 0x0002), CheckProcess(host, 0x0002)), + Check("token-duplicate", CheckToken(self, 0x0002), CheckToken(host, 0x0002)), + Check("token-assign-primary", CheckToken(self, 0x0001), CheckToken(host, 0x0001)) + }; + Console.WriteLine("[" + String.Join(",", results) + "]"); + return 0; + } +} diff --git a/tests/security/secure-host-windows.ps1 b/tests/security/secure-host-windows.ps1 new file mode 100644 index 0000000..a2820cf --- /dev/null +++ b/tests/security/secure-host-windows.ps1 @@ -0,0 +1,226 @@ +[CmdletBinding()] +param([Parameter(Mandatory=$true)][string]$Node,[switch]$DisposableRunner) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +if (!$DisposableRunner) { throw 'Use an explicitly designated disposable Windows runner' } +if ($PSVersionTable.PSEdition -ne 'Desktop' -or ![Environment]::Is64BitProcess) { + throw 'Use 64-bit Windows PowerShell for LocalAccounts and DirectorySecurity' +} +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$operator = [Security.Principal.WindowsPrincipal]::new($identity) +if (!$operator.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Provisioning the disposable account pair requires an elevated bootstrap' +} +$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path +$Node = (Resolve-Path -LiteralPath $Node).Path +$parent = [Environment]::GetFolderPath('CommonApplicationData') +$suffix = [Guid]::NewGuid().ToString('N').Substring(0,12) +$root = Join-Path $parent "FlowKit-Profile-$suffix" +$publicDir = Join-Path $root 'public' +$hostDir = Join-Path $root 'host-private' +$modelDir = Join-Path $root 'model-scratch' +$hostUser = $modelUser = $hostProcess = $modelProcess = $null +$admins = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') +$system = [Security.Principal.SecurityIdentifier]::new('S-1-5-18') +$users = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') +$hostPassword = ConvertTo-SecureString ('Fk1!' + [Guid]::NewGuid().ToString('N')) -AsPlainText -Force +$modelPassword = ConvertTo-SecureString ('Fk1!' + [Guid]::NewGuid().ToString('N')) -AsPlainText -Force + +function New-ProtectedDirectory([string]$Path,[hashtable[]]$Grants) { + $security = [Security.AccessControl.DirectorySecurity]::new() + $security.SetAccessRuleProtection($true,$false) + foreach ($sid in @($admins,$system)) { + $security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $sid,'FullControl','ContainerInherit,ObjectInherit','None','Allow' + )) + } + foreach ($grant in $Grants) { + $security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $grant.Sid,$grant.Rights,'ContainerInherit,ObjectInherit','None','Allow' + )) + } + [void][IO.Directory]::CreateDirectory($Path,$security) +} + +function Read-Record([string]$Path) { + return (Get-Content -LiteralPath $Path -Encoding UTF8 -Raw | ConvertFrom-Json) +} + +function Wait-Record($Child,[string]$Path) { + $deadline = [DateTime]::UtcNow.AddSeconds(18) + while (![IO.File]::Exists($Path)) { + if ($Child.HasExited) { throw "Worker $($Child.Id) exited before producing its record" } + if ([DateTime]::UtcNow -ge $deadline) { throw 'Worker startup timed out' } + Start-Sleep -Milliseconds 100 + } + return Read-Record $Path +} + +function Start-ProfileWorker($Account,[Security.SecureString]$Password,[string]$Role,[string]$Scratch,[string]$TargetId='') { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = Join-Path $publicDir 'node.exe' + $start.WorkingDirectory = $publicDir + $arguments = @((Join-Path $publicDir 'tests\security\secure-host-worker.mjs'),$Role,$root) + if ($TargetId) { $arguments += $TargetId } + $start.Arguments = ($arguments | ForEach-Object { '"' + $_ + '"' }) -join ' ' + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.WindowStyle = 'Hidden' + $start.Domain = $env:COMPUTERNAME + $start.UserName = $Account.Name + $start.Password = $Password + $start.EnvironmentVariables.Clear() + $start.EnvironmentVariables['SystemRoot'] = $env:SystemRoot + $start.EnvironmentVariables['ComSpec'] = Join-Path $env:SystemRoot 'System32\cmd.exe' + $start.EnvironmentVariables['PATH'] = Join-Path $env:SystemRoot 'System32' + $start.EnvironmentVariables['TEMP'] = $Scratch + $start.EnvironmentVariables['TMP'] = $Scratch + if ($Role -eq 'host') { $start.EnvironmentVariables['FLOWKIT_HOST_CANARY'] = 'flowkit-disposable-host-fixture' } + $child = [Diagnostics.Process]::Start($start) + Write-Host (@{event='spawn';role=$Role;pid=$child.Id;account=$Account.Name;sid=$Account.SID.Value; + executable=$start.FileName;arguments=$arguments} | ConvertTo-Json -Compress) + return $child +} + +function Wait-Success($Child,[int]$TimeoutMilliseconds) { + if (!$Child.WaitForExit($TimeoutMilliseconds)) { throw "Worker $($Child.Id) timed out" } + if ($Child.ExitCode -ne 0) { throw "Worker $($Child.Id) exited $($Child.ExitCode)" } +} + +function Test-ModelStdio($Account,[Security.SecureString]$Password) { + $nodeCopy = Join-Path $publicDir 'node.exe' + $helper = Join-Path $publicDir 'codex-model-stdio.exe' + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $nodeCopy + $start.Arguments = '"' + (Join-Path $publicDir 'check-stdio.cjs') + '"' + $start.WorkingDirectory = $publicDir + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.WindowStyle = 'Hidden' + $start.Domain = $env:COMPUTERNAME + $start.UserName = $Account.Name + $start.Password = $Password + $start.EnvironmentVariables.Clear() + $start.EnvironmentVariables['SystemRoot'] = $env:SystemRoot + $start.EnvironmentVariables['TEMP'] = $modelDir + $start.EnvironmentVariables['TMP'] = $modelDir + $start.EnvironmentVariables['FLOWKIT_FIXTURE_MODEL_SID'] = $Account.SID.Value + $start.EnvironmentVariables['FLOWKIT_FIXTURE_STDIO_EXE'] = $helper + $child = [Diagnostics.Process]::Start($start) + try { + Wait-Success $child 15000 + $record = Read-Record (Join-Path $modelDir 'stdio-result.json') + if ($record.modelSid -ne $Account.SID.Value -or !$record.stdioAllowlist -or !$record.roundTrip) { + throw 'Model stdio launcher fixture mismatch' + } + Write-Host ($record | ConvertTo-Json -Compress) + } catch { + $errorFile = Join-Path $modelDir 'stdio-error.txt' + if ([IO.File]::Exists($errorFile)) { Write-Host (Get-Content -LiteralPath $errorFile -Raw) } + throw + } finally { + if (!$child.HasExited) { $child.Kill(); [void]$child.WaitForExit(5000) } + $child.Dispose() + } +} + +try { + $hostUser = New-LocalUser -Name "fkh$suffix" -Password $hostPassword -Description 'FlowKit disposable Host profile' + $modelUser = New-LocalUser -Name "fkm$suffix" -Password $modelPassword -Description 'FlowKit disposable model profile' + foreach ($account in @($hostUser,$modelUser)) { + if (!(Get-LocalGroupMember -SID $users | Where-Object { $_.SID -eq $account.SID })) { + Add-LocalGroupMember -SID $users -Member $account + } + } + New-ProtectedDirectory $root @( + @{Sid=$hostUser.SID;Rights='ReadAndExecute'} + @{Sid=$modelUser.SID;Rights='ReadAndExecute'} + ) + New-ProtectedDirectory $hostDir @(@{Sid=$hostUser.SID;Rights='Modify'}) + New-ProtectedDirectory $modelDir @(@{Sid=$modelUser.SID;Rights='Modify'}) + [void][IO.Directory]::CreateDirectory($publicDir) + foreach ($file in @( + 'tests\security\secure-host-worker.mjs', + 'src\interactive\sqlite-store.mjs','src\interactive\canonical.mjs' + )) { + $destination = Join-Path $publicDir $file + [void][IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) + Copy-Item -LiteralPath (Join-Path $repo $file) -Destination $destination + } + Copy-Item -LiteralPath $Node -Destination (Join-Path $publicDir 'node.exe') + # Compile before launching either ordinary user. The model runs only the built probe. + Add-Type -Path (Join-Path $PSScriptRoot 'secure-host-win-process.cs') -OutputType ConsoleApplication ` + -OutputAssembly (Join-Path $publicDir 'process-access.exe') + $compiler = Join-Path $env:SystemRoot 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' + $modelStdio = Join-Path $publicDir 'codex-model-stdio.exe' + & $compiler /nologo /warnaserror /target:exe "/out:$modelStdio" (Join-Path $PSScriptRoot 'codex-model-stdio-windows.cs') + if ($LASTEXITCODE -ne 0) { throw 'Model stdio launcher compile failed' } + Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'codex-model-stdio-fixture.cjs') ` + -Destination (Join-Path $publicDir 'check-stdio.cjs') + [IO.File]::WriteAllText((Join-Path $publicDir 'app-server'), @' +if (process.argv.slice(2).join('|') !== '--listen|stdio://|-c|web_search="disabled"') { + process.exit(2); +} +process.stdin.setEncoding('utf8'); +process.stdin.on('data', text => process.stdout.write(text)); +process.stderr.write('fixture stderr'); +'@ + ) + Test-ModelStdio $modelUser $modelPassword + [IO.File]::WriteAllText((Join-Path $publicDir 'task.json'),"flowkit-public-task`n") + + $hostProcess = Start-ProfileWorker $hostUser $hostPassword 'host' $hostDir + $ready = Wait-Record $hostProcess (Join-Path $hostDir 'host-ready.json') + if ($ready.pid -ne $hostProcess.Id -or $ready.identity.sid -ne $hostUser.SID.Value) { throw 'Host SID/PID mismatch' } + Write-Host ($ready | ConvertTo-Json -Depth 8 -Compress) + foreach ($resource in @($root,$publicDir,$hostDir,$modelDir)+ + @($ready.files.PSObject.Properties.Name | ForEach-Object { Join-Path $hostDir $_ })) { + $acl = Get-Acl -LiteralPath $resource + Write-Host (@{event='acl';path=$resource;owner=$acl.Owner;sddl=$acl.Sddl} | ConvertTo-Json -Compress) + } + + $modelProcess = Start-ProfileWorker $modelUser $modelPassword 'model' $modelDir ([string]$ready.pid) + Wait-Success $modelProcess 45000 + $result = Read-Record (Join-Path $modelDir 'model-result.json') + if ($result.pid -ne $modelProcess.Id -or $result.identity.sid -ne $modelUser.SID.Value -or + !$result.publicTaskReadable -or $result.checks.Count -ne 43 -or $result.processEvidence.Count -ne 6) { + throw 'Model identity or negative test count mismatch' + } + Write-Host ($result | ConvertTo-Json -Depth 8 -Compress) + [IO.File]::WriteAllText((Join-Path $hostDir 'stop'),'stop') + Wait-Success $hostProcess 15000 + $closed = Read-Record (Join-Path $hostDir 'host-result.json') + if (!$closed.keyUsable -or !$closed.protectedBytesUnchanged) { throw 'Host postcondition failed' } + Write-Host ($closed | ConvertTo-Json -Depth 8 -Compress) +} catch { + foreach ($errorPath in @( + (Join-Path $hostDir 'host-error.json'),(Join-Path $modelDir 'model-error.json'), + (Join-Path $modelDir 'model-file-result.json') + )) { + if ([IO.File]::Exists($errorPath)) { Write-Host (Get-Content -LiteralPath $errorPath -Encoding UTF8 -Raw) } + } + throw +} finally { + try { + foreach ($child in @($modelProcess,$hostProcess)) { + if ($null -ne $child) { + if (!$child.HasExited) { $child.Kill(); [void]$child.WaitForExit(5000) } + $child.Dispose() + } + } + if ([IO.Directory]::Exists($root)) { + $resolved = Get-Item -LiteralPath $root + if ($resolved.FullName -ne $root -or $resolved.Parent.FullName -ne $parent -or + $resolved.Name -ne "FlowKit-Profile-$suffix" -or + ($resolved.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Refusing to remove a path other than the disposable profile root' + } + Remove-Item -LiteralPath $resolved.FullName -Recurse -Force + } + } finally { + foreach ($account in @($modelUser,$hostUser)) { + if ($null -ne $account) { Remove-LocalUser -SID $account.SID } + } + } +} diff --git a/tests/security/secure-host-worker.mjs b/tests/security/secure-host-worker.mjs new file mode 100644 index 0000000..309ea73 --- /dev/null +++ b/tests/security/secure-host-worker.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {createHash,createPrivateKey,generateKeyPairSync,sign,verify} from 'node:crypto'; +import {constants} from 'node:fs'; +import {copyFile,lstat,open,readFile,readdir,readlink,rename,unlink,writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import {DatabaseSync} from 'node:sqlite'; +import {setTimeout as delay} from 'node:timers/promises'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; + +const [mode,root,hostPidArg]=process.argv.slice(2); +assert.ok(['host','model'].includes(mode) && path.isAbsolute(root)); +const hostDir=path.join(root,'host-private'); +const modelDir=path.join(root,'model-scratch'); +const publicTask=path.join(root,'public','task.json'); +const key=path.join(hostDir,'signing-key.pem'); +const database=path.join(hostDir,'checkpoint.db'); +const backup=path.join(hostDir,'backup.db'); +const config=path.join(hostDir,'host-config.json'); +const allowlist=path.join(hostDir,'program-allowlist.json'); +const files=[key,config,allowlist,database,database+'-wal',database+'-shm',backup]; +const canary='flowkit-disposable-host-fixture'; + +async function report(folder,name,value) { + const destination=path.join(folder,name); + await writeFile(destination+'.tmp',JSON.stringify(value,null,2)+'\n',{mode:0o600}); + await rename(destination+'.tmp',destination); +} + +async function identity() { + if (process.platform==='linux') { + const status=await readFile('/proc/self/status','utf8'); + const readField=(name)=>status.match(new RegExp('^'+name+':\\s*(.+)$','m'))?.[1].trim(); + const uid=process.getuid(); + const gid=process.getgid(); + const groups=process.getgroups(); + assert.notEqual(uid,0); + assert.ok(groups.every(group=>group===gid), 'unexpected supplementary group'); + assert.equal(readField('NoNewPrivs'),'1'); + for (const field of ['CapInh','CapPrm','CapEff','CapBnd','CapAmb']) { + assert.match(readField(field),/^0+$/,field); + } + return {uid,gid,groups,status:status.split('\n').filter(line=>/^(Uid|Gid|Groups|Cap|NoNewPrivs):?/.test(line))}; + } + assert.equal(process.platform,'win32'); + const whoami=path.join(process.env.SystemRoot,'System32','whoami.exe'); + const query=(...args)=>execFileSync(whoami,args,{encoding:'utf8',windowsHide:true,timeout:10000}); + const user=query('/user','/fo','csv','/nh').trim(); + const sid=user.match(/S-1-[\d-]+/)?.[0]; + const groups=query('/groups','/fo','csv','/nh'); + const privileges=query('/priv','/fo','csv','/nh'); + assert.match(sid,/^S-1-5-21-/); + assert.doesNotMatch(groups,/S-1-5-32-544(?:["\s,]|$)/,'local administrator group present'); + assert.doesNotMatch(privileges,/Se(?:Debug|Tcb|Impersonate|AssignPrimaryToken|Backup|Restore|TakeOwnership)Privilege/i); + return {sid,user,groups,privileges}; +} + +async function hashes() { + return Object.fromEntries(await Promise.all(files.map(async file=>{ + const digest=createHash('sha256').update(await readFile(file)).digest('hex'); + return [path.basename(file),digest]; + }))); +} + +function verifyDatabase(filename) { + const handle=new DatabaseSync(filename,{readOnly:true}); + try { + assert.equal(handle.prepare('PRAGMA integrity_check').get().integrity_check,'ok'); + assert.equal(handle.prepare("SELECT count(*) AS n FROM sqlite_schema WHERE name='fk_i_runs'").get().n,1); + } finally {handle.close();} +} + +async function runHost() { + const actualIdentity=await identity(); + assert.equal(process.env.FLOWKIT_HOST_CANARY,canary); + let windowsAccessPolicy; + if (process.platform==='win32') { + const probe=path.join(root,'public','process-access.exe'); + windowsAccessPolicy=JSON.parse(execFileSync(probe,['restrict',String(process.pid)],{ + encoding:'utf8',windowsHide:true,timeout:10000, + })); + assert.deepEqual(windowsAccessPolicy,{ + sid:actualIdentity.sid,processDaclApplied:true,tokenDaclApplied:true, + }); + } + const pair=generateKeyPairSync('ed25519'); + await writeFile(key,pair.privateKey.export({format:'pem',type:'pkcs8'}),{mode:0o600}); + await writeFile(config,'{"fixture":true}\n',{mode:0o600}); + await writeFile(allowlist,'[]\n',{mode:0o600}); + const store=createSqliteCheckpointStore(database,{namespace:'disposable-profile'}); + try { + const connection=new DatabaseSync(database); + try {connection.prepare('VACUUM INTO ?').run(backup);} + finally {connection.close();} + const before=await hashes(); + const fileModes=Object.fromEntries(await Promise.all([hostDir,...files].map(async file=>{ + const s=await lstat(file); + return [path.basename(file),{uid:s.uid,mode:(s.mode&0o777).toString(8)}]; + }))); + if (process.platform==='linux') { + assert.equal(fileModes['host-private'].mode,'700'); + assert.equal(fileModes['signing-key.pem'].mode,'600'); + assert.ok(Object.values(fileModes).every(file=>file.uid===actualIdentity.uid)); + } + await report(hostDir,'host-ready.json',{ + pid:process.pid,ppid:process.ppid,identity:actualIdentity,files:before,fileModes,windowsAccessPolicy, + }); + const until=Date.now()+75000; + while (true) { + try {await lstat(path.join(hostDir,'stop')); break;} + catch (error) {if (error.code!=='ENOENT') throw error;} + assert.ok(Date.now()1 && hostPid!==process.pid); + const actualIdentity=await identity(); + assert.equal(process.env.FLOWKIT_HOST_CANARY,undefined); + assert.equal(await readFile(publicTask,'utf8'),'flowkit-public-task\n'); + const checks=[]; + async function denied(resource,operation,callback) { + try {await callback();} + catch (error) { + assert.ok(['EACCES','EPERM'].includes(error.code),resource+' '+operation+': '+error.code); + checks.push({resource,operation,code:error.code}); + return; + } + assert.fail(resource+' '+operation+' was allowed'); + } + await denied('host-private','list',()=>readdir(hostDir)); + const incoming=path.join(modelDir,'replacement'); + await writeFile(incoming,'disposable replacement',{mode:0o600}); + for (const file of files) { + const resource=path.basename(file); + await denied(resource,'read',()=>readFile(file)); + await denied(resource,'copy',()=>copyFile(file,path.join(modelDir,'copy'),constants.COPYFILE_EXCL)); + await denied(resource,'open-write',async()=>{const handle=await open(file,'r+'); await handle.close();}); + await denied(resource,'delete',()=>unlink(file)); + await denied(resource,'move',()=>rename(file,path.join(modelDir,'moved'))); + await denied(resource,'replace',()=>rename(incoming,file)); + } + + let processEvidence; + if (process.platform==='linux') { + const hostProc='/proc/'+hostPid; + await denied('host-fd','list',()=>readdir(hostProc+'/fd')); + await denied('host-env','read',()=>readFile(hostProc+'/environ')); + await denied('host-memory','open-read',async()=>{const handle=await open(hostProc+'/mem','r'); await handle.close();}); + await denied('host-process','signal-permission',()=>process.kill(hostPid,0)); + const fds=[]; + for (const fd of await readdir('/proc/self/fd')) { + try {fds.push({fd,target:await readlink('/proc/self/fd/'+fd)});} + catch (error) {if (error.code!=='ENOENT') throw error;} + } + assert.ok(fds.every(fd=>!fd.target.startsWith(hostDir+'/')),'a Host fixture file descriptor is visible'); + processEvidence={fds}; + } else { + await report(modelDir,'model-file-result.json',{pid:process.pid,identity:actualIdentity,publicTaskReadable:true,checks}); + const probe=path.join(root,'public','process-access.exe'); + processEvidence=JSON.parse(execFileSync(probe,[String(hostPid)],{ + encoding:'utf8',windowsHide:true,timeout:10000, + })); + assert.equal(processEvidence.length,6); + assert.ok(processEvidence.every(result=>result.denied===true),JSON.stringify(processEvidence)); + } + await report(modelDir,'model-result.json',{ + pid:process.pid,ppid:process.ppid,identity:actualIdentity,publicTaskReadable:true,checks,processEvidence, + }); +} + +try { + if (mode==='host') await runHost(); + else await runModel(); +} catch (error) { + await report(mode==='host'?hostDir:modelDir,mode+'-error.json',{ + pid:process.pid,code:error.code,message:error.message,stack:error.stack, + }).catch(()=>{}); + console.error(error); + process.exitCode=1; +} diff --git a/tests/types/interactive.ts b/tests/types/interactive.ts index 446452a..db7488c 100644 --- a/tests/types/interactive.ts +++ b/tests/types/interactive.ts @@ -46,7 +46,7 @@ import { normalizeHostAgentReceipt, } from '../../types/interactive.js'; import type { - HostAgentReceipt,HostAgentAttestation,PublicInteractiveAgentTask, + HostAgentReceipt,HostAgentAttestation,PublicInteractiveAgentTask,PublicInteractiveTerminalFrame, TrustedInteractiveHostOptions, } from '../../types/interactive.js'; @@ -58,10 +58,17 @@ const trustedOptions:TrustedInteractiveHostOptions={ disclosurePolicyId:'tenant-approved-fields-v1', allowedInputKeys:['message'], authorizeDisclosure:async evidence=>evidence.task.kind==='agent', + authorizeTerminalAccess:context=>context.action==='run:read', }; const trusted=createTrustedInteractiveHost(trustedOptions); const publicTask:Promise= trusted.getPublicAgentTask({runId:'r'}); +const publicTerminal:Promise=trusted.getPublicTerminalFrame({runId:'r'}); +void publicTerminal.then(frame=>{ + // @ts-expect-error public projections omit private State + void frame.state; + return trusted.readTrustedTerminalResult({resultRef:frame.resultRef}); +}); const receipt:HostAgentReceipt={ kind:'agent-result',runId:'r',requestId:'q',nodeId:'n',attempt:1,result:{answer:'ok'}, }; diff --git a/types/interactive.d.ts b/types/interactive.d.ts index a178b2a..56654d4 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -79,7 +79,8 @@ export interface InteractiveAgentPending { overview?: {step: number}; } export type InteractiveRunStatus = - 'running' | 'waiting' | 'paused' | 'succeeded' | 'failed' | 'errored'; + 'running' | 'waiting' | 'paused' | 'succeeded' | 'failed' | 'errored' | 'cancelled'; +/** Execution frame for trusted Host callers. It can contain private State. */ export interface InteractiveRunFrame { namespace: string; runId: string; @@ -251,6 +252,41 @@ export interface InteractiveDisclosureContext { task:PublicInteractiveAgentTask; disclosurePolicyId:string; } +export interface InteractiveTerminalAccessContext { + action:'run:read'|'result:read'; + namespace:string; + runId:string; + revision:number; +} + +export interface InteractiveTerminalResultRef { + runId:string; + revision:number; + projectionVersion:1; +} + +export interface PublicInteractiveTerminalFrame { + protocolVersion:1; + runId:string; + revision:number; + status:'succeeded'|'failed'|'errored'|'cancelled'; + pending:readonly []; + checkpointRef:{runId:string;revision:number}; + terminal?:{nodeId:string;status:'succeeded'|'failed'}; + resultRef:InteractiveTerminalResultRef; +} + +export interface TrustedInteractiveTerminalResult { + projectionVersion:1; + namespace:string; + runId:string; + status:PublicInteractiveTerminalFrame['status']; + terminal?:PublicInteractiveTerminalFrame['terminal']; + state:Record; + contextError:null; + causeCode?:string; +} + export interface TrustedInteractiveHost { readonly policyHash:string; readonly securityMode:'ed25519-agent-receipt-v1'; @@ -266,6 +302,12 @@ export interface TrustedInteractiveHost { attestation:HostAgentAttestation; }):Promise; } +export interface TrustedInteractiveHostWithTerminalAccess extends TrustedInteractiveHost { + /** Public terminal projection, subject to the Host's run:read authorization. */ + getPublicTerminalFrame(input:{runId:string}):Promise; + /** Host-only result read, subject to a separate result:read authorization. */ + readTrustedTerminalResult(input:{resultRef:InteractiveTerminalResultRef}):Promise; +} export interface TrustedInteractiveHostOptions { store:CheckpointStore; runtime:InteractiveRuntime; @@ -278,6 +320,8 @@ export interface TrustedInteractiveHostOptions { allowedInputKeys?:string[]; disclosurePolicyId:string; authorizeDisclosure(context:Readonly):boolean|Promise; + /** Host-owned authorizer, required for both terminal projection methods. */ + authorizeTerminalAccess?(context:Readonly):boolean|Promise; maxProofAgeMs?:number; clockSkewMs?:number; now?:()=>number; @@ -285,4 +329,170 @@ export interface TrustedInteractiveHostOptions { } export declare function createTrustedInteractiveHost( options:TrustedInteractiveHostOptions -):TrustedInteractiveHost; +):TrustedInteractiveHostWithTerminalAccess; + +export interface TransitionRecordV1 { + namespace:string; + runId:string; + transitionId:string; + fromRevision:number|null; + toRevision:number; + fromBlockId:string|null; + toBlockId:string|null; + programHash:string; + eventDigest:string; + /** Present for the R5 transition identity scheme. */ + proposalDigest?:string; + committedAt:string; +} + +export interface ReceiptRecordV1 { + namespace:string; + runId:string; + requestId:string; + kind:'agent'; + submissionDigest:string; + acceptedRevision:number; + outcome:'succeeded'|'failed'|'legacy'; + transitionId:string; + /** P0-A execution identity retained by the SQLite compatibility facade. */ + nodeId?:string; + attempt?:number; +} + +/** P0-A persistence payload, shared with the legacy SQLite checkpoint format. */ +export type RunCheckpointV1 = InteractiveCheckpoint & { + startKey:string; + startIntentHash:string; + policyHash:string; + transitionIdVersion?:1; +}; + +export type CASResultV1 = + | {committed:true;checkpoint:RunCheckpointV1} + | {committed:false;currentRevision:number}; + +/** All reads must be linearizable within namespace, including history. */ +export interface CheckpointStoreV1 { + readonly namespace:string; + /** Atomically creates Genesis and protects the registered Program from release. */ + createIfAbsent(startKey:string,initial:RunCheckpointV1,genesis:TransitionRecordV1):Promise<{created:boolean;checkpoint:RunCheckpointV1}>; + load(runId:string):Promise; + compareAndSwap(runId:string,expectedRevision:number,tx:{next:RunCheckpointV1;transition:TransitionRecordV1;receipt?:ReceiptRecordV1}):Promise; + loadTransition(runId:string,transitionId:string):Promise; + loadReceipt(runId:string,requestId:string):Promise; +} + +export interface TrustedProgramRefV1 { + namespace:string; + artifactHash:string; + programId:string; +} + +export interface TrustedProgramRegistryV1 { + readonly namespace:string; + putImmutable(bytes:Uint8Array,manifest:InteractiveProgramIdentity):Promise; + resolveByHash(artifactHash:string):Promise; + describe(artifactHash:string):Promise; + retain(runId:string,artifactHash:string):Promise; + release(runId:string,artifactHash:string):Promise; +} + +export declare function createSqliteInteractivePersistence(file:string,options:{namespace:string;migrationBackupPath?:string}):{ + readonly store:CheckpointStoreV1; + readonly programRegistry:TrustedProgramRegistryV1; + readonly adminController:InteractiveAdminControllerV1; + close():void; +}; + +declare const adminContextBrand:unique symbol; +export interface TrustedAdminContextV1 { + readonly [adminContextBrand]:true; + readonly namespace:string; + readonly subjectId:string; +} +export interface InteractiveAdminControllerV1 { + adminTerminate(runId:string,reasonCode:string,auth:TrustedAdminContextV1):Promise; +} +export declare function createInteractiveAdminContext(options:{ + namespace:string; + subjectId:string; + authorize(request:Readonly<{namespace:string;subjectId:string;action:'run:admin-terminate';runId:string;reasonCode:string}>):boolean|Promise; +}):TrustedAdminContextV1; + +declare const hostContextBrand:unique symbol; +export interface TrustedHostContextV1 { + readonly [hostContextBrand]:true; + readonly namespace:string; + readonly subjectId:string; + readTerminalResult(resultRef:InteractiveTerminalResultRef, + options:Omit):Promise; +} + +export interface PublicPendingV1 { + requestId:string; + nodeId:string; + kind:'agent'; + attempt:number; + activation:number; + intent:'execute'; + task:Pick; +} + +export interface InteractiveFrameV1 { + protocolVersion:1; + runId:string; + revision:number; + status:Exclude; + pending:ReadonlyArray; + checkpointRef:{runId:string;revision:number}; + terminal?:{nodeId:string;status:'succeeded'|'failed'}; + resultRef?:InteractiveTerminalResultRef; + acknowledgement?:{requestId:string;acceptedRevision:number;repeated:boolean}; + diagnostics?:readonly InteractiveDiagnostic[]; +} + +export declare function createInteractiveHostContext(options:{ + namespace:string; + subjectId:string; + authorize(request:Readonly<{namespace:string;subjectId:string;action:'run:create'|'run:complete'|'run:resume'|'result:read'; + startKey?:string;source?:string;artifactHash?:string;runId?:string;requestId?:string;revision?:number}>):boolean|Promise; + authorizeTask(request:Readonly<{namespace:string;subjectId:string;runId:string;requestId:string;revision:number; + task:PublicPendingV1['task']}>):boolean|Promise; +}):TrustedHostContextV1; + +type BindingV1 = {id:string;version:string;mode:Mode;implementation:F}; +export interface InteractiveRuntimeV1 { + validateResult?:BindingV1<'pure-local',NonNullable>; + skillResolver?:BindingV1<'trusted-read',(reference:string,meta?:InteractiveSkillDescriptor)=>string|Promise>; +} + +export interface InteractivePolicyV1 { + id:string; + version:string; + bindingManifest:Readonly>; + disclosePolicyId:string; + capabilityStage:'P0-A'; + canonicalizationVersion:'jcs-rfc8785-v1'; + requiredSkillManifestHash?:string; +} + +export interface InteractiveOptionsV1 { + readonly store:CheckpointStoreV1; + readonly programRegistry:TrustedProgramRegistryV1; + readonly policy:InteractivePolicyV1; + readonly auth:TrustedHostContextV1; + maxStagesPerCall?:number; + signal?:AbortSignal; +} + +export type HostSubmissionV1 = + | {kind:'agent';runId:string;requestId:string;outcome:'succeeded';result:Record} + | {kind:'agent';runId:string;requestId:string;outcome:'failed';error:{code:string;message:string;retryable?:boolean; + origin?:'runtime'|'executor'|'validator'|'store'|'policy'}}; + +export declare function start(source:string|TrustedProgramRefV1,runtime:InteractiveRuntimeV1, + initialState:Record,options:InteractiveOptionsV1&{startKey:string}):Promise; +export declare function complete(submission:HostSubmissionV1,runtime:InteractiveRuntimeV1, + options:InteractiveOptionsV1):Promise; +export declare function resume(runId:string,runtime:InteractiveRuntimeV1,options:InteractiveOptionsV1):Promise;