From 21b02e785cacf703c359d7d4f853b8196aa17b19 Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 22:07:23 +0800 Subject: [PATCH 01/72] chore(interactive): open P0-A staged Draft PR (no code changes) Base: main 5a82a88160e01b0e9e2e457dea6af1e3db2c54e0 (after G02 #21). Stages G01/G03/G04, H3-01..05, native Grok Build acceptance, C01-C24, M6 will be committed here one by one, each stopping at independent review. From c3a3fdfc573ec068d99564ac2f2232b2cbe90b3c Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 22:47:06 +0800 Subject: [PATCH 02/72] docs(interactive): record G01 frozen contract audit --- docs/evidence/p0a-g01-20261112/README.md | 49 +++++++ docs/evidence/p0a-g01-20261112/commands.md | 21 +++ docs/evidence/p0a-g01-20261112/probe-cf08.mjs | 22 +++ docs/evidence/p0a-g01-20261112/sha256.txt | 14 ++ .../evidence/p0a-g01-20261112/traceability.md | 134 ++++++++++++++++++ .../p0a-g01-20261112/verified-inputs.txt | 6 + 6 files changed, 246 insertions(+) create mode 100644 docs/evidence/p0a-g01-20261112/README.md create mode 100644 docs/evidence/p0a-g01-20261112/commands.md create mode 100644 docs/evidence/p0a-g01-20261112/probe-cf08.mjs create mode 100644 docs/evidence/p0a-g01-20261112/sha256.txt create mode 100644 docs/evidence/p0a-g01-20261112/traceability.md create mode 100644 docs/evidence/p0a-g01-20261112/verified-inputs.txt 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 From d17a7da7a0efbd6b0ab4e468586d132f648b2e23 Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 22:47:07 +0800 Subject: [PATCH 03/72] docs(interactive): record G03 Grok Build capability limits --- docs/evidence/p0a-g03-20261112/README.md | 34 +++++++ docs/evidence/p0a-g03-20261112/commands.md | 25 +++++ docs/evidence/p0a-g03-20261112/probe.mjs | 33 +++++++ docs/evidence/p0a-g03-20261112/run.mjs | 81 +++++++++++++++ docs/evidence/p0a-g03-20261112/sha256.txt | 109 +++++++++++++++++++++ 5 files changed, 282 insertions(+) create mode 100644 docs/evidence/p0a-g03-20261112/README.md create mode 100644 docs/evidence/p0a-g03-20261112/commands.md create mode 100644 docs/evidence/p0a-g03-20261112/probe.mjs create mode 100644 docs/evidence/p0a-g03-20261112/run.mjs create mode 100644 docs/evidence/p0a-g03-20261112/sha256.txt 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 From 6989190412817d3ee8740531cf48c3297b619934 Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 22:56:31 +0800 Subject: [PATCH 04/72] fix(interactive): retain validated compile feature stage --- docs/evidence/p0a-g04-cf08-20261112/README.md | 26 +++++++++++++++++++ src/interactive/compile.mjs | 2 +- tests/interactive/stage2-integration.test.mjs | 16 ++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 docs/evidence/p0a-g04-cf08-20261112/README.md 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/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/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'); From 9c66d1d04b5452b051059f4f9cfbde0dc716bbba Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 23:07:21 +0800 Subject: [PATCH 05/72] feat(interactive): separate public terminal projection from trusted result --- docs/evidence/p0a-g04-20261112/README.md | 23 ++++++++ docs/interactive.md | 8 +++ src/interactive/trusted-host.mjs | 70 +++++++++++++++++++++++- tests/interactive/trusted-host.test.mjs | 62 +++++++++++++++++++++ tests/package/consumer.mjs | 6 ++ tests/types/interactive.ts | 9 ++- types/interactive.d.ts | 46 +++++++++++++++- 7 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/p0a-g04-20261112/README.md 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/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/src/interactive/trusted-host.mjs b/src/interactive/trusted-host.mjs index 315e226..6121f55 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'].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/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/package/consumer.mjs b/tests/package/consumer.mjs index 036def0..b74c9c4 100644 --- a/tests/package/consumer.mjs +++ b/tests/package/consumer.mjs @@ -75,6 +75,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 +97,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/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..6555222 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -80,6 +80,7 @@ export interface InteractiveAgentPending { } export type InteractiveRunStatus = 'running' | 'waiting' | 'paused' | 'succeeded' | 'failed' | 'errored'; +/** 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'; + 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,4 @@ export interface TrustedInteractiveHostOptions { } export declare function createTrustedInteractiveHost( options:TrustedInteractiveHostOptions -):TrustedInteractiveHost; +):TrustedInteractiveHostWithTerminalAccess; From 6d300873c8343f9b695ea57ff806b511228c5e4f Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 23:22:20 +0800 Subject: [PATCH 06/72] test(security): add isolated Host fixtures for Linux and Windows --- tests/security/secure-host-linux.mjs | 113 +++++++++++++ tests/security/secure-host-win-process.ps1 | 60 +++++++ tests/security/secure-host-windows.ps1 | 167 +++++++++++++++++++ tests/security/secure-host-worker.mjs | 180 +++++++++++++++++++++ 4 files changed, 520 insertions(+) create mode 100644 tests/security/secure-host-linux.mjs create mode 100644 tests/security/secure-host-win-process.ps1 create mode 100644 tests/security/secure-host-windows.ps1 create mode 100644 tests/security/secure-host-worker.mjs diff --git a/tests/security/secure-host-linux.mjs b/tests/security/secure-host-linux.mjs new file mode 100644 index 0000000..6e26258 --- /dev/null +++ b/tests/security/secure-host-linux.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import {spawn,spawnSync} from 'node:child_process'; +import {chmod,chown,copyFile,mkdir,mkdtemp,readFile,rm,writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {setTimeout as delay} from 'node:timers/promises'; + +assert.equal(process.platform,'linux'); +assert.equal(process.getuid(),0,'run the disposable profile bootstrap as root'); +const repo=fileURLToPath(new URL('../../',import.meta.url)); +const hostUid=41001; +const modelUid=41002; +for (const id of [hostUid,modelUid]) { + for (const database of ['passwd','group']) { + const lookup=spawnSync('/usr/bin/getent',[database,String(id)]); + assert.equal(lookup.status,2,'use a disposable runner with unused fixture identities'); + } +} +const root=await mkdtemp(path.join(tmpdir(),'flowkit-profile-')); +const publicDir=path.join(root,'public'); +const hostDir=path.join(root,'host-private'); +const modelDir=path.join(root,'model-scratch'); +const worker=path.join(publicDir,'tests/security/secure-host-worker.mjs'); +let host,model; + +function launch(uid,mode,extraArgs=[]) { + const args=[ + '--reuid='+uid,'--regid='+uid,'--clear-groups','--inh-caps=-all', + '--bounding-set=-all','--no-new-privs',process.execPath,worker,mode,root,...extraArgs, + ]; + const child=spawn('/usr/bin/setpriv',args,{ + cwd:publicDir, + env:{PATH:'/usr/bin:/bin',LANG:'C',...(mode==='host'?{FLOWKIT_HOST_CANARY:'flowkit-disposable-host-fixture'}:{})}, + stdio:['ignore','pipe','pipe'], + }); + console.log(JSON.stringify({event:'spawn',mode,command:'/usr/bin/setpriv',args,pid:child.pid})); + child.stdout.pipe(process.stdout,{end:false}); + child.stderr.pipe(process.stderr,{end:false}); + const finished=new Promise(resolve=>{ + 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);} +} + +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','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); + } + 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.ps1 b/tests/security/secure-host-win-process.ps1 new file mode 100644 index 0000000..4937c59 --- /dev/null +++ b/tests/security/secure-host-win-process.ps1 @@ -0,0 +1,60 @@ +param([Parameter(Mandatory=$true)][int]$HostProcessId) +$ErrorActionPreference = 'Stop' + +# Request and close handles only. This probe never reads memory or uses a token. +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class FlowKitProcessAccess { + [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); + + public static int CheckProcess(int pid, uint rights) { + IntPtr handle = OpenProcess(rights, false, pid); + if (handle == IntPtr.Zero) return Marshal.GetLastWin32Error(); + CloseHandle(handle); + return 0; + } + + public static int CheckToken(int pid, uint rights) { + IntPtr process = OpenProcess(0x1000, 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); + } + } +} +'@ + +$checks = @( + @{ Name='process-vm-read'; Rights=0x0010 } + @{ Name='process-vm-write'; Rights=0x0020 } + @{ Name='process-duplicate-handle'; Rights=0x0040 } + @{ Name='process-create-thread'; Rights=0x0002 } +) +$results = @( + foreach ($check in $checks) { + if ([FlowKitProcessAccess]::CheckProcess($PID,$check.Rights) -ne 0) { + throw "Process access probe did not pass its positive control: $($check.Name)" + } + $code = [FlowKitProcessAccess]::CheckProcess($HostProcessId,$check.Rights) + [pscustomobject]@{ operation=$check.Name; denied=($code -eq 5); win32Error=$code } + } + foreach ($check in @(@{Name='token-duplicate';Rights=0x0002},@{Name='token-assign-primary';Rights=0x0001})) { + if ([FlowKitProcessAccess]::CheckToken($PID,$check.Rights) -ne 0) { + throw "Token access probe did not pass its positive control: $($check.Name)" + } + $code = [FlowKitProcessAccess]::CheckToken($HostProcessId,$check.Rights) + [pscustomobject]@{ operation=$check.Name; denied=($code -eq 5); win32Error=$code } + } +) +ConvertTo-Json -InputObject $results -Compress diff --git a/tests/security/secure-host-windows.ps1 b/tests/security/secure-host-windows.ps1 new file mode 100644 index 0000000..c6e74ae --- /dev/null +++ b/tests/security/secure-host-windows.ps1 @@ -0,0 +1,167 @@ +[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)" } +} + +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','tests\security\secure-host-win-process.ps1', + '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') + [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'))) { + 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..56c046a --- /dev/null +++ b/tests/security/secure-host-worker.mjs @@ -0,0 +1,180 @@ +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); + 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}); + 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 { + const powershell=path.join(process.env.SystemRoot,'System32','WindowsPowerShell','v1.0','powershell.exe'); + const probe=path.join(import.meta.dirname,'secure-host-win-process.ps1'); + processEvidence=JSON.parse(execFileSync(powershell,[ + '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',probe,'-HostProcessId',String(hostPid), + ],{encoding:'utf8',windowsHide:true,timeout:20000})); + 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; +} From 1ec7d2eeb77ba77801b88aae7db3dc0665cdc001 Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 23:22:20 +0800 Subject: [PATCH 07/72] ci(security): run dual-account Host isolation checks --- .github/workflows/ci.yml | 18 ++++++++++ docs/evidence/p0a-g05-g06-20261112/README.md | 36 ++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 docs/evidence/p0a-g05-g06-20261112/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa3607b..9f64395 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,11 +85,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-g05-g06-20261112/README.md b/docs/evidence/p0a-g05-g06-20261112/README.md new file mode 100644 index 0000000..acc742c --- /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 保留恢复权限。测试用临时账户和目录在结束时清理。 + +## 验收映射 + +| 范围 | 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。只请求并关闭测试句柄。 | +| 句柄范围 | 实际枚举受限进程的 `/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 保留现有阻断。 From ce179a1867a9d20466b8bf1b500ba017ad5ee480 Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 23:25:31 +0800 Subject: [PATCH 08/72] test(security): precompile the Windows access probe before logon --- tests/security/secure-host-win-process.cs | 58 +++++++++++++++++++++ tests/security/secure-host-win-process.ps1 | 60 ---------------------- tests/security/secure-host-windows.ps1 | 10 +++- tests/security/secure-host-worker.mjs | 10 ++-- 4 files changed, 71 insertions(+), 67 deletions(-) create mode 100644 tests/security/secure-host-win-process.cs delete mode 100644 tests/security/secure-host-win-process.ps1 diff --git a/tests/security/secure-host-win-process.cs b/tests/security/secure-host-win-process.cs new file mode 100644 index 0000000..549c3d4 --- /dev/null +++ b/tests/security/secure-host-win-process.cs @@ -0,0 +1,58 @@ +using System; +using System.Globalization; +using System.Runtime.InteropServices; + +public static class FlowKitProcessAccess { + [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(); + + // 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) { + const uint queryLimitedInformation = 0x1000; + 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) + "}"; + } + + public static int Main(string[] args) { + int host; + int self = GetCurrentProcessId(); + if (args.Length != 1 || !Int32.TryParse(args[0], out host) || host <= 1 || host == self) return 2; + 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-win-process.ps1 b/tests/security/secure-host-win-process.ps1 deleted file mode 100644 index 4937c59..0000000 --- a/tests/security/secure-host-win-process.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -param([Parameter(Mandatory=$true)][int]$HostProcessId) -$ErrorActionPreference = 'Stop' - -# Request and close handles only. This probe never reads memory or uses a token. -Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; -public static class FlowKitProcessAccess { - [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); - - public static int CheckProcess(int pid, uint rights) { - IntPtr handle = OpenProcess(rights, false, pid); - if (handle == IntPtr.Zero) return Marshal.GetLastWin32Error(); - CloseHandle(handle); - return 0; - } - - public static int CheckToken(int pid, uint rights) { - IntPtr process = OpenProcess(0x1000, 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); - } - } -} -'@ - -$checks = @( - @{ Name='process-vm-read'; Rights=0x0010 } - @{ Name='process-vm-write'; Rights=0x0020 } - @{ Name='process-duplicate-handle'; Rights=0x0040 } - @{ Name='process-create-thread'; Rights=0x0002 } -) -$results = @( - foreach ($check in $checks) { - if ([FlowKitProcessAccess]::CheckProcess($PID,$check.Rights) -ne 0) { - throw "Process access probe did not pass its positive control: $($check.Name)" - } - $code = [FlowKitProcessAccess]::CheckProcess($HostProcessId,$check.Rights) - [pscustomobject]@{ operation=$check.Name; denied=($code -eq 5); win32Error=$code } - } - foreach ($check in @(@{Name='token-duplicate';Rights=0x0002},@{Name='token-assign-primary';Rights=0x0001})) { - if ([FlowKitProcessAccess]::CheckToken($PID,$check.Rights) -ne 0) { - throw "Token access probe did not pass its positive control: $($check.Name)" - } - $code = [FlowKitProcessAccess]::CheckToken($HostProcessId,$check.Rights) - [pscustomobject]@{ operation=$check.Name; denied=($code -eq 5); win32Error=$code } - } -) -ConvertTo-Json -InputObject $results -Compress diff --git a/tests/security/secure-host-windows.ps1 b/tests/security/secure-host-windows.ps1 index c6e74ae..f6f8714 100644 --- a/tests/security/secure-host-windows.ps1 +++ b/tests/security/secure-host-windows.ps1 @@ -104,7 +104,7 @@ try { New-ProtectedDirectory $modelDir @(@{Sid=$modelUser.SID;Rights='Modify'}) [void][IO.Directory]::CreateDirectory($publicDir) foreach ($file in @( - 'tests\security\secure-host-worker.mjs','tests\security\secure-host-win-process.ps1', + 'tests\security\secure-host-worker.mjs', 'src\interactive\sqlite-store.mjs','src\interactive\canonical.mjs' )) { $destination = Join-Path $publicDir $file @@ -112,6 +112,9 @@ try { 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') [IO.File]::WriteAllText((Join-Path $publicDir 'task.json'),"flowkit-public-task`n") $hostProcess = Start-ProfileWorker $hostUser $hostPassword 'host' $hostDir @@ -138,7 +141,10 @@ try { 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'))) { + 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 diff --git a/tests/security/secure-host-worker.mjs b/tests/security/secure-host-worker.mjs index 56c046a..3ae1ff8 100644 --- a/tests/security/secure-host-worker.mjs +++ b/tests/security/secure-host-worker.mjs @@ -155,11 +155,11 @@ async function runModel() { assert.ok(fds.every(fd=>!fd.target.startsWith(hostDir+'/')),'a Host fixture file descriptor is visible'); processEvidence={fds}; } else { - const powershell=path.join(process.env.SystemRoot,'System32','WindowsPowerShell','v1.0','powershell.exe'); - const probe=path.join(import.meta.dirname,'secure-host-win-process.ps1'); - processEvidence=JSON.parse(execFileSync(powershell,[ - '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',probe,'-HostProcessId',String(hostPid), - ],{encoding:'utf8',windowsHide:true,timeout:20000})); + 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)); } From 57a39c84934071450fadbe238dcc475f50b84d30 Mon Sep 17 00:00:00 2001 From: rocky Date: Thu, 12 Nov 2026 23:29:27 +0800 Subject: [PATCH 09/72] test(security): restrict Windows Host process and token DACLs --- docs/evidence/p0a-g05-g06-20261112/README.md | 4 +- tests/security/secure-host-win-process.cs | 55 ++++++++++++++++++-- tests/security/secure-host-worker.mjs | 14 ++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/docs/evidence/p0a-g05-g06-20261112/README.md b/docs/evidence/p0a-g05-g06-20261112/README.md index acc742c..b75b516 100644 --- a/docs/evidence/p0a-g05-g06-20261112/README.md +++ b/docs/evidence/p0a-g05-g06-20261112/README.md @@ -18,7 +18,7 @@ Linux 启动器要求一次性环境,且 UID/GID 41001 和 41002 未占用。 .\tests\security\secure-host-windows.ps1 -Node (Get-Command node.exe).Source -DisposableRunner ``` -Windows 使用一次性 Windows Server 2025 运行机、提升权限的 64 位 Windows PowerShell 和两个随机命名的本地普通用户。引导进程创建显式 DACL,只有 Host 能修改私有目录,SYSTEM 和 Administrators 保留恢复权限。测试用临时账户和目录在结束时清理。 +Windows 使用一次性 Windows Server 2025 运行机、提升权限的 64 位 Windows PowerShell 和两个随机命名的本地普通用户。引导进程创建显式文件 DACL,只有 Host 能修改私有目录,SYSTEM 和 Administrators 保留恢复权限。测试 Host 在生成密钥前收紧自己的进程和 Token DACL,之后引导进程才启动受限进程。测试用临时账户和目录在结束时清理。 ## 验收映射 @@ -27,7 +27,7 @@ Windows 使用一次性 Windows Server 2025 运行机、提升权限的 64 位 W | SEC-01 进程身份 | 核对 Host 和受限进程的实际 UID/GID、进程身份、能力和附加组。 | 核对两个进程的实际 SID、Administrators 组及调试等高权限。 | | SEC-02 密钥、配置和产物清单 | 测试各文件的读取、复制、打开写权限、删除、移走和替换。验证 Host 签名仍可用。 | 同样的文件测试及实际文件/目录 DACL。 | | SEC-03 SQLite、WAL、SHM 和备份 | 数据库打开期间验证六类访问被拒。Host 关闭后重新打开主库和备份做完整性检查,并核对受保护文件的字节摘要。 | 同样的文件和完整性测试。 | -| SEC-04 进程访问 | 验证 Host 进程 fd、环境和内存入口的读取被拒,并检查无副作用的信号权限探测。 | 验证内存、线程创建和句柄复制权限请求被拒,验证不可获取可复制或可分配的 Host Token。只请求并关闭测试句柄。 | +| SEC-04 进程访问 | 验证 Host 进程 fd、环境和内存入口的读取被拒,并检查无副作用的信号权限探测。 | 验证内存、线程创建和句柄复制权限请求被拒,验证不可获取可复制或可分配的 Host Token。探针只请求并关闭测试句柄。最终部署还需验证模型先于 Host 运行时的启动保护。 | | 句柄范围 | 实际枚举受限进程的 `/proc/self/fd`,校验没有 Host 私有文件,原生启动链仍待验证。 | 当前启动器分别启动两个账户,不向受限进程提供 Host 私有句柄。系统级子进程句柄表仍待在最终启动方式下验证。 | | SEC-17 Linux 实际受限进程 | 验证跨 UID 的脚本进程和 OS 拒绝。原生模型进程与可信来源仍待 G07 以后验证。 | 不适用 Linux UID 条款。 | diff --git a/tests/security/secure-host-win-process.cs b/tests/security/secure-host-win-process.cs index 549c3d4..602c1cc 100644 --- a/tests/security/secure-host-win-process.cs +++ b/tests/security/secure-host-win-process.cs @@ -1,8 +1,15 @@ 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)] @@ -11,6 +18,8 @@ public static class FlowKitProcessAccess { 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) { @@ -21,8 +30,7 @@ private static int CheckProcess(int pid, uint rights) { } private static int CheckToken(int pid, uint rights) { - const uint queryLimitedInformation = 0x1000; - IntPtr process = OpenProcess(queryLimitedInformation, false, pid); + IntPtr process = OpenProcess(QueryLimitedInformation, false, pid); if (process == IntPtr.Zero) return Marshal.GetLastWin32Error(); try { IntPtr token; @@ -40,10 +48,51 @@ private static string Check(string name, int positiveControl, int result) { + ",\"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(); - if (args.Length != 1 || !Int32.TryParse(args[0], out host) || host <= 1 || host == self) return 2; + 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)), diff --git a/tests/security/secure-host-worker.mjs b/tests/security/secure-host-worker.mjs index 3ae1ff8..309ea73 100644 --- a/tests/security/secure-host-worker.mjs +++ b/tests/security/secure-host-worker.mjs @@ -73,6 +73,16 @@ function verifyDatabase(filename) { 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}); @@ -92,7 +102,9 @@ async function runHost() { 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}); + 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;} From 9a17bd9adeeeb13e183ddf9594e92b45eb6d85fa Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 07:18:27 +0800 Subject: [PATCH 10/72] test(interactive): verify multi-process receipts and delayed acknowledgement recovery --- docs/evidence/p0a-g07-g12-20261113/matrix.md | 32 +++++ tests/interactive/complex-c01-c12.test.mjs | 142 ++++++++++++++++++- 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/p0a-g07-g12-20261113/matrix.md 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..84fc9ca --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/matrix.md @@ -0,0 +1,32 @@ +# G11 · C01–C24 验收矩阵 + +源场景见 [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/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}); +}); From 70143668394fc5f4e4d1d26f62788c14a66e17e3 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 07:18:27 +0800 Subject: [PATCH 11/72] test(interactive): verify long ledger and installed Interactive types --- .github/workflows/ci.yml | 3 + docs/evidence/p0a-g07-g12-20261113/README.md | 32 ++++ scripts/bench-interactive-ledger.mjs | 145 +++++++++++++++++++ tests/package/consumer-dom.ts | 20 +++ 4 files changed, 200 insertions(+) create mode 100644 docs/evidence/p0a-g07-g12-20261113/README.md create mode 100644 scripts/bench-interactive-ledger.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f64395..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 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..32c0d91 --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/README.md @@ -0,0 +1,32 @@ +# 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/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/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; From e1cd5c7c4f89397fbdee50e1a430074c4c610e9d Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 07:46:59 +0800 Subject: [PATCH 12/72] feat(interactive): add compatible async SDK and versioned transition identities --- .../p0a-compat-codex-20261113/README.md | 31 ++ scripts/package-files.mjs | 2 +- src/interactive/coordinator.mjs | 131 ++++-- src/interactive/index.mjs | 1 + src/interactive/sqlite-store.mjs | 29 +- src/interactive/versioned.mjs | 417 ++++++++++++++++++ tests/interactive/versioned.test.mjs | 171 +++++++ tests/package/consumer.mjs | 22 +- types/interactive.d.ts | 150 +++++++ 9 files changed, 906 insertions(+), 48 deletions(-) create mode 100644 docs/evidence/p0a-compat-codex-20261113/README.md create mode 100644 src/interactive/versioned.mjs create mode 100644 tests/interactive/versioned.test.mjs 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/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/coordinator.mjs b/src/interactive/coordinator.mjs index 6031f40..2d45359 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -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,17 @@ 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'); 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 +498,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 +506,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 +526,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 +533,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 +544,25 @@ 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); 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 +589,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 +630,21 @@ 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'); 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 +671,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 +688,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,7 +707,8 @@ 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); return asPublicFrame(cp); diff --git a/src/interactive/index.mjs b/src/interactive/index.mjs index ad23b25..d030f7f 100644 --- a/src/interactive/index.mjs +++ b/src/interactive/index.mjs @@ -7,3 +7,4 @@ 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,createSqliteInteractivePersistence} from './versioned.mjs'; diff --git a/src/interactive/sqlite-store.mjs b/src/interactive/sqlite-store.mjs index 0f2db54..ada3039 100644 --- a/src/interactive/sqlite-store.mjs +++ b/src/interactive/sqlite-store.mjs @@ -42,6 +42,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 +56,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); } @@ -135,6 +136,7 @@ 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 = ?'); function transaction(mode, work) { db.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN'); @@ -287,7 +289,7 @@ 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'); @@ -301,6 +303,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 +330,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'))}; }); }, @@ -423,6 +429,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/versioned.mjs b/src/interactive/versioned.mjs new file mode 100644 index 0000000..8a5f00e --- /dev/null +++ b/src/interactive/versioned.mjs @@ -0,0 +1,417 @@ +import {buildCoordinator} from './coordinator.mjs'; +import {compileInteractive} from './compile.mjs'; +import {createSqliteCheckpointStore} from './sqlite-store.mjs'; +import {assertSHA256,canonicalBytes,safeJSONSnapshot,sha256Bytes} from './canonical.mjs'; + +const authCallbacks=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 createSqliteInteractivePersistence(file,{namespace}={}) { + const legacy=createSqliteCheckpointStore(file,{namespace}); + 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=next.status==='succeeded'||next.status==='failed'||next.status==='errored' ? 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'); + } + }, + }); + return Object.freeze({store,programRegistry,close:()=>legacy.close()}); +} + +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'].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!=='errored' ? {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'].includes(status)) { + output.resultRef={runId,revision,projectionVersion:1}; + if (status!=='errored') output.terminal={nodeId:frame.terminal.nodeId,status}; + } 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'); + } + 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/versioned.test.mjs b/tests/interactive/versioned.test.mjs new file mode 100644 index 0000000..3ebc8ae --- /dev/null +++ b/tests/interactive/versioned.test.mjs @@ -0,0 +1,171 @@ +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 {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import { + compileInteractive,complete,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'); +}); diff --git a/tests/package/consumer.mjs b/tests/package/consumer.mjs index b74c9c4..1b826eb 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','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 { diff --git a/types/interactive.d.ts b/types/interactive.d.ts index 6555222..a2873ec 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -330,3 +330,153 @@ export interface TrustedInteractiveHostOptions { export declare function createTrustedInteractiveHost( options:TrustedInteractiveHostOptions ):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}):{ + readonly store:CheckpointStoreV1; + readonly programRegistry:TrustedProgramRegistryV1; + close():void; +}; + +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; From 6892aa4a4caeab0e99c32c7b27ab77a5e1d72c9a Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 09:17:06 +0800 Subject: [PATCH 13/72] feat(interactive): migrate SQLite for atomic audited admin termination --- src/interactive/coordinator.mjs | 7 +- src/interactive/index.mjs | 3 +- src/interactive/sqlite-store.mjs | 104 ++++++++++++++++++++++++++- src/interactive/trusted-host.mjs | 2 +- src/interactive/versioned.mjs | 53 +++++++++++--- tests/interactive/versioned.test.mjs | 98 ++++++++++++++++++++++++- tests/package/consumer.mjs | 2 +- types/interactive.d.ts | 22 +++++- 8 files changed, 270 insertions(+), 21 deletions(-) diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index 2d45359..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; @@ -482,6 +482,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall 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') { @@ -546,6 +547,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall for (;;) { 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); @@ -636,6 +638,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall 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'; @@ -710,7 +713,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall 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 d030f7f..1a1e5f3 100644 --- a/src/interactive/index.mjs +++ b/src/interactive/index.mjs @@ -7,4 +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,createSqliteInteractivePersistence} from './versioned.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 ada3039..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) { @@ -74,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'); @@ -89,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,', @@ -119,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; @@ -137,6 +204,8 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { 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'); @@ -223,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}) { @@ -294,6 +392,7 @@ export function createSqliteCheckpointStore(file, {namespace} = {}) { 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'); } @@ -350,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); diff --git a/src/interactive/trusted-host.mjs b/src/interactive/trusted-host.mjs index 6121f55..c479fdb 100644 --- a/src/interactive/trusted-host.mjs +++ b/src/interactive/trusted-host.mjs @@ -182,7 +182,7 @@ export function createTrustedInteractiveHost({ 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'].includes(checkpoint.status) + if (checkpoint.runId!==runId || !['succeeded','failed','errored','cancelled'].includes(checkpoint.status) || (revision!==undefined && revision!==checkpoint.revision)) throw denied(); let decision; diff --git a/src/interactive/versioned.mjs b/src/interactive/versioned.mjs index 8a5f00e..2b4292b 100644 --- a/src/interactive/versioned.mjs +++ b/src/interactive/versioned.mjs @@ -1,9 +1,10 @@ import {buildCoordinator} from './coordinator.mjs'; import {compileInteractive} from './compile.mjs'; -import {createSqliteCheckpointStore} from './sqlite-store.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'); @@ -32,8 +33,19 @@ function refFor(namespace,identity) { } /** Two asynchronous interfaces over the same SQLite connection and transaction backend. */ -export function createSqliteInteractivePersistence(file,{namespace}={}) { - const legacy=createSqliteCheckpointStore(file,{namespace}); +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) { @@ -64,7 +76,7 @@ export function createSqliteInteractivePersistence(file,{namespace}={}) { assertSHA256(transition.eventDigest); assertSHA256(transition.proposalDigest); const id=hash([namespace,runId,expectedRevision,current.blockId,transition.eventDigest, current.artifactHash,transition.proposalDigest]); - const to=next.status==='succeeded'||next.status==='failed'||next.status==='errored' ? null : next.blockId; + 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'); } @@ -125,7 +137,28 @@ export function createSqliteInteractivePersistence(file,{namespace}={}) { } }, }); - return Object.freeze({store,programRegistry,close:()=>legacy.close()}); + 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) { @@ -249,13 +282,13 @@ async function readTerminalResult(resultRef,options) { 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'].includes(cp.status)) { + || 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!=='errored' ? {terminal:{nodeId:cp.terminal.nodeId,status:cp.status}} : {}), + ...(cp.status==='succeeded'||cp.status==='failed' ? {terminal:{nodeId:cp.terminal.nodeId,status:cp.status}} : {}), ...(typeof cp.terminal?.causeCode==='string' ? {causeCode:cp.terminal.causeCode} : {}), }); } @@ -324,9 +357,8 @@ async function publicFrame(frame,options,callbacks) { || 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'].includes(status)) { - output.resultRef={runId,revision,projectionVersion:1}; - if (status!=='errored') output.terminal={nodeId:frame.terminal.nodeId,status}; + } 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; } @@ -379,6 +411,7 @@ export async function complete(submission,runtime,options) { 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))) { diff --git a/tests/interactive/versioned.test.mjs b/tests/interactive/versioned.test.mjs index 3ebc8ae..0ce43e9 100644 --- a/tests/interactive/versioned.test.mjs +++ b/tests/interactive/versioned.test.mjs @@ -3,9 +3,10 @@ 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,createInteractiveCoordinator,createInteractiveHostContext, + compileInteractive,complete,createInteractiveAdminContext,createInteractiveCoordinator,createInteractiveHostContext, createSqliteCheckpointStore,createSqliteInteractivePersistence,resume,start, } from '../../src/interactive/index.mjs'; @@ -169,3 +170,98 @@ test('R5 file source works without Agent bindings and honors pre-aborted operati 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.mjs b/tests/package/consumer.mjs index 1b826eb..a1f8b44 100644 --- a/tests/package/consumer.mjs +++ b/tests/package/consumer.mjs @@ -31,7 +31,7 @@ assert.equal(await readFile('sample.compiled.mjs', 'utf8'), output.code); assert.deepEqual(Object.keys(interactive),[ - 'compileInteractive','complete','createAgentReceiptStatement','createInteractiveCoordinator', + 'compileInteractive','complete','createAgentReceiptStatement','createInteractiveAdminContext','createInteractiveCoordinator', 'createInteractiveHostContext','createSqliteCheckpointStore','createSqliteInteractivePersistence', 'createTrustedInteractiveHost','encodeAgentReceiptStatement', 'loadInteractiveProgram','normalizeHostAgentReceipt','resume','start', diff --git a/types/interactive.d.ts b/types/interactive.d.ts index a2873ec..56654d4 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -79,7 +79,7 @@ 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; @@ -269,7 +269,7 @@ export interface PublicInteractiveTerminalFrame { protocolVersion:1; runId:string; revision:number; - status:'succeeded'|'failed'|'errored'; + status:'succeeded'|'failed'|'errored'|'cancelled'; pending:readonly []; checkpointRef:{runId:string;revision:number}; terminal?:{nodeId:string;status:'succeeded'|'failed'}; @@ -398,12 +398,28 @@ export interface TrustedProgramRegistryV1 { release(runId:string,artifactHash:string):Promise; } -export declare function createSqliteInteractivePersistence(file:string,options:{namespace:string}):{ +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; From eb5823094cdee0f5d8fe8a558a9824e1c712db4d Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 09:17:06 +0800 Subject: [PATCH 14/72] test(interactive): record Codex App Server acceptance and inline limits --- .../p0a-admin-codex-20261113/README.md | 39 +++ tests/security/codex-flowkit-probe.mjs | 254 ++++++++++++++++++ tests/security/codex-model-identity.mjs | 20 ++ 3 files changed, 313 insertions(+) create mode 100644 docs/evidence/p0a-admin-codex-20261113/README.md create mode 100644 tests/security/codex-flowkit-probe.mjs create mode 100644 tests/security/codex-model-identity.mjs 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/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs new file mode 100644 index 0000000..5660606 --- /dev/null +++ b/tests/security/codex-flowkit-probe.mjs @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {generateKeyPairSync,randomUUID,sign} from 'node:crypto'; +import {appendFileSync,mkdirSync} from 'node:fs'; +import {mkdtemp,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'; + +const [exe,cwd,out,requested='loop',profile='direct']=process.argv.slice(2); +const size=requested==='loop'?0:Number(requested); +assert.ok(path.isAbsolute(exe) && path.isAbsolute(cwd) && path.isAbsolute(out)); +assert.ok([0,65536,262144,2093056].includes(size)); +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})), + 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=size?null:await mkdtemp(path.join(tmpdir(),'flowkit-codex-evidence-')); +let store,host,program,frame,currentTask; +const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; +let command=exe,args=appArgs; +if (profile==='isolated') { + 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)); + 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); + args=[node,fileURLToPath(new URL('./codex-model-identity.mjs',import.meta.url)),String(modelUid),String(modelGid),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); + args=[exe,...appArgs]; + } +} +const child=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;}); +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=setTimeout(()=>{finish('timeout');child.kill();},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'); + 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:{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'}); + const keys=generateKeyPairSync('ed25519'); + host=createTrustedInteractiveHost({ + store, runtime:{skillResolver:async()=> 'Read the public question and submit its requested answer.', + validateResult:(_schema,value)=>value.answer==='ready'?[]:['Expected ready']}, + trustedPublicKeys:{proof:keys.publicKey},trustedArtifacts:[program.programIdentity.artifactHash], + allowedInputKeys:['question'],disclosurePolicyId:'probe-v1',authorizeDisclosure:()=>true, + authorizeTerminalAccess:({action})=>action==='run:read', + }); + return keys.privateKey; +} + +let key; +async function handleTool(name,args,identity) { + 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.equal(args.nonce,nonce); + 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))}); + return currentTask; + } + if (name==='flowkit_submit') { + assert.equal(frame?.status,'waiting'); + assert.equal(args.requestId,currentTask.requestId); + const receipt={runId:currentTask.runId,requestId:currentTask.requestId,nodeId:currentTask.nodeId, + attempt:currentTask.attempt,kind:'agent-result',result:{answer:args.answer}}; + 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)}); + 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}; + } + assert.equal(name,'flowkit_continue'); + assert.equal(args.runId,frame?.runId); + const final=await host.getPublicTerminalFrame({runId:args.runId}); + assert.equal(final.status,'succeeded'); + 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=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); + successful.push(p.tool); + send({id:message.id,result:{success:true,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); + 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.hostSid,process.env.FLOWKIT_HOST_SID); + assert.equal(reported.modelSid,process.env.FLOWKIT_MODEL_SID); + assert.equal(reported.hostElevated,false); + assert.equal(reported.modelElevated,false); + } + isolatedIdentity=reported; + journal('isolated-identity',{hostUid:process.getuid?.(),hostGid:process.getgid?.(),...reported}); + } catch(error) {failure=error.message;finish('identity-error');} + } +}); +let isolatedIdentity; + +async function run() { + if (!size) key=await prepareFlowKit(); + 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:specs}); + threadId=thread.id; + const prompt=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.` + : `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:child.pid,observerPid:process.pid}); + return finished; +} +try { + const outcome=await Promise.race([run(),finished]); + const expected=size?['flowkit_inline']:['flowkit_start','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,profile,successful,inlineObserved,failure})); + if(!passed)process.exitCode=1; +} catch(error) {console.error(error.message);process.exitCode=1;} +finally { + clearTimeout(timer); + child.stdin.end(); + child.kill(); + store?.close(); + if(fixture) await rm(fixture,{recursive:true,force:true}); +} diff --git a/tests/security/codex-model-identity.mjs b/tests/security/codex-model-identity.mjs new file mode 100644 index 0000000..ca8a7d4 --- /dev/null +++ b/tests/security/codex-model-identity.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import {readFileSync} 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'); +process.stdout.write(JSON.stringify({method:'flowkit/profile/identity', + params:{uid:Number(uid),gid:Number(gid),groups,capabilities,noNewPrivs,pid:process.pid}})+'\n',()=>{ + execve(exe,[exe,...args],process.env); +}); From 7acdfdd0137bc34cd4407ee9ffb8c2fa1e5a18a7 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 09:32:34 +0800 Subject: [PATCH 15/72] feat(interactive): bind immutable file handoff to observed Codex calls --- .../README.md | 16 ++ examples/interactive/codex-file-handoff.mjs | 173 ++++++++++++++++++ tests/interactive/codex-file-handoff.test.mjs | 134 ++++++++++++++ tests/security/codex-flowkit-probe.mjs | 99 +++++++--- 4 files changed, 396 insertions(+), 26 deletions(-) create mode 100644 docs/evidence/p0a-codex-file-fallback-20261113/README.md create mode 100644 examples/interactive/codex-file-handoff.mjs create mode 100644 tests/interactive/codex-file-handoff.test.mjs 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..c942228 --- /dev/null +++ b/docs/evidence/p0a-codex-file-fallback-20261113/README.md @@ -0,0 +1,16 @@ +# 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 仍 BLOCKED。此次文件回退实测的对象是 Host 合成输入和模型提交的真实引用。 + +定向用例覆盖入口文件在登记前被替换、截断、超限、非法 UTF-8/重复键/schema、错误 Pending 或会话、缺少开始事件、重复或并发消费、过期、已领取后重开。登记成功后替换或删除入口文件,签收使用已固定的原始快照。 diff --git a/examples/interactive/codex-file-handoff.mjs b/examples/interactive/codex-file-handoff.mjs new file mode 100644 index 0000000..8c802fc --- /dev/null +++ b/examples/interactive/codex-file-handoff.mjs @@ -0,0 +1,173 @@ +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); +const sameFile=(a,b)=>a.dev===b.dev && a.ino===b.ino; + +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 + || !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'"); + + 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=Buffer.from(saved.bytes); + if (bytes.length!==saved.size || sha256Bytes(bytes)!==saved.sha256) throw fail('FK_FILE_STORE'); + return {bytes,sha256:saved.sha256,byteLength:saved.size,state:saved.state,acceptedRevision:saved.accepted_revision}; + }, + // 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'); + } + const bytes=Buffer.from(saved.bytes); + if (bytes.length!==saved.size || sha256Bytes(bytes)!==saved.sha256) throw fail('FK_FILE_STORE'); + 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}); + if (!Number.isSafeInteger(result?.acceptedRevision) || result.acceptedRevision<1 + || ack.run(result.acceptedRevision,call.id,call.callId).changes!==1) throw fail('FK_FILE_UNCERTAIN'); + return result; + }, + }); +} diff --git a/tests/interactive/codex-file-handoff.test.mjs b/tests/interactive/codex-file-handoff.test.mjs new file mode 100644 index 0000000..699d074 --- /dev/null +++ b/tests/interactive/codex-file-handoff.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import {randomUUID} from 'node:crypto'; +import {mkdir,mkdtemp,rename,rm,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 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/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 5660606..ab793af 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import {spawn} from 'node:child_process'; import {generateKeyPairSync,randomUUID,sign} from 'node:crypto'; import {appendFileSync,mkdirSync} from 'node:fs'; -import {mkdtemp,rm,writeFile} from 'node:fs/promises'; +import {mkdir,mkdtemp,rm,writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import path from 'node:path'; import {createInterface} from 'node:readline'; @@ -12,11 +12,15 @@ import { createTrustedInteractiveHost,encodeAgentReceiptStatement, } 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,out,requested='loop',profile='direct']=process.argv.slice(2); -const size=requested==='loop'?0:Number(requested); +const fallback=requested.startsWith('file-'); +const artifactSize=fallback?Number(requested.slice(5)):0; +const size=requested==='loop'||fallback?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(); @@ -29,11 +33,13 @@ 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})), - tool('flowkit_submit','Submit the answer for the exact current FlowKit request',objectSchema({requestId:str,answer: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=size?null:await mkdtemp(path.join(tmpdir(),'flowkit-codex-evidence-')); -let store,host,program,frame,currentTask; +let store,host,program,frame,currentTask,handoff,staged; const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; let command=exe,args=appArgs; if (profile==='isolated') { @@ -85,7 +91,7 @@ async function prepareFlowKit() { 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:{answer:{type:'string'}}},retries:0,next:'done'}), +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}); @@ -94,16 +100,39 @@ terminal('done',{name:'Done',status:'succeeded'}) const keys=generateKeyPairSync('ed25519'); host=createTrustedInteractiveHost({ store, runtime:{skillResolver:async()=> 'Read the public question and submit its requested answer.', - validateResult:(_schema,value)=>value.answer==='ready'?[]:['Expected ready']}, + validateResult:(_schema,value)=>fallback ? (value.artifact?.id===staged?.id?[]:['Expected staged reference']) + : value.answer==='ready'?[]:['Expected ready']}, trustedPublicKeys:{proof:keys.publicKey},trustedArtifacts:[program.programIdentity.artifactHash], allowedInputKeys:['question'],disclosurePolicyId:'probe-v1',authorizeDisclosure:()=>true, authorizeTerminalAccess:({action})=>action==='run:read', }); + if (fallback) { + const inbox=path.join(fixture,'inbox'); + await mkdir(inbox,{mode:0o700}); + handoff=createFileHandoff({inbox,database:path.join(fixture,'file-handoff.sqlite'), + validateArtifact:value=>Object.keys(value).length===1 && typeof value.answer==='string'}); + } return keys.privateKey; } let key; -async function handleTool(name,args,identity) { +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 handleTool(name,args,identity,started) { if (size) { const canonical=canonicalBytes(args); inlineObserved={expected:size,received:canonical.byteLength,matched:canonical.equals(expectedBytes), @@ -120,30 +149,45 @@ async function handleTool(name,args,identity) { 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))}); - return 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') { assert.equal(frame?.status,'waiting'); assert.equal(args.requestId,currentTask.requestId); - const receipt={runId:currentTask.runId,requestId:currentTask.requestId,nodeId:currentTask.nodeId, - attempt:currentTask.attempt,kind:'agent-result',result:{answer:args.answer}}; - 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)}); - 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}; + 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})=>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'); - assert.equal(frame.state.answer,'ready'); + 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, @@ -180,7 +224,7 @@ lines.on('line',line=>{ 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); + const result=await handleTool(p.tool,p.arguments,identity,started); successful.push(p.tool); send({id:message.id,result:{success:true,contentItems:[{type:'inputText',text:JSON.stringify(result)}]}}); } catch(error) { @@ -230,7 +274,9 @@ async function run() { threadId=thread.id; const prompt=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.` - : `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.`; + : 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:child.pid,observerPid:process.pid}); @@ -238,17 +284,18 @@ async function run() { } try { const outcome=await Promise.race([run(),finished]); - const expected=size?['flowkit_inline']:['flowkit_start','flowkit_submit','flowkit_continue']; + const expected=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,profile,successful,inlineObserved,failure})); + 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(); child.kill(); + handoff?.close(); store?.close(); if(fixture) await rm(fixture,{recursive:true,force:true}); } From 9584184d7098a402a15f3edd8bfb11ea95f7ed49 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 09:41:37 +0800 Subject: [PATCH 16/72] Fix Windows Node 22 file handoff identity checks --- .../README.md | 6 ++++ examples/interactive/codex-file-handoff.mjs | 10 +++++-- tests/interactive/codex-file-handoff.test.mjs | 30 ++++++++++++++++++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/docs/evidence/p0a-codex-file-fallback-20261113/README.md b/docs/evidence/p0a-codex-file-fallback-20261113/README.md index c942228..7300749 100644 --- a/docs/evidence/p0a-codex-file-fallback-20261113/README.md +++ b/docs/evidence/p0a-codex-file-fallback-20261113/README.md @@ -14,3 +14,9 @@ Host 在预设入口用受信来源登记字节数、SHA-256、Pending 和会话 两个样本由受信合成生产者生成。模型实际调用了 App Server 的动态工具并提交引用。Host 在签收前删除入口文件。日志分别记录原生开始事件、回调、快照 SHA-256、签名、接纳和终态。Windows/Linux 双账户原生宿主、生产文件入口的权限部署、真实签后崩溃恢复及 NAT-14/15 仍 BLOCKED。此次文件回退实测的对象是 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 无文件符号链接权限时实测目录联接点。两项拒绝后,同一请求仍能登记合法文件。Windows/Linux 双账户 Codex App Server 仍 BLOCKED。 diff --git a/examples/interactive/codex-file-handoff.mjs b/examples/interactive/codex-file-handoff.mjs index 8c802fc..56de05c 100644 --- a/examples/interactive/codex-file-handoff.mjs +++ b/examples/interactive/codex-file-handoff.mjs @@ -12,7 +12,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); -const sameFile=(a,b)=>a.dev===b.dev && a.ino===b.ino; + +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; @@ -33,7 +38,7 @@ async function capture(file,size,hash) { try { const before=await handle.stat({bigint:true}); const named=await lstat(file,{bigint:true}); - if (!before.isFile() || !named.isFile() || before.nlink!==1n + 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'); @@ -45,6 +50,7 @@ async function capture(file,size,hash) { length+=bytesRead; } const after=await handle.stat({bigint:true}); + if (after.nlink!==1n) throw fail('FK_FILE_SOURCE'); if (length!==size || after.size!==before.size) throw fail('FK_FILE_LENGTH'); if (after.mtimeNs!==before.mtimeNs || after.ctimeNs!==before.ctimeNs) throw fail('FK_FILE_CHANGED'); const snapshot=bytes.subarray(0,length); diff --git a/tests/interactive/codex-file-handoff.test.mjs b/tests/interactive/codex-file-handoff.test.mjs index 699d074..d1a58c4 100644 --- a/tests/interactive/codex-file-handoff.test.mjs +++ b/tests/interactive/codex-file-handoff.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import {randomUUID} from 'node:crypto'; -import {mkdir,mkdtemp,rename,rm,truncate,writeFile} from 'node:fs/promises'; +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'; @@ -96,6 +96,34 @@ test('file fallback rejects changed, truncated, oversize and invalid bytes befor 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'})); From 4deb7c884db0ad6174d4f86f2cf9a9999419ac74 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 10:07:17 +0800 Subject: [PATCH 17/72] Prepare isolated Codex App Server acceptance runners --- .../p0a-codex-isolated-20261113/README.md | 34 ++++ tests/security/codex-flowkit-probe.mjs | 29 ++- tests/security/codex-model-stdio-fixture.cjs | 30 ++++ tests/security/codex-model-stdio-windows.cs | 168 ++++++++++++++++++ tests/security/secure-host-linux.mjs | 42 ++++- tests/security/secure-host-windows.ps1 | 47 +++++ 6 files changed, 344 insertions(+), 6 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/README.md create mode 100644 tests/security/codex-model-stdio-fixture.cjs create mode 100644 tests/security/codex-model-stdio-windows.cs 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..1127d7a --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/README.md @@ -0,0 +1,34 @@ +# 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 +Add-Type -Path .\tests\security\codex-model-stdio-windows.cs ` + -OutputType ConsoleApplication -OutputAssembly C:\FlowKitLab\codex-model-stdio.exe +``` + +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 的账户。当前探针支持预先配置的本机受信启动器,也支持回环 SSH。SSH 的固定命令以 Model 身份执行 `setpriv --no-new-privs`,再用绝对 Node 路径执行 `tests/security/codex-model-identity.mjs`,参数为 Model UID、GID、Codex 绝对路径及 `app-server --listen stdio:// -c 'web_search="disabled"'`。Model 账户应无额外组成员关系,命令只使用 Model 的 HOME、私有临时目录和受控 PATH。helper 核对有效 UID/GID、组、能力和 `NoNewPrivs`,再同 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`,探针会传入 Node、身份 helper、Model UID/GID、Codex 和固定 App Server 参数。 + +## 实际验收与留档 + +在 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 双账户、独立登录和模型签收仍需测试机运行记录。缺少原始记录的项目保持 BLOCKED。 diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index ab793af..5014a1a 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import {spawn} from 'node:child_process'; +import {execFileSync,spawn} from 'node:child_process'; import {generateKeyPairSync,randomUUID,sign} from 'node:crypto'; import {appendFileSync,mkdirSync} from 'node:fs'; import {mkdir,mkdtemp,rm,writeFile} from 'node:fs/promises'; @@ -42,12 +42,14 @@ const fixture=size?null:await mkdtemp(path.join(tmpdir(),'flowkit-codex-evidence let store,host,program,frame,currentTask,handoff,staged; const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; let command=exe,args=appArgs; +let hostSid; if (profile==='isolated') { 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); @@ -61,8 +63,19 @@ if (profile==='isolated') { 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=spawn(command,args, {cwd,stdio:['pipe','pipe','pipe'],windowsHide:true}); @@ -71,6 +84,8 @@ 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)); @@ -243,6 +258,7 @@ lines.on('line',line=>{ 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)); @@ -250,19 +266,21 @@ lines.on('line',line=>{ assert.match(reported.capabilities,/^0+$/); assert.equal(reported.noNewPrivs,'1'); } else { - assert.equal(reported.hostSid,process.env.FLOWKIT_HOST_SID); assert.equal(reported.modelSid,process.env.FLOWKIT_MODEL_SID); - assert.equal(reported.hostElevated,false); assert.equal(reported.modelElevated,false); + assert.equal(reported.stdioAllowlist,true); } isolatedIdentity=reported; - journal('isolated-identity',{hostUid:process.getuid?.(),hostGid:process.getgid?.(),...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(); await request('initialize',{clientInfo:{name:'flowkit-acceptance',title:'FlowKit acceptance',version:'0.1.0'}, capabilities:{experimentalApi:true}}); @@ -279,7 +297,8 @@ async function run() { : `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:child.pid,observerPid:process.pid}); + journal('turn',{model,threadId,turnId,requestedBytes:size,pid:isolatedIdentity?.pid??child.pid, + launcherPid:child.pid,observerPid:process.pid}); return finished; } try { diff --git a/tests/security/codex-model-stdio-fixture.cjs b/tests/security/codex-model-stdio-fixture.cjs new file mode 100644 index 0000000..8bdbb91 --- /dev/null +++ b/tests/security/codex-model-stdio-fixture.cjs @@ -0,0 +1,30 @@ +const assert = require('node:assert/strict'); +const {execFileSync, spawnSync} = require('node:child_process'); +const {writeFileSync} = require('node:fs'); +const path = require('node:path'); + +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, +})); diff --git a/tests/security/codex-model-stdio-windows.cs b/tests/security/codex-model-stdio-windows.cs new file mode 100644 index 0000000..385baee --- /dev/null +++ b/tests/security/codex-model-stdio-windows.cs @@ -0,0 +1,168 @@ +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)) { + var principal = new WindowsPrincipal(user); + int elevationType, length; + if (!GetTokenInformation(token, 18, out elevationType, sizeof(int), out length)) + throw Win32("Read process token elevation type"); + if (elevationType != 1 || principal.IsInRole(WindowsBuiltInRole.Administrator) + || 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.Message); return 1; } + } +} diff --git a/tests/security/secure-host-linux.mjs b/tests/security/secure-host-linux.mjs index 6e26258..c99a641 100644 --- a/tests/security/secure-host-linux.mjs +++ b/tests/security/secure-host-linux.mjs @@ -64,6 +64,36 @@ async function finish(workerProcess,timeout) { finally {clearTimeout(timer);} } +function verifyModelExec() { + const input='fixture stdin\n'; + 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), + process.execPath,path.join(publicDir,'model-stdio-fixture.cjs'), + ],{cwd:publicDir,env:{PATH:'/usr/bin:/bin',LANG:'C'},stdio:['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,input}); + 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}); @@ -71,12 +101,22 @@ try { await mkdir(folder,{mode:0o700}); await chown(folder,uid,uid); } - for (const relative of ['tests/security/secure-host-worker.mjs','src/interactive/sqlite-store.mjs','src/interactive/canonical.mjs']) { + 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); } + await writeFile(path.join(publicDir,'model-stdio-fixture.cjs'),` +let input=''; +process.stdin.setEncoding('utf8').on('data',data=>{input+=data;}); +process.stdin.on('end',()=>{ + console.log(JSON.stringify({pid:process.pid,uid:process.getuid(),gid:process.getgid(),input})); + process.stderr.write('fixture stderr'); +}); +`,{mode:0o644}); + await verifyModelExec(); await writeFile(path.join(publicDir,'task.json'),'flowkit-public-task\n',{mode:0o644}); host=launch(hostUid,'host'); const ready=await waitReady(); diff --git a/tests/security/secure-host-windows.ps1 b/tests/security/secure-host-windows.ps1 index f6f8714..61f8dee 100644 --- a/tests/security/secure-host-windows.ps1 +++ b/tests/security/secure-host-windows.ps1 @@ -88,6 +88,39 @@ function Wait-Success($Child,[int]$TimeoutMilliseconds) { 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) + } 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' @@ -115,6 +148,20 @@ try { # 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') + Add-Type -Path (Join-Path $PSScriptRoot 'codex-model-stdio-windows.cs') -OutputType ConsoleApplication ` + -OutputAssembly (Join-Path $publicDir 'codex-model-stdio.exe') + 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 From 52633b280ebfb2cf7aa8aec7e407d359ec9c17b5 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 10:10:42 +0800 Subject: [PATCH 18/72] Restore blocking stdio for isolated Linux Codex --- .../p0a-codex-isolated-20261113/README.md | 12 +++++- tests/security/codex-flowkit-probe.mjs | 17 ++++++-- tests/security/codex-model-exec-linux.c | 27 +++++++++++++ tests/security/codex-model-identity.mjs | 10 ++--- tests/security/secure-host-linux.mjs | 40 ++++++++++++++----- 5 files changed, 86 insertions(+), 20 deletions(-) create mode 100644 tests/security/codex-model-exec-linux.c diff --git a/docs/evidence/p0a-codex-isolated-20261113/README.md b/docs/evidence/p0a-codex-isolated-20261113/README.md index 1127d7a..219c430 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/README.md +++ b/docs/evidence/p0a-codex-isolated-20261113/README.md @@ -23,12 +23,20 @@ SSH 固定命令使用此启动器,唯一参数为 Model 可运行的绝对 `c ## Linux -使用两个不同非零 UID 和 GID 的账户。当前探针支持预先配置的本机受信启动器,也支持回环 SSH。SSH 的固定命令以 Model 身份执行 `setpriv --no-new-privs`,再用绝对 Node 路径执行 `tests/security/codex-model-identity.mjs`,参数为 Model UID、GID、Codex 绝对路径及 `app-server --listen stdio:// -c 'web_search="disabled"'`。Model 账户应无额外组成员关系,命令只使用 Model 的 HOME、私有临时目录和受控 PATH。helper 核对有效 UID/GID、组、能力和 `NoNewPrivs`,再同 PID 切换到 Codex。固定命令及 Model 的 shell 启动环境由测试机操作者保护。 +使用两个不同非零 UID 和 GID 的账户。使用 glibc 2.34+、Linux 5.9+ 的测试机,编译并部署非特权的 exec shim。 -在 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`,探针会传入 Node、身份 helper、Model UID/GID、Codex 和固定 App Server 参数。 +```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 双账户、独立登录和模型签收仍需测试机运行记录。缺少原始记录的项目保持 BLOCKED。 + +Linux 云任务在基线 `9584184d7098a402a15f3edd8bfb11ea95f7ed49` 拉取后,用专用容器实测 UID/GID 41001/41002。47 项 OS 权限负例通过。用容器内启动器和 stdio 修复,真实 App Server 完成了同 PID 初始化。三个样本均在真实回合返回 401,工具调用、文件登记、签名和接纳均为零。Linux 报告归档 SHA-256 为 `136db4c02521fafaf76d8486d3cfd8cb597f81b39e0d0eceaecac9e61c5c0360`。 diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 5014a1a..b1ca74d 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -58,7 +58,10 @@ if (profile==='isolated') { assert.notEqual(hostGid,modelGid); assert.equal(process.getuid(),hostUid); assert.equal(process.getgid(),hostGid); - args=[node,fileURLToPath(new URL('./codex-model-identity.mjs',import.meta.url)),String(modelUid),String(modelGid),exe,...appArgs]; + 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 @@ -90,7 +93,7 @@ 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=setTimeout(()=>{finish('timeout');child.kill();},size?95000:65000); +const timer=setTimeout(()=>finish('timeout'),size?95000:65000); function send(message) { record('app-server.requests.ndjson',JSON.stringify(message)+'\n'); @@ -313,7 +316,15 @@ try { finally { clearTimeout(timer); child.stdin.end(); - child.kill(); + if (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(); store?.close(); if(fixture) await rm(fixture,{recursive:true,force:true}); 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 index ca8a7d4..06e2958 100644 --- a/tests/security/codex-model-identity.mjs +++ b/tests/security/codex-model-identity.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import {readFileSync} from 'node:fs'; +import {readFileSync,writeSync} from 'node:fs'; import {execve} from 'node:process'; const [uid,gid,exe,...args]=process.argv.slice(2); @@ -14,7 +14,7 @@ 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'); -process.stdout.write(JSON.stringify({method:'flowkit/profile/identity', - params:{uid:Number(uid),gid:Number(gid),groups,capabilities,noNewPrivs,pid:process.pid}})+'\n',()=>{ - execve(exe,[exe,...args],process.env); -}); +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/secure-host-linux.mjs b/tests/security/secure-host-linux.mjs index c99a641..ce684ca 100644 --- a/tests/security/secure-host-linux.mjs +++ b/tests/security/secure-host-linux.mjs @@ -66,12 +66,13 @@ async function finish(workerProcess,timeout) { 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), - process.execPath,path.join(publicDir,'model-stdio-fixture.cjs'), - ],{cwd:publicDir,env:{PATH:'/usr/bin:/bin',LANG:'C'},stdio:['pipe','pipe','pipe']}); + 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;}); @@ -88,7 +89,7 @@ function verifyModelExec() { 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,input}); + 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})); }); @@ -108,14 +109,33 @@ try { await copyFile(path.join(repo,relative),destination); await chmod(destination,0o644); } - await writeFile(path.join(publicDir,'model-stdio-fixture.cjs'),` -let input=''; -process.stdin.setEncoding('utf8').on('data',data=>{input+=data;}); -process.stdin.on('end',()=>{ - console.log(JSON.stringify({pid:process.pid,uid:process.getuid(),gid:process.getgid(),input})); - process.stderr.write('fixture stderr'); -}); + 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'); From 493213cc739d29cd5e1ca4e6e390b630ecc36030 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 10:12:08 +0800 Subject: [PATCH 19/72] Capture Windows launcher fixture failures --- tests/security/codex-model-stdio-fixture.cjs | 55 +++++++++++--------- tests/security/secure-host-windows.ps1 | 4 ++ 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/tests/security/codex-model-stdio-fixture.cjs b/tests/security/codex-model-stdio-fixture.cjs index 8bdbb91..a7fa15f 100644 --- a/tests/security/codex-model-stdio-fixture.cjs +++ b/tests/security/codex-model-stdio-fixture.cjs @@ -3,28 +3,33 @@ const {execFileSync, spawnSync} = require('node:child_process'); const {writeFileSync} = require('node:fs'); const path = require('node:path'); -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, -})); +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/secure-host-windows.ps1 b/tests/security/secure-host-windows.ps1 index 61f8dee..a41ca74 100644 --- a/tests/security/secure-host-windows.ps1 +++ b/tests/security/secure-host-windows.ps1 @@ -115,6 +115,10 @@ function Test-ModelStdio($Account,[Security.SecureString]$Password) { 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() From 695bc0ec577946ddd538a6140e05a948404b935b Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 10:14:18 +0800 Subject: [PATCH 20/72] Compile Windows stdio helper as a standalone executable --- docs/evidence/p0a-codex-isolated-20261113/README.md | 4 ++-- tests/security/codex-model-stdio-windows.cs | 2 +- tests/security/secure-host-windows.ps1 | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/README.md b/docs/evidence/p0a-codex-isolated-20261113/README.md index 219c430..79884d5 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/README.md +++ b/docs/evidence/p0a-codex-isolated-20261113/README.md @@ -13,8 +13,8 @@ 在指定 Windows 测试机上准备两个普通本地账户。编译受信启动器,在账户可读、仅管理员可修改的目录放置结果。 ```powershell -Add-Type -Path .\tests\security\codex-model-stdio-windows.cs ` - -OutputType ConsoleApplication -OutputAssembly C:\FlowKitLab\codex-model-stdio.exe +& "$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) diff --git a/tests/security/codex-model-stdio-windows.cs b/tests/security/codex-model-stdio-windows.cs index 385baee..f106811 100644 --- a/tests/security/codex-model-stdio-windows.cs +++ b/tests/security/codex-model-stdio-windows.cs @@ -163,6 +163,6 @@ public static int Main(string[] args) { if (!String.Equals(executable, args[0], StringComparison.OrdinalIgnoreCase)) return 2; return Run(executable); } - catch (Exception error) { Console.Error.WriteLine(error.Message); return 1; } + catch (Exception error) { Console.Error.WriteLine(error.ToString()); return 1; } } } diff --git a/tests/security/secure-host-windows.ps1 b/tests/security/secure-host-windows.ps1 index a41ca74..a2820cf 100644 --- a/tests/security/secure-host-windows.ps1 +++ b/tests/security/secure-host-windows.ps1 @@ -152,8 +152,10 @@ try { # 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') - Add-Type -Path (Join-Path $PSScriptRoot 'codex-model-stdio-windows.cs') -OutputType ConsoleApplication ` - -OutputAssembly (Join-Path $publicDir 'codex-model-stdio.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'), @' From d9dcc0067b5d24e8a91bbe459256e99d9f285428 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 10:15:44 +0800 Subject: [PATCH 21/72] Read Windows token elevation without impersonation --- tests/security/codex-model-stdio-windows.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/security/codex-model-stdio-windows.cs b/tests/security/codex-model-stdio-windows.cs index f106811..4d4a65e 100644 --- a/tests/security/codex-model-stdio-windows.cs +++ b/tests/security/codex-model-stdio-windows.cs @@ -84,11 +84,14 @@ private static string OrdinaryUserSid(IntPtr process) { if (!OpenProcessToken(process, 8, out token)) throw Win32("Open model token"); try { using (WindowsIdentity user = new WindowsIdentity(token)) { - var principal = new WindowsPrincipal(user); - int elevationType, length; + int elevationType, elevated, length; if (!GetTokenInformation(token, 18, out elevationType, sizeof(int), out length)) throw Win32("Read process token elevation type"); - if (elevationType != 1 || principal.IsInRole(WindowsBuiltInRole.Administrator) + 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)) From 81c6a29c7b165e24401461df17670cf9445a88b0 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 11:01:07 +0800 Subject: [PATCH 22/72] Record live Windows and Linux dual-account acceptance --- .../README.md | 4 +-- .../p0a-codex-isolated-20261113/README.md | 4 +-- .../live-samples.md | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/live-samples.md diff --git a/docs/evidence/p0a-codex-file-fallback-20261113/README.md b/docs/evidence/p0a-codex-file-fallback-20261113/README.md index 7300749..4413704 100644 --- a/docs/evidence/p0a-codex-file-fallback-20261113/README.md +++ b/docs/evidence/p0a-codex-file-fallback-20261113/README.md @@ -11,7 +11,7 @@ Host 在预设入口用受信来源登记字节数、SHA-256、Pending 和会话 | 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 仍 BLOCKED。此次文件回退实测的对象是 Host 合成输入和模型提交的真实引用。 +两个样本由受信合成生产者生成。模型实际调用了 App Server 的动态工具并提交引用。Host 在签收前删除入口文件。日志分别记录原生开始事件、回调、快照 SHA-256、签名、接纳和终态。在该同账户验收阶段,Windows/Linux 双账户原生宿主、生产文件入口的权限部署、真实签后崩溃恢复及 NAT-14/15 均待验证。此次文件回退实测的对象是 Host 合成输入和模型提交的真实引用。 定向用例覆盖入口文件在登记前被替换、截断、超限、非法 UTF-8/重复键/schema、错误 Pending 或会话、缺少开始事件、重复或并发消费、过期、已领取后重开。登记成功后替换或删除入口文件,签收使用已固定的原始快照。 @@ -19,4 +19,4 @@ Host 在预设入口用受信来源登记字节数、SHA-256、Pending 和会话 基线 `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 无文件符号链接权限时实测目录联接点。两项拒绝后,同一请求仍能登记合法文件。Windows/Linux 双账户 Codex App Server 仍 BLOCKED。 +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 index 79884d5..572a72c 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/README.md +++ b/docs/evidence/p0a-codex-isolated-20261113/README.md @@ -37,6 +37,6 @@ cc -O2 -Wall -Wextra -Werror tests/security/codex-model-exec-linux.c -o /path/to 在 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 双账户、独立登录和模型签收仍需测试机运行记录。缺少原始记录的项目保持 BLOCKED。 +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 权限负例通过。用容器内启动器和 stdio 修复,真实 App Server 完成了同 PID 初始化。三个样本均在真实回合返回 401,工具调用、文件登记、签名和接纳均为零。Linux 报告归档 SHA-256 为 `136db4c02521fafaf76d8486d3cfd8cb597f81b39e0d0eceaecac9e61c5c0360`。 +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..3529207 --- /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 的内联传输、双会话竞争、签后强杀与自动 Outbox 恢复、完整 NAT-14/15 与 C12/S13 仍为 BLOCKED。验收记录保存 Host 验签接纳及声明摘要,离线独立重验原始签名仍待取证。 + +| 原始证据包 | ZIP SHA-256 | +|---|---| +| Windows 本次验收交付 `flowkit-windows-dual-account-evidence.zip` | `5442cd27e74674451a1f16ea88c24f84f01ca7a74fab7f6d0fd8fc036dca4d76` | +| Linux FlowKit 云任务交付 `FlowKit-Linux-Live-Three-Samples-20261113.zip` | `b18af80ac30c49694bdff4b4d1aeb4c0c5e835bcd849a09152b057af4474cb50` | + +两个归档包含实际执行记录、原始协议日志、Host 观察日志和逐文件哈希。设备码、密码和令牌留在登录边界内。 From 81a4c6e4400f95ca5dc0b771342411d0f587a464 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 11:15:36 +0800 Subject: [PATCH 23/72] Recover signed Codex receipts from a durable Host outbox --- .../outbox-live.md | 13 ++ examples/interactive/codex-outbox.mjs | 131 +++++++++++++++ tests/interactive/codex-outbox.test.mjs | 53 ++++++ tests/security/codex-flowkit-probe.mjs | 130 +++++++++++--- tests/security/codex-outbox-live.mjs | 158 ++++++++++++++++++ 5 files changed, 458 insertions(+), 27 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/outbox-live.md create mode 100644 examples/interactive/codex-outbox.mjs create mode 100644 tests/interactive/codex-outbox.test.mjs create mode 100644 tests/security/codex-outbox-live.mjs 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..1514cc1 --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md @@ -0,0 +1,13 @@ +# 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 沿用既有契约。此验收使用小内联答案。文件回退引用的崩溃恢复、签名前及 CAS 后强杀、签名轮换、生产保留策略和完整 NAT-14/15、C12/S13 仍待专项证据。实验目录含一次性私钥,导出时只取协议日志、Host 观察、监督器日志和公钥。 diff --git a/examples/interactive/codex-outbox.mjs b/examples/interactive/codex-outbox.mjs new file mode 100644 index 0000000..062aac3 --- /dev/null +++ b/examples/interactive/codex-outbox.mjs @@ -0,0 +1,131 @@ +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,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 record({task,started,callback,modelPid},receipt,key) { + const normalized=normalizeHostAgentReceipt(receipt); + const cp=store.load(normalized.runId); + const {threadId,turnId,callId,tool,args}=callback; + if (![threadId,turnId,callId].every(identifier) || tool!=='flowkit_submit' + || !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 || normalized.result?.answer!==args.answer + || 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'); + } + 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); + const issuedAt=now(); + const statement=createAgentReceiptStatement({run:cp,receipt:normalized,keyId:'proof', + proofId,issuedAt,expiresAt:issuedAt+120000}); + 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)}:{})}; + } + + async function deliver(proofId) { + 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 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'); + 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/tests/interactive/codex-outbox.test.mjs b/tests/interactive/codex-outbox.test.mjs new file mode 100644 index 0000000..3f4f94d --- /dev/null +++ b/tests/interactive/codex-outbox.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import {generateKeyPairSync,verify} from 'node:crypto'; +import {mkdtempSync,rmSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import {openCodexOutbox} from '../../examples/interactive/codex-outbox.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}); + assert.throws(()=>outbox.record(observed(call,{...call,turnId:'other'}),receipt,keys.privateKey), + {code:'FK_OUTBOX_OBSERVATION'}); + const id=outbox.record(observed(),receipt,keys.privateKey); + assert.throws(()=>outbox.record(observed(),receipt,keys.privateKey)); + const signed=outbox.describe(id); + assert.equal(signed.state,'SIGNED'); + assert.equal(accepts,0); + outbox.close(); + outbox=open(); + assert.deepEqual(await outbox.recover('run'),[{...signed,state:'ACKED',acceptedRevision:4}]); + assert.deepEqual(await outbox.recover('run'),[]); + assert.equal(accepts,1); + + const retry=outbox.record(observed({...call,callId:'retry'}),receipt,keys.privateKey); + const expired=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,2); +}); diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index b1ca74d..18b4c29 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -1,8 +1,8 @@ import assert from 'node:assert/strict'; import {execFileSync,spawn} from 'node:child_process'; -import {generateKeyPairSync,randomUUID,sign} from 'node:crypto'; +import {createPrivateKey,createPublicKey,generateKeyPairSync,randomUUID,sign} from 'node:crypto'; import {appendFileSync,mkdirSync} from 'node:fs'; -import {mkdir,mkdtemp,rm,writeFile} from 'node:fs/promises'; +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'; @@ -13,11 +13,15 @@ import { } 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']=process.argv.slice(2); +const [exe,cwd,out,requested='loop',profile='direct',scenarioDir,answer]=process.argv.slice(2); +const scenario=['outbox-worker','outbox-recover','outbox-setup'].includes(requested); +const worker=requested==='outbox-worker'; +const offline=scenario&&!worker; const fallback=requested.startsWith('file-'); const artifactSize=fallback?Number(requested.slice(5)):0; -const size=requested==='loop'||fallback?0:Number(requested); +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)); @@ -38,12 +42,14 @@ const specs=size ? [tool('flowkit_inline','Measure an inline synthetic payload', :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=size?null:await mkdtemp(path.join(tmpdir(),'flowkit-codex-evidence-')); -let store,host,program,frame,currentTask,handoff,staged; +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)); +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') { +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. @@ -80,20 +86,20 @@ if (profile==='isolated') { '-o','ProxyJump=none','-o','ControlMaster=no','-o','ControlPath=none','flowkit-model']; } } -const child=spawn(command,args, +const child=offline?null:spawn(command,args, {cwd,stdio:['pipe','pipe','pipe'],windowsHide:true}); -child.stdin.on('error',()=>{}); +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=setTimeout(()=>finish('timeout'),size?95000:65000); +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'); @@ -115,22 +121,33 @@ 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'}); - const keys=generateKeyPairSync('ed25519'); + 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,'proof.private.pem'))); + } host=createTrustedInteractiveHost({ store, runtime:{skillResolver:async()=> 'Read the public question and submit its requested answer.', validateResult:(_schema,value)=>fallback ? (value.artifact?.id===staged?.id?[]:['Expected staged reference']) - : value.answer==='ready'?[]:['Expected ready']}, - trustedPublicKeys:{proof:keys.publicKey},trustedArtifacts:[program.programIdentity.artifactHash], + : (scenario?['alpha','beta','ready'].includes(value.answer):value.answer==='ready')?[]:['Unexpected answer']}, + trustedPublicKeys:{proof:publicKey},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}); if (fallback) { const inbox=path.join(fixture,'inbox'); await mkdir(inbox,{mode:0o700}); handoff=createFileHandoff({inbox,database:path.join(fixture,'file-handoff.sqlite'), validateArtifact:value=>Object.keys(value).length===1 && typeof value.answer==='string'}); } - return keys.privateKey; + return privateKey; } let key; @@ -160,6 +177,7 @@ async function handleTool(name,args,identity,started) { return {bytes:canonical.byteLength}; } if (name==='flowkit_start') { + assert.ok(!worker); assert.equal(args.nonce,nonce); assert.equal(frame,undefined); frame=await host.start({programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, @@ -184,6 +202,26 @@ async function handleTool(name,args,identity,started) { return {task:currentTask,fileReference:staged}; } if (name==='flowkit_submit') { + if (worker) { + assert.deepEqual(args,{requestId:currentTask.requestId,answer}); + const receipt={runId:currentTask.runId,requestId:currentTask.requestId,nodeId:currentTask.nodeId, + attempt:currentTask.attempt,kind:'agent-result',result:{answer}}; + const proofId=outbox.record({task:currentTask,modelPid:isolatedIdentity?.pid??child.pid, + started:{...started,callId:identity.callId,args:started.arguments}, + callback:{...identity,tool:name,args}},receipt,key); + const signed=outbox.describe(proofId); + const release=new Promise(resolve=>process.once('message',resolve)); + journal('outbox-signed',{...identity,...signed}); + process.send({event:'signed',...signed,threadId,turnId,callId:identity.callId,hostPid:process.pid, + modelPid:isolatedIdentity?.pid??child.pid,requestId:currentTask.requestId}); + assert.equal(await release,'deliver'); + try {frame=await outbox.deliver(proofId);} + catch (error) { + if (error.code!=='FK_I_RECEIPT_CONFLICT') throw error; + return {conflict:true,...outbox.describe(proofId)}; + } + return {runId:frame.runId,...outbox.describe(proofId)}; + } assert.equal(frame?.status,'waiting'); assert.equal(args.requestId,currentTask.requestId); return acceptResult({answer:args.answer},identity); @@ -213,8 +251,8 @@ async function handleTool(name,args,identity,started) { return final; } -const lines=createInterface({input:child.stdout}); -lines.on('line',line=>{ +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) { @@ -243,8 +281,12 @@ lines.on('line',line=>{ 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) { + journal('outbox-delivered',result); + process.send({event:'delivered',...result}); + } successful.push(p.tool); - send({id:message.id,result:{success:true,contentItems:[{type:'inputText',text:JSON.stringify(result)}]}}); + 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}); @@ -285,15 +327,23 @@ 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,'run.json'),'utf8')); + currentTask=await host.getPublicAgentTask({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:specs}); + multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}}, + dynamicTools:worker?specs.filter(spec=>spec.name==='flowkit_submit'):specs}); threadId=thread.id; - const prompt=size + const prompt=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.` @@ -305,18 +355,21 @@ async function run() { return finished; } try { + if (offline) await runOutboxOffline(); + else { const outcome=await Promise.race([run(),finished]); - const expected=size?['flowkit_inline']:['flowkit_start',fallback?'flowkit_submit_file':'flowkit_submit','flowkit_continue']; + const expected=worker?['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.exitCode===null && child.signalCode===null) { + 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'}); @@ -326,6 +379,29 @@ finally { } else child.kill(); } handoff?.close(); + outbox?.close(); store?.close(); - if(fixture) await rm(fixture,{recursive:true,force:true}); + 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') { + frame=await host.start({programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, + startKey:nonce,state:{question:'Submit the assigned answer.'}}); + await writeFile(path.join(fixture,'run.json'),JSON.stringify({runId:frame.runId}),{mode:0o600,flag:'wx'}); + const task=await host.getPublicAgentTask({runId:frame.runId}); + console.log(JSON.stringify({runId:frame.runId,requestId:task.requestId,revision:frame.revision})); + return; + } + const {runId}=JSON.parse(await readFile(path.join(fixture,'run.json'),'utf8')); + const before=store.load(runId); + const beforeReceipt=before.pending?store.getReceipt(runId,before.pending.requestId):null; + 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:before.pending?store.getReceipt(runId,before.pending.requestId):null}})); } diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs new file mode 100644 index 0000000..4e31faf --- /dev/null +++ b/tests/security/codex-outbox-live.mjs @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import {fork} from 'node:child_process'; +import {appendFileSync,mkdirSync,readFileSync} 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 {createSqliteCheckpointStore} from '../../src/interactive/index.mjs'; +import {sha256Bytes} from '../../src/interactive/canonical.mjs'; + +const [exe,cwd,root,profile='isolated']=process.argv.slice(2); +assert.ok([exe,cwd,root].every(path.isAbsolute)); +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) { + const out=path.join(dir,label); + const child=fork(probe,[exe,cwd,out,mode,profile,dir,...(answer?[answer]:[])], + {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) { + const result=await launch(mode,dir,label).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 publicKey=createPublicKey(readFileSync(path.join(dir,'proof.public.pem'))); + 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(sha256Bytes(row.statement),proof.statementHash); + assert.equal(sha256Bytes(row.signature),proof.signatureHash); + assert.ok(verify(null,row.statement,publicKey,row.signature)); + assert.equal(receipt.runId,setup.runId); + assert.equal(receipt.requestId,setup.requestId); + return {proofId:proof.proofId,state:row.state,receiptHash:row.receipt_hash,answer:receipt.result.answer, + acceptedRevision:row.accepted_revision,statementHash:proof.statementHash,signatureHash:proof.signatureHash}; + }); + return {status:cp.status,revision:cp.revision,answer:cp.state.answer,checkpointHash:journal.at(-1).checkpointHash, + 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.equal(state.journalRows,state.revision+1); +} + +try { + 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','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); + console.log(JSON.stringify({passed:true,profile,race:afterRace,crash:afterCrash})); +} finally { + for (const child of children) if (child.exitCode===null && child.signalCode===null) child.kill('SIGKILL'); +} From 56ddf9391b32b4ae02dbd70d6f1f23bd2d0b4652 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 11:32:46 +0800 Subject: [PATCH 24/72] Record Windows and Linux native Outbox acceptance --- .../live-samples.md | 2 +- .../p0a-codex-isolated-20261113/outbox-live.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/live-samples.md b/docs/evidence/p0a-codex-isolated-20261113/live-samples.md index 3529207..2d35966 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/live-samples.md +++ b/docs/evidence/p0a-codex-isolated-20261113/live-samples.md @@ -16,7 +16,7 @@ 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 的内联传输、双会话竞争、签后强杀与自动 Outbox 恢复、完整 NAT-14/15 与 C12/S13 仍为 BLOCKED。验收记录保存 Host 验签接纳及声明摘要,离线独立重验原始签名仍待取证。 +P、M、T 级证据覆盖这三个样本的实际程序运行、真实模型回合和独立系统身份下的原生工具通道。完整 256 KiB 和近 2 MiB 的内联传输、完整 NAT-14/15 与 C12/S13 仍为 BLOCKED。此批的验收记录保存 Host 验签接纳及声明摘要。后续[双会话竞争与 Outbox 实测](./outbox-live.md)覆盖另两个场景,并保存了可离线验签的证据。 | 原始证据包 | ZIP SHA-256 | |---|---| diff --git a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md index 1514cc1..ef099b1 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md +++ b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md @@ -11,3 +11,21 @@ node tests/security/codex-outbox-live.mjs Date: Fri, 13 Nov 2026 11:45:05 +0800 Subject: [PATCH 25/72] Recover Codex file receipts and reconcile post-CAS Outbox Ack --- .../outbox-live.md | 9 ++ examples/interactive/codex-file-handoff.mjs | 36 +++++- examples/interactive/codex-outbox.mjs | 28 ++++- tests/interactive/codex-outbox.test.mjs | 69 ++++++++++- tests/security/codex-flowkit-probe.mjs | 117 +++++++++++------- tests/security/codex-outbox-live.mjs | 106 +++++++++++++--- 6 files changed, 293 insertions(+), 72 deletions(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md index ef099b1..d580984 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md +++ b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md @@ -23,6 +23,15 @@ Windows 与 Linux 均验证实现提交 `81a4c6e4400f95ca5dc0b771342411d0f587a46 两个平台在强杀前后均为 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 补记。 + | 归档 | SHA-256 | |---|---| | Windows `flowkit-windows-outbox-live.zip` | `a66e85c0b746538af2f3f93c8a978e963bcc2434af76af9ba50a8fc4d1a2b7a9` | diff --git a/examples/interactive/codex-file-handoff.mjs b/examples/interactive/codex-file-handoff.mjs index 56de05c..610ac47 100644 --- a/examples/interactive/codex-file-handoff.mjs +++ b/examples/interactive/codex-file-handoff.mjs @@ -123,6 +123,21 @@ export function createFileHandoff({database,inbox,validateArtifact,now=Date.now} 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, @@ -130,10 +145,21 @@ export function createFileHandoff({database,inbox,validateArtifact,now=Date.now} if (!validId(id)) throw fail('FK_FILE_REFERENCE'); const saved=read.get(id); if (!saved) return null; - const bytes=Buffer.from(saved.bytes); - if (bytes.length!==saved.size || sha256Bytes(bytes)!==saved.sha256) throw fail('FK_FILE_STORE'); + 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'); @@ -165,14 +191,12 @@ export function createFileHandoff({database,inbox,validateArtifact,now=Date.now} || current.pending.attempt!==binding.attempt) { throw fail('FK_FILE_PENDING'); } - const bytes=Buffer.from(saved.bytes); - if (bytes.length!==saved.size || sha256Bytes(bytes)!==saved.sha256) throw fail('FK_FILE_STORE'); + 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}); - if (!Number.isSafeInteger(result?.acceptedRevision) || result.acceptedRevision<1 - || ack.run(result.acceptedRevision,call.id,call.callId).changes!==1) throw fail('FK_FILE_UNCERTAIN'); + acknowledge(call.id,call.callId,result?.acceptedRevision); return result; }, }); diff --git a/examples/interactive/codex-outbox.mjs b/examples/interactive/codex-outbox.mjs index 062aac3..83b0db3 100644 --- a/examples/interactive/codex-outbox.mjs +++ b/examples/interactive/codex-outbox.mjs @@ -19,7 +19,7 @@ function decode(bytes,limit) { } /** A dedicated SQLite file in an operator-protected Host directory. */ -export function openCodexOutbox({database,store,host,now=Date.now}) { +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;} @@ -43,20 +43,32 @@ export function openCodexOutbox({database,store,host,now=Date.now}) { 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'); + } + function record({task,started,callback,modelPid},receipt,key) { const normalized=normalizeHostAgentReceipt(receipt); const cp=store.load(normalized.runId); const {threadId,turnId,callId,tool,args}=callback; - if (![threadId,turnId,callId].every(identifier) || tool!=='flowkit_submit' + 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 || normalized.result?.answer!==args.answer + || 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); @@ -82,7 +94,7 @@ export function openCodexOutbox({database,store,host,now=Date.now}) { ...(row.statement?{statementHash:sha256Bytes(row.statement),signatureHash:sha256Bytes(row.signature)}:{})}; } - async function deliver(proofId) { + 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); @@ -93,6 +105,12 @@ export function openCodexOutbox({database,store,host,now=Date.now}) { || 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(); @@ -109,6 +127,8 @@ export function openCodexOutbox({database,store,host,now=Date.now}) { } 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'); diff --git a/tests/interactive/codex-outbox.test.mjs b/tests/interactive/codex-outbox.test.mjs index 3f4f94d..d549828 100644 --- a/tests/interactive/codex-outbox.test.mjs +++ b/tests/interactive/codex-outbox.test.mjs @@ -1,10 +1,13 @@ import assert from 'node:assert/strict'; -import {generateKeyPairSync,verify} from 'node:crypto'; -import {mkdtempSync,rmSync} from 'node:fs'; +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=>{ @@ -38,16 +41,74 @@ test('Codex Outbox replays exact signed bytes, reconciles historical Ack and blo 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,1); + assert.equal(accepts,2); const retry=outbox.record(observed({...call,callId:'retry'}),receipt,keys.privateKey); const expired=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,2); + 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, + signAndAccept({artifact}) { + const observed={...event,args:reference}; + const receipt={...pending,kind:'agent-result',result:{artifact}}; + assert.throws(()=>outbox.record({task:pending,started:observed,callback:observed,modelPid:100}, + {...receipt,result:{artifact:{...artifact,sha256:binding.programHash}}},keys.privateKey),{code:'FK_FILE_BINDING'}); + proofId=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/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 18b4c29..4a91952 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -15,12 +15,12 @@ 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]=process.argv.slice(2); +const [exe,cwd,out,requested='loop',profile='direct',scenarioDir,answer,cut,scenarioBytes]=process.argv.slice(2); const scenario=['outbox-worker','outbox-recover','outbox-setup'].includes(requested); const worker=requested==='outbox-worker'; const offline=scenario&&!worker; -const fallback=requested.startsWith('file-'); -const artifactSize=fallback?Number(requested.slice(5)):0; +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)); @@ -44,7 +44,8 @@ const specs=size ? [tool('flowkit_inline','Measure an inline synthetic payload', ]; 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)); +if (worker) assert.ok(process.send && ['alpha','beta','ready'].includes(answer) + && (!cut || cut==='after-cas')); let store,host,program,frame,currentTask,handoff,staged,outbox; const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; let command=exe,args=appArgs; @@ -112,7 +113,7 @@ function request(method,params) { async function prepareFlowKit() { const file=path.join(fixture,'probe.workflow.js'); - await writeFile(file,`import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl'; + 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'}), @@ -132,25 +133,33 @@ terminal('done',{name:'Done',status:'succeeded'}) publicKey=createPublicKey(await readFile(path.join(fixture,'proof.public.pem'))); if (worker) privateKey=createPrivateKey(await readFile(path.join(fixture,'proof.private.pem'))); } + 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 ? (value.artifact?.id===staged?.id?[]:['Expected staged reference']) + validateResult:(_schema,value)=>fallback ? validateArtifactResult(value) : (scenario?['alpha','beta','ready'].includes(value.answer):value.answer==='ready')?[]:['Unexpected answer']}, trustedPublicKeys:{proof:publicKey},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}); - if (fallback) { - const inbox=path.join(fixture,'inbox'); - await mkdir(inbox,{mode:0o700}); - handoff=createFileHandoff({inbox,database:path.join(fixture,'file-handoff.sqlite'), - validateArtifact:value=>Object.keys(value).length===1 && typeof value.answer==='string'}); - } + 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}; @@ -167,6 +176,34 @@ async function acceptResult(result,identity,pending=currentTask) { 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 proofId=outbox.record({task:currentTask,modelPid:isolatedIdentity?.pid??child.pid, + started:{...started,callId:identity.callId,args:started.arguments}, + callback:{...identity,tool:name,args}},receipt,key); + const signed=outbox.describe(proofId); + const detail={...signed,threadId,turnId,callId:identity.callId,hostPid:process.pid, + modelPid:isolatedIdentity?.pid??child.pid,requestId:currentTask.requestId, + ...(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); @@ -177,12 +214,15 @@ async function handleTool(name,args,identity,started) { return {bytes:canonical.byteLength}; } if (name==='flowkit_start') { - assert.ok(!worker); + assert.ok(!worker || fallback); assert.equal(args.nonce,nonce); - 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}); + 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; @@ -204,23 +244,7 @@ async function handleTool(name,args,identity,started) { if (name==='flowkit_submit') { if (worker) { assert.deepEqual(args,{requestId:currentTask.requestId,answer}); - const receipt={runId:currentTask.runId,requestId:currentTask.requestId,nodeId:currentTask.nodeId, - attempt:currentTask.attempt,kind:'agent-result',result:{answer}}; - const proofId=outbox.record({task:currentTask,modelPid:isolatedIdentity?.pid??child.pid, - started:{...started,callId:identity.callId,args:started.arguments}, - callback:{...identity,tool:name,args}},receipt,key); - const signed=outbox.describe(proofId); - const release=new Promise(resolve=>process.once('message',resolve)); - journal('outbox-signed',{...identity,...signed}); - process.send({event:'signed',...signed,threadId,turnId,callId:identity.callId,hostPid:process.pid, - modelPid:isolatedIdentity?.pid??child.pid,requestId:currentTask.requestId}); - assert.equal(await release,'deliver'); - try {frame=await outbox.deliver(proofId);} - catch (error) { - if (error.code!=='FK_I_RECEIPT_CONFLICT') throw error; - return {conflict:true,...outbox.describe(proofId)}; - } - return {runId:frame.runId,...outbox.describe(proofId)}; + return deliverCandidate({answer},name,args,identity,started); } assert.equal(frame?.status,'waiting'); assert.equal(args.requestId,currentTask.requestId); @@ -229,7 +253,8 @@ async function handleTool(name,args,identity,started) { 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})=>acceptResult({artifact},identity,pending), + loadPending:store.load,signAndAccept:({artifact,pending})=>worker + ?deliverCandidate({artifact},name,args,identity,started):acceptResult({artifact},identity,pending), }); } assert.equal(name,'flowkit_continue'); @@ -281,7 +306,7 @@ lines?.on('line',line=>{ 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) { + if (worker && p.tool!=='flowkit_start') { journal('outbox-delivered',result); process.send({event:'delivered',...result}); } @@ -330,6 +355,7 @@ async function run() { if (worker) { const {runId}=JSON.parse(await readFile(path.join(fixture,'run.json'),'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}}); @@ -338,9 +364,11 @@ async function run() { 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=>spec.name==='flowkit_submit'):specs}); + dynamicTools:worker?specs.filter(spec=>fallback?spec.name!=='flowkit_continue':spec.name==='flowkit_submit'):specs}); threadId=thread.id; - const prompt=worker + 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 @@ -358,7 +386,8 @@ try { if (offline) await runOutboxOffline(); else { const outcome=await Promise.race([run(),finished]); - const expected=worker?['flowkit_submit']:size?['flowkit_inline']:['flowkit_start',fallback?'flowkit_submit_file':'flowkit_submit','flowkit_continue']; + 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); @@ -390,18 +419,18 @@ async function runOutboxOffline() { if (requested==='outbox-setup') { frame=await host.start({programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, startKey:nonce,state:{question:'Submit the assigned answer.'}}); - await writeFile(path.join(fixture,'run.json'),JSON.stringify({runId:frame.runId}),{mode:0o600,flag:'wx'}); const task=await host.getPublicAgentTask({runId:frame.runId}); + await writeFile(path.join(fixture,'run.json'),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}=JSON.parse(await readFile(path.join(fixture,'run.json'),'utf8')); + const {runId,requestId}=JSON.parse(await readFile(path.join(fixture,'run.json'),'utf8')); const before=store.load(runId); - const beforeReceipt=before.pending?store.getReceipt(runId,before.pending.requestId):null; + const beforeReceipt=store.getReceipt(runId,requestId); 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:before.pending?store.getReceipt(runId,before.pending.requestId):null}})); + journalRows:store.listJournal(runId).length,receipt:store.getReceipt(runId,requestId)}})); } diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index 4e31faf..9e992b5 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -1,24 +1,26 @@ import assert from 'node:assert/strict'; import {fork} from 'node:child_process'; -import {appendFileSync,mkdirSync,readFileSync} from 'node:fs'; +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 {createSqliteCheckpointStore} from '../../src/interactive/index.mjs'; -import {sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.mjs'; -const [exe,cwd,root,profile='isolated']=process.argv.slice(2); +const [exe,cwd,root,profile='isolated',suite='original']=process.argv.slice(2); assert.ok([exe,cwd,root].every(path.isAbsolute)); +assert.ok(['original','followup'].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) { +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?[answer]:[])], + 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=''; @@ -53,8 +55,8 @@ async function waitFor(worker,event) { } finally {clearTimeout(timer);child.off('message',listener);} } -async function offline(mode,dir,label) { - const result=await launch(mode,dir,label).closed; +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); } @@ -80,11 +82,12 @@ function inspect(dir,setup,proofs) { assert.ok(verify(null,row.statement,publicKey,row.signature)); assert.equal(receipt.runId,setup.runId); assert.equal(receipt.requestId,setup.requestId); - return {proofId:proof.proofId,state:row.state,receiptHash:row.receipt_hash,answer:receipt.result.answer, + return {proofId:proof.proofId,state:row.state,receiptHash:row.receipt_hash,...receipt.result, acceptedRevision:row.accepted_revision,statementHash:proof.statementHash,signatureHash:proof.signatureHash}; }); - return {status:cp.status,revision:cp.revision,answer:cp.state.answer,checkpointHash:journal.at(-1).checkpointHash, - indexed,journalRows:journal.length,rows}; + return {status:cp.status,revision:cp.revision,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();} } @@ -100,14 +103,15 @@ function finishedState(state,winner) { 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); } -try { +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 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); @@ -131,7 +135,7 @@ try { 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','ready'); + const victim=launch('outbox-worker',crashDir,'victim',{answer:'ready'}); const proof=await waitFor(victim,'signed'); const beforeCrash=inspect(crashDir,crash,[proof]); pendingState(beforeCrash,crash); @@ -152,7 +156,81 @@ try { finishedState(afterCrash,afterCrash.rows[0]); assert.equal(afterCrash.indexed.acceptedRevision,crash.revision+1); log('recovery-passed',afterCrash); - console.log(JSON.stringify({passed:true,profile,race:afterRace,crash: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) { + const {rows,...storeBefore}=before; + const {rows:finalRows,...storeAfter}=after; + assert.deepEqual(storeAfter,storeBefore); + } + 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}; +} + +try { + const result=suite==='followup'?{ + afterCas:await crashCase('after-cas',{cut:'after-cas'}), + file256:await crashCase('file-262144',{bytes:262144}), + fileNearLimit:await crashCase('file-2093056',{bytes:2093056}), + }:await originalSuite(); + console.log(JSON.stringify({passed:true,profile,suite,...result})); } finally { for (const child of children) if (child.exitCode===null && child.signalCode===null) child.kill('SIGKILL'); } From 380be089552996b53fb5b0a13f5a0d032eac922e Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 11:55:26 +0800 Subject: [PATCH 26/72] Record post-CAS and file Outbox recovery on both hosts --- .../p0a-codex-isolated-20261113/outbox-live.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md index d580984..8bbe093 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md +++ b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md @@ -10,7 +10,7 @@ node tests/security/codex-outbox-live.mjs Date: Fri, 13 Nov 2026 12:34:24 +0800 Subject: [PATCH 27/72] Exercise Codex Outbox signing cuts and key rotation --- .../outbox-live.md | 12 +- examples/interactive/codex-outbox.mjs | 11 +- tests/interactive/codex-outbox.test.mjs | 24 ++-- tests/security/codex-flowkit-probe.mjs | 51 +++++-- tests/security/codex-outbox-live.mjs | 132 ++++++++++++++++-- 5 files changed, 193 insertions(+), 37 deletions(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md index 8bbe093..3ded9b3 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md +++ b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md @@ -47,4 +47,14 @@ 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 的策略哈希属于独立场景。 diff --git a/examples/interactive/codex-outbox.mjs b/examples/interactive/codex-outbox.mjs index 83b0db3..18b9c58 100644 --- a/examples/interactive/codex-outbox.mjs +++ b/examples/interactive/codex-outbox.mjs @@ -53,7 +53,8 @@ export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { || Object.keys(result??{}).length!==1 || result.answer!==args.answer) throw fail('FK_OUTBOX_OBSERVATION'); } - function record({task,started,callback,modelPid},receipt,key) { + 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; @@ -78,9 +79,10 @@ export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { 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:'proof', - proofId,issuedAt,expiresAt:issuedAt+120000}); + 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'); @@ -91,7 +93,8 @@ export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { 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)}:{})}; + ...(row.statement?{statementHash:sha256Bytes(row.statement),signatureHash:sha256Bytes(row.signature), + expiresAt:decode(row.statement,4096).expiresAt}:{})}; } async function deliver(proofId,{afterAccept}={}) { diff --git a/tests/interactive/codex-outbox.test.mjs b/tests/interactive/codex-outbox.test.mjs index d549828..6a15530 100644 --- a/tests/interactive/codex-outbox.test.mjs +++ b/tests/interactive/codex-outbox.test.mjs @@ -34,10 +34,18 @@ test('Codex Outbox replays exact signed bytes, reconciles historical Ack and blo let outbox=open(); t.after(()=>{outbox.close();rmSync(directory,{recursive:true,force:true});}); const observed=(callback=call,started=callback)=>({task,modelPid:100,callback,started}); - assert.throws(()=>outbox.record(observed(call,{...call,turnId:'other'}),receipt,keys.privateKey), + await assert.rejects(outbox.record(observed(call,{...call,turnId:'other'}),receipt,keys.privateKey), {code:'FK_OUTBOX_OBSERVATION'}); - const id=outbox.record(observed(),receipt,keys.privateKey); - assert.throws(()=>outbox.record(observed(),receipt,keys.privateKey)); + 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); @@ -50,8 +58,8 @@ test('Codex Outbox replays exact signed bytes, reconciles historical Ack and blo assert.deepEqual(await outbox.recover('run'),[]); assert.equal(accepts,2); - const retry=outbox.record(observed({...call,callId:'retry'}),receipt,keys.privateKey); - const expired=outbox.record(observed({...call,callId:'expired',args:{...call.args,answer:'stale'}}), + 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']]); @@ -89,12 +97,12 @@ test('file Outbox recovery verifies the claimed bytes and replays an already-rec const call={...event,arguments:reference}; let proofId; await assert.rejects(handoff.consume({started:call,callback:call,loadPending:store.load, - signAndAccept({artifact}) { + async signAndAccept({artifact}) { const observed={...event,args:reference}; const receipt={...pending,kind:'agent-result',result:{artifact}}; - assert.throws(()=>outbox.record({task:pending,started:observed,callback:observed,modelPid:100}, + 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=outbox.record({task:pending,started:observed,callback:observed,modelPid:100},receipt,keys.privateKey); + 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); diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 4a91952..081f72d 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -16,9 +16,11 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m 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'].includes(requested); +const scenario=['outbox-worker','outbox-recover','outbox-setup','outbox-next','outbox-replay'].includes(requested); const worker=requested==='outbox-worker'; const offline=scenario&&!worker; +const rotation=cut==='rotate-old'||cut==='rotate-new'; +const runRecord=cut==='rotate-new'?'run-next.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); @@ -45,7 +47,7 @@ const specs=size ? [tool('flowkit_inline','Measure an inline synthetic payload', 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 || cut==='after-cas')); + && (!cut || ['after-cas','before-sign','expire','rotate-old','rotate-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; @@ -131,7 +133,17 @@ terminal('done',{name:'Done',status:'succeeded'}) } } else { publicKey=createPublicKey(await readFile(path.join(fixture,'proof.public.pem'))); - if (worker) privateKey=createPrivateKey(await readFile(path.join(fixture,'proof.private.pem'))); + if (worker) privateKey=createPrivateKey(await readFile(path.join(fixture, + cut==='rotate-new'?'next.private.pem':'proof.private.pem'))); + } + const trustedPublicKeys={proof:publicKey}; + if (rotation) { + 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 (fallback) { const inbox=path.join(fixture,'inbox'); @@ -143,7 +155,7 @@ terminal('done',{name:'Done',status:'succeeded'}) 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:{proof:publicKey},trustedArtifacts:[program.programIdentity.artifactHash], + trustedPublicKeys,trustedArtifacts:[program.programIdentity.artifactHash], allowedInputKeys:['question'],disclosurePolicyId:'probe-v1',authorizeDisclosure:()=>true, authorizeTerminalAccess:({action})=>action==='run:read', }); @@ -179,12 +191,21 @@ async function acceptResult(result,identity,pending=currentTask) { 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 proofId=outbox.record({task:currentTask,modelPid:isolatedIdentity?.pid??child.pid, + 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); + callback:{...identity,tool:name,args}},receipt,key,{ + keyId:cut==='rotate-new'?'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={...signed,threadId,turnId,callId:identity.callId,hostPid:process.pid, - modelPid:isolatedIdentity?.pid??child.pid,requestId:currentTask.requestId, + const detail={...details,...signed, ...(result.artifact?{artifact:result.artifact}:{})}; const release=new Promise(resolve=>process.once('message',resolve)); journal('outbox-signed',{...identity,...detail}); @@ -353,7 +374,7 @@ async function run() { if (profile==='isolated') await identityReady; if (!size) key=await prepareFlowKit(); if (worker) { - const {runId}=JSON.parse(await readFile(path.join(fixture,'run.json'),'utf8')); + const {runId}=JSON.parse(await readFile(path.join(fixture,runRecord),'utf8')); currentTask=await host.getPublicAgentTask({runId}); frame=store.load(runId); } @@ -416,17 +437,23 @@ finally { async function runOutboxOffline() { await prepareFlowKit(); - if (requested==='outbox-setup') { + 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,'run.json'),JSON.stringify({runId:frame.runId,requestId:task.requestId}),{mode:0o600,flag:'wx'}); + 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,'run.json'),'utf8')); + 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-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); diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index 9e992b5..77fce44 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -5,13 +5,14 @@ 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'].includes(suite)); +assert.ok(['original','followup','signing'].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'), @@ -69,7 +70,6 @@ function inspect(dir,setup,proofs) { const cp=store.load(setup.runId); const indexed=store.getReceipt(setup.runId,setup.requestId); const journal=store.listJournal(setup.runId); - const publicKey=createPublicKey(readFileSync(path.join(dir,'proof.public.pem'))); const get=db.prepare('SELECT * FROM delivery WHERE proof_id=?'); const rows=proofs.map(proof=>{ const row=get.get(proof.proofId); @@ -77,15 +77,26 @@ function inspect(dir,setup,proofs) { 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(sha256Bytes(row.statement),proof.statementHash); - assert.equal(sha256Bytes(row.signature),proof.signatureHash); - assert.ok(verify(null,row.statement,publicKey,row.signature)); 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, - acceptedRevision:row.accepted_revision,statementHash:proof.statementHash,signatureHash:proof.signatureHash}; + 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,answer:cp.state.answer,artifact:cp.state.artifact, + 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();} @@ -107,6 +118,12 @@ function finishedState(state,winner) { 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}); @@ -213,22 +230,113 @@ async function crashCase(label,{cut='',bytes}={}) { assert.equal(winner.state,'ACKED'); finishedState(after,winner); assert.equal(after.indexed.acceptedRevision,setup.revision+1); - if (cut) { - const {rows,...storeBefore}=before; - const {rows:finalRows,...storeAfter}=after; - assert.deepEqual(storeAfter,storeBefore); - } + 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}; +} + try { const result=suite==='followup'?{ afterCas:await crashCase('after-cas',{cut:'after-cas'}), file256:await crashCase('file-262144',{bytes:262144}), fileNearLimit:await crashCase('file-2093056',{bytes:2093056}), + }:suite==='signing'?{ + beforeSign:await beforeSignCase(),expired:await expiredCase(),rotation:await rotationCase(), }:await originalSuite(); console.log(JSON.stringify({passed:true,profile,suite,...result})); } finally { From cfe867c9c12b6c83bffa5f4a1ffbec0fe9d5c6a2 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 12:50:01 +0800 Subject: [PATCH 28/72] Record native Outbox signing lifecycle results on both hosts --- .../p0a-codex-isolated-20261113/outbox-live.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md index 3ded9b3..2fda8d3 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md +++ b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md @@ -58,3 +58,18 @@ node tests/security/codex-outbox-live.mjs Date: Fri, 13 Nov 2026 13:05:42 +0800 Subject: [PATCH 29/72] Probe revoked Host trust and current Outbox retention --- .../revocation-retention.md | 21 +++++++++ tests/security/codex-flowkit-probe.mjs | 38 ++++++++++++--- tests/security/codex-outbox-live.mjs | 46 ++++++++++++++++++- 3 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md 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..d62eabd --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md @@ -0,0 +1,21 @@ +# 旧公钥撤销与 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` 的文件行。 + +## 待确认的发布契约 + +- **撤销后历史 Ack。** 建议将撤销视为停止新接纳。历史 Ack 由独立受权的只读对账路径从 Store 账本返回,核对 namespace、Run、Request、节点、attempt 与规范回执摘要。撤销旧钥后,旧签名仍走现有接纳路径的拒绝行为。 +- **既有 Run 迁移。** 建议正常轮换沿用已冻结策略直到旧 Run 结束,新 Run 使用新策略。紧急撤销后,旧 Run 保持阻断,由受权操作员对账。若需要原地继续既有 Run,应另审带版本、事务与审计的迁移协议,并规定在途 Pending、历史签名和 Journal 的处理方式。 +- **保留窗口。** 建议由部署配置规定期限与容量。待投递的 `SIGNED` 和未解决的 `OBSERVED`、`BLOCKED`、`CONFLICT` 保留到对账或审计释放。终态且对账完毕的 Run 在审计窗口后才清理 Outbox 正文与可回收快照。仍被有效结果引用的快照、合法保留标记和 Store 接纳索引继续保留。 + +上述发布行为待确认。当前验收只覆盖现有契约下的拒绝、账本保留和新策略下新 Run 的运行。 diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 081f72d..f16154c 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -16,11 +16,15 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m 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'].includes(requested); +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=cut==='rotate-old'||cut==='rotate-new'; -const runRecord=cut==='rotate-new'?'run-next.json':'run.json'; +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); @@ -47,7 +51,7 @@ const specs=size ? [tool('flowkit_inline','Measure an inline synthetic payload', 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'].includes(cut))); + && (!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; @@ -134,16 +138,17 @@ terminal('done',{name:'Done',status:'succeeded'}) } else { publicKey=createPublicKey(await readFile(path.join(fixture,'proof.public.pem'))); if (worker) privateKey=createPrivateKey(await readFile(path.join(fixture, - cut==='rotate-new'?'next.private.pem':'proof.private.pem'))); + nextSigner?'next.private.pem':'proof.private.pem'))); } const trustedPublicKeys={proof:publicKey}; - if (rotation) { + 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'); @@ -196,7 +201,7 @@ async function deliverCandidate(result,name,args,identity,started) { 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:cut==='rotate-new'?'next':'proof', + 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}); @@ -448,6 +453,25 @@ async function runOutboxOffline() { 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, diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index 77fce44..b2edce2 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -12,7 +12,7 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m 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'].includes(suite)); +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'), @@ -330,6 +330,48 @@ async function rotationCase() { 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() Date: Fri, 13 Nov 2026 13:15:13 +0800 Subject: [PATCH 30/72] Record native trust revocation and Outbox retention evidence --- .../outbox-live.md | 2 +- .../revocation-retention.md | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md index 2fda8d3..0cc2105 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md +++ b/docs/evidence/p0a-codex-isolated-20261113/outbox-live.md @@ -67,7 +67,7 @@ Windows 与 Linux 均在 `ddf8c688abaa6450c615b81e3bd0daaceaed53b8` 使用独立 | 未接纳回执过期 | 实际时钟超过期限及 5 秒容差后转为 `BLOCKED`,无接纳索引,Checkpoint 和 Journal 保持不变。 | | 签名轮换和旧 Ack | 旧钥与新钥分别签署并接纳各自的真实 Run。旧签名过期后重放返回原接纳 revision 4,两个 Run 的 Checkpoint、Journal 和接纳索引均保持不变。 | -两端保存实际工具事件、监督与重启日志,以及用于独立复核的原始签名和公钥。证据为指定样本的 P/M/T。旧公钥撤销、生产保留策略,以及完整 NAT-14/15、C12/S13 和供应商可验证证明仍缺对应证据。 +两端保存实际工具事件、监督与重启日志,以及用于独立复核的原始签名和公钥。证据为指定样本的 P/M/T。[移除旧公钥后的拒绝与 Outbox 保留基线](./revocation-retention.md) 已单独验证。生产撤销和保留策略,以及完整 NAT-14/15、C12/S13 和供应商可验证证明仍缺对应证据。 | 原始证据归档 | SHA-256 | |---|---| diff --git a/docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md b/docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md index d62eabd..3b15e90 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md +++ b/docs/evidence/p0a-codex-isolated-20261113/revocation-retention.md @@ -12,10 +12,30 @@ node tests/security/codex-outbox-live.mjs Date: Fri, 13 Nov 2026 13:32:42 +0800 Subject: [PATCH 31/72] Map dual-platform Codex evidence to P0-A release gates --- docs/evidence/p0a-g07-g12-20261113/README.md | 2 + docs/evidence/p0a-g07-g12-20261113/matrix.md | 2 + .../p0a-g07-g12-20261113/release-matrix.md | 138 ++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 docs/evidence/p0a-g07-g12-20261113/release-matrix.md diff --git a/docs/evidence/p0a-g07-g12-20261113/README.md b/docs/evidence/p0a-g07-g12-20261113/README.md index 32c0d91..8e351aa 100644 --- a/docs/evidence/p0a-g07-g12-20261113/README.md +++ b/docs/evidence/p0a-g07-g12-20261113/README.md @@ -1,5 +1,7 @@ # 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。历史模型结果在本批只做重放,原始证据等级不变。 | 阶段 | 裁决 | 已验证事实与继续条件 | diff --git a/docs/evidence/p0a-g07-g12-20261113/matrix.md b/docs/evidence/p0a-g07-g12-20261113/matrix.md index 84fc9ca..e01579f 100644 --- a/docs/evidence/p0a-g07-g12-20261113/matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/matrix.md @@ -1,5 +1,7 @@ # 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。 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) From d0cb0b5600bab1d8fc48e01efe3715289d3c9836 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 14:15:08 +0800 Subject: [PATCH 32/72] Align first-release checkpoints with R5 and add Host operations --- .../p0a-g07-g12-20261113/release-matrix.md | 24 +- docs/interactive.md | 20 ++ examples/interactive/codex-file-handoff.mjs | 19 +- examples/interactive/codex-outbox.mjs | 129 +++++++++- scripts/bench-interactive-ledger.mjs | 5 +- scripts/package-files.mjs | 2 +- src/interactive/checkpoint.mjs | 146 +++++++++++ src/interactive/coordinator.mjs | 88 +++---- src/interactive/sqlite-store.mjs | 242 +++++++++--------- src/interactive/trusted-host.mjs | 54 +++- src/interactive/versioned.mjs | 121 ++++----- tests/integration/m6-e2e.test.mjs | 15 +- tests/interactive/codex-outbox.test.mjs | 86 ++++++- tests/interactive/complex-c01-c12.test.mjs | 20 +- tests/interactive/coordinator.test.mjs | 10 +- tests/interactive/crash.test.mjs | 8 +- tests/interactive/s07-capacity.test.mjs | 38 +-- tests/interactive/store.test.mjs | 7 +- tests/interactive/trusted-host.test.mjs | 63 +++++ tests/interactive/versioned.test.mjs | 74 ++---- tests/package/consumer.mjs | 2 +- tests/security/codex-outbox-live.mjs | 3 +- types/interactive.d.ts | 46 +++- 23 files changed, 849 insertions(+), 373 deletions(-) create mode 100644 src/interactive/checkpoint.mjs diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 4be96a1..0fcc75d 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -111,28 +111,28 @@ - **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 格式 +首发测试库按 R5 结构重建,开发期归档保留用于证据复核。以下实现的程序回归使用新建 SQLite 库。真实 Codex Windows/Linux 双账户在新实现上的复验及生产运维演练仍为 BLOCKED。 -**推荐 P0-A 登记现有持久结构为明确的兼容例外。** 当前异步五操作承载现有 P0-A `RunCheckpointV1`,与 R5 §5 的嵌套 `program`、`policyIdentity`、`execution` 等字段有实质差异。发布契约应写清支持的 payload 及兼容矩阵,现有库和历史 Run 可继续使用。代价是接入方不能按 R5 全量结构直接互操作,差异需记入发行说明并留待独立复审。 +### 1. Checkpoint 格式 -**备选是本次发行前完成版本化对齐。** 冻结新的格式版本,明确旧读写方、双版本读取或离线迁移、历史回执与 Journal 的处理,再演练备份、升级和回滚。优点是发布契约与 R5 一致,代价是扩大 Store、类型和恢复回归范围。此项需要对格式及版本路径单独授权。 [当前类型声明](../../../types/interactive.d.ts) · [兼容实现范围](../p0a-compat-codex-20261113/README.md) +首发直接采用 R5 §5 的 `RunCheckpointV1`,包括异步 Store 契约及 SQLite 持久化内容。首发测试库重建。验证新建、暂停和跨进程恢复、首次接纳与历史 Ack、终态及专用管理审计。 [类型声明](../../../types/interactive.d.ts) ### 2. 旧钥撤销后的历史 Ack -**推荐撤销使旧钥停止签发和接纳,历史 Ack 走独立受权的只读对账。** 按 namespace、Run、request、node、attempt 和规范回执摘要精确匹配持久接纳记录,仅返回原接纳信息。找不到或不匹配时拒绝。发行验收须覆盖跨租户、摘要错配、未接纳旧回执、重复查询及零 CAS/Journal/Checkpoint 写入。代价是新增 Host 权限和审计面,收益是能在关闭旧信任后恢复丢失的 Ack。 - -**备选仅保留运维对账。** 由受权人员通过 Host 私有 Store 核对原接纳记录,接口面较小,调用方需要人工介入。E4 只证明旧钥仍受信时的过期重放。E5 证明移除旧钥后原接纳入口拒绝且账本仍在。 [当前撤销与对账记录](../p0a-codex-isolated-20261113/revocation-retention.md) +撤销使旧钥停止签发和接纳。历史 Ack 走独立受权的 Host 私有只读对账,按 namespace、Run、request、node、attempt 和规范回执摘要精确匹配持久接纳记录,仅返回原接纳信息。验证跨租户、摘要错配、未接纳旧回执、重复查询和零 CAS/Journal/Checkpoint 写入。 [撤销实测基线](../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) +正常轮换让旧 Run 按原批准策略完成,新 Run 使用新策略。紧急撤销时停用仍允许旧钥的 Host,既有 Run 保持阻断并交受权人员对账。原地换策略续跑留待后续专项设计。生产验收覆盖所有启用 Host 对撤销的生效。 [Host 轮换设计](../../interactive-trusted-host-security-v2-design.md#63-错误和恢复) ### 4. Outbox 保留与容量 -**推荐由 Host 部署配置设定审计期限和容量上限。** 保留待投递的 `SIGNED`、未对账的 `OBSERVED`/`BLOCKED`/`CONFLICT`、受合规保留或有效引用的快照,以及 Store 接纳索引。终态且已对账的数据过审计窗口后才清理可回收正文和快照。达到容量上限时停止接收需要落盘的新工作并告警。具体天数与字节上限按目标部署的合规、吞吐和磁盘预算确认,随后实测容量、清理崩溃恢复和历史对账。 +Host 部署配置审计期限和容量上限。保留待投递及未对账的记录、受合规保留或有效引用的快照和 Store 接纳索引。终态且已对账的数据过审计窗口后,由受权运维触发清理。达到容量上限时停止接收需落盘的新工作并告警。部署期限、容量和真实恢复演练仍需取证。 [Outbox 设计 §6.4](../../interactive-trusted-host-security-v2-design.md#64-host-only-观察证据与签名回执-outbox关闭-dr-13) -**备选由受权人员在同一保留约束下执行清理。** 可减少首版后台清理逻辑,增加值守成本与容量告警响应时间。当前只验证记录留存及过期引用的局部行为。 [Outbox 设计 §6.4](../../interactive-trusted-host-security-v2-design.md#64-host-only-观察证据与签名回执-outbox关闭-dr-13) · [保留实测](../p0a-codex-isolated-20261113/revocation-retention.md) +| 首发回归 | 可复核的 P 级覆盖 | 仍为 BLOCKED | +|---|---|---| +| R5 Checkpoint | SQLite 原字节与异步 Store 回读一致、阶段恢复、首次接纳、管理审计和包消费者。[版本化 SDK 回归](../../../tests/interactive/versioned.test.mjs) | 同一发行候选上的真实 Codex 双平台回归与 G12 独立裁决。 | +| Ack、轮换与撤销 | 两条 SQLite 连接验证旧策略停止新建、可处理既有 Run、撤销后不能签发或接纳,并可通过受权查询返回原 revision。[Host 回归](../../../tests/interactive/trusted-host.test.mjs) | 真实部署所有 Host 的停用和多 Store 运维演练。 | +| Outbox 容量和清理 | 两条连接共享配额,满额拒绝并通知。权限、保留窗口、保留标记和审计事务覆盖。文件快照经授权确认可回收后清理,中断可重试。[Outbox 回归](../../../tests/interactive/codex-outbox.test.mjs) | 生产容量和审计窗口、实际告警、清理后的磁盘占用及真实双账户恢复。配额计入活动记录和待清理引用的逻辑字节,数据库页、WAL、文件快照和审计另计。 | diff --git a/docs/interactive.md b/docs/interactive.md index d281e4a..9470a2a 100644 --- a/docs/interactive.md +++ b/docs/interactive.md @@ -1,5 +1,25 @@ # FlowKit Interactive — P0-A M4 Coordinator / M5 Trusted Host +The asynchronous Store and the SQLite checkpoint payload use R5 +`RunCheckpointV1`. Initialize a fresh SQLite database for the first release. +The checkpoint holds the Program and policy identity, execution position, +pending task and accepted submission when validation is pending. Durable receipt +history is in the separate receipt index and Journal. + +For planned key rotation, configure the outgoing Host with `drainOnly: true` +and use the new policy for new Runs. SQLite records the drain restriction for +other Host processes using that Store. For emergency revocation, a trusted +operator can call `revokeKey` with the SHA-256 digest of the public key's DER +SPKI. Configure the independent `authorizeKeyRevocation` gate. All affected +workers and the Codex Outbox signer must share the Host Store's trust state. +Deployments using separate Stores must apply revocation to each Store before +resuming service. Affected Runs require operator reconciliation. + +`readHistoricalAck` uses the separate `authorizeReceiptAudit` gate. It +requires the namespace, Run, request, node, attempt and exact receipt digest +and returns the original acceptance revision. Keep both operations on the +trusted Host, outside model tool exposure. + > **Internal/maintainer integration only.** The Standard root API remains exactly > `run / compile / compileSource / check / evalWhen / isControlSignal`. > Interactive APIs live under `@devcodex-labs/flowkit/interactive`. diff --git a/examples/interactive/codex-file-handoff.mjs b/examples/interactive/codex-file-handoff.mjs index 610ac47..d5d2fb3 100644 --- a/examples/interactive/codex-file-handoff.mjs +++ b/examples/interactive/codex-file-handoff.mjs @@ -119,9 +119,12 @@ export function createFileHandoff({database,inbox,validateArtifact,now=Date.now} 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 releaseUnwritten=db.prepare("UPDATE file_handoff SET state='staged',call_id=NULL WHERE id=? AND state='claimed' AND call_id=?"); 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'"); + const reclaim=db.prepare(`DELETE FROM file_handoff WHERE id=? AND namespace=? AND run_id=? AND request_id=? + AND call_id=? AND accepted_revision=? AND sha256=? AND size=? AND state='acked'`); function checkedBytes(saved) { const bytes=Buffer.from(saved.bytes); @@ -141,6 +144,14 @@ export function createFileHandoff({database,inbox,validateArtifact,now=Date.now} return Object.freeze({ close:()=>db.close(), purgeExpired:()=>purge.run(now()).changes, + // Called by an authorized Outbox cleanup only after Host confirms no live reference or hold. + reclaimAcknowledged(artifact,{namespace,runId,requestId,callId,acceptedRevision}) { + if (!validId(artifact.id)) throw fail('FK_FILE_REFERENCE'); + if (reclaim.run(artifact.id,namespace,runId,requestId,callId,acceptedRevision, + artifact.sha256,artifact.byteLength).changes===1) return true; + if (read.get(artifact.id)) throw fail('FK_FILE_BINDING'); + return false; + }, readSnapshot(id) { if (!validId(id)) throw fail('FK_FILE_REFERENCE'); const saved=read.get(id); @@ -195,7 +206,13 @@ export function createFileHandoff({database,inbox,validateArtifact,now=Date.now} 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}); + let result; + try {result=await signAndAccept({binding,callId:call.callId,artifact,pending:current.pending});} + catch (error) { + // The Outbox capacity error is raised before it persists any observation or signature. + if (error.code==='FK_OUTBOX_CAPACITY') releaseUnwritten.run(call.id,call.callId); + throw error; + } acknowledge(call.id,call.callId,result?.acceptedRevision); return result; }, diff --git a/examples/interactive/codex-outbox.mjs b/examples/interactive/codex-outbox.mjs index 18b9c58..ab0a63d 100644 --- a/examples/interactive/codex-outbox.mjs +++ b/examples/interactive/codex-outbox.mjs @@ -1,8 +1,9 @@ -import {randomUUID,sign} from 'node:crypto'; +import {createPublicKey,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 {sqliteHostTrust} from '../../src/interactive/sqlite-store.mjs'; import { createAgentReceiptStatement,encodeAgentReceiptStatement,normalizeHostAgentReceipt,MAX_AGENT_RECEIPT_BYTES, } from '../../src/interactive/host-attestation.mjs'; @@ -19,8 +20,17 @@ function decode(bytes,limit) { } /** A dedicated SQLite file in an operator-protected Host directory. */ -export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { +export function openCodexOutbox({database,store,host,handoff,now=Date.now, + maxBytes=64*1024*1024,maxRecords=4096,retentionMs=30*24*60*60*1000, + authorizeCleanup,onCapacity=({records,bytes,maxRecords,maxBytes})=>{ + process.emitWarning(`Outbox capacity: ${records}/${maxRecords} records, ${bytes}/${maxBytes} bytes`, + {code:'FK_OUTBOX_CAPACITY'}); + }}) { if (!path.isAbsolute(database)) throw new TypeError('Outbox requires an absolute database path'); + if (![maxBytes,maxRecords,retentionMs].every(value=>Number.isSafeInteger(value) && value>0) + || typeof onCapacity!=='function' || (authorizeCleanup!==undefined && typeof authorizeCleanup!=='function')) { + throw new TypeError('Invalid Outbox retention configuration'); + } try {closeSync(openSync(database,'wx',0o600));} catch (error) {if (error.code!=='EEXIST') throw error;} const db=new DatabaseSync(database); @@ -32,16 +42,43 @@ export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { + " 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'))," + + ' recorded_at INTEGER NOT NULL, acked_at INTEGER, reserved_bytes INTEGER NOT NULL,' + ' UNIQUE(thread_id,turn_id,call_id)) STRICT;' + + 'CREATE TABLE IF NOT EXISTS cleanup_audit (operation_id TEXT PRIMARY KEY, namespace TEXT NOT NULL,' + + ' subject_id TEXT NOT NULL, reason_code TEXT NOT NULL, at INTEGER NOT NULL, deleted_count INTEGER NOT NULL) STRICT;' + + 'CREATE TABLE IF NOT EXISTS snapshot_cleanup (proof_id TEXT PRIMARY KEY, namespace TEXT NOT NULL,' + + ' run_id TEXT NOT NULL, request_id TEXT NOT NULL, artifact TEXT NOT NULL, call_id TEXT NOT NULL,' + + ' accepted_revision INTEGER NOT NULL) 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 insert=db.prepare("INSERT INTO delivery VALUES (?,?,?,?,?,?,?,?,?,?,NULL,NULL,NULL,'OBSERVED',?,NULL,?)"); 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 finish=db.prepare("UPDATE delivery SET state=?,accepted_revision=?,acked_at=? 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"); + const usage=db.prepare(`SELECT COUNT(*) AS records,COALESCE(SUM(bytes),0) AS bytes FROM ( + SELECT reserved_bytes AS bytes FROM delivery UNION ALL + SELECT length(artifact)+length(call_id)+length(request_id)+length(run_id)+length(namespace)+128 + FROM snapshot_cleanup)`); + const eligible=db.prepare("SELECT proof_id,run_id,request_id,receipt_hash,accepted_revision,observation" + +" FROM delivery WHERE namespace=? AND state='ACKED' AND acked_at<=? ORDER BY acked_at"); + const remove=db.prepare("DELETE FROM delivery WHERE proof_id=? AND namespace=? AND state='ACKED'"); + const audit=db.prepare('INSERT INTO cleanup_audit VALUES (?,?,?,?,?,?)'); + const queueSnapshot=db.prepare('INSERT INTO snapshot_cleanup VALUES (?,?,?,?,?,?,?)'); + const pendingSnapshots=db.prepare('SELECT * FROM snapshot_cleanup WHERE namespace=? ORDER BY rowid'); + const removeSnapshotJob=db.prepare('DELETE FROM snapshot_cleanup WHERE proof_id=? AND namespace=?'); + const trust=store[sqliteHostTrust]; + + function transaction(work) { + db.exec('BEGIN IMMEDIATE'); + try { + const result=work(); + db.exec('COMMIT'); + return result; + } catch(error) {db.exec('ROLLBACK');throw error;} + } function checkResult(tool,args,result,binding,callId) { if (tool==='flowkit_submit_file' && handoff && Object.keys(result??{}).length===1 @@ -57,6 +94,8 @@ export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { {keyId='proof',ttlMs=120000,afterObserve}={}) { const normalized=normalizeHostAgentReceipt(receipt); const cp=store.load(normalized.runId); + if (!cp || cp.policyHash!==host.policyHash) throw fail('FK_OUTBOX_OBSERVATION'); + trust?.assertActive(cp.policyHash); const {threadId,turnId,callId,tool,args}=callback; if (![threadId,turnId,callId].every(identifier) || normalized.kind!=='agent-result' || !Number.isSafeInteger(modelPid) || modelPid<2 @@ -77,14 +116,29 @@ export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { 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); + const reserved=observation.byteLength+receiptBytes.byteLength+4096+64; + try { + transaction(()=>{ + const current=usage.get(); + if (current.records>=maxRecords || reserved>maxBytes-current.bytes) throw fail('FK_OUTBOX_CAPACITY'); + insert.run(proofId,cp.namespace,normalized.runId,normalized.requestId,threadId,turnId,callId, + observation,receiptBytes,receiptHash,now(),reserved); + }); + } catch(error) { + if (error.code==='FK_OUTBOX_CAPACITY') { + try {onCapacity({namespace:store.namespace,...usage.get(),maxBytes,maxRecords});} catch {} + } + throw error; + } 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); + const signReceipt=()=>sign(null,statementBytes,key); + const signature=trust + ? trust.sign(cp.policyHash,sha256Bytes(createPublicKey(key).export({type:'spki',format:'der'})),signReceipt) + : signReceipt(); if (save.run(statementBytes,signature,proofId).changes!==1) throw fail('FK_OUTBOX_STATE'); return proofId; } @@ -118,27 +172,80 @@ export function openCodexOutbox({database,store,host,handoff,now=Date.now}) { 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); + finish.run('BLOCKED',null,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); + if (error.code==='FK_I_RECEIPT_CONFLICT') finish.run('CONFLICT',null,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); + finish.run('ACKED',ack.acceptedRevision,now(),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 function cleanup({namespace,subjectId,reasonCode}={}) { + if (!authorizeCleanup || namespace!==store.namespace || !identifier(subjectId) + || typeof reasonCode!=='string' || !/^[A-Z][A-Z0-9_.-]{0,63}$/.test(reasonCode)) throw fail('FK_OUTBOX_DENIED'); + let decision; + try { decision=await authorizeCleanup(Object.freeze({action:'outbox:cleanup',namespace,subjectId,reasonCode})); } catch {} + // The Host's retention authority supplies the protected Runs for this operation. + if (decision?.allowed!==true || !Array.isArray(decision.heldRunIds) + || !decision.heldRunIds.every(identifier) + || (decision.canDeleteSnapshot!==undefined && typeof decision.canDeleteSnapshot!=='function')) { + throw fail('FK_OUTBOX_DENIED'); + } + const held=new Set(decision.heldRunIds); + const result=transaction(()=>{ + const at=now(); + let deleted=0; + for (const row of eligible.all(namespace,at-retentionMs)) { + if (held.has(row.run_id)) continue; + const cp=store.load(row.run_id); + if (!cp || !['succeeded','failed','errored','cancelled'].includes(cp.status)) continue; + const observation=decode(row.observation,4096); + const indexed=store.getReceipt(row.run_id,row.request_id); + if (indexed?.digest!==row.receipt_hash || indexed.acceptedRevision!==row.accepted_revision + || indexed.nodeId!==observation.nodeId || indexed.attempt!==observation.attempt) continue; + if (observation.tool==='flowkit_submit_file') { + // Keep the proof available until the operator confirms the snapshot is safe to reclaim. + const evidence=get.get(row.proof_id,namespace); + const receipt=decode(evidence.receipt,MAX_AGENT_RECEIPT_BYTES); + const artifact=receipt.result?.artifact; + if (!handoff || !artifact) continue; + queueSnapshot.run(row.proof_id,namespace,row.run_id,row.request_id, + canonicalBytes(artifact,2048).toString('utf8'),evidence.call_id,row.accepted_revision); + } + deleted+=remove.run(row.proof_id,namespace).changes; + } + const operationId=randomUUID(); + if (deleted) audit.run(operationId,namespace,subjectId,reasonCode,at,deleted); + return {operationId:deleted?operationId:null,deleted}; + }); + let snapshotsReclaimed=0; + for (const row of pendingSnapshots.all(namespace)) { + if (held.has(row.run_id) || !handoff || !decision.canDeleteSnapshot) continue; + const artifact=Object.freeze(JSON.parse(row.artifact)); + const checkpoint=store.load(row.run_id); + if (decision.canDeleteSnapshot(Object.freeze({ + namespace,runId:row.run_id,requestId:row.request_id,artifact,checkpoint, + }))!==true) continue; + handoff.reclaimAcknowledged(artifact,{namespace,runId:row.run_id,requestId:row.request_id, + callId:row.call_id,acceptedRevision:row.accepted_revision}); + snapshotsReclaimed+=removeSnapshotJob.run(row.proof_id,namespace).changes; + } + return {...result,snapshotsReclaimed}; + } + + return Object.freeze({record,describe,deliver,cleanup,usage:()=>usage.get(),close:()=>db.close(), async recover(runId) { const results=[]; for (const {proof_id:proofId} of signed.all(store.namespace,runId)) { diff --git a/scripts/bench-interactive-ledger.mjs b/scripts/bench-interactive-ledger.mjs index 05191fb..36bb4b1 100644 --- a/scripts/bench-interactive-ledger.mjs +++ b/scripts/bench-interactive-ledger.mjs @@ -98,8 +98,7 @@ async function measure(program,maxStagesPerCall) { assert.ok(casCommitted>=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)); + assert.equal(checkpoint.receiptCount,receipts.length); 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)); @@ -107,7 +106,7 @@ async function measure(program,maxStagesPerCall) { 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}; + journalRows:journal.length,receiptIndexRows:receiptIndex.length,checkpointReceiptCount:checkpoint.receiptCount}; } finally {store.close();} bytes.databaseClosed=await fileBytes(database); bytes.walClosed=await fileBytes(database+'-wal'); diff --git a/scripts/package-files.mjs b/scripts/package-files.mjs index a88ab42..12d8187 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', 'versioned'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), + ...['compile', 'identity', 'load', 'canonical', 'checkpoint', '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/checkpoint.mjs b/src/interactive/checkpoint.mjs new file mode 100644 index 0000000..973871b --- /dev/null +++ b/src/interactive/checkpoint.mjs @@ -0,0 +1,146 @@ +import {MAX_CHECKPOINT_BYTES,assertSHA256,canonicalBytes,sha256Bytes} from './canonical.mjs'; + +const finished=new Set(['succeeded','failed','errored','cancelled']); +const phases=new Set(['enter','prepare','dispatch-ready','awaiting','validate','commit','route','finished']); +const hash=value=>sha256Bytes(canonicalBytes(value)); +const invalid=()=>Object.assign(new Error('FK_I_INVALID_CHECKPOINT: invalid R5 checkpoint'),{code:'FK_I_INVALID_CHECKPOINT'}); +const object=value=>value!==null && typeof value==='object' && !Array.isArray(value); +const text=value=>typeof value==='string' && value.length>0; +const count=value=>Number.isSafeInteger(value) && value>=0; +const keys=(value,required,optional=[])=>object(value) && required.every(key=>Object.hasOwn(value,key)) + && Object.keys(value).every(key=>required.includes(key)||optional.includes(key)); + +export function checkpointPolicy(policy,policyHash) { + return { + id:policy.id??'flowkit-host',version:policy.version??'1',policyHash, + bindingManifestHash:hash(policy.bindingManifest??{}), + ...(policy.skillManifestHash ? {skillManifestHash:policy.skillManifestHash} : {}), + }; +} + +function pendingFromExecution(cp) { + const task=cp.pending??cp.prepared; + if (!task) return []; + const {requestId,nodeId,kind,attempt,name,instruction,input,missing,resultSchema,reasons,overview}=task; + const publicTask={name,instruction,input,missing,resultSchema,reasons,...(overview?{overview}:{})}; + return [{requestId,nodeId,kind,attempt,name,activation:cp.controlStack.at(-1).activation, + issuedRevision:task.issuedRevision??cp.revision,intent:'execute',internalPayload:{},publicTask}]; +} + +/** The Coordinator's execution view is transient. Stores serialize this R5 envelope. */ +export function encodeCheckpoint(cp) { + const phase=finished.has(cp.status)?'finished':cp.phase; + const {namespace,runId,startKey,startIntentHash,revision,status,program,policyIdentity,state}=cp; + const receivedSubmission=phase==='validate' ? cp.receivedSubmission : null; + const candidate=cp.candidate?.submissionKind==='agent-result' && object(cp.candidate.result) + ? {kind:'agent',requestId:cp.candidate.requestId,result:cp.candidate.result} : null; + const causeCode=cp.terminal?.causeCode ?? ( + cp.pauseReason==='skill-temporary'?'FK_I_TEMPORARY_SKILL':undefined); + return { + schemaVersion:1,protocolVersion:1,namespace,runId,startKey,startIntentHash,revision,status,program, + programRef:{namespace,artifactHash:program.artifactHash,programId:program.workflowId}, + policyIdentity, + execution:{blockId:cp.blockId,phase,activationSeq:cp.activationSeq,agentStepCounter:cp.activationSeq, + controlStack:phase==='finished'?[]:cp.controlStack,contextError:cp.contextError??null}, + state,candidate,receivedSubmission,pending:pendingFromExecution(cp),inFlightOperations:[], + terminal:status==='succeeded'||status==='failed'?cp.terminal:null, + lastTransitionId:cp.lastTransitionId??null, + limits:{stageCount:cp.stageCount??0,receiptCount:cp.receiptCount??0,bytes:MAX_CHECKPOINT_BYTES}, + ...(causeCode?{causeCode}:{}), + }; +} + +export function decodeCheckpoint(cp) { + if (!keys(cp,['schemaVersion','protocolVersion','namespace','runId','startKey','startIntentHash', + 'revision','status','program','programRef','policyIdentity','execution','state','candidate', + 'receivedSubmission','pending','inFlightOperations','terminal','lastTransitionId','limits'],['causeCode']) + || cp.schemaVersion!==1 || cp.protocolVersion!==1 + || ![cp.namespace,cp.runId,cp.startKey].every(text) || !count(cp.revision) || !object(cp.state) + || !keys(cp.programRef,['namespace','artifactHash','programId']) + || !keys(cp.program,['artifactHash','sourceHash','workflowId','workflowVersion','compilerVersion','programAbiVersion', + 'semanticsVersion','checkpointSchemaVersion','canonicalizationVersion','implementedStage']) + || ![cp.program.workflowId,cp.program.workflowVersion,cp.program.compilerVersion].every(text) + || cp.program.programAbiVersion!==1 || cp.program.checkpointSchemaVersion!==1 + || cp.program.semanticsVersion!=='interactive-state-machine-v1' || cp.program.implementedStage!=='P0-A' + || cp.program.canonicalizationVersion!=='jcs-rfc8785-v1' || cp.programRef.namespace!==cp.namespace + || cp.programRef.artifactHash!==cp.program.artifactHash || cp.programRef.programId!==cp.program.workflowId + || !keys(cp.policyIdentity,['id','version','policyHash','bindingManifestHash'],['skillManifestHash']) + || ![cp.policyIdentity.id,cp.policyIdentity.version].every(text) + || !keys(cp.execution,['blockId','phase','activationSeq','agentStepCounter','controlStack','contextError']) + || !text(cp.execution.blockId) || !phases.has(cp.execution.phase) + || !count(cp.execution.activationSeq) || cp.execution.agentStepCounter!==cp.execution.activationSeq + || !Array.isArray(cp.execution.controlStack) + || !keys(cp.limits,['stageCount','receiptCount','bytes']) + || !count(cp.limits.stageCount) || !count(cp.limits.receiptCount) + || cp.limits.stageCount>cp.revision || cp.limits.receiptCount>cp.revision + || cp.limits.bytes!==MAX_CHECKPOINT_BYTES + || !Array.isArray(cp.pending) || cp.pending.length>1 + || !Array.isArray(cp.inFlightOperations) || cp.inFlightOperations.length) throw invalid(); + + const {namespace,runId,startKey,startIntentHash,revision,status,program,policyIdentity,execution,state, + receivedSubmission,lastTransitionId}=cp; + const {phase,blockId,controlStack,activationSeq,contextError}=execution; + const hasPending=status==='waiting'||phase==='dispatch-ready'; + if ((!finished.has(status) && !['waiting','paused','running'].includes(status)) + || (phase==='finished')!==finished.has(status) || (phase==='awaiting')!==(status==='waiting') + || (cp.pending.length===1)!==hasPending || (phase==='validate')!==(receivedSubmission!==null) + || (phase!=='finished' && !blockId.endsWith('::'+phase)) + || (cp.terminal!==null)!==['succeeded','failed'].includes(status) + || (cp.terminal!==null && (!keys(cp.terminal,['status','nodeId']) || cp.terminal.status!==status + || !text(cp.terminal.nodeId)))) throw invalid(); + + for (const digest of [startIntentHash,program.sourceHash,program.artifactHash,policyIdentity.policyHash, + policyIdentity.bindingManifestHash,...(policyIdentity.skillManifestHash?[policyIdentity.skillManifestHash]:[]), + ...(lastTransitionId===null?[]:[lastTransitionId])]) assertSHA256(digest); + let pending=null; + if (hasPending) { + const task=cp.pending[0]; + if (!keys(task,['requestId','nodeId','kind','activation','attempt','issuedRevision','intent','name','internalPayload','publicTask']) + || ![task.requestId,task.nodeId,task.name].every(text) || task.kind!=='agent' || task.intent!=='execute' + || !count(task.issuedRevision) || task.issuedRevision>revision + || task.activation!==controlStack.at(-1)?.activation || task.attempt!==controlStack.at(-1)?.attempt + || task.nodeId!==controlStack.at(-1)?.nodeId || !object(task.internalPayload) + || !object(task.publicTask) || task.publicTask.name!==task.name) throw invalid(); + pending={...task.publicTask,kind:'agent',id:task.nodeId,nodeId:task.nodeId,runId,requestId:task.requestId, + attempt:task.attempt,issuedRevision:task.issuedRevision}; + } + let candidate=null; + if (phase==='validate') { + const accepted=receivedSubmission; + const frame=controlStack.at(-1); + if (!keys(accepted,['requestId','kind','activation','attempt','canonicalDigest','acceptedRevision','payload','receivedAt']) + || !text(accepted.requestId) || !text(accepted.receivedAt) || accepted.kind!=='agent' + || accepted.activation!==frame?.activation || accepted.attempt!==frame?.attempt + || accepted.acceptedRevision!==revision || !object(accepted.payload)) throw invalid(); + assertSHA256(accepted.canonicalDigest); + const payload=accepted.payload; + const succeeded=payload.kind==='agent-result' || (payload.kind==='agent' && payload.outcome==='succeeded'); + const failed=payload.kind==='agent-call-failed' || (payload.kind==='agent' && payload.outcome==='failed'); + if ((!succeeded && !failed) || hash(payload)!==accepted.canonicalDigest + || payload.runId!==runId || payload.requestId!==accepted.requestId + || (payload.nodeId!==undefined && payload.nodeId!==frame.nodeId) + || (payload.attempt!==undefined && payload.attempt!==frame.attempt) + || (succeeded && !Object.hasOwn(payload,'result')) + || (failed && !text((payload.failure??payload.error)?.message)) + || (cp.candidate!==null)!==(succeeded && object(payload.result)) + || (cp.candidate && (!keys(cp.candidate,['kind','requestId','result']) || cp.candidate.kind!=='agent' + || cp.candidate.requestId!==accepted.requestId + || hash(cp.candidate.result)!==hash(payload.result)))) throw invalid(); + candidate={kind:'agent',requestId:accepted.requestId,nodeId:frame.nodeId,attempt:accepted.attempt, + submissionKind:succeeded?'agent-result':'agent-call-failed',validated:false, + ...(succeeded?{result:payload.result}:{failure:{message:(payload.failure??payload.error).message}})}; + } else if (phase==='commit') { + if (!keys(cp.candidate,['kind','requestId','result']) || cp.candidate.kind!=='agent' + || !text(cp.candidate.requestId) || !object(cp.candidate.result)) throw invalid(); + const frame=controlStack.at(-1); + candidate={...cp.candidate,nodeId:frame?.nodeId,attempt:frame?.attempt,submissionKind:'agent-result',validated:true}; + } else if (cp.candidate!==null) throw invalid(); + + return {schemaVersion:1,namespace,runId,startKey,startIntentHash,revision,status,state,program,policyIdentity, + artifactHash:program.artifactHash,policyHash:policyIdentity.policyHash,blockId, + phase:phase==='finished'?blockId.slice(blockId.lastIndexOf('::')+2):phase,controlStack,activationSeq,contextError, + candidate,receivedSubmission,lastTransitionId,stageCount:cp.limits.stageCount,receiptCount:cp.limits.receiptCount, + pending:phase==='awaiting'?pending:null,prepared:phase==='dispatch-ready'?pending:null, + terminal:status==='errored'?{status,nodeId:blockId.slice(0,blockId.lastIndexOf('::')),causeCode:cp.causeCode}:cp.terminal, + ...(status==='paused'?{pauseReason:cp.causeCode==='FK_I_TEMPORARY_SKILL'?'skill-temporary':'stage-budget'}:{})}; +} diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index 0662850..4dd96f5 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -1,12 +1,9 @@ import {randomUUID} from 'node:crypto'; import {MAX_INLINE_AGENT_RESULT_BYTES, canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs'; import {loadInteractiveProgram} from './load.mjs'; +import {checkpointPolicy} from './checkpoint.mjs'; 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; -const MAX_INLINE_RECEIPT_CACHE = 16; const MAX_REASONS = 32; const MAX_REASON_LENGTH = 512; @@ -26,9 +23,6 @@ function validStageCount(value) { } return value; } -function stableTransitionId(cp, kind) { - return 't:' + fingerprint([cp.namespace,cp.runId,cp.revision,cp.blockId,kind]).slice(7); -} function internalEvent(cp, kind, extra = {}) { const payload = { schemaVersion:1, forBlockId:cp.blockId, kind, @@ -83,9 +77,8 @@ function confirmCurrentBlock(cp, program) { if (cp.prepared != null && cp.phase !== 'dispatch-ready') { throw failure('FK_I_INVALID_CHECKPOINT','prepared request outside dispatch-ready phase'); } - if (!Array.isArray(cp.receipts) || cp.receipts.length > MAX_RECEIPTS - || !Number.isSafeInteger(cp.activationSeq) || cp.activationSeq < 0) { - throw failure('FK_I_INVALID_CHECKPOINT','invalid receipt ledger or activation counter'); + if (!Number.isSafeInteger(cp.activationSeq) || cp.activationSeq < 0) { + throw failure('FK_I_INVALID_CHECKPOINT','invalid activation counter'); } return {block,record}; } @@ -142,10 +135,11 @@ function applyStack(cp, effect) { return frames; } function asPublicFrame(cp) { + const {issuedRevision:_issuedRevision,...publicPending}=cp.pending??{}; return copy({ namespace:cp.namespace,runId:cp.runId,status:cp.status,revision:cp.revision, ...(FINISHED.has(cp.status) ? {state:cp.state} : {}), - ...(cp.status === 'waiting' ? {pending:cp.pending} : {}), + ...(cp.status === 'waiting' ? {pending:publicPending} : {}), ...(cp.status === 'paused' ? {pauseReason:cp.pauseReason ?? 'stage-budget'} : {}), ...(FINISHED.has(cp.status) ? {terminal:cp.terminal ?? null} : {}), }); @@ -189,7 +183,7 @@ function assertRuntime(program,runtime) { } } } -function initialCheckpoint(namespace, runId, program, policyHash, state) { +function initialCheckpoint(namespace, runId, program, policyIdentity, state) { let snapshot; try { snapshot=copy(state); } catch(error) { @@ -200,12 +194,13 @@ function initialCheckpoint(namespace, runId, program, policyHash, state) { } return { schemaVersion:1,namespace,runId,revision:0, - artifactHash:program.artifactHash,policyHash,status:'running', + artifactHash:program.artifactHash,program:{...program.interactiveProgram.identity,artifactHash:program.artifactHash}, + policyHash:policyIdentity.policyHash,policyIdentity,status:'running', blockId:program.programTable.entryBlockId, phase:'enter',state:snapshot, - controlStack:[],activationSeq:0,receipts:[], + controlStack:[],activationSeq:0,stageCount:0,receiptCount:0,lastTransitionId:null, pending:null,prepared:null,candidate:null,terminal:null, - contextError:null, + contextError:null,receivedSubmission:null, }; } @@ -226,7 +221,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall || typeof store.createRun !== 'function' || typeof store.compareAndSwap !== 'function' || typeof store.putArtifact !== 'function' - || typeof store.getArtifact !== 'function') { + || typeof store.getArtifact !== 'function' || typeof store.getReceipt !== 'function') { throw new TypeError('A CheckpointStore with atomic CAS and artifact retention is required'); } nonempty(store.namespace,'store.namespace'); @@ -240,6 +235,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall }); const policySnapshot = copy(policy); const policyHash = fingerprint(policySnapshot); + const policyIdentity=checkpointPolicy(policySnapshot,policyHash); const stageBudget = validStageCount(maxStagesPerCall); // Only verified immutable Program objects are cached, never Store records. // Even on a cache hit every stage re-reads the Host's Store, checks exact @@ -250,9 +246,6 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall 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'); } @@ -285,7 +278,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall } } assertRuntime(program,execution); - confirmCurrentBlock(cp,program); + if (!FINISHED.has(cp.status)) confirmCurrentBlock(cp,program); return program; } @@ -303,8 +296,6 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall } 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, @@ -316,7 +307,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall || proposal.kind==='capacity-terminal' ? null : args.nextCheckpoint.blockId, programHash:cp.artifactHash,eventDigest,proposalDigest,committedAt:new Date().toISOString(), }; - return store.compareAndSwap({...args,transitionId,journalEntry:{ + return store.compareAndSwap({...args,transitionId,nextCheckpoint:{...args.nextCheckpoint,lastTransitionId:transitionId},journalEntry:{ ...args.journalEntry,transition,...(receiptOutcome ? {receiptOutcome} : {}), }}); } @@ -326,14 +317,13 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall const next={ ...cp,revision:cp.revision+1,status:'errored', terminal:{status:'errored',nodeId,causeCode:'FK_I_CHECKPOINT_CAPACITY'}, - pending:null,prepared:null,candidate:null, + pending:null,prepared:null,candidate:null,receivedSubmission:null, pauseReason:null, }; // This terminal transition may use the Store's reserved 256 KiB; it // never drops State or accepted receipt identities. 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'}); @@ -357,7 +347,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall } function stepCAS(cp, proposal, event, legacyEventKind, extras={}) { - const next = {...cp,revision:cp.revision+1}; + const next = {...cp,revision:cp.revision+1,stageCount:cp.stageCount+1,receivedSubmission:null}; if (proposal.kind === 'raise-error') { next.status='errored'; next.terminal={status:'errored',nodeId:proposal.nodeId,causeCode:proposal.causeCode}; @@ -374,7 +364,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall } if (proposal.kind === 'publish-pending') { if (cp.prepared == null) throw failure('FK_I_INVALID_CHECKPOINT','publish without prepared request'); - next.pending=cp.prepared;next.prepared=null; + next.pending={...cp.prepared,issuedRevision:next.revision};next.prepared=null; } if (proposal.kind === 'commit-state') { if (proposal.mergeIntent.kind !== 'commit-candidate' @@ -404,9 +394,8 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall && proposal.stackEffect.kind === 'push') { next.activationSeq=cp.activationSeq+1; } - const transitionId=stableTransitionId(cp,legacyEventKind + '/' + proposal.kind); const write=casOrCapacityExit(cp,{ - runId:cp.runId,expectedRevision:cp.revision,nextCheckpoint:next,transitionId, + runId:cp.runId,expectedRevision:cp.revision,nextCheckpoint:next, journalEntry:{ kind:proposal.kind,event:legacyEventKind,from:cp.blockId, to:proposal.toBlockId ?? null,causeCode:proposal.causeCode ?? null, @@ -489,7 +478,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall if (!['stage-budget','skill-temporary'].includes(cp.pauseReason)) return asPublicFrame(cp); const unpause={...cp,status:'running',revision:cp.revision+1,pauseReason:null}; const committed=await casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, - nextCheckpoint:unpause,transitionId:stableTransitionId(cp,'resume-budget'), + nextCheckpoint:unpause, journalEntry:{kind:'resume-budget'}},{kind:'resume-budget'},{kind:'resume-budget'}); if (!committed.committed) continue; // Budget unpause is a control action, not a Program stage. @@ -508,7 +497,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall const paused={...cp,revision:cp.revision+1,status:'paused', pauseReason:'skill-temporary'}; const result=await casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, - nextCheckpoint:paused,transitionId:stableTransitionId(cp,'pause-skill'), + nextCheckpoint:paused, journalEntry:{kind:'pause',reason:'skill-temporary'}}, {kind:'skill-temporary'},{kind:'pause',reason:'skill-temporary'}); if (!result.committed) continue; @@ -555,7 +544,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall const paused={...cp,revision:cp.revision+1,status:'paused', pauseReason:'stage-budget'}; const result=await casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, - nextCheckpoint:paused,transitionId:stableTransitionId(cp,'pause-budget'), + nextCheckpoint:paused, journalEntry:{kind:'pause',reason:'stage-budget'}}, {kind:'budget-exhausted'},{kind:'pause',reason:'stage-budget'}); if (result.committed) return asPublicFrame(result.checkpoint); @@ -590,13 +579,10 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall throw failure('FK_I_PROGRAM_MISMATCH','identity and verified Program differ'); } assertRuntime(program,execution); // before any mutation, reject unavailable Stage bindings - const initial=initialCheckpoint(store.namespace,runId,program,policyHash,stateSnapshot); + const initial=initialCheckpoint(store.namespace,runId,program,policyIdentity,stateSnapshot); checkSignal(); const registered=store.putArtifact({programIdentity:declared,bytes:byteSnapshot}); - if (versioned) { - await registered; - initial.transitionIdVersion=1; - } + if (versioned) await registered; checkSignal(); const opening=store.createRun({startKey,startIntent:intentSnapshot,checkpoint:initial}); const opened=versioned ? await opening : opening; @@ -606,7 +592,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall /** 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, - submissionDigest}={}) { + submissionDigest,receivedPayload}={}) { 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)) { @@ -633,21 +619,18 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall && (oversizedInline(result) || oversizedInline(rawSubmission)); const submission=inlineTooLarge ? null : copy(rawSubmission); if (versioned) assertSHA256(submissionDigest); - const legacyDigest=inlineTooLarge ? null : fingerprint(submission); + const digest=inlineTooLarge ? null : (submissionDigest??fingerprint(submission)); for (;;) { 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 receiptLookup=indexed ? store.getReceipt(runId,requestId) : null; - const already=(versioned ? await receiptLookup : receiptLookup) - ?? cp.receipts.find(r=>r.requestId===requestId); + const receiptLookup=store.getReceipt(runId,requestId); + const already=versioned ? await receiptLookup : receiptLookup; if (already) { - if (already.digest!==digest || (cp.transitionIdVersion===undefined - && (already.nodeId!==nodeId || already.attempt!==attempt))) { + if (already.digest!==digest || (already.nodeId!==undefined && already.nodeId!==nodeId) + || (already.attempt!==undefined && already.attempt!==attempt)) { throw failure('FK_I_RECEIPT_CONFLICT','Different receipt with accepted requestId'); } const current=await drive(runId); @@ -662,8 +645,7 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall if (!pending || !frame || pending.kind!=='agent' || pending.requestId!==requestId || pending.nodeId!==nodeId || pending.attempt!==attempt || frame.nodeId!==nodeId - || frame.attempt!==attempt || block.nodeId!==nodeId - || (!indexed && cp.receipts.length >= MAX_RECEIPTS)) { + || frame.attempt!==attempt || block.nodeId!==nodeId) { throw failure('FK_I_STALE_RECEIPT','Wrong request, node, attempt, or exhausted ledger'); } const accept=block.acceptTarget; @@ -682,17 +664,17 @@ export function buildCoordinator({store, runtime, policy = {}, maxStagesPerCall const ledgerEntry={requestId,nodeId,attempt,digest,acceptedRevision:cp.revision+1}; const next={ ...cp,revision:cp.revision+1,blockId:accept,phase:'validate', - status:'running',pending:null,prepared:null, + status:'running',pending:null,prepared:null,receiptCount:cp.receiptCount+1, + receivedSubmission:{kind:'agent',requestId,activation:frame.activation,attempt, + canonicalDigest:digest,acceptedRevision:cp.revision+1,payload:receivedPayload??submission, + receivedAt:new Date().toISOString()}, candidate:{kind:'agent',requestId,nodeId,attempt, submissionKind:kind, ...(kind==='agent-result'?{result:submission.result} :{failure:submission.failure}),validated:false}, - receipts:indexed - ? [...cp.receipts.slice(-(MAX_INLINE_RECEIPT_CACHE-1)),ledgerEntry] - : [...cp.receipts,ledgerEntry], }; const persisted=await casOrCapacityExit(cp,{runId,expectedRevision:cp.revision, - nextCheckpoint:next,transitionId:stableTransitionId(cp,'receipt:'+requestId), + nextCheckpoint:next, journalEntry:{kind:'receipt-accepted',nodeId,attempt,requestId,digest}}, {kind:'receipt-accepted',requestId,nodeId,attempt,submissionDigest:digest}, {kind:'accept-receipt',toBlockId:accept,submissionDigest:digest}, diff --git a/src/interactive/sqlite-store.mjs b/src/interactive/sqlite-store.mjs index 34ebc39..e5d23a4 100644 --- a/src/interactive/sqlite-store.mjs +++ b/src/interactive/sqlite-store.mjs @@ -1,17 +1,18 @@ import {DatabaseSync} from 'node:sqlite'; import {Buffer} from 'node:buffer'; -import {closeSync,openSync,existsSync} from 'node:fs'; -import path from 'node:path'; +import {closeSync,openSync} from 'node:fs'; import { MAX_CHECKPOINT_BYTES, MAX_ACTIVE_CHECKPOINT_BYTES, MAX_JOURNAL_BYTES, assertSHA256, canonicalBytes, decodeCanonical, safeJSONSnapshot, sha256Bytes, } from './canonical.mjs'; +import {checkpointPolicy,decodeCheckpoint,encodeCheckpoint} from './checkpoint.mjs'; + const TERMINAL = new Set(['succeeded', 'failed', 'errored', 'cancelled']); const STATUSES = new Set(['running', 'waiting', 'paused', ...TERMINAL]); export const sqliteAdminTerminate=Symbol('SQLite admin terminate'); +export const sqliteHostTrust=Symbol('SQLite Host trust'); 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) { @@ -47,7 +48,6 @@ 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) @@ -61,11 +61,14 @@ function assertCheckpoint(value, namespace, runId, revision, artifactHash) { } } function immutableIdentity(previous, next) { - for (const key of ['namespace', 'runId', 'schemaVersion', 'artifactHash', 'startKey', 'startIntentHash', 'policyHash', 'transitionIdVersion']) { + for (const key of ['namespace', 'runId', 'schemaVersion', 'artifactHash', 'startKey', 'startIntentHash', 'policyHash']) { if (previous[key] !== next[key]) { throw failure('FK_I_INVALID_CHECKPOINT', 'immutable field changed: ' + key); } } + if (digest(previous.program)!==digest(next.program) || digest(previous.policyIdentity)!==digest(next.policyIdentity)) { + throw failure('FK_I_INVALID_CHECKPOINT','Program and policy identity are immutable'); + } } function journalDigest(namespace, runId, revision, transitionId, previousHash, checkpointHash, entry) { return sha256Bytes(canonicalBytes({ @@ -79,24 +82,9 @@ 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'); @@ -109,10 +97,13 @@ function openStore(file,namespace,backupPath) { if (journalMode !== 'wal' || synchronous !== 2 || foreignKeys !== 1) { throw failure('FK_I_STORE_UNSAFE', 'WAL, synchronous FULL and foreign keys are required'); } - 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([ + const version=db.prepare('PRAGMA user_version').get().user_version; + if (version!==3) { + if (version!==0 || db.prepare("SELECT 1 FROM sqlite_schema WHERE name='fk_i_runs'").get()) { + throw failure('FK_I_STORE_SCHEMA','initialize a new R5 SQLite store'); + } + db.exec([ + 'BEGIN IMMEDIATE;', '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,', @@ -141,50 +132,26 @@ function openStore(file,namespace,backupPath) { ' PRIMARY KEY (namespace, run_id, revision), UNIQUE (namespace, run_id, transition_id),', ' 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'); - } + 'CREATE TABLE IF NOT EXISTS 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;', + 'CREATE TABLE IF NOT EXISTS fk_i_host_policy_keys (', + ' namespace TEXT NOT NULL, policy_hash TEXT NOT NULL, key_hash TEXT NOT NULL,', + ' PRIMARY KEY (namespace, policy_hash, key_hash)', + ') STRICT;', + 'CREATE TABLE IF NOT EXISTS fk_i_revoked_keys (', + ' namespace TEXT NOT NULL, key_hash TEXT NOT NULL, revoked_at TEXT NOT NULL,', + ' PRIMARY KEY (namespace, key_hash)', + ') STRICT;', + 'CREATE TABLE IF NOT EXISTS fk_i_draining_policies (', + ' namespace TEXT NOT NULL, policy_hash TEXT NOT NULL,', + ' PRIMARY KEY (namespace, policy_hash)', + ') STRICT;', + 'PRAGMA user_version = 3; COMMIT;', + ].join('\n')); } } catch (error) { db.close(); @@ -193,7 +160,7 @@ function openStore(file,namespace,backupPath) { const read = db.prepare('SELECT * FROM fk_i_runs WHERE namespace = ? AND run_id = ?'); const byStartKey = db.prepare('SELECT * FROM fk_i_runs WHERE namespace = ? AND start_key = ?'); - const latest = db.prepare('SELECT revision, checkpoint_hash, entry_hash FROM fk_i_journal WHERE namespace = ? AND run_id = ? ORDER BY revision DESC LIMIT 1'); + const latest = db.prepare('SELECT revision, transition_id, checkpoint_hash, entry_hash FROM fk_i_journal WHERE namespace = ? AND run_id = ? ORDER BY revision DESC LIMIT 1'); const allEntries = db.prepare('SELECT * FROM fk_i_journal WHERE namespace = ? AND run_id = ? ORDER BY revision'); const artifact = db.prepare('SELECT * FROM fk_i_artifacts WHERE namespace = ? AND artifact_hash = ?'); const putArtifact = db.prepare('INSERT OR IGNORE INTO fk_i_artifacts(namespace, artifact_hash, source_hash, identity_json, program_bytes) VALUES (?, ?, ?, ?, ?)'); @@ -204,8 +171,23 @@ function openStore(file,namespace,backupPath) { 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; + const addAdminEvent=db.prepare(`INSERT INTO fk_i_admin_events + (namespace,run_id,transition_id,admin_subject_id,reason_code,created_at) VALUES (?,?,?,?,?,?)`); + const registerKey=db.prepare('INSERT OR IGNORE INTO fk_i_host_policy_keys VALUES (?,?,?)'); + const revokeKey=db.prepare('INSERT OR IGNORE INTO fk_i_revoked_keys VALUES (?,?,?)'); + const revokedPolicy=db.prepare(`SELECT 1 FROM fk_i_host_policy_keys AS p JOIN fk_i_revoked_keys AS r + USING (namespace,key_hash) WHERE p.namespace=? AND p.policy_hash=? LIMIT 1`); + const registeredKey=db.prepare('SELECT 1 FROM fk_i_host_policy_keys WHERE namespace=? AND policy_hash=? AND key_hash=?'); + const setDraining=db.prepare('INSERT OR IGNORE INTO fk_i_draining_policies VALUES (?,?)'); + const isDraining=db.prepare('SELECT 1 FROM fk_i_draining_policies WHERE namespace=? AND policy_hash=?'); + + function assertActive(policyHash) { + if (revokedPolicy.get(namespace,policyHash)) throw failure('FK_I_HOST_DENIED','Host policy has been revoked'); + } + function assertStartAllowed(policyHash) { + assertActive(policyHash); + if (isDraining.get(namespace,policyHash)) throw failure('FK_I_HOST_DENIED','Host policy only drains existing Runs'); + } function transaction(mode, work) { db.exec(mode === 'write' ? 'BEGIN IMMEDIATE' : 'BEGIN'); @@ -220,13 +202,14 @@ function openStore(file,namespace,backupPath) { } function decodeRow(row) { if (!row) return null; - const cp = decodeCanonical(row.checkpoint_json, row.checkpoint_hash, MAX_CHECKPOINT_BYTES); + const cp = decodeCheckpoint(decodeCanonical(row.checkpoint_json, row.checkpoint_hash, MAX_CHECKPOINT_BYTES)); assertCheckpoint(cp, namespace, row.run_id, row.revision, row.artifact_hash); if (cp.startKey !== row.start_key || cp.startIntentHash !== row.start_intent_hash) { throw failure('FK_I_STORE_CORRUPT', 'run identity differs from checkpoint'); } const tail = latest.get(namespace, row.run_id); - if (!tail || tail.revision !== row.revision || tail.checkpoint_hash !== row.checkpoint_hash) { + if (!tail || tail.revision !== row.revision || tail.checkpoint_hash !== row.checkpoint_hash + || cp.lastTransitionId!==(tail.transition_id.startsWith('sha256:')?tail.transition_id:digest(['store-cas',tail.transition_id]))) { throw failure('FK_I_STORE_CORRUPT', 'journal does not match latest checkpoint'); } return cp; @@ -264,22 +247,6 @@ function openStore(file,namespace,backupPath) { throw failure('FK_I_RECEIPT_CONFLICT', 'accepted request identity changed'); } } - function legacyIndexBackfill(runId, receipts) { - for (const receipt of receipts ?? []) { - if (!receipt || !Number.isSafeInteger(receipt.acceptedRevision)) { - throw failure('FK_I_STORE_CORRUPT', 'invalid legacy receipt index'); - } - const entry = journalAtRevision.get(namespace, runId, receipt.acceptedRevision); - if (!entry) throw failure('FK_I_STORE_CORRUPT', 'legacy receipt journal missing'); - const record = { - requestId:receipt.requestId,nodeId:receipt.nodeId,attempt:receipt.attempt, - digest:receipt.digest,acceptedRevision:receipt.acceptedRevision, - entryHash:entry.entry_hash, - }; - receiptProof(runId, record); - putReceiptIndex(runId, record, entry.entry_hash); - } - } function appendJournal(runId, revision, transitionId, checkpointHash, previousHash, entry) { identifier(transitionId, 'transitionId'); const detail = safeJSONSnapshot(entry, MAX_JOURNAL_BYTES); @@ -292,26 +259,47 @@ function openStore(file,namespace,backupPath) { return Object.freeze({ namespace, close() { db.close(); }, + [sqliteHostTrust]:Object.freeze({ + register(policyHash,keys,drainOnly) { + transaction('write',()=>{ + for (const key of keys) registerKey.run(namespace,policyHash,key); + if (drainOnly) setDraining.run(namespace,policyHash); + }); + }, + assertActive, + assertStartAllowed, + sign(policyHash,keyHash,sign) { + return transaction('write',()=>{ + assertActive(policyHash); + if (!registeredKey.get(namespace,policyHash,keyHash)) { + throw failure('FK_I_HOST_DENIED','signing key is outside the Run policy'); + } + return sign(); + }); + }, + revoke(keyHash) { + assertSHA256(keyHash); + return transaction('write',()=>revokeKey.run(namespace,keyHash,new Date().toISOString()).changes===1); + }, + }), [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}; + pending:null,prepared:null,candidate:null,receivedSubmission: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 transitionId=digest([namespace,runId,current.revision,current.blockId,eventDigest,current.artifactHash,proposalDigest]); 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); + next.lastTransitionId=transitionId; + const bytes=boundedCheckpointBytes(encodeCheckpoint(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'); @@ -401,15 +389,25 @@ function openStore(file,namespace,backupPath) { namespace, startKey, artifactHash:base.artifactHash, state:base.state, 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. - const checkpointHash = sha256Bytes(checkpointBytes); return transaction('write', () => { + assertStartAllowed(base.policyHash??digest({})); + const registered=artifact.get(namespace,base.artifactHash); + if (!registered) throw failure('FK_I_PROGRAM_MISMATCH','Program bytes must be registered first'); + const program=JSON.parse(registered.identity_json); + if (base.program && digest(base.program)!==digest(program)) throw failure('FK_I_PROGRAM_MISMATCH','Program identity mismatch'); + const initial={...base,startKey,startIntentHash,program,activationSeq:base.activationSeq??0, + policyHash:base.policyHash??digest({}),policyIdentity:base.policyIdentity??checkpointPolicy({},base.policyHash??digest({})), + receivedSubmission:null,stageCount:0,receiptCount:0}; + const eventDigest=digest(['genesis',startIntentHash]); + const proposalDigest=digest(['create',initial.blockId]); + const transitionId=digest([namespace,initial.runId,null,null,eventDigest,initial.artifactHash,proposalDigest]); + if (genesis && genesis.transitionId!==transitionId) throw failure('FK_I_INVALID_TRANSITION','invalid genesis'); + const transition=genesis??{namespace,runId:initial.runId,programHash:initial.artifactHash, + fromRevision:null,toRevision:0,fromBlockId:null,toBlockId:initial.blockId,eventDigest,proposalDigest, + transitionId,committedAt:new Date().toISOString()}; + initial.lastTransitionId=transitionId; + const checkpointBytes=boundedCheckpointBytes(encodeCheckpoint(initial)); + const checkpointHash=sha256Bytes(checkpointBytes); const existing = byStartKey.get(namespace, startKey); if (existing) { const current = decodeRow(existing); @@ -424,14 +422,10 @@ function openStore(file,namespace,backupPath) { if (checkpointBytes.byteLength > MAX_ACTIVE_CHECKPOINT_BYTES) { throw failure('FK_I_CHECKPOINT_CAPACITY', 'initial Run would leave no error reserve'); } - if (!artifact.get(namespace, initial.artifactHash)) { - throw failure('FK_I_PROGRAM_MISMATCH', 'Program bytes must be registered first'); - } insertRun.run(namespace, initial.runId, startKey, startIntentHash, 0, initial.artifactHash, checkpointBytes.toString('utf8'), checkpointHash); - appendJournal(initial.runId, 0, genesis?.transitionId ?? ('start:' + startIntentHash), - checkpointHash, null, {kind:'created',...(genesis ? {transition:genesis} : {})}); - return {created:true, checkpoint:JSON.parse(checkpointBytes.toString('utf8'))}; + appendJournal(initial.runId,0,transitionId,checkpointHash,null,{kind:'created',transition}); + return {created:true,checkpoint:decodeCheckpoint(JSON.parse(checkpointBytes.toString('utf8')))}; }); }, @@ -455,6 +449,7 @@ function openStore(file,namespace,backupPath) { const row = read.get(namespace, runId); if (!row) throw failure('FK_I_RUN_NOT_FOUND', 'run is not accessible in this namespace'); const current = decodeRow(row); + assertActive(current.policyHash); if (row.revision !== expectedRevision) { return {committed:false, checkpoint:current}; } @@ -466,7 +461,10 @@ function openStore(file,namespace,backupPath) { } assertCheckpoint(next, namespace, runId, expectedRevision + 1, row.artifact_hash); immutableIdentity(current, next); - const bytes = canonicalBytes(next); + next.lastTransitionId=transitionId.startsWith('sha256:') ? transitionId : digest(['store-cas',transitionId]); + const persisted=encodeCheckpoint(next); + const bytes=boundedCheckpointBytes(persisted); + const checkpoint=decodeCheckpoint(persisted); if (!TERMINAL.has(next.status) && bytes.byteLength > MAX_ACTIVE_CHECKPOINT_BYTES) { throw failure('FK_I_CHECKPOINT_CAPACITY', 'nonterminal Checkpoint reserve exhausted'); } @@ -479,21 +477,24 @@ function openStore(file,namespace,backupPath) { const entryHash = appendJournal(runId, next.revision, transitionId, checkpointHash, last.entry_hash, journal); if (journal?.kind === 'receipt-accepted') { - // Backfill old inline receipts *before* a future Coordinator may - // compact its cache. Every old index row is verified against Journal. - legacyIndexBackfill(runId, current.receipts); if (current.status !== 'waiting' || next.phase !== 'validate' || next.candidate?.requestId !== journal.requestId || next.candidate?.nodeId !== journal.nodeId - || next.candidate?.attempt !== journal.attempt) { + || next.candidate?.attempt !== journal.attempt + || next.receivedSubmission?.canonicalDigest!==journal.digest + || current.pending?.requestId!==journal.requestId + || current.pending.nodeId!==journal.nodeId || current.pending.attempt!==journal.attempt + || next.receiptCount!==current.receiptCount+1) { throw failure('FK_I_INVALID_RECEIPT', 'receipt transition does not match the Pending'); } putReceiptIndex(runId, { requestId:journal.requestId,nodeId:journal.nodeId,attempt:journal.attempt, digest:journal.digest,acceptedRevision:next.revision, },entryHash); + } else if (next.receiptCount!==current.receiptCount || (current.status==='waiting' && next.phase==='validate')) { + throw failure('FK_I_INVALID_RECEIPT','receipt acceptance requires the durable index entry'); } - return {committed:true, checkpoint:JSON.parse(bytes.toString('utf8'))}; + return {committed:true,checkpoint}; }); }, @@ -504,7 +505,7 @@ function openStore(file,namespace,backupPath) { return transaction('read', () => { const run = read.get(namespace, runId); if (!run) return null; - const cp = decodeRow(run); + decodeRow(run); const record = readReceipt.get(namespace, runId, requestId); const indexed = record ? { requestId:record.request_id,nodeId:record.node_id,attempt:record.attempt, @@ -518,14 +519,7 @@ function openStore(file,namespace,backupPath) { digest:indexed.digest,acceptedRevision:indexed.acceptedRevision, }; } - // Runs created before the S07 index keep their original inline history - // until it is transactionally backfilled by the next accepted receipt. - const legacy = cp.receipts?.find(x => x.requestId === requestId); - if (!legacy) return null; - const historical = journalAtRevision.get(namespace, runId, legacy.acceptedRevision); - if (!historical) throw failure('FK_I_STORE_CORRUPT', 'legacy receipt journal missing'); - receiptProof(runId, {...legacy,entryHash:historical.entry_hash}); - return safeJSONSnapshot(legacy); + return null; }); }, diff --git a/src/interactive/trusted-host.mjs b/src/interactive/trusted-host.mjs index c479fdb..201ae4c 100644 --- a/src/interactive/trusted-host.mjs +++ b/src/interactive/trusted-host.mjs @@ -3,6 +3,7 @@ import { } from 'node:crypto'; import {MAX_CHECKPOINT_BYTES, canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs'; import {createInteractiveCoordinator} from './coordinator.mjs'; +import {sqliteHostTrust} from './sqlite-store.mjs'; import { createAgentReceiptStatement,encodeAgentReceiptStatement, normalizeHostAgentReceipt, @@ -98,6 +99,7 @@ export function createTrustedInteractiveHost({ trustedPublicKeys,trustedArtifacts, allowedInputKeys=[], disclosurePolicyId,authorizeDisclosure,authorizeTerminalAccess, + authorizeReceiptAudit,authorizeKeyRevocation,drainOnly=false, maxProofAgeMs=5*60*1000,clockSkewMs=5000, now=()=>Date.now(),maxStagesPerCall=128, }={}) { @@ -111,6 +113,12 @@ export function createTrustedInteractiveHost({ if (authorizeTerminalAccess!==undefined && typeof authorizeTerminalAccess!=='function') { throw new TypeError('Terminal access authorizer must be a function'); } + for (const authorize of [authorizeReceiptAudit,authorizeKeyRevocation]) { + if (authorize!==undefined && typeof authorize!=='function') throw new TypeError('Host management authorizer required'); + } + if (typeof drainOnly!=='boolean') throw new TypeError('drainOnly must be boolean'); + const trust=store[sqliteHostTrust]; + if (authorizeKeyRevocation && !trust) throw new TypeError('Durable Host trust store required'); name(disclosurePolicyId); numericPolicy(maxProofAgeMs,'maxProofAgeMs',3600000); if (maxProofAgeMs<1000) throw new TypeError('maxProofAgeMs must be >= 1000'); @@ -143,8 +151,10 @@ export function createTrustedInteractiveHost({ const coordinator=createInteractiveCoordinator({ store,runtime,policy:effectivePolicy,maxStagesPerCall, }); + trust?.register(coordinator.policyHash,fingerprints.map(key=>key.publicKeyHash),drainOnly); function assertPinned(runId) { + trust?.assertActive(coordinator.policyHash); const cp=store.load(runId); if (!cp || cp.namespace!==store.namespace || cp.policyHash!==coordinator.policyHash @@ -156,6 +166,8 @@ export function createTrustedInteractiveHost({ /** No bytes are imported until the Host's compiler artifact allowlist passes. */ async function start(options={}) { + trust?.assertStartAllowed(coordinator.policyHash); + if (drainOnly) throw denied('this Host only drains existing Runs'); if (!options || typeof options!=='object' || !options.programIdentity || !approvalSet.has(options.programIdentity.artifactHash)) { throw denied('unapproved Program artifact'); @@ -274,7 +286,7 @@ export function createTrustedInteractiveHost({ if (decision!==true) throw denied('Host did not approve model disclosure'); // An async disclosure gate may overlap a successful receipt/commit from a // separate Host process. Never hand the model a superseded Pending. - const current=store.load(runId); + const current=assertPinned(runId); if (!current || current.status!=='waiting' || current.revision!==cp.revision || current.policyHash!==cp.policyHash || current.pending?.requestId!==pending.requestId) { @@ -283,6 +295,35 @@ export function createTrustedInteractiveHost({ return task; } + async function authorizeManagement(authorize,request) { + let allowed; + try { allowed=await authorize?.(Object.freeze(request)); } catch {} + if (allowed!==true) throw denied(); + } + + async function readHistoricalAck(input) { + let request; + try { + request=safeJSONSnapshot(input,2048); + strictFields(request,['namespace','runId','requestId','nodeId','attempt','receiptHash']); + if (request.namespace!==store.namespace || ![request.runId,request.requestId,request.nodeId].every( + value=>typeof value==='string' && value.length>0 && value.length<=256 && !/[\u0000-\u001f\u007f]/.test(value)) + || !Number.isSafeInteger(request.attempt) || request.attempt<1 || request.attempt>11) throw denied(); + assertSHA256(request.receiptHash); + } catch {throw denied();} + await authorizeManagement(authorizeReceiptAudit,{...request,action:'receipt:read-history'}); + const record=store.getReceipt(request.runId,request.requestId); + if (!record || record.digest!==request.receiptHash || record.requestId!==request.requestId + || record.nodeId!==request.nodeId || record.attempt!==request.attempt) throw denied(); + return Object.freeze({...request,acceptedRevision:record.acceptedRevision}); + } + + async function revokeKey(publicKeyHash) { + try {assertSHA256(publicKeyHash);} catch {throw denied();} + await authorizeManagement(authorizeKeyRevocation,{action:'host:revoke-key',namespace:store.namespace,publicKeyHash}); + return trust.revoke(publicKeyHash); + } + /** Validate the independent Host signature before ANY receipt CAS. */ async function accept({receipt,attestation}={}) { const normalized=normalizeHostAgentReceipt(receipt); @@ -303,9 +344,7 @@ export function createTrustedInteractiveHost({ // introduced. NEW receipts still require fresh proofs and signatures // are checked in both cases; raw Coordinator access stays Host-private. const receiptDigest=sha256Bytes(canonicalBytes(normalized)); - const previous=typeof store.getReceipt==='function' - ? store.getReceipt(normalized.runId,normalized.requestId) - : cp.receipts?.find(r=>r.requestId===normalized.requestId); + const previous=store.getReceipt(normalized.runId,normalized.requestId); const alreadyAccepted=previous?.requestId===normalized.requestId && previous.nodeId===normalized.nodeId && previous.attempt===normalized.attempt && previous.digest===receiptDigest; @@ -339,11 +378,16 @@ export function createTrustedInteractiveHost({ } // The underlying Coordinator retains all M4 identity/CAS/retry checks. // The model receives neither this closure nor a raw complete() capability. - return coordinator.complete(normalized); + try {return await coordinator.complete(normalized);} + catch (error) { + if (error.code==='FK_I_HOST_DENIED') throw invalidProof(); + throw error; + } } return Object.freeze({ start,resume,load,getPublicAgentTask,getPublicTerminalFrame,readTrustedTerminalResult,accept, + readHistoricalAck,revokeKey, policyHash:coordinator.policyHash, securityMode:'ed25519-agent-receipt-v1', }); diff --git a/src/interactive/versioned.mjs b/src/interactive/versioned.mjs index 2b4292b..b2af28b 100644 --- a/src/interactive/versioned.mjs +++ b/src/interactive/versioned.mjs @@ -1,8 +1,10 @@ import {buildCoordinator} from './coordinator.mjs'; import {compileInteractive} from './compile.mjs'; -import {createSqliteCheckpointStore,migrateSqliteInteractiveStore,sqliteAdminTerminate} from './sqlite-store.mjs'; +import {createSqliteCheckpointStore,sqliteAdminTerminate} from './sqlite-store.mjs'; import {assertSHA256,canonicalBytes,safeJSONSnapshot,sha256Bytes} from './canonical.mjs'; +import {decodeCheckpoint,encodeCheckpoint} from './checkpoint.mjs'; + const authCallbacks=new WeakMap(); const adminCallbacks=new WeakMap(); const hash=value=>sha256Bytes(canonicalBytes(value)); @@ -43,28 +45,33 @@ export function createInteractiveAdminContext({namespace,subjectId,authorize}={} return auth; } -export function createSqliteInteractivePersistence(file,{namespace,migrationBackupPath}={}) { - const legacy=migrationBackupPath===undefined ? createSqliteCheckpointStore(file,{namespace}) - : migrateSqliteInteractiveStore(file,{namespace,backupPath:migrationBackupPath}); +export function createSqliteInteractivePersistence(file,{namespace}={}) { + const backend=createSqliteCheckpointStore(file,{namespace}); const store=Object.freeze({ namespace, async createIfAbsent(startKey,initial,genesis) { - const {startKey:declaredKey,startIntentHash, ...checkpoint}=initial; + const {startKey:declaredKey,startIntentHash, ...checkpoint}=decodeCheckpoint(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 + const expectedGenesis=genesisFor({...checkpoint,startIntentHash}); + if (declaredKey!==startKey || startIntentHash!==expected || 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.fromBlockId!==null || genesis.toRevision!==0 || genesis.toBlockId!==checkpoint.blockId + || genesis.namespace!==namespace || genesis.runId!==initial.runId || genesis.programHash!==checkpoint.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}); + if (initial.lastTransitionId!==genesis.transitionId) throw fail('FK_I_INVALID_TRANSITION','invalid checkpoint head'); + const result=backend.createRun({startKey,checkpoint,genesis}); + return {...result,checkpoint:encodeCheckpoint(result.checkpoint)}; + }, + async load(runId) { + const cp=backend.load(runId); + return cp ? encodeCheckpoint(cp) : null; }, - async load(runId) {return legacy.load(runId);}, - async compareAndSwap(runId,expectedRevision,{next,transition,receipt}) { - const current=legacy.load(runId); + async compareAndSwap(runId,expectedRevision,{next:nextEnvelope,transition,receipt}) { + const next=decodeCheckpoint(nextEnvelope); + const current=backend.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 @@ -72,14 +79,13 @@ export function createSqliteInteractivePersistence(file,{namespace,migrationBack || 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'); - } + 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 || id!==next.lastTransitionId || to!==transition.toBlockId) { + throw fail('FK_I_INVALID_TRANSITION','transition digest mismatch'); } const journalEntry={kind:receipt?'receipt-accepted':'versioned-transition',transition}; if (receipt) { @@ -89,7 +95,7 @@ export function createSqliteInteractivePersistence(file,{namespace,migrationBack || (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) { + || next.receivedSubmission?.canonicalDigest!==receipt.submissionDigest) { throw fail('FK_I_INVALID_RECEIPT','receipt identity mismatch'); } Object.assign(journalEntry,{requestId:receipt.requestId,nodeId:next.candidate?.nodeId, @@ -97,42 +103,43 @@ export function createSqliteInteractivePersistence(file,{namespace,migrationBack } 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, + const result=backend.compareAndSwap({runId,expectedRevision,nextCheckpoint:next, transitionId:transition.transitionId,journalEntry}); - return result.committed ? result : {committed:false,currentRevision:result.checkpoint.revision}; + return result.committed ? {committed:true,checkpoint:encodeCheckpoint(result.checkpoint)} + : {committed:false,currentRevision:result.checkpoint.revision}; }, async loadTransition(runId,transitionId) { - const entry=legacy.readJournalRecord(runId,{transitionId}); + const entry=backend.readJournalRecord(runId,{transitionId}); return entry?.entry?.transition ?? null; }, async loadReceipt(runId,requestId) { - const receipt=legacy.getReceipt(runId,requestId); + const receipt=backend.getReceipt(runId,requestId); if (!receipt) return null; - const saved=legacy.readJournalRecord(runId,{revision:receipt.acceptedRevision}); + const saved=backend.readJournalRecord(runId,{revision:receipt.acceptedRevision}); return {namespace,runId,requestId,kind:'agent',submissionDigest:receipt.digest, - acceptedRevision:receipt.acceptedRevision,outcome:saved.entry.receiptOutcome ?? 'legacy', + acceptedRevision:receipt.acceptedRevision,outcome:saved.entry.receiptOutcome, transitionId:saved.transitionId,nodeId:receipt.nodeId,attempt:receipt.attempt}; }, }); const programRegistry=Object.freeze({ namespace, async putImmutable(bytes,manifest) { - legacy.putArtifact({bytes,programIdentity:manifest}); + backend.putArtifact({bytes,programIdentity:manifest}); return refFor(namespace,manifest); }, async resolveByHash(artifactHash) { - const value=legacy.getArtifact(artifactHash); + const value=backend.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 describe(artifactHash) {return backend.getArtifact(artifactHash)?.programIdentity ?? null;}, async retain(runId,artifactHash) { - if (legacy.load(runId)?.artifactHash!==artifactHash) { + if (backend.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) { + if (backend.load(runId)?.artifactHash===artifactHash) { throw fail('FK_I_ARTIFACT_IN_USE','durable Run still retains the Program'); } }, @@ -148,11 +155,11 @@ export function createSqliteInteractivePersistence(file,{namespace,migrationBack 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); + const cp=backend[sqliteAdminTerminate](runId,reason,auth.subjectId); return terminalFrame(cp.runId,cp.revision,cp.status,cp.terminal); }, }); - return Object.freeze({store,programRegistry,adminController,close:()=>legacy.close()}); + return Object.freeze({store,programRegistry,adminController,close:()=>backend.close()}); } function terminalFrame(runId,revision,status,terminal) { @@ -173,7 +180,7 @@ function executionStore(store,registry) { 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 cp ? decodeCheckpoint(cp) : null; } return { namespace:store.namespace, @@ -198,10 +205,11 @@ function executionStore(store,registry) { 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)); + const genesis=genesisFor(initial); + const opened=await store.createIfAbsent(startKey,encodeCheckpoint({...initial,lastTransitionId:genesis.transitionId}),genesis); if (opened?.checkpoint?.namespace!==store.namespace || opened.checkpoint.startKey!==startKey || opened.checkpoint.startIntentHash!==startIntentHash) throw uncertain(); - return opened; + return {...opened,checkpoint:decodeCheckpoint(opened.checkpoint)}; }, async getReceipt(runId,requestId) { const receipt=await store.loadReceipt(runId,requestId); @@ -225,12 +233,13 @@ function executionStore(store,registry) { outcome:journalEntry.receiptOutcome ?? (nextCheckpoint.candidate.submissionKind==='agent-result'?'succeeded':'failed'), transitionId,nodeId:journalEntry.nodeId,attempt:journalEntry.attempt, } : undefined; + const next=encodeCheckpoint(nextCheckpoint); let result; try { - result=await store.compareAndSwap(runId,expectedRevision,{next:nextCheckpoint,transition,receipt}); + result=await store.compareAndSwap(runId,expectedRevision,{next,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; + 'FK_I_TERMINAL_IMMUTABLE','FK_I_REVISION_EXHAUSTED','FK_I_RUN_NOT_FOUND','FK_I_HOST_DENIED'].includes(error?.code)) throw error; let saved,latest; try { saved=await store.loadTransition(runId,transitionId); @@ -249,7 +258,7 @@ function executionStore(store,registry) { } 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; + && hash(result.checkpoint)===hash(next)) return {committed:true,checkpoint:decodeCheckpoint(result.checkpoint)}; if (result?.committed===false && Number.isSafeInteger(result.currentRevision) && result.currentRevision>expectedRevision) { const latest=await read(runId); @@ -282,14 +291,14 @@ async function readTerminalResult(resultRef,options) { 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)) { + || cp.policyIdentity.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} : {}), + ...(typeof cp.causeCode==='string' ? {causeCode:cp.terminal.causeCode} : {}), }); } @@ -343,7 +352,7 @@ async function publicFrame(frame,options,callbacks) { 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(); + || checkpoint.status!=='waiting' || checkpoint.pending[0]?.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; @@ -354,8 +363,8 @@ async function publicFrame(frame,options,callbacks) { 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, + || current.status!=='waiting' || current.pending[0]?.requestId!==requestId) throw uncertain(); + output.pending.push({requestId,nodeId,attempt,activation:checkpoint.pending[0].activation, kind:'agent',intent:'execute',task}); } else if (['succeeded','failed','errored','cancelled'].includes(status)) { Object.assign(output,terminalFrame(runId,revision,status,frame.terminal)); @@ -413,20 +422,14 @@ export async function complete(submission,runtime,options) { } 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; + let task=historical ?? cp.pending[0]; if (historical && (typeof historical.nodeId!=='string'||!Number.isSafeInteger(historical.attempt))) { - const indexed=cp.receipts?.find(receipt=>receipt.requestId===input.requestId); + const transition=await options.store.loadTransition(input.runId,historical.transitionId); 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 (transition?.namespace!==cp.namespace || transition.runId!==cp.runId + || transition.transitionId!==historical.transitionId || transition.toRevision!==historical.acceptedRevision + || !transition.toBlockId?.endsWith(suffix)) throw uncertain(); + 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))) { @@ -434,7 +437,7 @@ export async function complete(submission,runtime,options) { } const {runId,requestId,outcome}=input; const result=await coordinator.complete({runId,requestId,nodeId:task.nodeId,attempt:task.attempt, - submissionDigest:hash(input), + submissionDigest:hash(input),receivedPayload:input, ...(outcome==='succeeded' ? {result:input.result} : {kind:'agent-call-failed',failure:{message:input.error.message}})}); const frame=await publicFrame(result,options,callbacks); diff --git a/tests/integration/m6-e2e.test.mjs b/tests/integration/m6-e2e.test.mjs index 6c1de14..619c409 100644 --- a/tests/integration/m6-e2e.test.mjs +++ b/tests/integration/m6-e2e.test.mjs @@ -1,3 +1,4 @@ +import {encodeCheckpoint} from '../../src/interactive/checkpoint.mjs'; import test,{after} from 'node:test'; import assert from 'node:assert/strict'; import { @@ -134,7 +135,7 @@ function assertContinuousJournal(snapshot) { assert.deepEqual(snapshot.journal.map(item=>item.revision), Array.from({length:snapshot.cp.revision+1},(_,i)=>i)); assert.equal(snapshot.journal.at(-1).checkpointHash, - sha256Bytes(canonicalBytes(snapshot.cp))); + sha256Bytes(canonicalBytes(encodeCheckpoint(snapshot.cp)))); for(let i=1;i Node Host -> separately spawned worker -> au assert.equal(persisted.cp.status,'succeeded'); assert.equal(persisted.cp.state.secret,privateSentinel); assert.equal(persisted.cp.state.answer,'external-worker-processed:hello'); - assert.equal(persisted.cp.receipts.length,1); - assert.equal(persisted.cp.receipts[0].acceptedRevision, + assert.equal(persisted.cp.receiptCount,1); + assert.equal(persisted.journal.find(row=>row.entry.kind==='receipt-accepted').revision, host.events.find(x=>x.event==='receipt-accepted').acceptedRevision); assert.equal(persisted.cp.artifactHash,f.compiled.programIdentity.artifactHash); assert.ok(persisted.artifact.bytes.equals(Buffer.from(f.compiled.code))); @@ -220,7 +221,7 @@ test('M6: after Pending publish, killing Host and reopening only persisted Progr assert.ok(recovered.events.find(x=>x.event==='receipt-accepted').acceptedRevision>revision); const done=f.readStore(); assert.equal(done.cp.status,'succeeded'); - assert.equal(done.cp.receipts.length,1); + assert.equal(done.cp.receiptCount,1); assertContinuousJournal(done); console.log('M6_RECOVERY_METRICS '+JSON.stringify({ schemaVersion:1,resumeMs:Math.round(resumeMs), @@ -238,12 +239,12 @@ test('M6: worker finished but Host crashed before durable receipt; retry preserv assert.ok(killed.events.some(e=>e.event==='worker-exited')); const before=f.readStore(); assert.equal(before.cp.status,'waiting'); - assert.equal(before.cp.receipts.length,0); + assert.equal(before.cp.receiptCount,0); const sameRequest=before.cp.pending.requestId; const resumed=await invoke(f,'resume'); assert.equal(resumed.code,0,resumed.stderr); assert.equal(resumed.events.find(x=>x.event==='task-published').requestId,sameRequest); - assert.equal(f.readStore().cp.receipts.length,1); + assert.equal(f.readStore().cp.receiptCount,1); assert.equal(f.readStore().cp.state.answer,'external-worker-processed:hello'); }); @@ -296,7 +297,7 @@ test('M6: false Condition chooses failure Terminal without launching worker and assert.equal(host.code,0,host.stderr); assert.equal(host.events.at(-1).status,'failed'); assert.equal(host.events.some(x=>x.event==='task-published'),false); - assert.equal(f.readStore().cp.receipts.length,0); + assert.equal(f.readStore().cp.receiptCount,0); const standard=await f.standardResult(); assert.equal(standard.terminal,'declined'); assert.equal(sha256Bytes(canonicalBytes(standard.state)), diff --git a/tests/interactive/codex-outbox.test.mjs b/tests/interactive/codex-outbox.test.mjs index 6a15530..10618e4 100644 --- a/tests/interactive/codex-outbox.test.mjs +++ b/tests/interactive/codex-outbox.test.mjs @@ -23,7 +23,7 @@ test('Codex Outbox replays exact signed bytes, reconciles historical Ack and blo 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}}) { + const host={policyHash:checkpoint.policyHash,accept({receipt:supplied,attestation:{statement,signature}}) { assert.deepEqual(supplied,receipt); assert.ok(verify(null,encodeAgentReceiptStatement(statement),keys.publicKey,Buffer.from(signature,'base64url'))); accepts++; @@ -82,19 +82,28 @@ test('file Outbox recovery verifies the claimed bytes and replays an already-rec const sourceId=randomUUID(); const file=path.join(inbox,sourceId+'.json'); writeFileSync(file,bytes); - let ack=null,accepts=0; + let ack=null,accepts=0,now=1000,allowSnapshot=false,interrupt=true; const store={namespace:'host',load:()=>cp,getReceipt:()=>ack}; - const host={accept({attestation:{statement}}) { + const host={policyHash:cp.policyHash,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}); + const protectedHandoff={...handoff,reclaimAcknowledged(...args) { + const removed=handoff.reclaimAcknowledged(...args); + if (interrupt) {interrupt=false;throw new Error('interrupted after snapshot removal');} + return removed; + }}; + const outbox=openCodexOutbox({database,store,host,handoff:protectedHandoff,now:()=>now,retentionMs:100, + authorizeCleanup:()=>({allowed:true,heldRunIds:[],canDeleteSnapshot:()=>allowSnapshot})}); 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}; + await assert.rejects(handoff.consume({started:call,callback:call,loadPending:store.load, + signAndAccept(){throw Object.assign(new Error('full'),{code:'FK_OUTBOX_CAPACITY'});}}),{code:'FK_OUTBOX_CAPACITY'}); + assert.equal(handoff.readSnapshot(reference.id).state,'staged'); let proofId; await assert.rejects(handoff.consume({started:call,callback:call,loadPending:store.load, async signAndAccept({artifact}) { @@ -119,4 +128,73 @@ test('file Outbox recovery verifies the claimed bytes and replays an already-rec assert.deepEqual(handoff.readSnapshot(reference.id),{bytes,sha256:sha256Bytes(bytes),byteLength:bytes.length, state:'acked',acceptedRevision:4}); assert.deepEqual(await outbox.recover('run'),[]); + cp.status='succeeded'; + now+=101; + const cleanup={namespace:'host',subjectId:'ops',reasonCode:'RETENTION'}; + assert.equal((await outbox.cleanup(cleanup)).deleted,1); + assert.equal(handoff.readSnapshot(reference.id).state,'acked'); + allowSnapshot=true; + await assert.rejects(outbox.cleanup(cleanup),/interrupted after snapshot removal/); + assert.equal(handoff.readSnapshot(reference.id),null); + assert.equal((await outbox.cleanup(cleanup)).snapshotsReclaimed,1); + assert.equal((await outbox.cleanup(cleanup)).snapshotsReclaimed,0); + assert.equal(ack.acceptedRevision,4); +}); + +test('Outbox reserves capacity across connections and audits authorized retention cleanup atomically',async t=>{ + const directory=mkdtempSync(path.join(tmpdir(),'flowkit-outbox-retention-')); + const database=path.join(directory,'outbox.sqlite'); + const keys=generateKeyPairSync('ed25519'); + const pending={runId:'run',requestId:'pending',nodeId:'agent',attempt:1}; + const cp={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 now=1000,ack,approved=false,holds=[],alerts=0; + const store={namespace:'host',load:()=>cp,getReceipt:()=>ack}; + const host={policyHash:cp.policyHash,accept({attestation:{statement}}) { + ack={digest:statement.receiptHash,nodeId:pending.nodeId,attempt:1,acceptedRevision:4}; + return {acceptedRevision:4}; + }}; + const options={database,store,host,now:()=>now,maxRecords:2,retentionMs:100, + authorizeCleanup:request=>({allowed:approved && request.subjectId==='ops',heldRunIds:holds}), + onCapacity:()=>{alerts++;}}; + const a=openCodexOutbox(options); + const b=openCodexOutbox(options); + const db=new DatabaseSync(database); + t.after(()=>{db.close();a.close();b.close();rmSync(directory,{recursive:true,force:true});}); + const observed=callId=>{ + const event={...call,callId}; + return {task:pending,started:event,callback:event,modelPid:100}; + }; + let unsigned; + await assert.rejects(a.record(observed('unsigned'),receipt,keys.privateKey,{afterObserve(row){ + unsigned=row.proofId; + throw new Error('interrupted'); + }}),/interrupted/); + const accepted=await b.record(observed('accepted'),receipt,keys.privateKey); + await assert.rejects(a.record(observed('capacity'),receipt,keys.privateKey),{code:'FK_OUTBOX_CAPACITY'}); + assert.equal(alerts,1); + const request={namespace:'host',subjectId:'ops',reasonCode:'RETENTION'}; + await assert.rejects(a.cleanup(request),{code:'FK_OUTBOX_DENIED'}); + await b.deliver(accepted); + approved=true; + assert.equal((await a.cleanup(request)).deleted,0); + now+=101; + assert.equal((await a.cleanup(request)).deleted,0); // The Run is still active. + cp.status='succeeded'; + holds=['run']; + assert.equal((await a.cleanup(request)).deleted,0); + holds=[]; + db.exec("CREATE TRIGGER fail_cleanup BEFORE INSERT ON cleanup_audit BEGIN SELECT RAISE(ABORT,'audit failure'); END"); + await assert.rejects(a.cleanup(request),/audit failure/); + assert.equal(b.describe(accepted).state,'ACKED'); + db.exec('DROP TRIGGER fail_cleanup'); + const {operationId,deleted}=await a.cleanup(request); + assert.equal(deleted,1); + assert.equal(db.prepare('SELECT deleted_count FROM cleanup_audit WHERE operation_id=?').get(operationId).deleted_count,1); + assert.equal(b.usage().records,1); + assert.equal(b.describe(unsigned).state,'OBSERVED'); + assert.equal(ack.acceptedRevision,4); }); diff --git a/tests/interactive/complex-c01-c12.test.mjs b/tests/interactive/complex-c01-c12.test.mjs index ae055dd..3933db0 100644 --- a/tests/interactive/complex-c01-c12.test.mjs +++ b/tests/interactive/complex-c01-c12.test.mjs @@ -183,7 +183,7 @@ function evidence(id,f,frame,extra={}){ scenarioId:id,execution:'SCRIPTED_TRUSTED_HOST',modelCalls:0, programHash:f.compiled.programIdentity.artifactHash,runId:frame.runId, terminalStatus:cp.status,revision:cp.revision,journalRevisions:journal.length, - receiptCacheSize:cp.receipts.length,...extra,result:'PASS', + receiptCount:cp.receiptCount,...extra,result:'PASS', })); } @@ -210,14 +210,14 @@ test('C02 false/default/unknown Condition routes do not dispatch unreachable Age }}); assert.equal(early.status,'failed'); assert.equal(early.terminal.nodeId,'declined'); - assert.equal(f.store.load('C02-early').receipts.length,0); + assert.equal(f.store.load('C02-early').receiptCount,0); audit(f,'C02-early'); const missing=await start(f,{runId:'C02-missing',state:{ request:'software task', }}); assert.equal(missing.status,'errored'); assert.equal(missing.terminal.causeCode,'FK_R002'); - assert.equal(f.store.load('C02-missing').receipts.length,0); + assert.equal(f.store.load('C02-missing').receiptCount,0); audit(f,'C02-missing'); const pending=await start(f,{runId:'C02-late'}); const {frame,trace}=await fourSteps(f,pending,{ @@ -280,7 +280,7 @@ test('C04 repeated invalid review schema consumes exactly retries+1 and FK_R004' evidence('C04',f,frame,{attempts,causeCode:'FK_R004'}); }); -test('C10 36 Agent chain keeps 16 recent receipt cache but all 36 Ack identities',async t=>{ +test('C10 36 Agent chain keeps all 36 Ack identities in the receipt index',async t=>{ const count=36; const f=await fixture(t,{ source:longSource(count), @@ -301,7 +301,7 @@ test('C10 36 Agent chain keeps 16 recent receipt cache but all 36 Ack identities } assert.equal(frame.status,'succeeded'); const {cp}=audit(f,frame.runId); - assert.equal(cp.receipts.length,16); + assert.equal(cp.receiptCount,count); assert.equal(Object.keys(cp.state).length,count+2); for(const entry of accepted){ assert.equal(f.store.getReceipt(frame.runId,entry.p.requestId).acceptedRevision, @@ -314,7 +314,7 @@ test('C10 36 Agent chain keeps 16 recent receipt cache but all 36 Ack identities assert.equal(old.acceptedRevision,accepted[0].acceptedRevision); assert.equal(old.revision,frame.revision); evidence('C10',f,frame,{agents:count,history:accepted.length, - recentCache:cp.receipts.length,historicalAck:true}); + acceptedReceipts:cp.receiptCount,historicalAck:true}); }); test('C11 stage budget 1 and 128 produce same final State bytes and node order',async t=>{ @@ -358,13 +358,13 @@ test('C13 cross-budget branch product: true/false/missing and late rejection',as state:{ready:false,request:'software task'}}); assert.equal(declined.status,'failed'); assert.equal(declined.terminal.nodeId,'declined'); - assert.equal(f.store.load(declined.runId).receipts.length,0); + assert.equal(f.store.load(declined.runId).receiptCount,0); const missing=await start(f,{runId:'C13-missing-'+budget, state:{request:'software task'}}); assert.equal(missing.status,'errored'); assert.equal(missing.terminal.causeCode,'FK_R002'); - assert.equal(f.store.load(missing.runId).receipts.length,0); + assert.equal(f.store.load(missing.runId).receiptCount,0); const falseFirst=await start(f,{runId:'C13-late-false-'+budget}); const rejected=await fourSteps(f,falseFirst,{ @@ -579,7 +579,7 @@ test('C18 two interleaved Runs isolate branching, start identity and Journal',as ready:false,request:'software task'}}); assert.equal(a.status,'waiting'); assert.equal(b.status,'failed'); - assert.equal(f.store.load(b.runId).receipts.length,0); + assert.equal(f.store.load(b.runId).receiptCount,0); const replay=await f.host.start({...f.compiled,runId:'C18-shadow', startKey:'C18-run-A',state:{ready:true,request:'software task'}}); assert.equal(replay.runId,a.runId); @@ -590,7 +590,7 @@ test('C18 two interleaved Runs isolate branching, start identity and Journal',as a=(await fourSteps(f,a)).frame; assert.equal(a.status,'succeeded'); assert.equal(f.store.load(b.runId).status,'failed'); - assert.equal(f.store.load(b.runId).receipts.length,0); + assert.equal(f.store.load(b.runId).receiptCount,0); audit(f,a.runId);audit(f,b.runId); evidence('C18',f,a,{parallelRuns:2, duplicateStartIdempotent:true,conflictingStartRejected:true}); diff --git a/tests/interactive/coordinator.test.mjs b/tests/interactive/coordinator.test.mjs index 86a1292..f1c81cd 100644 --- a/tests/interactive/coordinator.test.mjs +++ b/tests/interactive/coordinator.test.mjs @@ -190,7 +190,7 @@ test('M4 retries invalid Agent outputs at most retries+1, preserving State and F assert.equal(new Set(ids).size,3); const final=store.load(current.runId); assert.equal(final.status,'errored'); - assert.equal(final.receipts.length,3); + assert.equal(final.receiptCount,3); assert.equal((await api.resume({runId:current.runId})).revision,final.revision); }); @@ -306,7 +306,7 @@ test('M4 concurrent complete from two independent Store connections accepts one ]); assert.equal(a.acceptedRevision,b.acceptedRevision); assert.ok(a.duplicate!==b.duplicate); - assert.equal(store.load(waiting.runId).receipts.length,1); + assert.equal(store.load(waiting.runId).receiptCount,1); assert.equal(store.load(waiting.runId).status,'succeeded'); }); @@ -393,14 +393,14 @@ test('M4 a successful receipt CAS followed by a lost response is recovered by du await assert.rejects(original.complete(submitted),/lost CAS response/); const accepted=store.load(waiting.runId); assert.equal(accepted.phase,'validate'); - assert.equal(accepted.receipts.length,1); - assert.equal(accepted.receipts[0].requestId,waiting.pending.requestId); + assert.equal(accepted.receiptCount,1); + assert.equal(accepted.receivedSubmission.requestId,waiting.pending.requestId); const recovered=await coordinator(store).complete(submitted); check(recovered,'succeeded'); assert.equal(recovered.duplicate,true); assert.equal(recovered.acceptedRevision,accepted.revision); assert.equal(recovered.state.answer,'once'); - assert.equal(store.load(waiting.runId).receipts.length,1); + assert.equal(store.load(waiting.runId).receiptCount,1); }); diff --git a/tests/interactive/crash.test.mjs b/tests/interactive/crash.test.mjs index ba3d977..49d8531 100644 --- a/tests/interactive/crash.test.mjs +++ b/tests/interactive/crash.test.mjs @@ -108,7 +108,7 @@ test('M4 real process SIGKILL before/after Agent enter, prepare and durable publ }); assert.equal(done.status,'succeeded'); assert.equal(done.state.answer,'complete'); - assert.equal(db.load(runId).receipts.length,1); + assert.equal(db.load(runId).receiptCount,1); assert.deepEqual(db.listJournal(runId).map(x=>x.revision), Array.from({length:done.revision+1},(_,i)=>i)); } @@ -139,9 +139,9 @@ test('M4 real process SIGKILL before/after receipt acceptance, validation and st assert.deepEqual(finished.state,{preexisting:'constant',answer:'once'}); const duplicate=await api.complete(receipt); assert.equal(duplicate.duplicate,true); - assert.equal(duplicate.acceptedRevision,db.load(runId).receipts[0].acceptedRevision); + assert.equal(duplicate.acceptedRevision,db.getReceipt(runId,waiting.pending.requestId).acceptedRevision); assert.equal(duplicate.revision,finished.revision); - assert.equal(db.load(runId).receipts.length,1); + assert.equal(db.load(runId).receiptCount,1); assert.deepEqual(db.listJournal(runId).map(x=>x.revision), Array.from({length:finished.revision+1},(_,i)=>i)); } @@ -191,7 +191,7 @@ test('M4 two independent Node processes racing the same complete accept one rece assert.deepEqual([first.duplicate,second.duplicate].sort(),[false,true]); assert.equal(first.status,'succeeded'); assert.equal(second.status,'succeeded'); - assert.equal(db.load(waiting.runId).receipts.length,1); + assert.equal(db.load(waiting.runId).receiptCount,1); assert.deepEqual(db.load(waiting.runId).state, {preexisting:'constant',answer:'once'}); }); diff --git a/tests/interactive/s07-capacity.test.mjs b/tests/interactive/s07-capacity.test.mjs index 73a0494..da490cc 100644 --- a/tests/interactive/s07-capacity.test.mjs +++ b/tests/interactive/s07-capacity.test.mjs @@ -1,3 +1,4 @@ +import {encodeCheckpoint} from '../../src/interactive/checkpoint.mjs'; import test,{after} from 'node:test'; import assert from 'node:assert/strict'; import {mkdtemp,rm} from 'node:fs/promises'; @@ -80,7 +81,7 @@ function fillWaitingTo(f,original,limit,headroom=75){ ...current,revision:current.revision+1, state:{...current.state,filler:''}, }; - const base=canonicalBytes(next).byteLength; + const base=canonicalBytes(encodeCheckpoint(next)).byteLength; assert.ok(limit-base-headroom>0); next.state.filler='x'.repeat(limit-base-headroom); return next; @@ -116,7 +117,7 @@ test('S07 valid 64 KiB receipt at safe active headroom deterministically errors }).committed,true); const before=f.store.load(w.runId); assert.equal(before.status,'waiting'); - assert.ok(MAX_ACTIVE_CHECKPOINT_BYTES-canonicalBytes(before).byteLength<=75); + assert.ok(MAX_ACTIVE_CHECKPOINT_BYTES-canonicalBytes(encodeCheckpoint(before)).byteLength<=75); await assert.rejects(f.api.complete(receipt(w,{answer:'v'.repeat(64*1024)})), {code:'FK_I_CHECKPOINT_CAPACITY'}); const ended=f.store.load(w.runId); @@ -144,7 +145,7 @@ test('S07 oversized Host-owned inline Agent output is a durable terminal capacit assert.equal(snapshot.status,'errored'); assert.equal(snapshot.terminal.causeCode,'FK_I_CHECKPOINT_CAPACITY'); assert.deepEqual(snapshot.state,{valuable:'kept'}); - assert.equal(snapshot.receipts.length,0); + assert.equal(snapshot.receiptCount,0); assert.equal(f.store.getReceipt(runId,w.pending.requestId),null); checkJournal(f.store,runId); } @@ -162,7 +163,7 @@ test('S07 initial State exceeding soft OR hard budget deterministically fails be } }); -test('S07 21 Agent receipts retain a constant-size Checkpoint cache but all old Ack identities in SQLite index',async t=>{ +test('S07 21 Agent receipts keep all Ack identities in the SQLite index',async t=>{ const f=await fixture(t,21); let current=await waiting(f); let first,originalReceipt; @@ -177,8 +178,7 @@ test('S07 21 Agent receipts retain a constant-size Checkpoint cache but all old } assert.equal(current.status,'succeeded'); const final=f.store.load(current.runId); - assert.equal(final.receipts.length,16); - assert.ok(!final.receipts.some(x=>x.requestId===originalReceipt.requestId)); + assert.equal(final.receiptCount,21); assert.equal(f.store.getReceipt(current.runId,originalReceipt.requestId).acceptedRevision,first); const reopened=createSqliteCheckpointStore(f.file,{namespace:'s07'}); t.after(()=>reopened.close()); @@ -190,7 +190,7 @@ test('S07 21 Agent receipts retain a constant-size Checkpoint cache but all old checkJournal(f.store,current.runId); }); -test('S07 M5 signed duplicate recovery checks index even after the old receipt leaves Checkpoint cache',async t=>{ +test('S07 M5 signed duplicate recovery checks index even without an inline receipt cache',async t=>{ const f=await fixture(t,21); const keys=generateKeyPairSync('ed25519'); let clock=1794400000000; @@ -225,7 +225,7 @@ test('S07 M5 signed duplicate recovery checks index even after the old receipt l frame=completed; } assert.equal(frame.status,'succeeded'); - assert.equal(f.store.load(frame.runId).receipts.length,16); + assert.equal(f.store.load(frame.runId).receiptCount,21); clock+=3600000; const old=await host.accept({receipt:originalReceipt,attestation:originalSigned}); assert.equal(old.duplicate,true); @@ -233,27 +233,28 @@ test('S07 M5 signed duplicate recovery checks index even after the old receipt l checkJournal(f.store,frame.runId); }); -test('S07 pre-upgrade inline receipt history is transactionally indexed before the next receipt is committed',async t=>{ +test('S07 the durable receipt index is authoritative',async t=>{ const f=await fixture(t,2); const first=await waiting(f); const receiptA=receipt(first,{answer:'a'}); const afterA=await f.api.complete(receiptA); assert.equal(afterA.status,'waiting'); - const original=f.store.load(first.runId).receipts[0]; + const original=f.store.getReceipt(first.runId,receiptA.requestId); const db=new DatabaseSync(f.file); try { const count=db.prepare('DELETE FROM fk_i_receipt_index WHERE namespace=? AND run_id=? AND request_id=?') .run('s07',first.runId,receiptA.requestId).changes; assert.equal(count,1); } finally {db.close()} - assert.deepEqual(f.store.getReceipt(first.runId,receiptA.requestId),original); + assert.equal(f.store.getReceipt(first.runId,receiptA.requestId),null); const afterB=await f.api.complete(receipt(afterA,{answer:'b'})); assert.equal(afterB.status,'succeeded'); - assert.equal(f.store.getReceipt(first.runId,receiptA.requestId).acceptedRevision,original.acceptedRevision); + assert.equal(f.store.getReceipt(first.runId,receiptA.requestId),null); + assert.equal(original.requestId,receiptA.requestId); const db2=new DatabaseSync(f.file); try { assert.equal(db2.prepare('SELECT COUNT(*) AS n FROM fk_i_receipt_index WHERE namespace=? AND run_id=?') - .get('s07',first.runId).n,2); + .get('s07',first.runId).n,1); }finally{db2.close()} }); @@ -271,16 +272,17 @@ test('S07 corrupting receipt index identity cannot turn a different body into ac }); -test('S07 pre-upgrade already-durable near-2MiB waiting Run has a terminal recovery exit without discarding State',async t=>{ +test('S07 an already-durable near-2MiB waiting Run has a terminal recovery exit without discarding State',async t=>{ const f=await fixture(t); const w=await waiting(f,{runId:'legacy-near-limit',startKey:'legacy-near-limit'}); const old=f.store.load(w.runId); const next=fillWaitingTo(f,w,MAX_CHECKPOINT_BYTES); - const bytes=canonicalBytes(next); + const transitionId='sha256:'+'b'.repeat(64); + next.lastTransitionId=transitionId; + const bytes=canonicalBytes(encodeCheckpoint(next)); assert.ok(bytes.byteLength > MAX_ACTIVE_CHECKPOINT_BYTES); const checkpointHash=sha256Bytes(bytes); - const transitionId='pre-upgrade-soft-limit-checkpoint'; - const entry={kind:'legacy-large-checkpoint-test'}; + const entry={kind:'large-checkpoint-test'}; const db=new DatabaseSync(f.file); try { db.exec('BEGIN IMMEDIATE'); @@ -335,7 +337,7 @@ test('S07 capacity guard at publish and after an accepted receipt uses determini assert.equal(ended.status,'errored'); assert.equal(ended.terminal.causeCode,'FK_I_CHECKPOINT_CAPACITY'); assert.deepEqual(f.store.load(ended.runId).state,{tiny:'unchanged'}); - assert.equal(f.store.load(ended.runId).receipts.length,0); + assert.equal(f.store.load(ended.runId).receiptCount,0); assert.equal(f.store.listJournal(ended.runId).filter(x=>x.entry.kind==='publish-pending').length,0); const w=await waiting(f,{runId:'commit-cap',startKey:'commit-cap'}); diff --git a/tests/interactive/store.test.mjs b/tests/interactive/store.test.mjs index c9d38af..46db2eb 100644 --- a/tests/interactive/store.test.mjs +++ b/tests/interactive/store.test.mjs @@ -1,3 +1,4 @@ +import {encodeCheckpoint} from '../../src/interactive/checkpoint.mjs'; import test, {after} from 'node:test'; import assert from 'node:assert/strict'; import {mkdtemp, rm} from 'node:fs/promises'; @@ -13,7 +14,8 @@ const artifactBytes = Buffer.from('// verified compiler bytes fixture\nexport co const identity = { artifactHash:hash(artifactBytes), sourceHash:'sha256:' + 'a'.repeat(64), programAbiVersion:1, semanticsVersion:'interactive-state-machine-v1', - implementedStage:'P0-A', + implementedStage:'P0-A',workflowId:'store-fixture',workflowVersion:'1.0.0',compilerVersion:'1.0.0', + checkpointSchemaVersion:1,canonicalizationVersion:'jcs-rfc8785-v1', }; const checkpoint = (namespace, runId, state = {}) => ({ schemaVersion:1, namespace, runId, revision:0, status:'running', @@ -23,6 +25,7 @@ const checkpoint = (namespace, runId, state = {}) => ({ const next = (cp, status = 'running') => ({ ...cp, revision:cp.revision + 1, status, phase:'route', blockId:'gate::route', + ...(status==='succeeded'?{terminal:{status,nodeId:'gate'}}:{}), }); // Module teardown runs after every test-specific DatabaseSync.close() hook. @@ -160,7 +163,7 @@ test('M3 CAS atomically advances revision and hash-chained Journal; stale/duplic assert.deepEqual(history.map(v=>v.revision),[0,1,2]); assert.equal(history[1].previousHash,history[0].entryHash); assert.equal(history[2].previousHash,history[1].entryHash); - assert.equal(history[2].checkpointHash,sha256Bytes(canonicalBytes(terminal.checkpoint))); + assert.equal(history[2].checkpointHash,sha256Bytes(canonicalBytes(encodeCheckpoint(terminal.checkpoint)))); assert.equal(store.load('r').revision,2); }); diff --git a/tests/interactive/trusted-host.test.mjs b/tests/interactive/trusted-host.test.mjs index e686702..2401cc2 100644 --- a/tests/interactive/trusted-host.test.mjs +++ b/tests/interactive/trusted-host.test.mjs @@ -10,6 +10,7 @@ import {emitInteractive} from '../../src/compiler/interactive/emit.mjs'; import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; import {createTrustedInteractiveHost} from '../../src/interactive/trusted-host.mjs'; +import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; import { createAgentReceiptStatement,encodeAgentReceiptStatement,normalizeHostAgentReceipt, } from '../../src/interactive/host-attestation.mjs'; @@ -96,6 +97,68 @@ async function rejectUnchanged(f,receipt,attestation) { assert.equal(f.store.listJournal(f.frame.runId).length,count); } +test('Host drains the old policy, blocks revoked workers across connections, and reads only the exact original Ack',async t=>{ + let approved=false; + const f=await waiting(t,{authorizeReceiptAudit:request=>approved && request.action==='receipt:read-history'}); + const secondStore=createSqliteCheckpointStore(path.join(f.dir,'run.db'),{namespace:f.store.namespace}); + t.after(()=>secondStore.close()); + const oldWorker=createTrustedInteractiveHost({...f.hostOptions,store:secondStore}); + const pending=await oldWorker.start({...f.program,startKey:'pending-old',state:{ready:true}}); + const nextKeys=generateKeyPairSync('ed25519'); + const nextHost=createTrustedInteractiveHost({...f.hostOptions,store:secondStore, + trustedPublicKeys:{next:nextKeys.publicKey},authorizeKeyRevocation:()=>approved}); + const drain=createTrustedInteractiveHost({...f.hostOptions,drainOnly:true}); + await assert.rejects(oldWorker.start({...f.program,startKey:'should-not-start',state:{ready:true}}), + {code:'FK_I_HOST_DENIED'}); + const receipt=agentResult(f.frame); + const attestation=proof(f.store.load(receipt.runId),receipt,f.keys.privateKey); + const accepted=await drain.accept({receipt,attestation}); + assert.equal(accepted.status,'succeeded'); + const next=await nextHost.start({...f.program,startKey:'new-policy',state:{ready:true}}); + assert.equal(next.status,'waiting'); + assert.notEqual(f.store.load(next.runId).policyHash,f.store.load(pending.runId).policyHash); + + const oldKeyHash=sha256Bytes(f.keys.publicKey.export({type:'spki',format:'der'})); + await assert.rejects(nextHost.revokeKey(oldKeyHash),{code:'FK_I_HOST_DENIED'}); + const pendingReceipt=agentResult(pending); + const pendingProof=proof(f.store.load(pending.runId),pendingReceipt,f.keys.privateKey); + const call={threadId:'thread',turnId:'turn',callId:'call',tool:'flowkit_submit', + args:{requestId:pendingReceipt.requestId,answer:'safe'}}; + const outbox=openCodexOutbox({database:path.join(f.dir,'outbox.sqlite'),store:secondStore,host:oldWorker,now:()=>CLOCK}); + t.after(()=>outbox.close()); + const pendingTask=await oldWorker.getPublicAgentTask({runId:pending.runId}); + const observed={task:pendingTask,started:call,callback:call,modelPid:100}; + let unsigned; + approved=true; + await assert.rejects(outbox.record(observed,pendingReceipt,f.keys.privateKey,{ + async afterObserve(row) { + unsigned=row.proofId; + assert.equal(await nextHost.revokeKey(oldKeyHash),true); + }, + }),{code:'FK_I_HOST_DENIED'}); + assert.equal(outbox.describe(unsigned).state,'OBSERVED'); + assert.equal(outbox.describe(unsigned).statementHash,undefined); + assert.equal(await nextHost.revokeKey(oldKeyHash),false); + await assert.rejects(oldWorker.resume({runId:pending.runId}),{code:'FK_I_HOST_DENIED'}); + await assert.rejects(oldWorker.accept({receipt:pendingReceipt,attestation:pendingProof}),{code:'FK_I_PROVENANCE_REJECTED'}); + await assert.rejects(oldWorker.accept({receipt,attestation}),{code:'FK_I_PROVENANCE_REJECTED'}); + assert.equal(f.store.getReceipt(pending.runId,pendingReceipt.requestId),null); + assert.equal((await nextHost.resume({runId:next.runId})).status,'waiting'); + const {namespace,runId,requestId,nodeId,attempt,receiptHash}=attestation.statement; + const query={namespace,runId,requestId,nodeId,attempt,receiptHash}; + const before=f.store.load(runId); + const journal=f.store.listJournal(runId); + for (const malformed of [{namespace:'other'},{receiptHash:'sha256:'+'0'.repeat(64)},{attempt:2}]) { + await assert.rejects(f.host.readHistoricalAck({...query,...malformed}),{code:'FK_I_HOST_DENIED'}); + } + await assert.rejects(f.host.readHistoricalAck({...query,runId:pending.runId,requestId:pendingReceipt.requestId}), + {code:'FK_I_HOST_DENIED'}); + assert.deepEqual(await f.host.readHistoricalAck(query),{...query,acceptedRevision:accepted.acceptedRevision}); + assert.deepEqual(await f.host.readHistoricalAck(query),{...query,acceptedRevision:accepted.acceptedRevision}); + assert.deepEqual(f.store.load(runId),before); + assert.deepEqual(f.store.listJournal(runId),journal); +}); + test('M5 authorized public task contains only allowlisted declared input, not State or Host internals',async t=>{ const f=await waiting(t); const task=await f.host.getPublicAgentTask({runId:f.frame.runId}); diff --git a/tests/interactive/versioned.test.mjs b/tests/interactive/versioned.test.mjs index 0ce43e9..d877f44 100644 --- a/tests/interactive/versioned.test.mjs +++ b/tests/interactive/versioned.test.mjs @@ -6,7 +6,7 @@ import path from 'node:path'; import {DatabaseSync} from 'node:sqlite'; import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; import { - compileInteractive,complete,createInteractiveAdminContext,createInteractiveCoordinator,createInteractiveHostContext, + compileInteractive,complete,createInteractiveAdminContext,createInteractiveHostContext, createSqliteCheckpointStore,createSqliteInteractivePersistence,resume,start, } from '../../src/interactive/index.mjs'; @@ -54,25 +54,8 @@ 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=>{ +test('R5 uses the same persisted envelope in SQLite and the asynchronous Store',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); @@ -83,7 +66,18 @@ test('R5 facade uses the five async operations, separates disclosure and preserv 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'}); + const envelope=await f.options.store.load(waiting.runId); + const db=new DatabaseSync(f.file,{readOnly:true}); + try { + const row=db.prepare('SELECT checkpoint_json,checkpoint_hash FROM fk_i_runs WHERE run_id=?').get(waiting.runId); + assert.deepEqual(JSON.parse(row.checkpoint_json),envelope); + assert.equal(row.checkpoint_hash,sha256Bytes(canonicalBytes(envelope))); + assert.equal(envelope.execution.phase,'awaiting'); + assert.equal(envelope.pending[0].issuedRevision,waiting.revision); + assert.equal(envelope.policyIdentity.id,policy.id); + assert.equal(envelope.program.artifactHash,f.program.artifactHash); + assert.equal(Object.hasOwn(envelope,'artifactHash'),false); + } finally {db.close();} 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'}); @@ -156,7 +150,7 @@ test('R5 CAS recovers from a lost response only with durable transition evidence 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); + assert.deepEqual(await f.options.store.load(second.runId),stale); }); test('R5 file source works without Agent bindings and honors pre-aborted operations',async t=>{ @@ -171,67 +165,55 @@ test('R5 file source works without Agent bindings and honors pre-aborted operati 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=>{ +test('S12-A creates its audit table in a fresh R5 database 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 persisted=f.persistence; + { 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}), + assert.equal(db.prepare('PRAGMA user_version').get().user_version,3); + await assert.rejects(persisted.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'}); + await assert.rejects(persisted.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/); + await assert.rejects(persisted.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); + const result=await persisted.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}; + const opts={...f.options,...persisted}; 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); + assert.deepEqual(await persisted.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((await persisted.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')}); + const migrated=createSqliteInteractivePersistence(f.file,{namespace:ns}); try { const opts={...f.options,...migrated}; const waiting=await start(f.program,runtime,{message:'hello'},opts); diff --git a/tests/package/consumer.mjs b/tests/package/consumer.mjs index a1f8b44..a94092e 100644 --- a/tests/package/consumer.mjs +++ b/tests/package/consumer.mjs @@ -55,7 +55,7 @@ try { }); assert.equal(frame.status,'succeeded'); assert.equal(Object.hasOwn(frame,'state'),false); - assert.equal((await versioned.store.load(frame.runId)).transitionIdVersion,1); + assert.equal((await versioned.store.load(frame.runId)).protocolVersion,1); } finally {versioned.close();} assert.ok(compiledInteractive.programIdentity?.artifactHash); const store=interactive.createSqliteCheckpointStore('interactive-demo.db',{namespace:'consumer'}); diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index b2edce2..6d68cdf 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -1,3 +1,4 @@ +import {encodeCheckpoint} from '../../src/interactive/checkpoint.mjs'; import assert from 'node:assert/strict'; import {fork} from 'node:child_process'; import {appendFileSync,mkdirSync,readFileSync,readdirSync} from 'node:fs'; @@ -97,7 +98,7 @@ function inspect(dir,setup,proofs) { 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)), + checkpointHash:journal.at(-1).checkpointHash,checkpointDigest:sha256Bytes(canonicalBytes(encodeCheckpoint(cp))), journalDigest:sha256Bytes(canonicalBytes(journal)),indexed,journalRows:journal.length,rows}; } finally {store?.close();db.close();} } diff --git a/types/interactive.d.ts b/types/interactive.d.ts index 56654d4..0036c78 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -129,7 +129,7 @@ export interface CheckpointStore { * Store). Resolves acceptedRevision/digest after a receipt leaves the * Checkpoint's bounded recent-receipts cache. */ - getReceipt?(runId: string, requestId: string): { + getReceipt(runId: string, requestId: string): { requestId: string; nodeId: string; attempt: number; @@ -287,6 +287,14 @@ export interface TrustedInteractiveTerminalResult { causeCode?:string; } +export interface HistoricalAckRequest { + namespace:string; + runId:string; + requestId:string; + nodeId:string; + attempt:number; + receiptHash:string; +} export interface TrustedInteractiveHost { readonly policyHash:string; readonly securityMode:'ed25519-agent-receipt-v1'; @@ -301,6 +309,9 @@ export interface TrustedInteractiveHost { receipt:HostAgentReceipt; attestation:HostAgentAttestation; }):Promise; + readHistoricalAck(input:HistoricalAckRequest):Promise>; + /** Revoke the SHA-256 fingerprint of a public key's SPKI in the Host Store. */ + revokeKey(publicKeyHash:string):Promise; } export interface TrustedInteractiveHostWithTerminalAccess extends TrustedInteractiveHost { /** Public terminal projection, subject to the Host's run:read authorization. */ @@ -322,6 +333,9 @@ export interface TrustedInteractiveHostOptions { authorizeDisclosure(context:Readonly):boolean|Promise; /** Host-owned authorizer, required for both terminal projection methods. */ authorizeTerminalAccess?(context:Readonly):boolean|Promise; + authorizeReceiptAudit?(context:Readonly):boolean|Promise; + authorizeKeyRevocation?(context:Readonly<{action:'host:revoke-key';namespace:string;publicKeyHash:string}>):boolean|Promise; + drainOnly?:boolean; maxProofAgeMs?:number; clockSkewMs?:number; now?:()=>number; @@ -360,12 +374,32 @@ export interface ReceiptRecordV1 { attempt?:number; } -/** P0-A persistence payload, shared with the legacy SQLite checkpoint format. */ -export type RunCheckpointV1 = InteractiveCheckpoint & { +export type RunCheckpointV1 = { + schemaVersion:1; + protocolVersion:1; + namespace:string; + runId:string; startKey:string; startIntentHash:string; - policyHash:string; - transitionIdVersion?:1; + revision:number; + status:InteractiveRunStatus; + program:InteractiveProgramIdentity; + programRef:TrustedProgramRefV1; + policyIdentity:{id:string;version:string;policyHash:string;bindingManifestHash:string;skillManifestHash?:string}; + execution:{blockId:string;phase:'enter'|'prepare'|'dispatch-ready'|'awaiting'|'validate'|'commit'|'route'|'finished'; + activationSeq:number;agentStepCounter:number;controlStack:readonly Record[]; + contextError:{code:string;message:string;retryable?:boolean;origin?:'runtime'|'executor'|'validator'|'store'|'policy'}|null}; + state:Record; + candidate:{kind:'agent';requestId:string;result:Record}|null; + receivedSubmission:{kind:'agent';requestId:string;activation:number;attempt:number;canonicalDigest:string; + acceptedRevision:number;payload:Record;receivedAt:string}|null; + pending:Array<{requestId:string;nodeId:string;kind:'agent';activation:number;attempt:number;issuedRevision:number; + intent:'execute';name:string;internalPayload:Record;publicTask:Record}>; + inFlightOperations:Record[]; + terminal:{nodeId:string;status:'succeeded'|'failed'}|null; + lastTransitionId:string|null; + limits:{stageCount:number;receiptCount:number;bytes:number}; + causeCode?:string; }; export type CASResultV1 = @@ -398,7 +432,7 @@ export interface TrustedProgramRegistryV1 { release(runId:string,artifactHash:string):Promise; } -export declare function createSqliteInteractivePersistence(file:string,options:{namespace:string;migrationBackupPath?:string}):{ +export declare function createSqliteInteractivePersistence(file:string,options:{namespace:string}):{ readonly store:CheckpointStoreV1; readonly programRegistry:TrustedProgramRegistryV1; readonly adminController:InteractiveAdminControllerV1; From d3674bca09d305f88828a77b1d3653937fc5da5e Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 14:18:53 +0800 Subject: [PATCH 33/72] Stage the R5 checkpoint module in both isolation test launchers --- tests/security/secure-host-linux.mjs | 2 +- tests/security/secure-host-windows.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/security/secure-host-linux.mjs b/tests/security/secure-host-linux.mjs index ce684ca..3c37fa6 100644 --- a/tests/security/secure-host-linux.mjs +++ b/tests/security/secure-host-linux.mjs @@ -103,7 +103,7 @@ try { 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']) { + 'src/interactive/sqlite-store.mjs','src/interactive/checkpoint.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); diff --git a/tests/security/secure-host-windows.ps1 b/tests/security/secure-host-windows.ps1 index a2820cf..cd88ef4 100644 --- a/tests/security/secure-host-windows.ps1 +++ b/tests/security/secure-host-windows.ps1 @@ -142,7 +142,7 @@ try { [void][IO.Directory]::CreateDirectory($publicDir) foreach ($file in @( 'tests\security\secure-host-worker.mjs', - 'src\interactive\sqlite-store.mjs','src\interactive\canonical.mjs' + 'src\interactive\sqlite-store.mjs','src\interactive\checkpoint.mjs','src\interactive\canonical.mjs' )) { $destination = Join-Path $publicDir $file [void][IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) From 5efa127cf8a26081985e454b9719619a39fdc4b2 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 14:30:23 +0800 Subject: [PATCH 34/72] Exercise R5 recovery, scoped revocation and retention with live Codex --- tests/security/codex-flowkit-probe.mjs | 97 +++++++++++++++++-- tests/security/codex-outbox-live.mjs | 127 ++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 12 deletions(-) diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index f16154c..503b5bf 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -7,6 +7,7 @@ import {tmpdir} from 'node:os'; import path from 'node:path'; import {createInterface} from 'node:readline'; import {fileURLToPath} from 'node:url'; +import {DatabaseSync} from 'node:sqlite'; import { compileInteractive,createAgentReceiptStatement,createSqliteCheckpointStore, createTrustedInteractiveHost,encodeAgentReceiptStatement, @@ -17,13 +18,13 @@ 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); + 'outbox-check-revocation','release-check','release-revoke','release-clean'].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' +const runRecord=['rotate-new','rotate-old-next','revoked-pending','release-cap-next','release-byte-next'].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; @@ -51,7 +52,8 @@ const specs=size ? [tool('flowkit_inline','Measure an inline synthetic payload', 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))); + && (!cut || ['after-cas','before-sign','expire','rotate-old','rotate-new','rotate-old-next','revoked-new', + 'release-cap','release-cap-next','release-byte-next','release-after-receipt'].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; @@ -129,7 +131,7 @@ terminal('done',{name:'Done',status:'succeeded'}) 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') { + if (!scenario || (requested==='outbox-setup' && cut!=='release-shared')) { ({publicKey,privateKey}=generateKeyPairSync('ed25519')); if (scenario) { await writeFile(path.join(fixture,'proof.public.pem'),publicKey.export({type:'spki',format:'pem'}),{mode:0o600,flag:'wx'}); @@ -158,13 +160,28 @@ terminal('done',{name:'Done',status:'succeeded'}) } 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']}, + validateResult(_schema,value) { + if (worker && cut==='release-after-receipt') { + journal('release-validation-barrier',{runId:currentTask.runId,requestId:currentTask.requestId}); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,60000); + throw new Error('Supervisor did not terminate Host at the validation barrier'); + } + return 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', + authorizeReceiptAudit:({action})=>offline && action==='receipt:read-history', + authorizeKeyRevocation:({action})=>requested==='release-revoke' && action==='host:revoke-key', + }); + if (scenario) outbox=openCodexOutbox({database:path.join(fixture,'outbox.sqlite'),store,host,handoff, + ...(cut.startsWith('release-cap') || requested==='release-clean'?{maxRecords:1,retentionMs:1}:{}), + ...(cut==='release-byte-next'?{maxRecords:100,maxBytes:1}:{}), + authorizeCleanup:({subjectId})=>({allowed:requested==='release-clean' && subjectId==='operator', + heldRunIds:cut==='held'?[answer]:[]}), + onCapacity:usage=>journal('outbox-capacity-alert',usage), }); - if (scenario) outbox=openCodexOutbox({database:path.join(fixture,'outbox.sqlite'),store,host,handoff}); return privateKey; } @@ -198,7 +215,8 @@ async function deliverCandidate(result,name,args,identity,started) { 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, + let proofId; + try {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', @@ -208,7 +226,12 @@ async function deliverCandidate(result,name,args,identity,started) { process.send({event:'observed',...details,...observed}); await new Promise(()=>{}); }:undefined, - }); + });} catch(error) { + if (!['release-cap-next','release-byte-next'].includes(cut) || error.code!=='FK_OUTBOX_CAPACITY') throw error; + journal('release-capacity-rejected',{...details,code:error.code,usage:outbox.usage()}); + process.send({event:'capacity-rejected',...details,code:error.code,usage:outbox.usage()}); + return {conflict:true,code:error.code}; + } const signed=outbox.describe(proofId); const detail={...details,...signed, ...(result.artifact?{artifact:result.artifact}:{})}; @@ -453,6 +476,62 @@ async function runOutboxOffline() { 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==='release-revoke') { + const pem=createPublicKey(await readFile(path.join(fixture,'proof.public.pem'))); + const keyHash=sha256Bytes(pem.export({type:'spki',format:'der'})); + assert.equal(await host.revokeKey(keyHash),true); + assert.equal(await host.revokeKey(keyHash),false); + console.log(JSON.stringify({runId,keyHash,revoked:true})); + return; + } + if (requested==='release-check') { + const beforeJournal=store.listJournal(runId); + if (cut==='active') { + assert.equal((await host.load({runId})).revision,before.revision); + } else { + await assert.rejects(host.load({runId}),{code:'FK_I_HOST_DENIED'}); + await assert.rejects(host.resume({runId}),{code:'FK_I_HOST_DENIED'}); + if (answer) await assert.rejects(outbox.deliver(answer),{code:'FK_I_PROVENANCE_REJECTED'}); + } + let ack=null; + if (beforeReceipt) { + const query={namespace:store.namespace,runId,requestId,nodeId:beforeReceipt.nodeId, + attempt:beforeReceipt.attempt,receiptHash:beforeReceipt.digest}; + await assert.rejects(host.readHistoricalAck({...query,receiptHash:'sha256:'+'0'.repeat(64)}),{code:'FK_I_HOST_DENIED'}); + await assert.rejects(host.readHistoricalAck({...query,namespace:'other'}),{code:'FK_I_HOST_DENIED'}); + ack=await host.readHistoricalAck(query); + assert.equal(ack.acceptedRevision,beforeReceipt.acceptedRevision); + } + assert.deepEqual(store.load(runId),before); + assert.deepEqual(store.listJournal(runId),beforeJournal); + console.log(JSON.stringify({runId,revision:before.revision,active:cut==='active',ack,unchanged:true})); + return; + } + if (requested==='release-clean') { + const db=new DatabaseSync(path.join(fixture,'outbox.sqlite')); + const beforeUsage=outbox.usage(); + const beforeJournal=store.listJournal(runId); + const request={namespace:store.namespace,subjectId:'operator',reasonCode:'RETENTION'}; + try { + await assert.rejects(outbox.cleanup({...request,subjectId:'unauthorized'}),{code:'FK_OUTBOX_DENIED'}); + if (cut==='abort-audit') { + db.exec("CREATE TRIGGER acceptance_abort BEFORE INSERT ON cleanup_audit BEGIN SELECT RAISE(ABORT,'audit interruption'); END"); + try {await assert.rejects(outbox.cleanup(request),/audit interruption/);} + finally {db.exec('DROP TRIGGER acceptance_abort');} + assert.deepEqual(outbox.usage(),beforeUsage); + } else { + const result=await outbox.cleanup(request); + assert.equal(result.deleted,cut==='held'?0:1); + if (result.operationId) assert.equal(db.prepare('SELECT deleted_count FROM cleanup_audit WHERE operation_id=?') + .get(result.operationId).deleted_count,1); + } + assert.deepEqual(store.load(runId),before); + assert.deepEqual(store.listJournal(runId),beforeJournal); + assert.deepEqual(store.getReceipt(runId,requestId),beforeReceipt); + console.log(JSON.stringify({runId,cut,beforeUsage,afterUsage:outbox.usage(),receipt:beforeReceipt,unchanged:true})); + } finally {db.close();} + return; + } if (requested==='outbox-check-revocation') { assert.ok(revoked && before.policyHash!==host.policyHash); const rejected={}; diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index 6d68cdf..1ef35da 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -1,7 +1,7 @@ import {encodeCheckpoint} from '../../src/interactive/checkpoint.mjs'; import assert from 'node:assert/strict'; import {fork} from 'node:child_process'; -import {appendFileSync,mkdirSync,readFileSync,readdirSync} from 'node:fs'; +import {appendFileSync,copyFileSync,existsSync,mkdirSync,readFileSync,readdirSync} from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {DatabaseSync} from 'node:sqlite'; @@ -13,7 +13,7 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m 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)); +assert.ok(['original','followup','signing','revocation','release-r5'].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'), @@ -125,6 +125,121 @@ function assertSameStore(before,after) { assert.deepEqual(storeAfter,storeBefore); } +function readR5(dir,runId) { + const db=new DatabaseSync(path.join(dir,'run.sqlite'),{readOnly:true}); + try { + const row=db.prepare('SELECT checkpoint_json,checkpoint_hash FROM fk_i_runs WHERE namespace=? AND run_id=?') + .get('codex-proof',runId); + const cp=JSON.parse(row.checkpoint_json); + assert.equal(sha256Bytes(Buffer.from(row.checkpoint_json)),row.checkpoint_hash); + assert.equal(cp.protocolVersion,1); + assert.equal(cp.schemaVersion,1); + assert.equal(Object.hasOwn(cp,'artifactHash'),false); + assert.equal(cp.program.artifactHash,cp.programRef.artifactHash); + return cp; + } finally {db.close();} +} + +async function r5ValidationRecovery() { + const dir=path.join(root,'r5-validate'); + mkdirSync(dir,{mode:0o700}); + const setup=await offline('outbox-setup',dir,'setup'); + const waiting=readR5(dir,setup.runId); + assert.equal(waiting.execution.phase,'awaiting'); + assert.equal(waiting.pending[0].requestId,setup.requestId); + const victim=launch('outbox-worker',dir,'victim',{answer:'ready',cut:'release-after-receipt'}); + const proof=await waitFor(victim,'signed'); + victim.child.send('deliver'); + const observer=path.join(dir,'victim','observer.ndjson'); + const deadline=Date.now()+30000; + while (!existsSync(observer) || !readFileSync(observer,'utf8').includes('"event":"release-validation-barrier"')) { + if (Date.now()>=deadline || victim.child.exitCode!==null) throw new Error('R5 validation barrier not reached'); + await delay(50); + } + const saved=readR5(dir,setup.runId); + assert.equal(saved.execution.phase,'validate'); + assert.equal(saved.revision,setup.revision+1); + assert.equal(saved.receivedSubmission.requestId,setup.requestId); + assert.equal(saved.receivedSubmission.canonicalDigest,proof.receiptHash); + assert.deepEqual(saved.pending,[]); + assert.deepEqual(saved.candidate.result,{answer:'ready'}); + const before=inspect(dir,setup,[proof]); + assert.equal(before.indexed.acceptedRevision,saved.revision); + assert.equal(before.rows[0].state,'SIGNED'); + log('r5-validation-before-kill',{setup,proof,checkpoint:saved,before}); + assert.ok(victim.child.kill('SIGKILL')); + assert.equal((await victim.closed).signal,'SIGKILL'); + assert.deepEqual(readR5(dir,setup.runId),saved); + const restored=await offline('outbox-recover',dir,'restart'); + assert.equal(restored.before.revision,saved.revision); + const after=inspect(dir,setup,[proof]); + finishedState(after,after.rows[0]); + assert.deepEqual(after.indexed,before.indexed); + assert.equal(readR5(dir,setup.runId).execution.phase,'finished'); + log('r5-validation-recovered',{after,restored}); +} + +async function crossStoreRevocation() { + const first=path.join(root,'revoke-a'),second=path.join(root,'revoke-b'); + mkdirSync(first,{mode:0o700}); + mkdirSync(second,{mode:0o700}); + const a=await offline('outbox-setup',first,'setup'); + for (const key of ['proof.public.pem','proof.private.pem']) { + copyFileSync(path.join(first,key),path.join(second,key)); + } + const b=await offline('outbox-setup',second,'setup',{cut:'release-shared'}); + const accepted=await acceptLive(first,a,'accepted','','alpha'); + const bWorker=launch('outbox-worker',second,'pending',{answer:'beta'}); + const inFlight=await waitFor(bWorker,'signed'); + const beforeB=inspect(second,b,[inFlight]); + pendingState(beforeB,b); + assert.ok(bWorker.child.kill('SIGKILL')); + assert.equal((await bWorker.closed).signal,'SIGKILL'); + const revokeA=await offline('release-revoke',first,'revoke'); + const rejectedA=await offline('release-check',first,'check',{answer:accepted.proofId}); + assert.equal(rejectedA.ack.acceptedRevision,a.revision+1); + const stillActive=await offline('release-check',second,'before-local-revocation',{cut:'active'}); + assert.equal(stillActive.active,true); + const revokeB=await offline('release-revoke',second,'revoke'); + assert.equal(revokeB.keyHash,revokeA.keyHash); + const rejectedB=await offline('release-check',second,'check',{answer:inFlight.proofId}); + assert.equal(rejectedB.ack,null); + assert.ok(Date.now() Date: Fri, 13 Nov 2026 14:48:18 +0800 Subject: [PATCH 35/72] Record dual-account R5 release verification on Windows and Linux --- .../r5-release-live.md | 35 +++++++++++++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 17 ++++----- 2 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/r5-release-live.md diff --git a/docs/evidence/p0a-codex-isolated-20261113/r5-release-live.md b/docs/evidence/p0a-codex-isolated-20261113/r5-release-live.md new file mode 100644 index 0000000..8459998 --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/r5-release-live.md @@ -0,0 +1,35 @@ +# R5 首发候选的双账户复验 + +实现基线为 `d3674bca09d305f88828a77b1d3653937fc5da5e`。新增实测入口候选为 `5efa127cf8a26081985e454b9719619a39fdc4b2`,相对实现基线仅改两个验收探针。每次使用全新 Host 私有目录和 R5 SQLite 库。Host 与 Codex App Server 分属两个普通 OS 账户,Model 工具仅接触当前公开任务和提交入口。复验采用真实模型回合及合成业务任务。 + +```text +node tests/security/codex-outbox-live.mjs <全新Host私有目录> isolated followup +node tests/security/codex-outbox-live.mjs <另一全新Host私有目录> isolated release-r5 +``` + +## Windows 与 Linux 实测结果 + +两端在 `d3674bc` 的 `followup` 各使用三个真实会话,在 `5efa127` 的 `release-r5` 各使用七个真实会话。Windows 使用本机两个普通账户和受保护回环启动器。Linux 由 FlowKit 任务先快进拉取对应提交,在专用容器中用普通 Host UID/GID 41001、独立 Model UID/GID 41002 运行,并核对了 64 个部署文件的 SHA-256。 + +| 场景 | 两端结果 | +|---|---| +| 基线恢复 | `followup` 三项 PASS。CAS 后、Outbox 补 Ack 前强杀只补记 `ACKED`。262,144 B 和 2,093,056 B 文件引用从私有快照按原签名恢复。 | +| R5 已接纳回执恢复 | PASS。真实模型回执进入 revision 4 后,纯校验器阻塞在第一个调用点。原 SQLite JSON 为 R5 `execution.phase=validate`,`receivedSubmission` 与接纳索引同在,`pending=[]`。强杀后原 Checkpoint 完全相同。新 Host 不启动模型,复用原回执恢复至 revision 9、`finished`,接纳索引仍为 revision 4。 | +| 撤销后原 Ack | PASS。Store A 撤销旧钥后,旧策略拒绝加载、恢复和旧回执重放。独立授权的历史查询返回原 revision 4,错租户和错摘要拒绝,Checkpoint 和 Journal 不变。 | +| 跨 Store 撤销 | PASS。每个平台各用两个独立 SQLite Store 和同一测试公钥指纹。仅撤销 A 时 B 仍可加载,随后对 B 显式执行同一撤销,B 拒绝其尚未过期的原签名投递,仍停在 waiting revision 3。撤销在各自 Store 生效。 | +| Outbox 满额与清理 | PASS。真实工具提交分别触发一条记录上限和一字节逻辑配额负例,记录告警且 Pending、Journal 和接纳索引不变。未授权清理被拒,保留标记保住原行,注入 SQLite 审计写入失败后清理回滚。独立 Host 进程重试清理,原 Ack 仍可对账,释放的容量允许新模型会话完成同一 Pending。 | + +Windows 原始证据归档 `flowkit-windows-r5-live.zip` SHA-256 为 `cf93b1c5c7b6973223423ce25250dfd71656e588a23f25c2514e48d89d307992`。归档含进程监督、App Server 协议、Host 观察、公开验证钥和逐文件哈希。对留存的七份签名独立复核通过。容量样本清理过的正文和签名已按规则删除,归档保留该提交的事件与摘要。 + +Linux 原始归档由 FlowKit 任务保管,压缩包及逐文件哈希均已复核。 + +| 归档 | SHA-256 | +|---|---| +| `FlowKit-Linux-R5-Followup-20261113.zip` | `34b43b87b0134ee05ba9bf4bcf29e93e5b1a74b2779e8901bbddac8c8e2bcc89` | +| `FlowKit-Linux-Release-R5-20261113.zip` | `31f75ac0d73ec01840ffdad3dcae9cd464fbc7b53a4768c12b3c78edfda5be73` | + +Linux 的 `release-r5` 独立核对了五份签名(含清理前留存的一份)和五条 Run 的 Journal 链。观察器有两次 SQLite 锁冲突跳读,正式断言所需快照均已取得。[实测候选 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43379312101) 八项通过。 + +## 证据边界 + +这些样本覆盖 P/M/T。跨 Store 结果适用于分别对两个 Store 执行撤销的运维方式。生产部署的所有 Store 撤销协调、真实磁盘预算、生产保留窗口和实际告警仍为 BLOCKED。清理中断样本使用真实 SQLite 事务的审计写入失败注入。文件快照清理中断重试已有程序回归,真实双账户文件样本本轮验证强杀后的快照恢复。完整 SEC/NAT、C12/S13/T27、N/V 级及 G12 独立复审仍待完成。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 0fcc75d..6011dc9 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -15,6 +15,7 @@ | 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) | +| E8 | `d3674bca09d305f88828a77b1d3653937fc5da5e`、`5efa127cf8a26081985e454b9719619a39fdc4b2` | R5 恢复基线、validate 阶段恢复、撤销后原 Ack、两个独立 Store 分别撤销、Outbox 满额和清理。[平台裁决、命令、原始日志与 SHA-256](../p0a-codex-isolated-20261113/r5-release-live.md) | ## SEC · 生产配置与候选入口 @@ -33,12 +34,12 @@ | 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-14 | BLOCKED,生产轮换与撤销 | E4 验证仍受信的两把钥轮换和原 Ack。E5 验证仅信任新钥时旧 Run 拒绝。E8 验证两独立 Store 分别撤销、未接纳旧回执拒绝和受权只读返回历史 Ack。生产全量 Store 协调、实际权限变更及适用的证书/KMS 操作待测。 | | 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 复核,见下方待确认方案。 | +| SEC-18 | PASS,双平台 Outbox 样本 | E2/E3/E4 覆盖签名前、签后 CAS 前及 CAS 后 `ACKED` 前的真实强杀、原字节验签与对账、未接纳过期拒绝和仍受信的历史 Ack。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | +| SEC-19 | BLOCKED,生产有界保留 | E8 验证真实工具触发记录数及逻辑字节拒绝、保留标记、受权清理、审计写失败回滚和重试。生产期限、真实磁盘占用、告警及双账户文件快照清理中断仍待取证。 | ## NAT · Codex 等价验收 @@ -82,7 +83,7 @@ | C12 | BLOCKED,E1–E5 补齐多个原生子样本,完整 SEC/NAT 和来源链待复审。 | | C13 | PASS,P 级分支与预算。 | | C14 | PASS,P 级链中错误与超限拒绝。原生入口负例归 SEC-08/12。 | -| C15 | PASS,P 级历史重传及 E3/E4 的原生历史 Ack。撤销后的独立对账另列。 | +| C15 | PASS,P 级历史重传、E3/E4 原生历史 Ack。E8 增补真实回执撤销后及清理后的受权历史对账样本。 | | C16 | BLOCKED,P 级可信失败路径已有。缺真实供应商失败。 | | C17 | PASS,P 级策略漂移拒绝;E5 补同类原生拒绝样本。 | | C18 | PASS,P 级多 Run 交错。 | @@ -113,7 +114,7 @@ ## 四项已确认的首发契约 -首发测试库按 R5 结构重建,开发期归档保留用于证据复核。以下实现的程序回归使用新建 SQLite 库。真实 Codex Windows/Linux 双账户在新实现上的复验及生产运维演练仍为 BLOCKED。 +首发测试库按 R5 结构重建,开发期归档保留用于证据复核。后续真实 Codex 双账户结果和原始日志索引见 [R5 首发候选复验](../p0a-codex-isolated-20261113/r5-release-live.md)。生产运维演练及正式裁决仍为 BLOCKED。 ### 1. Checkpoint 格式 @@ -133,6 +134,6 @@ Host 部署配置审计期限和容量上限。保留待投递及未对账的记 | 首发回归 | 可复核的 P 级覆盖 | 仍为 BLOCKED | |---|---|---| -| R5 Checkpoint | SQLite 原字节与异步 Store 回读一致、阶段恢复、首次接纳、管理审计和包消费者。[版本化 SDK 回归](../../../tests/interactive/versioned.test.mjs) | 同一发行候选上的真实 Codex 双平台回归与 G12 独立裁决。 | -| Ack、轮换与撤销 | 两条 SQLite 连接验证旧策略停止新建、可处理既有 Run、撤销后不能签发或接纳,并可通过受权查询返回原 revision。[Host 回归](../../../tests/interactive/trusted-host.test.mjs) | 真实部署所有 Host 的停用和多 Store 运维演练。 | -| Outbox 容量和清理 | 两条连接共享配额,满额拒绝并通知。权限、保留窗口、保留标记和审计事务覆盖。文件快照经授权确认可回收后清理,中断可重试。[Outbox 回归](../../../tests/interactive/codex-outbox.test.mjs) | 生产容量和审计窗口、实际告警、清理后的磁盘占用及真实双账户恢复。配额计入活动记录和待清理引用的逻辑字节,数据库页、WAL、文件快照和审计另计。 | +| R5 Checkpoint | SQLite 原字节与异步 Store 回读一致、首次接纳、管理审计和包消费者。[版本化 SDK 回归](../../../tests/interactive/versioned.test.mjs) · E8 记录真实强杀恢复。 | G12 独立裁决及完整原生故障矩阵。 | +| Ack、轮换与撤销 | 两条 SQLite 连接验证旧策略停止新建及撤销边界。[Host 回归](../../../tests/interactive/trusted-host.test.mjs) · E8 记录独立 Store 分别撤销及受权历史查询。 | 生产所有 Host/Store 的协调、撤销后签发停止及轮换演练。 | +| Outbox 容量和清理 | [Outbox 回归](../../../tests/interactive/codex-outbox.test.mjs) 覆盖文件快照回收中断重试。E8 验证真实工具满额、清理回滚及恢复。 | 生产容量和审计窗口、实际告警、清理后磁盘占用及真实双账户快照清理中断。配额计入活动记录和待清理引用的逻辑字节,数据库页、WAL、文件快照和审计另计。 | From e0df84b23ec78b49fd8beb8e800076ce27102c74 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 15:01:40 +0800 Subject: [PATCH 36/72] Exercise snapshot cleanup crash recovery and Host operations --- tests/security/codex-flowkit-probe.mjs | 58 +++++++++++-- tests/security/codex-outbox-live.mjs | 116 +++++++++++++++++++++++-- 2 files changed, 160 insertions(+), 14 deletions(-) diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 503b5bf..75f2800 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -18,7 +18,7 @@ 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','release-check','release-revoke','release-clean'].includes(requested); + 'outbox-check-revocation','release-check','release-revoke','release-clean','ops-clean'].includes(requested); const worker=requested==='outbox-worker'; const offline=scenario&&!worker; const rotation=['rotate-old','rotate-new','rotate-old-next'].includes(cut); @@ -27,6 +27,7 @@ const nextSigner=cut==='rotate-new'||cut==='revoked-new'; const runRecord=['rotate-new','rotate-old-next','revoked-pending','release-cap-next','release-byte-next'].includes(cut)?'run-next.json' :cut==='revoked-new'?'run-new.json':'run.json'; const fallback=requested.startsWith('file-') || (scenario && scenarioBytes!==undefined); +const clearArtifact=cut==='ops-clear'; 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)); @@ -53,7 +54,7 @@ const fixture=scenario?scenarioDir:size?null:await mkdtemp(path.join(tmpdir(),'f 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', - 'release-cap','release-cap-next','release-byte-next','release-after-receipt'].includes(cut))); + 'release-cap','release-cap-next','release-byte-next','release-after-receipt','ops-file','ops-clear'].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; @@ -124,7 +125,8 @@ async function prepareFlowKit() { 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'}), +output:{type:'object',properties:${fallback?'{artifact:{type:"object"}}':"{answer:{type:'string'}}"}},retries:0,next:'${cut==='ops-file'?'clear':'done'}'}), +${cut==='ops-file'?"node('clear',{name:'Clear completed reference',agent:'reviewer',inputs:['question'],output:{type:'object',properties:{artifact:{type:'null'}}},retries:0,next:'done'}),":''} terminal('done',{name:'Done',status:'succeeded'}) ]});`); program=await compileInteractive(file,{output:'memory',featureStage:'P0-A',skillCheck:false}); @@ -166,26 +168,46 @@ terminal('done',{name:'Done',status:'succeeded'}) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,60000); throw new Error('Supervisor did not terminate Host at the validation barrier'); } - return fallback ? validateArtifactResult(value) + return clearArtifact ? (value.artifact===null?[]:['Expected cleared reference']) + : 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', authorizeReceiptAudit:({action})=>offline && action==='receipt:read-history', - authorizeKeyRevocation:({action})=>requested==='release-revoke' && action==='host:revoke-key', + authorizeKeyRevocation:({action})=>requested==='release-revoke' && cut!=='denied' && action==='host:revoke-key', }); - if (scenario) outbox=openCodexOutbox({database:path.join(fixture,'outbox.sqlite'),store,host,handoff, + let cleanupHandoff=handoff; + if (requested==='ops-clean' && cut==='after-unlink') { + cleanupHandoff={...handoff,reclaimAcknowledged(...args) { + assert.equal(handoff.reclaimAcknowledged(...args),true); + stopAtCleanupCut(); + }}; + } + if (scenario) outbox=openCodexOutbox({database:path.join(fixture,'outbox.sqlite'),store,host,handoff:cleanupHandoff, ...(cut.startsWith('release-cap') || requested==='release-clean'?{maxRecords:1,retentionMs:1}:{}), + ...(requested==='ops-clean'?{retentionMs:1}:{}), ...(cut==='release-byte-next'?{maxRecords:100,maxBytes:1}:{}), - authorizeCleanup:({subjectId})=>({allowed:requested==='release-clean' && subjectId==='operator', - heldRunIds:cut==='held'?[answer]:[]}), + authorizeCleanup:({subjectId})=>({allowed:['release-clean','ops-clean'].includes(requested) && subjectId==='operator', + heldRunIds:cut==='held'?[answer]:[], + ...(requested==='ops-clean'?{canDeleteSnapshot:({artifact,checkpoint})=>{ + if (cut==='after-queue') stopAtCleanupCut(); + return checkpoint?.status==='succeeded' && checkpoint.state.artifact===null + && artifact.byteLength===artifactSize; + }}:{}), + }), onCapacity:usage=>journal('outbox-capacity-alert',usage), }); return privateKey; } let key; +function stopAtCleanupCut() { + process.send({event:'cleanup-cut',cut,hostPid:process.pid}); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,60000); + throw new Error('Supervisor did not stop cleanup at '+cut); +} function validateArtifactResult(value) { if (!scenario) return value.artifact?.id===staged?.id?[]:['Expected staged reference']; const artifact=value.artifact; @@ -291,6 +313,10 @@ async function handleTool(name,args,identity,started) { return {task:currentTask,fileReference:staged}; } if (name==='flowkit_submit') { + if (clearArtifact) { + assert.deepEqual(args,{requestId:currentTask.requestId,answer:'ready'}); + return acceptResult({artifact:null},identity); + } if (worker) { assert.deepEqual(args,{requestId:currentTask.requestId,answer}); return deliverCandidate({answer},name,args,identity,started); @@ -479,6 +505,11 @@ async function runOutboxOffline() { if (requested==='release-revoke') { const pem=createPublicKey(await readFile(path.join(fixture,'proof.public.pem'))); const keyHash=sha256Bytes(pem.export({type:'spki',format:'der'})); + if (cut==='denied') { + await assert.rejects(host.revokeKey(keyHash),{code:'FK_I_HOST_DENIED'}); + console.log(JSON.stringify({runId,keyHash,revoked:false,code:'FK_I_HOST_DENIED'})); + return; + } assert.equal(await host.revokeKey(keyHash),true); assert.equal(await host.revokeKey(keyHash),false); console.log(JSON.stringify({runId,keyHash,revoked:true})); @@ -507,6 +538,17 @@ async function runOutboxOffline() { console.log(JSON.stringify({runId,revision:before.revision,active:cut==='active',ack,unchanged:true})); return; } + if (requested==='ops-clean') { + const beforeJournal=store.listJournal(runId); + const result=await outbox.cleanup({namespace:store.namespace,subjectId:'operator',reasonCode:'RETENTION'}); + assert.deepEqual(store.load(runId),before); + assert.deepEqual(store.listJournal(runId),beforeJournal); + assert.deepEqual(store.getReceipt(runId,requestId),beforeReceipt); + const ack=await host.readHistoricalAck({namespace:store.namespace,runId,requestId,nodeId:beforeReceipt.nodeId, + attempt:beforeReceipt.attempt,receiptHash:beforeReceipt.digest}); + console.log(JSON.stringify({result,ack,usage:outbox.usage(),unchanged:true})); + return; + } if (requested==='release-clean') { const db=new DatabaseSync(path.join(fixture,'outbox.sqlite')); const beforeUsage=outbox.usage(); diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index 1ef35da..93c945b 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -1,7 +1,7 @@ import {encodeCheckpoint} from '../../src/interactive/checkpoint.mjs'; import assert from 'node:assert/strict'; import {fork} from 'node:child_process'; -import {appendFileSync,copyFileSync,existsSync,mkdirSync,readFileSync,readdirSync} from 'node:fs'; +import {appendFileSync,copyFileSync,existsSync,mkdirSync,readFileSync,readdirSync,statSync} from 'node:fs'; import path from 'node:path'; import {fileURLToPath} from 'node:url'; import {DatabaseSync} from 'node:sqlite'; @@ -13,7 +13,7 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m 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','release-r5'].includes(suite)); +assert.ok(['original','followup','signing','revocation','release-r5','ops-r5'].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'), @@ -179,7 +179,7 @@ async function r5ValidationRecovery() { log('r5-validation-recovered',{after,restored}); } -async function crossStoreRevocation() { +async function crossStoreRevocation({coordinated=false}={}) { const first=path.join(root,'revoke-a'),second=path.join(root,'revoke-b'); mkdirSync(first,{mode:0o700}); mkdirSync(second,{mode:0o700}); @@ -200,6 +200,14 @@ async function crossStoreRevocation() { assert.equal(rejectedA.ack.acceptedRevision,a.revision+1); const stillActive=await offline('release-check',second,'before-local-revocation',{cut:'active'}); assert.equal(stillActive.active,true); + if (coordinated) { + const denied=await offline('release-revoke',second,'unauthorized-revocation',{cut:'denied'}); + assert.equal(denied.revoked,false); + assert.equal(denied.code,'FK_I_HOST_DENIED'); + const active=await offline('release-check',second,'after-failed-revocation',{cut:'active'}); + assert.equal(active.active,true); + log('coordinated-revocation-hold',{confirmed:['A'],missing:['B'],denied,active}); + } const revokeB=await offline('release-revoke',second,'revoke'); assert.equal(revokeB.keyHash,revokeA.keyHash); const rejectedB=await offline('release-check',second,'check',{answer:inFlight.proofId}); @@ -207,6 +215,7 @@ async function crossStoreRevocation() { assert.ok(Date.now()event.event==='outbox-capacity-alert'); + assert.ok(alert); + assert.equal(other.length,0); + assert.equal(alert.namespace,'codex-proof'); + assert.equal(alert.records,1); + assert.equal(cut==='release-cap-next'?alert.maxRecords:alert.maxBytes,1); + log('capacity-alert-verified',{cut,alert,disk:diskSample(dir)}); + } assert.deepEqual(inspect(dir,second,[]),before); } const held=await offline('release-clean',dir,'held',{cut:'held',answer:first.runId}); @@ -353,8 +372,8 @@ async function crashCase(label,{cut='',bytes}={}) { return {after,snapshot:recoveredSnapshot}; } -async function acceptLive(dir,setup,label,cut,answer) { - const worker=launch('outbox-worker',dir,label,{cut,answer}); +async function acceptLive(dir,setup,label,cut,answer,bytes) { + const worker=launch('outbox-worker',dir,label,{cut,answer,bytes}); const proof=await waitFor(worker,'signed'); assert.equal(proof.requestId,setup.requestId); const delivery=waitFor(worker,'delivered'); @@ -364,6 +383,85 @@ async function acceptLive(dir,setup,label,cut,answer) { return proof; } +function diskSample(dir) { + const files={}; + for (const database of ['run.sqlite','outbox.sqlite','file-handoff.sqlite']) { + for (const suffix of ['','-wal','-shm']) { + const name=database+suffix; + const entry=statSync(path.join(dir,name),{throwIfNoEntry:false}); + if (entry) files[name]={length:entry.size,allocatedBytes:entry.blocks===undefined?null:entry.blocks*512}; + } + } + const entries=Object.values(files); + return {files,totalLength:entries.reduce((sum,file)=>sum+file.length,0), + totalAllocatedBytes:entries.some(file=>file.allocatedBytes===null)?null + :entries.reduce((sum,file)=>sum+file.allocatedBytes,0)}; +} + +function cleanupState(dir,artifact) { + const outbox=new DatabaseSync(path.join(dir,'outbox.sqlite'),{readOnly:true}); + const snapshot=new DatabaseSync(path.join(dir,'file-handoff.sqlite'),{readOnly:true}); + try { + return { + queue:outbox.prepare('SELECT proof_id,artifact,accepted_revision FROM snapshot_cleanup').all(), + fileReceipts:outbox.prepare("SELECT COUNT(*) AS n FROM delivery WHERE json_extract(CAST(observation AS TEXT),'$.tool')='flowkit_submit_file'").get().n, + audit:outbox.prepare('SELECT operation_id,deleted_count FROM cleanup_audit').all(), + snapshot:snapshot.prepare('SELECT state,size,sha256,accepted_revision FROM file_handoff WHERE id=?').get(artifact.id)??null, + }; + } finally {snapshot.close();outbox.close();} +} + +async function snapshotCleanupCrash(cut,bytes) { + const dir=path.join(root,'snapshot-'+cut); + mkdirSync(dir,{mode:0o700}); + const setup=await offline('outbox-setup',dir,'setup',{cut:'ops-file',bytes}); + const empty=diskSample(dir); + const proof=await acceptLive(dir,setup,'file','ops-file','ready',bytes); + const artifact=proof.artifact; + assert.equal(artifact.byteLength,bytes); + assert.equal(inspectSnapshot(dir,artifact).state,'acked'); + const live=inspect(dir,setup,[proof]); + assert.deepEqual(live.artifact,artifact); + assert.equal(live.status,'waiting'); + const retained=await offline('ops-clean',dir,'retained',{bytes}); + assert.equal(retained.result.deleted,0); + assert.equal(retained.result.snapshotsReclaimed,0); + const peak=diskSample(dir); + const clear=launch('outbox-worker',dir,'clear',{cut:'ops-clear',answer:'ready'}); + assert.equal((await clear.closed).code,0); + const before=inspect(dir,setup,[proof]); + assert.equal(before.status,'succeeded'); + assert.equal(before.artifact,null); + const cleaner=launch('ops-clean',dir,'cleaner',{cut,bytes}); + const barrier=await waitFor(cleaner,'cleanup-cut'); + assert.equal(barrier.cut,cut); + assert.equal(barrier.hostPid,cleaner.child.pid); + const atKill=cleanupState(dir,artifact); + assert.equal(atKill.queue.length,1); + assert.equal(atKill.queue[0].proof_id,proof.proofId); + assert.equal(atKill.fileReceipts,0); + assert.equal(atKill.audit.length,1); + assert.equal(atKill.snapshot?.state??null,cut==='after-queue'?'acked':null); + const during=diskSample(dir); + log('snapshot-cleanup-before-kill',{cut,artifact,barrier,atKill,during,before}); + assert.ok(cleaner.child.kill('SIGKILL')); + const killed=await cleaner.closed; + assert.equal(killed.signal,'SIGKILL'); + assert.deepEqual(cleanupState(dir,artifact),atKill); + log('snapshot-cleanup-kill-confirmed',{cut,pid:killed.pid,signal:killed.signal}); + const restored=await offline('ops-clean',dir,'restart',{bytes}); + assert.equal(restored.result.deleted,0); + assert.equal(restored.result.snapshotsReclaimed,1); + assert.equal(restored.ack.acceptedRevision,setup.revision+1); + assert.equal(restored.ack.receiptHash,proof.receiptHash); + assert.deepEqual(cleanupState(dir,artifact),{...atKill,queue:[],snapshot:null}); + const again=await offline('ops-clean',dir,'repeat',{bytes}); + assert.equal(again.result.snapshotsReclaimed,0); + assertSameStore(before,inspect(dir,setup,[])); + const after=diskSample(dir); + log('snapshot-cleanup-recovered',{cut,artifact,restored,again,disk:{empty,peak,during,after}}); +} + async function beforeSignCase() { const dir=path.join(root,'before-sign'); mkdirSync(dir,{mode:0o700}); @@ -490,7 +588,13 @@ async function revocationCase() { try { let result; - if (suite==='release-r5') { + if (suite==='ops-r5') { + await snapshotCleanupCrash('after-queue',262144); + await snapshotCleanupCrash('after-unlink',2093056); + await crossStoreRevocation({coordinated:true}); + await capacityAndCleanup(); + result={snapshotCleanupCuts:2,coordinatedRevocation:true,capacityAlerts:true}; + } else if (suite==='release-r5') { await r5ValidationRecovery(); await crossStoreRevocation(); await capacityAndCleanup(); From a9498ab1d06d66a59b793c761fa4717af45a5e3d Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 15:22:05 +0800 Subject: [PATCH 37/72] Record dual-account snapshot cleanup and operations evidence --- .../ops-r5-live.md | 77 +++++++++++++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 13 ++-- 2 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/ops-r5-live.md diff --git a/docs/evidence/p0a-codex-isolated-20261113/ops-r5-live.md b/docs/evidence/p0a-codex-isolated-20261113/ops-r5-live.md new file mode 100644 index 0000000..e629ff6 --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/ops-r5-live.md @@ -0,0 +1,77 @@ +# R5 文件清理强杀恢复与 Host 运维演练 + +基线 `2d56d04a4cdc788942a53ce61ed5f25301b0545b`,验收候选 `e0df84b23ec78b49fd8beb8e800076ce27102c74`。候选仅增加验收探针。Windows 使用当前机器两个普通账户,Linux 由 FlowKit 任务先拉取同一候选。每个场景使用全新 Host 私有测试目录和 SQLite 数据库。 + +```text +node tests/security/codex-outbox-live.mjs <全新Host私有根> isolated ops-r5 +``` + +## 两个清理切点 + +真实 Codex 会话提交 Host 固定的文件快照引用。Run 仍在等待下一任务且 State 引用文件时,受权清理保留原快照。第二个真实会话完成任务,测试工作流通过程序指定字段将 State 的引用清空。监督进程分别在下列持久化边界停止普通 Host 清理子进程。 + +| 切点 | 文件样本 | 强杀与恢复的断言 | +|---|---:|---| +| 清理任务已持久入队、快照尚在 | 262,144 B | Outbox 正文已删除,队列及审计已提交,快照为 `acked`。重启后删除快照并出队。 | +| 快照行已删除、清理任务尚未出队 | 2,093,056 B | 强杀后快照仍不存在,队列仍有原任务。重启识别快照已删除并完成出队。 | + +两次恢复都应保留一次原清理审计,重复清理无待处理任务,受权历史 Ack 仍为原 revision 4。Checkpoint、Journal 和接纳索引均需保持一致。进程终止发生在产品原有清理调用的外层受信回调边界。 + +## Windows 实测 + +本机普通 Host 和 Model 账户隔离,受信回环启动器按已绑定的工作目录启动真实 Codex App Server(初始化报告 `0.145.0`,Windows x86_64 build 26200)。运行编号 `outbox-20261113150055`,共 10 个真实会话,退出码 0,两个切点均通过。两次重启各处理一个待回收任务,重复清理处理 0 个,受权历史查询返回原 revision 4。第二个切点在重启前已看不到快照行。两例均保留一条原清理审计,Checkpoint、Journal 和接纳索引保持原值。 + +Windows 启动器执行的命令如下。原始脚本、启动日志、监督进程事件及已部署文件哈希随证据保留。 + +```powershell +& C:\ProgramData\FlowKit-Isolated-Lab\public\node.exe C:\ProgramData\FlowKit-Isolated-Lab\public\source\tests\security\codex-outbox-live.mjs C:\ProgramData\FlowKit-Isolated-Lab\public\codex.exe C:\ProgramData\FlowKit-Isolated-Lab\public\model-work C:\ProgramData\FlowKit-Isolated-Lab\host-private\outbox-20261113150055 isolated ops-r5 +``` + +| 文件样本 | 新库已分配字节 | 写入后已分配字节 | 强杀点已分配字节 | 恢复后已分配字节 | +|---|---:|---:|---:|---:| +| 262,144 B | 139,264 | 679,936 | 847,872 | 716,800 | +| 2,093,056 B | 139,264 | 4,341,760 | 4,509,696 | 4,378,624 | + +文件交接库在强杀点和清理恢复后均为 540,672 B 和 4,202,496 B(分别对应两个样本)。两次满额负例各写入一条本机告警,逻辑预留 5,218 B、1 条,数据库合计分配 147,456 B。跨 Store 演练在仅 A 确认时记录 B 待处理,B 的未授权调用返回 `FK_I_HOST_DENIED`。授权重试后,B 拒绝尚未过期的旧签名,原 Pending 停留在 revision 3。 + +Windows 原始归档 `flowkit-windows-ops-r5.zip` SHA-256 为 `21966bf9f7283b284e9d0f7d6b3bb43b233a86a6e7bd83297fb4a900a18cd2ba`。已复核归档内 76 个文件的逐项 SHA-256。日志保留 `supervisor.ndjson`、App Server 协议、Host 观察记录、公开验证钥和 `verified-results.json`。本轮为 P/M/T 级。文件交接、Outbox 和 Host 回归 27/27 通过,[候选 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43382790935) 8/8 通过。 + +## Linux 实测 + +FlowKit 任务先快进拉取同一候选,核对部署的 64 个文件哈希。使用普通 Host UID/GID `41001` 和 Model UID/GID `41002`,在 `/profile-ops-r5/attempt-1` 运行一次,退出码 0,约 88 秒,10 个真实会话。两次清理强杀后各完成 1 个待回收任务,重复清理完成 0 个,原 Ack 仍为 revision 4。独立复核涵盖 6 份签名、6 条完整 Journal 链和两份清理前的完整快照。清除 State 引用的两个调用保留原生事件、签名摘要和接纳记录,未留存其签名原字节。 + +Linux 正式运行的容器命令如下。外层控制器启动普通 Host 探针,再由既有受信启动链启动普通 Model 进程。精确进程参数和退出结果随归档保留。 + +```sh +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-ops-r5-controller.mjs attempt-1 +``` + +| 文件样本 | 强杀点文件长度/已分配字节 | 恢复后文件长度/已分配字节 | 恢复后文件交接库空闲页 | +|---|---:|---:|---:| +| 262,144 B | 811,208/815,104 | 716,800/716,800 | 128 | +| 2,093,056 B | 4,497,784/4,505,600 | 4,378,624/4,378,624 | 1,022 | + +两个快照表均为零行,文件交接库仍为 540,672 B 和 4,202,496 B。记录数与逻辑字节两类告警均触发,每次采样合计 147,456 B。跨 Store 演练也确认了 B 拒绝未授权撤销、保留待确认状态,补授权后才完成双库确认。 + +Linux 原始归档 `FlowKit-Linux-Ops-R5-20261113.zip` 由 [FlowKit 任务](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) 保管,SHA-256 为 `391c463d56f06e8f567f179b950c25e8b2c69dec36699a01c9468120cac6fad0`。Linux 端核对 ZIP 完整性和 296 个逐文件哈希。归档包含运行命令及退出码、监督和 App Server 日志、磁盘采样 CSV、独立验签报告及增量 Diff。结果为 P/M/T 级,完整 N/V 级及 G12 独立复审仍为 BLOCKED。 + +## 运维演练 + +- **跨 Store 撤销。** 每个平台用两个独立 Store 和同一测试公钥。A 应用撤销后进入部分完成状态。B 的未授权操作失败且 B 仍有效,演练保持未完成状态。对 B 授权重试后,逐库核对旧签名拒绝及历史接纳索引。测试监督器记录待确认和已确认清单。 +- **容量告警。** 用真实模型工具分别触发记录数和逻辑字节上限,核对每次拒绝都有 Host 告警,事件含 namespace、当前用量和生效上限;Pending、Journal 和接纳索引不变。告警记录在受保护的本地日志。 +- **物理占用。** 记录 Run、Outbox、文件交接三个数据库及当时存在的 WAL/SHM 文件长度。另记录运行时返回的已分配块字节。采样点为新库、已写快照、强杀边界和清理恢复后。该采样反映测试文件,不包含目录元数据、生产日志、备份和磁盘总余量。 + +## 生产参数建议 + +这些是部署压测起点,沿用现有示例的初始值,尚未冻结。 + +| 参数 | 建议 | +|---|---| +| 已对账正文保留窗口 | 对已终态、已对账且没有保留标记的正文,先以 30 天评估。窗口起点采用 Ack 落盘时间。未对账记录、有效引用、合规保留及 Store 接纳索引按既有保护规则处理。独立审计记录的期限由发布部署的审计政策决定。 | +| Outbox 逻辑配额 | 每个 Host Outbox 先以 64 MiB 或 4,096 条先到为上限做压测。按每个 Outbox 的高峰日写入量乘以保留天数,再加未对账积压和待清理引用,对条数与字节分别做预算。 | +| 磁盘预算和告警 | 对数据库、WAL、SHM、文件快照、审计和备份合计设置独立磁盘预算。以压测的峰值加恢复余量定额。可先在逻辑和磁盘预算达到 70% 发预警、85% 升级;触及硬上限维持已有拒绝与告警。 | +| 手工清理 | 保留每日检查和受权清理。逻辑记录删除后,SQLite 文件可能保留已分配页供后续复用。需要释放卷空间时另安排维护窗口和受控压缩操作,并在同一卷上验证空间回收和恢复。 | + +生产全量 Store 清单、暂停入口的外部控制、集中告警送达、最终磁盘预算及保留策略仍待部署验证。 + +正式协调时,以批准的 Host/Store 清单为撤销目标并记录逐项结果。先暂停仍可能用旧钥签发的入口,逐库执行并验证,全部确认后才恢复入口。本轮仅演练确认清单和部分失败处理,生产入口联动为 BLOCKED。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 6011dc9..5ae8e51 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -16,6 +16,7 @@ | 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) | | E8 | `d3674bca09d305f88828a77b1d3653937fc5da5e`、`5efa127cf8a26081985e454b9719619a39fdc4b2` | R5 恢复基线、validate 阶段恢复、撤销后原 Ack、两个独立 Store 分别撤销、Outbox 满额和清理。[平台裁决、命令、原始日志与 SHA-256](../p0a-codex-isolated-20261113/r5-release-live.md) | +| E9 | `e0df84b23ec78b49fd8beb8e800076ce27102c74` | Windows/Linux 双账户均通过文件快照清理两处强杀、两 Store 撤销协调演练、磁盘采样和本机容量告警,各 10 个真实会话。[命令、磁盘样本、归档 SHA-256 和参数建议](../p0a-codex-isolated-20261113/ops-r5-live.md) | ## SEC · 生产配置与候选入口 @@ -34,12 +35,12 @@ | 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 拒绝。E8 验证两独立 Store 分别撤销、未接纳旧回执拒绝和受权只读返回历史 Ack。生产全量 Store 协调、实际权限变更及适用的证书/KMS 操作待测。 | +| SEC-14 | BLOCKED,生产轮换与撤销 | E4 验证仍受信的两把钥轮换和原 Ack。E5 验证仅信任新钥时旧 Run 拒绝。E8 验证两独立 Store 分别撤销、未接纳旧回执拒绝和受权只读返回历史 Ack。E9 演练部分完成时保留待确认 Store、授权补撤销及逐库验收。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | | 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。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | -| SEC-19 | BLOCKED,生产有界保留 | E8 验证真实工具触发记录数及逻辑字节拒绝、保留标记、受权清理、审计写失败回滚和重试。生产期限、真实磁盘占用、告警及双账户文件快照清理中断仍待取证。 | +| SEC-19 | BLOCKED,生产有界保留 | E8 验证真实工具触发记录数及逻辑字节拒绝、保留标记、受权清理、审计写失败回滚和重试。E9 增补真实双账户快照清理强杀、数据库分配块采样和本机满额告警。生产保留期限、持续负载下的磁盘预算及集中告警送达仍待取证。 | ## NAT · Codex 等价验收 @@ -83,7 +84,7 @@ | C12 | BLOCKED,E1–E5 补齐多个原生子样本,完整 SEC/NAT 和来源链待复审。 | | C13 | PASS,P 级分支与预算。 | | C14 | PASS,P 级链中错误与超限拒绝。原生入口负例归 SEC-08/12。 | -| C15 | PASS,P 级历史重传、E3/E4 原生历史 Ack。E8 增补真实回执撤销后及清理后的受权历史对账样本。 | +| C15 | PASS,P 级历史重传、E3/E4 原生历史 Ack。E8 增补真实回执撤销后及清理后的受权历史对账样本。E9 文件快照清理强杀恢复后仍返回原 Ack。 | | C16 | BLOCKED,P 级可信失败路径已有。缺真实供应商失败。 | | C17 | PASS,P 级策略漂移拒绝;E5 补同类原生拒绝样本。 | | C18 | PASS,P 级多 Run 交错。 | @@ -130,10 +131,10 @@ ### 4. Outbox 保留与容量 -Host 部署配置审计期限和容量上限。保留待投递及未对账的记录、受合规保留或有效引用的快照和 Store 接纳索引。终态且已对账的数据过审计窗口后,由受权运维触发清理。达到容量上限时停止接收需落盘的新工作并告警。部署期限、容量和真实恢复演练仍需取证。 [Outbox 设计 §6.4](../../interactive-trusted-host-security-v2-design.md#64-host-only-观察证据与签名回执-outbox关闭-dr-13) +Host 部署配置保留期限和容量上限。保留待投递及未对账的记录、受合规保留或有效引用的快照和 Store 接纳索引。终态且已对账的数据过保留窗口后,由受权运维触发清理。达到容量上限时停止接收需落盘的新工作并告警。生产参数等待压测和确认。 [Outbox 设计 §6.4](../../interactive-trusted-host-security-v2-design.md#64-host-only-观察证据与签名回执-outbox关闭-dr-13) · [参数建议与实测占用](../p0a-codex-isolated-20261113/ops-r5-live.md) | 首发回归 | 可复核的 P 级覆盖 | 仍为 BLOCKED | |---|---|---| | R5 Checkpoint | SQLite 原字节与异步 Store 回读一致、首次接纳、管理审计和包消费者。[版本化 SDK 回归](../../../tests/interactive/versioned.test.mjs) · E8 记录真实强杀恢复。 | G12 独立裁决及完整原生故障矩阵。 | -| Ack、轮换与撤销 | 两条 SQLite 连接验证旧策略停止新建及撤销边界。[Host 回归](../../../tests/interactive/trusted-host.test.mjs) · E8 记录独立 Store 分别撤销及受权历史查询。 | 生产所有 Host/Store 的协调、撤销后签发停止及轮换演练。 | -| Outbox 容量和清理 | [Outbox 回归](../../../tests/interactive/codex-outbox.test.mjs) 覆盖文件快照回收中断重试。E8 验证真实工具满额、清理回滚及恢复。 | 生产容量和审计窗口、实际告警、清理后磁盘占用及真实双账户快照清理中断。配额计入活动记录和待清理引用的逻辑字节,数据库页、WAL、文件快照和审计另计。 | +| Ack、轮换与撤销 | 两条 SQLite 连接验证旧策略停止新建及撤销边界。[Host 回归](../../../tests/interactive/trusted-host.test.mjs) · E8 记录独立 Store 分别撤销及受权历史查询。E9 记录部分失败、补撤销和逐库确认。 | 生产所有 Host/Store 的协调、撤销后签发停止及轮换演练。 | +| Outbox 容量和清理 | [Outbox 回归](../../../tests/interactive/codex-outbox.test.mjs) 覆盖文件快照回收中断重试。E8 验证真实工具满额、清理回滚及恢复。E9 增补快照清理两处强杀、分配块采样和本机告警。 | 生产容量和保留窗口、集中告警送达及持续负载下的磁盘占用。配额计入活动记录和待清理引用的逻辑字节,数据库页、WAL、文件快照和审计另计。 | From 0d1da06bb3f8e56ebc54a2d32619c83d3cadea34 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 15:38:54 +0800 Subject: [PATCH 38/72] Exercise native Codex branching and protected terminal reads --- tests/security/codex-flowkit-probe.mjs | 210 +++++++++++++++++++++++-- tests/security/codex-outbox-live.mjs | 13 +- 2 files changed, 205 insertions(+), 18 deletions(-) diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 75f2800..d5ba91b 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -21,6 +21,8 @@ const scenario=['outbox-worker','outbox-recover','outbox-setup','outbox-next','o 'outbox-check-revocation','release-check','release-revoke','release-clean','ops-clean'].includes(requested); const worker=requested==='outbox-worker'; const offline=scenario&&!worker; +const branch=requested.startsWith('branch-')?requested.slice(7):null; +assert.ok(branch===null || ['approved','refused','closed','missing'].includes(branch)); 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'; @@ -29,7 +31,7 @@ const runRecord=['rotate-new','rotate-old-next','revoked-pending','release-cap-n const fallback=requested.startsWith('file-') || (scenario && scenarioBytes!==undefined); const clearArtifact=cut==='ops-clear'; const artifactSize=fallback?Number(scenario?scenarioBytes:requested.slice(5)):0; -const size=requested==='loop'||fallback||scenario?0:Number(requested); +const size=requested==='loop'||fallback||scenario||branch?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)); @@ -43,12 +45,18 @@ const journal=(event,details={})=>record('observer.ndjson',JSON.stringify({event 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 branchFields={requirementId:str,designId:str,planId:str,reviewApproved:{type:'boolean'}}; +const branchNodes=['requirements','design','plan','review']; +const branchInputKeys=['request','requirementId','designId','planId']; +const branchOutputs={requirementId:'REQ-001',designId:'DES-001',planId:'PLAN-001',reviewApproved:branch==='approved'}; +const branchMarker='HOST-PRIVATE-BRANCH-FIXTURE'; 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})), + :tool('flowkit_submit','Submit the result for the exact current FlowKit request',objectSchema(branch + ?{requestId:str,result:objectSchema(branchFields,[])}:{requestId:str,answer:str})), + tool('flowkit_continue',branch?'Read the next public task or terminal frame':'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)); @@ -101,6 +109,8 @@ const child=offline?null:spawn(command,args, child?.stdin.on('error',()=>{}); const pending=new Map(),starts=new Map(),completed=new Set(),successful=[]; let seq=0,threadId,turnId,turnStatus,failure,inlineObserved; +let branchTurnDone,branchAccess='model',branchNegative; +const branchReceipts=[]; let finish; const finished=new Promise(resolve=>{finish=resolve;}); let recordIdentity; @@ -109,7 +119,7 @@ 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); +const timer=offline?null:setTimeout(()=>finish('timeout'),branch?240000:size?95000:65000); function send(message) { record('app-server.requests.ndjson',JSON.stringify(message)+'\n'); @@ -122,7 +132,20 @@ function request(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'; + if (branch) { + const agents=branchNodes.map((id,index)=>{ + const field=Object.keys(branchFields)[index]; + return `node('${id}',{name:'${id}',agent:'${id}',inputs:['${branchInputKeys[index]}'], + output:{type:'object',properties:{${field}:{type:'${branchFields[field].type}'}}}, + retries:0,next:'${branchNodes[index+1]??'decision'}'})`; + }); + await writeFile(file,`import {defineWorkflow,condition,node,terminal} from '@devcodex-labs/flowkit/dsl'; +export default defineWorkflow({id:'codex-branch-probe',version:'1.0.0',name:'Codex branch probe',nodes:[ +condition('gate',{name:'Gate',branches:[{when:'ready',next:'requirements'},{when:'true',next:'declined'}]}), +${agents.join(',\n')}, +condition('decision',{name:'Decision',branches:[{when:'reviewApproved',next:'done'},{when:'true',next:'declined'}]}), +terminal('done',{name:'Done',status:'succeeded'}),terminal('declined',{name:'Declined',status:'failed'})]});`); + } else 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:'${cut==='ops-file'?'clear':'done'}'}), @@ -161,8 +184,14 @@ terminal('done',{name:'Done',status:'succeeded'}) 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) { + store, runtime:{skillResolver:async({nodeId})=>branch + ?branchInstruction(nodeId):'Read the public question and submit its requested answer.', + validateResult(schema,value) { + if (branch) { + const fields=Object.keys(schema.properties); + return Object.keys(value).length===fields.length && fields.every(field=>Object.hasOwn(value,field) + && value[field]===branchOutputs[field])?[]:['Unexpected branch result']; + } if (worker && cut==='release-after-receipt') { journal('release-validation-barrier',{runId:currentTask.runId,requestId:currentTask.requestId}); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,60000); @@ -173,11 +202,13 @@ terminal('done',{name:'Done',status:'succeeded'}) : (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', + allowedInputKeys:branch?branchInputKeys:['question'],disclosurePolicyId:'probe-v1',authorizeDisclosure:()=>true, + authorizeTerminalAccess:({action})=>action==='run:read' + || (branch!==null && branchAccess==='auditor' && action==='result:read'), authorizeReceiptAudit:({action})=>offline && action==='receipt:read-history', authorizeKeyRevocation:({action})=>requested==='release-revoke' && cut!=='denied' && action==='host:revoke-key', }); + if (branch) await writeFile(path.join(out,'proof.public.pem'),publicKey.export({type:'spki',format:'pem'})); let cleanupHandoff=handoff; if (requested==='ops-clean' && cut==='after-unlink') { cleanupHandoff={...handoff,reclaimAcknowledged(...args) { @@ -203,6 +234,13 @@ terminal('done',{name:'Done',status:'succeeded'}) } let key; +function branchInstruction(nodeId) { + const index=branchNodes.indexOf(nodeId); + assert.ok(index>=0); + const field=Object.keys(branchFields)[index]; + const input=branchInputKeys[index]; + return `Verify ${input} is ${JSON.stringify(index===0?'software task':branchOutputs[input])}. If it matches, submit result ${JSON.stringify({[field]:branchOutputs[field]})}.`; +} function stopAtCleanupCut() { process.send({event:'cleanup-cut',cut,hostPid:process.pid}); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,60000); @@ -227,6 +265,13 @@ async function acceptResult(result,identity,pending=currentTask) { statementDigest:sha256Bytes(statementBytes),...(result.artifact?{artifact:result.artifact}:{})}); const signature=sign(null,statementBytes,key).toString('base64url'); frame=await host.accept({receipt,attestation:{statement,signature}}); + if (branch) { + const indexed=store.getReceipt(frame.runId,receipt.requestId); + assert.equal(indexed.acceptedRevision,frame.acceptedRevision); + const proof={receipt,statement,signature,indexed,callId:identity.callId,threadId:identity.threadId,turnId:identity.turnId}; + branchReceipts.push(proof); + journal('branch-proof',proof); + } 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}; @@ -275,7 +320,59 @@ async function deliverCandidate(result,name,args,identity,started) { return {runId:frame.runId,...outbox.describe(proofId)}; } +function branchState() { + const checkpoint=store.load(frame.runId); + const journalRows=store.listJournal(frame.runId); + return {checkpoint,journalRows,indexed:currentTask?store.getReceipt(frame.runId,currentTask.requestId):null}; +} + +async function branchView() { + if (frame.status!=='waiting') return host.getPublicTerminalFrame({runId:frame.runId}); + currentTask=await host.getPublicAgentTask({runId:frame.runId}); + const index=branchReceipts.length; + const field=branchInputKeys[index]; + assert.equal(currentTask.nodeId,branchNodes[index]); + assert.equal(currentTask.runId,frame.runId); + assert.deepEqual(currentTask.missing,[]); + assert.deepEqual(currentTask.input,{[field]:index===0?'software task':branchOutputs[field]}); + assert.ok(!JSON.stringify(currentTask).includes(branchMarker)); + journal('branch-pending',{runId:currentTask.runId,requestId:currentTask.requestId,nodeId:currentTask.nodeId, + revision:frame.revision,input:currentTask.input}); + return currentTask; +} + +async function branchTool(name,args,identity) { + if (name==='flowkit_start') { + assert.equal(args.nonce,nonce); + assert.equal(frame,undefined); + const state={request:'software task',privateMarker:branchMarker}; + if (branch!=='missing') state.ready=branch!=='closed'; + frame=await host.start({programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, + startKey:nonce,state}); + return branchView(); + } + if (name==='flowkit_continue') { + assert.equal(args.runId,frame.runId); + return branchView(); + } + assert.equal(name,'flowkit_submit'); + const before=branchState(); + if (args.requestId!==currentTask?.requestId) { + assert.equal(branchNegative,'request'); + assert.deepEqual(branchState(),before); + journal('branch-request-denied',{runId:frame.runId,callId:identity.callId, + revision:before.checkpoint.revision,receiptCount:before.checkpoint.receiptCount,unchanged:true}); + return {conflict:true,code:'REQUEST_DENIED'}; + } + assert.equal(branchNegative,undefined); + assert.equal(frame.status,'waiting'); + const field=Object.keys(branchFields)[branchReceipts.length]; + assert.deepEqual(args.result,{[field]:branchOutputs[field]}); + return acceptResult(args.result,identity); +} + async function handleTool(name,args,identity,started) { + if (branch) return branchTool(name,args,identity); if (size) { const canonical=canonicalBytes(args); inlineObserved={expected:size,received:canonical.byteLength,matched:canonical.equals(expectedBytes), @@ -364,7 +461,9 @@ lines?.on('line',line=>{ } 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}); + if (branchNegative!=='observation' || p.item.tool!=='flowkit_submit') { + starts.set(p.item.id,{threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,arguments:p.item.arguments}); + } else journal('branch-start-withheld',{callId:p.item.id,threadId:p.threadId,turnId:p.turnId}); 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); @@ -372,8 +471,10 @@ lines?.on('line',line=>{ 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); + assert.ok((profile==='direct'||isolatedIdentity) && !completed.has(p.callId) && p.threadId===threadId && p.turnId===turnId); + assert.ok(started,'Missing Host observation'); + assert.ok( + 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)); @@ -388,6 +489,13 @@ lines?.on('line',line=>{ successful.push(p.tool); send({id:message.id,result:{success:!result.conflict,contentItems:[{type:'inputText',text:JSON.stringify(result)}]}}); } catch(error) { + if (branchNegative==='observation' && p.tool==='flowkit_submit' && error.message==='Missing Host observation' + && p.arguments.requestId===currentTask.requestId) { + journal('branch-observation-denied',{callId:p.callId,threadId,turnId,requestId:currentTask.requestId}); + successful.push(p.tool); + send({id:message.id,result:{success:false,contentItems:[{type:'inputText',text:'Missing Host observation'}]}}); + return; + } 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'}]}}); @@ -398,7 +506,8 @@ lines?.on('line',line=>{ } else if(message.method==='turn/completed') { turnStatus=p.turn.status; if (p.turn.error) failure=p.turn.error.message; - finish('turn-completed'); + if (branch) branchTurnDone(); + else finish('turn-completed'); } else if(message.method==='flowkit/profile/identity' && profile==='isolated') { const reported=p; try { @@ -424,6 +533,74 @@ lines?.on('line',line=>{ }); let isolatedIdentity; +async function branchTurn(model,prompt,tools) { + const start=successful.length; + const done=new Promise(resolve=>{branchTurnDone=resolve;}); + const {turn}=await request('turn/start',{threadId,input:[{type:'text',text:prompt}]}); + turnId=turn.id; + journal('turn',{model,threadId,turnId,pid:isolatedIdentity?.pid??child.pid,observerPid:process.pid}); + await done; + assert.equal(turnStatus,'completed'); + assert.equal(failure,undefined); + assert.deepEqual(successful.slice(start),tools); + assert.equal(completed.size,successful.length); +} + +async function runBranch(model) { + const active=['approved','refused'].includes(branch); + await branchTurn(model,`Call flowkit_start exactly once with nonce "${nonce}". Use no other tool in this turn.`,['flowkit_start']); + assert.equal(frame.status,active?'waiting':branch==='missing'?'errored':'failed'); + if (branch==='approved') { + const before=branchState(); + const signed=branchReceipts.length; + branchNegative='request'; + await branchTurn(model,'Fixture negative test: call flowkit_submit once with requestId "unbound-fixture-request" ' + +'and result {"requirementId":"REQ-001"}. After the expected rejection finish without retrying.', ['flowkit_submit']); + assert.deepEqual(branchState(),before); + assert.equal(branchReceipts.length,signed); + branchNegative='observation'; + await branchTurn(model,`Fixture negative test: call flowkit_submit once with requestId "${currentTask.requestId}" ` + +'and result {"requirementId":"REQ-001"}. After the expected rejection finish without retrying.', ['flowkit_submit']); + branchNegative=undefined; + assert.deepEqual(branchState(),before); + assert.equal(branchReceipts.length,signed); + } + if (active) { + const runId=frame.runId; + for (const nodeId of branchNodes) { + await branchTurn(model,`Call flowkit_continue with runId "${runId}". It returns the public ${nodeId} task. ` + +'Read its input and instruction. Call flowkit_submit exactly once using its requestId and the result required by the instruction. Then finish.', + ['flowkit_continue','flowkit_submit']); + assert.equal(frame.runId,runId); + } + } + const expectedStatus=branch==='approved'?'succeeded':branch==='missing'?'errored':'failed'; + assert.equal(frame.status,expectedStatus); + await branchTurn(model,`Call flowkit_continue once with runId "${frame.runId}" to read its public status. Then finish.`,['flowkit_continue']); + const before=branchState(); + const publicFrame=await host.getPublicTerminalFrame({runId:frame.runId}); + assert.equal(Object.hasOwn(publicFrame,'state'),false); + assert.ok(!JSON.stringify(publicFrame).includes(branchMarker)); + await assert.rejects(host.readTrustedTerminalResult({resultRef:publicFrame.resultRef}),{code:'FK_I_HOST_DENIED'}); + branchAccess='auditor'; + const trusted=await host.readTrustedTerminalResult({resultRef:publicFrame.resultRef}); + assert.equal(trusted.state.privateMarker,branchMarker); + if (active) assert.deepEqual(trusted.state,{ready:true,request:'software task',privateMarker:branchMarker,...branchOutputs}); + if (branch==='missing') assert.equal(trusted.causeCode,'FK_R002'); + assert.deepEqual(branchState(),before); + assert.equal(before.checkpoint.receiptCount,active?4:0); + assert.equal(new Set(branchReceipts.map(p=>p.receipt.requestId)).size,active?4:0); + assert.equal(before.journalRows.length,frame.revision+1); + assert.equal(before.journalRows.filter(row=>row.entry.kind==='publish-pending').length,active?4:0); + for (const [revision,row] of before.journalRows.entries()) { + assert.equal(row.revision,revision); + if (revision>0) assert.equal(row.previousHash,before.journalRows[revision-1].entryHash); + } + journal('branch-verified',{branch,programHash:program.programIdentity.artifactHash,publicFrame,trusted, + journalRows:before.journalRows,receipts:branchReceipts.length,protectedRead:true,unchanged:true}); + return 'branch-completed'; +} + async function run() { if (profile==='isolated') await identityReady; if (!size) key=await prepareFlowKit(); @@ -441,6 +618,7 @@ async function run() { multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}}, dynamicTools:worker?specs.filter(spec=>fallback?spec.name!=='flowkit_continue':spec.name==='flowkit_submit'):specs}); threadId=thread.id; + if (branch) return runBranch(model); 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 @@ -463,10 +641,10 @@ try { 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 + const passed=(branch?outcome==='branch-completed':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})); + console.log(JSON.stringify({passed,outcome,turnStatus,requestedBytes:size,artifactSize,profile,successful,inlineObserved,failure,branch})); if(!passed)process.exitCode=1; } } catch(error) {console.error(error.message);process.exitCode=1;} diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index 93c945b..7c64035 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -13,7 +13,7 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m 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','release-r5','ops-r5'].includes(suite)); +assert.ok(['original','followup','signing','revocation','release-r5','ops-r5','branch-r5'].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'), @@ -588,7 +588,16 @@ async function revocationCase() { try { let result; - if (suite==='ops-r5') { + if (suite==='branch-r5') { + const cases=[]; + for (const branch of ['approved','refused','closed','missing']) { + const observed=await offline('branch-'+branch,root,branch,{}); + assert.equal(observed.passed,true); + assert.equal(observed.branch,branch); + cases.push(observed); + } + result={cases}; + } else if (suite==='ops-r5') { await snapshotCleanupCrash('after-queue',262144); await snapshotCleanupCrash('after-unlink',2093056); await crossStoreRevocation({coordinated:true}); From 1eb68a9d6b81a464ef1f5f16fd7c6779efeb967d Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 16:05:16 +0800 Subject: [PATCH 39/72] Record dual-account Codex branch acceptance evidence --- .../branch-r5-live.md | 47 +++++++++++++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 11 +++-- 2 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/branch-r5-live.md diff --git a/docs/evidence/p0a-codex-isolated-20261113/branch-r5-live.md b/docs/evidence/p0a-codex-isolated-20261113/branch-r5-live.md new file mode 100644 index 0000000..837e81f --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/branch-r5-live.md @@ -0,0 +1,47 @@ +# R5 多阶段续跑与受权终态读取 + +起始 Draft 提交 `a9498ab1d06d66a59b793c761fa4717af45a5e3d`,实测候选 `0d1da06bb3f8e56ebc54a2d32619c83d3cadea34`。候选只扩展两份验收探针。运行使用真实 Codex App Server、分离的普通 Host/Model 账户和全新 SQLite 测试库。工作流和业务数据为合成样本。 + +```text +node tests/security/codex-outbox-live.mjs <全新Host私有根> isolated branch-r5 +``` + +## 样本与验收条件 + +- **多阶段续跑。** 每个真实 Agent 回合先经 `flowkit_continue` 读取同一个 Run 当前的公开任务,再经 `flowkit_submit` 提交该任务的结构化输出。Host 匹配实际工具开始和回调的 thread、turn、call 及规范参数后才签名。需求、设计、计划和审查各接纳一次,后一步只收到当前节点声明的上一步字段。 +- **分支。** 分别验证初始条件 `ready=false`、缺少 `ready`、四个 Agent 后真实工具提交 `reviewApproved=false`,以及两个条件都通过。初始拒绝和缺值样本仍有真实开始及读取回合,Run 的 Agent Pending 派发数和接纳数均须为零。模型按测试任务指令提交审查布尔值。 +- **受权读取。** 模型只收到终态公开 Frame。探针在模型回合结束后使用 Host 的同一个读取 API,先验证普通权限被拒,再切换到测试用的 Host 审计授权并核对完整 State。权限判定和读取在 Host 私有进程中执行,完整 State 只保存在 Host 观察日志。 +- **入口负例。** 真实工具回调带错 requestId 被拒。在另一回合,Host 有意扣留探针的开始事件索引,实际回调因缺观察被拒。两次均对比 Checkpoint、Journal 和接纳索引,确认零签名、零 CAS,随后合法回合接纳同一 Pending。 + +## Windows 实测 + +运行 `branch-20261113154026` 退出码 0。已核对两份部署探针的 SHA-256,测试后回环监听数为 0。四个真实 App Server 会话共 18 个模型回合,独立复核 8 份 Ed25519 签名和 4 条完整 Journal 链。Program SHA-256 为 `8174a1dcb733e02b74f2679c9f160b7574e0480c7e0ca6eb1fb9645a51477866`。 + +| 分支 | 真实回合 | 已接纳 | 终态与 revision | Journal | +|---|---:|---:|---|---:| +| 通过 | 8 | 4 | `succeeded`,34 | 35 | +| 审查拒绝 | 6 | 4 | `failed`,34 | 35 | +| 初始条件 false | 2 | 0 | `failed`,4 | 5 | +| 初始条件缺值 | 2 | 0 | `errored`,2,Host 私有原因 `FK_R002` | 3 | + +两组四 Agent 的接纳 revision 均为 6、13、20、27。独立复核关联了八份签名的开始、回调、参数和接纳摘要,核对了两次负例的失败响应和签名数,模型侧协议请求不含 Host 私有标记。 + +```powershell +& C:\ProgramData\FlowKit-Isolated-Lab\public\node.exe C:\ProgramData\FlowKit-Isolated-Lab\public\source\tests\security\codex-outbox-live.mjs C:\ProgramData\FlowKit-Isolated-Lab\public\codex.exe C:\ProgramData\FlowKit-Isolated-Lab\public\model-work C:\ProgramData\FlowKit-Isolated-Lab\host-private\branch-20261113154026 isolated branch-r5 +``` + +Windows 原始归档 `FlowKit-Windows-Branch-R5-20261113.zip` 的 SHA-256 为 `89eef48d085e44099041700996cea7b50b8b6305f5691e554c3335adcc9e6250`。归档内 33 份文件已逐项复核。原件含启动脚本、监督日志、协议、Host 观察、公开验签钥和可重跑的独立复核脚本。 + +## Linux 实测 + +FlowKit 任务先快进拉取同一候选并复核 64 个部署文件。外层控制器启动独立普通 Host 和 Model 账户。四个场景各跑一次,合计 4 个 App Server 会话、18 个真实回合、26 次动态工具回调,正式运行约 142 秒,退出码 0。四组终态、接纳数、revision、Journal 行数和 Program 摘要与 Windows 表一致。独立复核通过 8 份原始 Ed25519 签名、4 条 Journal 链,并核对终态 R5 快照与受权读取返回的 State 一致。 + +```sh +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-branch-r5-controller.mjs attempt-1 +``` + +Linux 原始归档 `FlowKit-Linux-Branch-R5-20261113.zip` 由 [FlowKit 任务](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) 保管,SHA-256 为 `9a6bdf89a0b6af3f9d601288c59a5f72a70156f144114e6588bd548644a5f2cd`。Linux 端核对了 ZIP 完整性和 254 份文件的逐项哈希。归档含正式命令和退出码、动态工具协议、终态 R5 快照、签名原字节、案例 CSV 和独立复核脚本。 + +## 证据边界 + +本批样本为 P/M/T 级。真实工具上的错误请求和扣留观察仅覆盖已绑定 stdio 会话的拒绝。当前探针固定 namespace 和普通 Model SID/UID,独立主体与跨租户入口隔离仍为 BLOCKED。真实迟到及失配事件、异常退出、完整来源链、生产授权及 G12 独立复审按原矩阵继续取证。定向回归 38/38,[候选 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43387262536) 8/8。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 5ae8e51..bda9a49 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -17,6 +17,7 @@ | E7 | `eb5823094cdee0f5d8fe8a558a9824e1c712db4d`、`7acdfdd0137bc34cd4407ee9ffb8c2fa1e5a18a7`、`9584184d7098a402a15f3edd8bfb11ea95f7ed49` | 64 KiB 同账户真实内联成功。256 KiB 和近 2 MiB 的内联试验均未发出工具调用。文件替换、链接、截断、摘要和重复消费有定向 P 级回归。[内联记录](../p0a-admin-codex-20261113/README.md) · [文件入口记录](../p0a-codex-file-fallback-20261113/README.md) | | E8 | `d3674bca09d305f88828a77b1d3653937fc5da5e`、`5efa127cf8a26081985e454b9719619a39fdc4b2` | R5 恢复基线、validate 阶段恢复、撤销后原 Ack、两个独立 Store 分别撤销、Outbox 满额和清理。[平台裁决、命令、原始日志与 SHA-256](../p0a-codex-isolated-20261113/r5-release-live.md) | | E9 | `e0df84b23ec78b49fd8beb8e800076ce27102c74` | Windows/Linux 双账户均通过文件快照清理两处强杀、两 Store 撤销协调演练、磁盘采样和本机容量告警,各 10 个真实会话。[命令、磁盘样本、归档 SHA-256 和参数建议](../p0a-codex-isolated-20261113/ops-r5-live.md) | +| E10 | `0d1da06bb3f8e56ebc54a2d32619c83d3cadea34` | Windows/Linux 双账户各 18 个真实模型回合。4 Agent+2 Conditions 多阶段续跑、受权终态读取、早拒与缺值零派发、错请求和扣留观察拒绝。[分平台结果、命令和原始归档 SHA-256](../p0a-codex-isolated-20261113/branch-r5-live.md) | ## SEC · 生产配置与候选入口 @@ -26,7 +27,7 @@ | 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-05 | BLOCKED,候选入口授权 | E1 真实 stdio 工具通道正常。E10 补实际 stdio 工具错 requestId、Host 扣留开始观察时拒绝及零签名、零 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 和正文,核对无写入及错误脱敏。 | @@ -52,7 +53,7 @@ | 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-05 | PASS,双平台 Codex 实测样本 | E10 同一 Run 依次读取四个合法 Pending,公开终态无私有 State。探针在 Host 私有进程验证普通权限拒绝及受权完整读取。发布启动链和实际审计主体仍待 G12 确认。 | | NAT-06 | BLOCKED,普通进程重放 | 对应 SEC-06。补绕过实际 Codex 通道时的拒绝、零签名、零 CAS。 | | NAT-07 | BLOCKED,自报来源 | 对应 SEC-09。补伪造来源字段无法替代独立观察的真实入口负例。 | | NAT-08 | PASS,强杀恢复与新会话重试样本 | E4 签名前强杀后原 `OBSERVED` 不自动接纳,新会话使用新 call ID 对同一 Pending 成功重试。E2 单独验证使用原证明的自动恢复。 | @@ -61,7 +62,7 @@ | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | -| NAT-14 | BLOCKED,完整时序与死锁负例 | E1/E2/E3 正常路径已观察到原生开始事件、匹配规范入参后才签收。补真实候选入口缺失、迟到或失配开始事件,以及有界超时、零签名、零 CAS 的可复验记录。 | +| NAT-14 | BLOCKED,完整时序与死锁负例 | E1/E2/E3 正常路径匹配原生开始和回调后签收。E10 对实际回调有意扣留开始事件索引,核对拒绝、零签名和零 CAS。迟到、失配和有界超时仍待测。 | | NAT-15 | PASS,双平台 Codex 实测样本 | E2 用原签名、回执和同一 Run 恢复签后 CAS 前切点。E3 增补文件引用与 CAS 后 Ack 修复,E4 增补真实过期拒绝。完整 N 级随 C12/S13 的组合证据复审。 | ## C01–C24 与阶段收口 @@ -70,8 +71,8 @@ | 条款 | 当前裁决与证据 | |---|---| -| C01 | BLOCKED,E1 是单 Agent 闭环。缺真实 4 Agent+2 Conditions 的原生记录。 | -| C02 | BLOCKED,条件 false 有 P 证据。缺真实模型拒绝和未派发路径的组合记录。 | +| C01 | PASS,E10 双平台 4 Agent+2 Conditions 的真实工具续跑,逐次核对声明输入、四份接纳和终态 State。 | +| C02 | PASS,E10 双平台真实工具提交审查拒绝后终态 `failed`,初始 false 及缺值均为零 Agent 派发。模型按用例指定的分支值提交。 | | C03 | BLOCKED,有受控无效结果重放。缺原生非法输出、按同一 Run 重试并恢复。 | | C04 | BLOCKED,重试耗尽有 P 证据。缺真实持续失败及完整工具日志。 | | C05 | BLOCKED,E2/E3/E4 的原生强杀和恢复样本 PASS。Pending 发布及纯校验前后的完整原生故障切点归 SEC-10/T29。 | From 60b745e1708f2e7c10b88254912fa074b9489d3e Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 16:15:42 +0800 Subject: [PATCH 40/72] Exercise native Codex session binding and observation deadlines --- tests/security/codex-flowkit-probe.mjs | 207 ++++++++++++++++++++++++- tests/security/codex-outbox-live.mjs | 9 +- 2 files changed, 208 insertions(+), 8 deletions(-) diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index d5ba91b..eb80fd7 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import {execFileSync,spawn} from 'node:child_process'; -import {createPrivateKey,createPublicKey,generateKeyPairSync,randomUUID,sign} from 'node:crypto'; +import {createPrivateKey,createPublicKey,generateKeyPairSync,randomUUID,sign,verify} from 'node:crypto'; import {appendFileSync,mkdirSync} from 'node:fs'; import {mkdir,mkdtemp,readFile,rm,writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; @@ -21,6 +21,7 @@ const scenario=['outbox-worker','outbox-recover','outbox-setup','outbox-next','o 'outbox-check-revocation','release-check','release-revoke','release-clean','ops-clean'].includes(requested); const worker=requested==='outbox-worker'; const offline=scenario&&!worker; +const entry=requested==='entry-r5'; const branch=requested.startsWith('branch-')?requested.slice(7):null; assert.ok(branch===null || ['approved','refused','closed','missing'].includes(branch)); const rotation=['rotate-old','rotate-new','rotate-old-next'].includes(cut); @@ -31,11 +32,12 @@ const runRecord=['rotate-new','rotate-old-next','revoked-pending','release-cap-n const fallback=requested.startsWith('file-') || (scenario && scenarioBytes!==undefined); const clearArtifact=cut==='ops-clear'; const artifactSize=fallback?Number(scenario?scenarioBytes:requested.slice(5)):0; -const size=requested==='loop'||fallback||scenario||branch?0:Number(requested); +const size=requested==='loop'||fallback||scenario||branch||entry?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)); +if (entry) assert.equal(profile,'isolated'); mkdirSync(out,{recursive:false}); const nonce=randomUUID(); const bodyLength=size===0?0:size-Buffer.byteLength(JSON.stringify({nonce,payload:''})); @@ -111,6 +113,10 @@ const pending=new Map(),starts=new Map(),completed=new Set(),successful=[]; let seq=0,threadId,turnId,turnStatus,failure,inlineObserved; let branchTurnDone,branchAccess='model',branchNegative; const branchReceipts=[]; +const entrySessions=new Map(),entryResources=new Map(),entryObservations=new Map(); +let entryCase,entryDenial,entryDone,entryDelay,entryDatabase,entryObservationReady; +let entrySignatures=0; +const observationWaitMs=500; let finish; const finished=new Promise(resolve=>{finish=resolve;}); let recordIdentity; @@ -119,7 +125,7 @@ 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'),branch?240000:size?95000:65000); +const timer=offline?null:setTimeout(()=>finish('timeout'),branch||entry?240000:size?95000:65000); function send(message) { record('app-server.requests.ndjson',JSON.stringify(message)+'\n'); @@ -154,6 +160,7 @@ 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)); + if (entry) return; store=createSqliteCheckpointStore(path.join(fixture,'run.sqlite'),{namespace:'codex-proof'}); let publicKey,privateKey; if (!scenario || (requested==='outbox-setup' && cut!=='release-shared')) { @@ -371,6 +378,95 @@ async function branchTool(name,args,identity) { return acceptResult(args.result,identity); } +function denyEntry(code) { + throw Object.assign(new Error(code),{code}); +} + +function captureEntryStart(p) { + const observation={threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,arguments:p.item.arguments}; + const callId=p.item.id; + if (entryCase==='timeout') return; + if (entryCase==='late') { + entryDelay=()=>new Promise(resolve=>setTimeout(()=>{ + journal('entry-late-observation',{callId,threadId:p.threadId,turnId:p.turnId,discarded:true}); + resolve(); + },25)); + return; + } + if (entryCase==='mismatch') observation.arguments={...observation.arguments,answer:'different-fixture-value'}; + entryObservations.set(callId,observation); + entryObservationReady?.(); +} + +async function submitEntry(p) { + const context=entrySessions.get(p.threadId); + if (!context || context.modelPid!==isolatedIdentity?.pid || p.threadId!==threadId || p.turnId!==turnId + || p.tool!=='flowkit_submit' || completed.has(p.callId)) denyEntry('SESSION_DENIED'); + const target=entryResources.get(p.arguments.requestId); + if (!target || target.namespace!==context.namespace || target.subject!==context.subject) denyEntry('TARGET_DENIED'); + const {store,task,host,key,publicKey}=target; + const checkpoint=store.load(task.runId); + if (checkpoint.status!=='waiting' || checkpoint.pending?.requestId!==task.requestId) denyEntry('TARGET_DENIED'); + if (!entryObservations.has(p.callId)) { + const begin=performance.now(); + await new Promise(resolve=>{ + const deadline=setTimeout(resolve,observationWaitMs); + entryObservationReady=()=>{clearTimeout(deadline);resolve();}; + }); + entryObservationReady=undefined; + if (!entryObservations.has(p.callId) || performance.now()-begin>=observationWaitMs) { + journal('entry-observation-deadline',{callId:p.callId,waitedMs:performance.now()-begin,limitMs:observationWaitMs}); + denyEntry('OBSERVATION_TIMEOUT'); + } + } + const observed=entryObservations.get(p.callId); + if (observed.threadId!==p.threadId || observed.turnId!==p.turnId || observed.tool!==p.tool + || !canonicalBytes(observed.arguments).equals(canonicalBytes(p.arguments))) denyEntry('OBSERVATION_MISMATCH'); + assert.deepEqual(p.arguments,{requestId:task.requestId,answer:'ready'}); + const receipt={runId:task.runId,requestId:task.requestId,nodeId:task.nodeId,attempt:task.attempt, + kind:'agent-result',result:{answer:p.arguments.answer}}; + const issuedAt=Date.now(); + const statement=createAgentReceiptStatement({run:checkpoint,receipt,keyId:'proof',proofId:p.callId,issuedAt,expiresAt:issuedAt+30000}); + const statementBytes=encodeAgentReceiptStatement(statement); + const signature=sign(null,statementBytes,key); + entrySignatures++; + journal('entry-signed',{callId:p.callId,subject:target.subject,namespace:target.namespace,receipt,statement, + signature:signature.toString('base64url'),publicKey}); + const result=await host.accept({receipt,attestation:{statement,signature:signature.toString('base64url')}}); + const indexed=store.getReceipt(task.runId,task.requestId); + assert.ok(verify(null,statementBytes,publicKey,signature)); + assert.equal(indexed.digest,statement.receiptHash); + assert.equal(indexed.acceptedRevision,result.acceptedRevision); + assert.equal(result.status,'succeeded'); + assert.equal(result.state.tenantMarker,target.namespace); + journal('entry-accepted',{callId:p.callId,subject:target.subject,namespace:target.namespace,indexed,revision:result.revision}); + return {accepted:true,revision:result.acceptedRevision}; +} + +async function handleEntryCallback(message) { + const p=message.params; + const begin=performance.now(); + journal('entry-callback',{case:entryCase,callId:p.callId,threadId:p.threadId,turnId:p.turnId}); + let result; + try { + result=await submitEntry(p); + assert.equal(entryDenial,undefined); + } catch(error) { + if (entryDenial===undefined || error.code!==entryDenial) { + failure=error.message; + journal('entry-unexpected',{callId:p.callId,code:error.code,detail:error.message}); + } + journal('entry-rejected',{case:entryCase,callId:p.callId,code:error.code,elapsedMs:performance.now()-begin}); + result={accepted:false,code:'REQUEST_DENIED'}; + } finally { + entryObservations.delete(p.callId); + } + assert.equal(entryDone,undefined); + entryDone=result; + successful.push(p.tool); + send({id:message.id,result:{success:result.accepted,contentItems:[{type:'inputText',text:JSON.stringify(result)}]}}); +} + async function handleTool(name,args,identity,started) { if (branch) return branchTool(name,args,identity); if (size) { @@ -461,6 +557,7 @@ lines?.on('line',line=>{ } const p=message.params; if(message.method==='item/started' && p.item.type==='dynamicToolCall') { + if (entry) {captureEntryStart(p);return;} if (branchNegative!=='observation' || p.item.tool!=='flowkit_submit') { starts.set(p.item.id,{threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,arguments:p.item.arguments}); } else journal('branch-start-withheld',{callId:p.item.id,threadId:p.threadId,turnId:p.turnId}); @@ -468,6 +565,7 @@ lines?.on('line',line=>{ } else if(message.method==='item/completed' && p.item.type==='dynamicToolCall') { completed.add(p.item.id); } else if (message.method==='item/tool/call') { + if (entry) {void handleEntryCallback(message).catch(error=>{failure=error.message;finish('entry-error');});return;} void (async()=>{ const started=starts.get(p.callId); try { @@ -506,7 +604,7 @@ lines?.on('line',line=>{ } else if(message.method==='turn/completed') { turnStatus=p.turn.status; if (p.turn.error) failure=p.turn.error.message; - if (branch) branchTurnDone(); + if (branch||entry) branchTurnDone(); else finish('turn-completed'); } else if(message.method==='flowkit/profile/identity' && profile==='isolated') { const reported=p; @@ -546,6 +644,101 @@ async function branchTurn(model,prompt,tools) { assert.equal(completed.size,successful.length); } +function entrySnapshot() { + const db=new DatabaseSync(entryDatabase,{readOnly:true}); + try { + return { + runs:db.prepare('SELECT * FROM fk_i_runs ORDER BY namespace,run_id').all(), + journal:db.prepare('SELECT * FROM fk_i_journal ORDER BY namespace,run_id,revision').all(), + receipts:db.prepare('SELECT * FROM fk_i_receipt_index ORDER BY namespace,run_id,request_id').all(), + }; + } finally {db.close();} +} + +async function openEntrySession(binding,model) { + const {thread}=await request('thread/start',{cwd,model,ephemeral:true,approvalPolicy:'never',sandbox:'read-only', + multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}},dynamicTools:specs.filter(spec=>spec.name==='flowkit_submit')}); + const session=Object.freeze({...binding,threadId:thread.id,modelPid:isolatedIdentity.pid}); + entrySessions.set(thread.id,session); + journal('entry-session',session); + return session; +} + +async function bindEntrySubject(binding,model) { + const store=createSqliteCheckpointStore(entryDatabase,{namespace:binding.namespace}); + let writes=0; + const instrumentedStore={...store,compareAndSwap(input){writes++;return store.compareAndSwap(input);}}; + const pair=generateKeyPairSync('ed25519'); + const publicKey=pair.publicKey.export({type:'spki',format:'pem'}); + const host=createTrustedInteractiveHost({store:instrumentedStore, + runtime:{skillResolver:async()=> 'Submit ready for the current task.', + validateResult:(_schema,value)=>value.answer==='ready'?[]:['Expected ready']}, + trustedPublicKeys:{proof:pair.publicKey},trustedArtifacts:[program.programIdentity.artifactHash], + allowedInputKeys:['question'],disclosurePolicyId:'entry-r5-fixture',authorizeDisclosure:()=>true}); + const frame=await host.start({runId:'entry-shared-run',startKey:'entry-shared-start', + programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, + state:{question:'Return ready.',tenantMarker:binding.namespace}}); + assert.equal(frame.status,'waiting'); + const task=await host.getPublicAgentTask({runId:frame.runId}); + assert.deepEqual(task.input,{question:'Return ready.'}); + const session=await openEntrySession(binding,model); + const target={...binding,store,host,task,key:pair.privateKey,publicKey,writes:()=>writes}; + assert.ok(!entryResources.has(task.requestId)); + entryResources.set(task.requestId,target); + journal('entry-binding',{...session,runId:task.runId,requestId:task.requestId,revision:frame.revision}); + return {session,target}; +} + +async function entryTurn(model,session,target,test,expectedDenial) { + threadId=session.threadId; + entryCase=test; + entryDenial=expectedDenial; + entryDone=undefined; + entryDelay=undefined; + const before=entrySnapshot(); + const signedBefore=entrySignatures; + const counts=()=>[...entryResources.values()].map(resource=>resource.writes()); + const beforeWrites=counts(); + const requestId=target.task.requestId; + await branchTurn(model,`Synthetic fixture. Call flowkit_submit exactly once with requestId "${requestId}" and answer "ready". ` + +'After the tool response, finish without retrying.', ['flowkit_submit']); + assert.equal(entryDone?.accepted,expectedDenial===undefined); + if (test==='late') assert.ok(entryDelay); + await entryDelay?.(); + const after=entrySnapshot(); + assert.equal(entrySignatures-signedBefore,expectedDenial?0:1); + if (expectedDenial) { + assert.deepEqual(before,after); + assert.deepEqual(beforeWrites,counts()); + } + journal('entry-case',{case:test,subject:session.subject,namespace:session.namespace,targetNamespace:target.namespace, + targetSubject:target.subject,requestId,accepted:entryDone.accepted,signatures:entrySignatures-signedBefore, + beforeWrites,afterWrites:counts(), + before:sha256Bytes(canonicalBytes(before)),after:sha256Bytes(canonicalBytes(after))}); +} + +async function runEntry(model) { + entryDatabase=path.join(fixture,'entry.sqlite'); + const a=await bindEntrySubject({subject:'tester-a',namespace:'tenant-a'},model); + const b=await bindEntrySubject({subject:'tester-b',namespace:'tenant-b'},model); + const peer=await openEntrySession({subject:'tester-b',namespace:'tenant-a'},model); + await entryTurn(model,peer,a.target,'wrong-subject','TARGET_DENIED'); + await entryTurn(model,peer,b.target,'wrong-tenant','TARGET_DENIED'); + await entryTurn(model,a.session,a.target,'mismatch','OBSERVATION_MISMATCH'); + await entryTurn(model,a.session,a.target,'timeout','OBSERVATION_TIMEOUT'); + await entryTurn(model,a.session,a.target,'late','OBSERVATION_TIMEOUT'); + await entryTurn(model,a.session,a.target,'valid-a'); + await entryTurn(model,b.session,b.target,'valid-b'); + const end=entrySnapshot(); + assert.deepEqual(end.runs.map(row=>[row.namespace,row.run_id,row.start_key,row.revision]), + [['tenant-a','entry-shared-run','entry-shared-start',9],['tenant-b','entry-shared-run','entry-shared-start',9]]); + assert.deepEqual(end.receipts.map(row=>[row.namespace,row.request_id,row.accepted_revision]), + [['tenant-a',a.target.task.requestId,4],['tenant-b',b.target.task.requestId,4]]); + for (const {store,task} of [a.target,b.target]) assert.equal(store.listJournal(task.runId).length,10); + journal('entry-verified',{snapshot:end,programHash:program.programIdentity.artifactHash}); + return 'entry-completed'; +} + async function runBranch(model) { const active=['approved','refused'].includes(branch); await branchTurn(model,`Call flowkit_start exactly once with nonce "${nonce}". Use no other tool in this turn.`,['flowkit_start']); @@ -614,6 +807,7 @@ async function run() { send({method:'initialized'}); const {data}=await request('model/list',{limit:30,includeHidden:false}); const model=data.find(entry=>entry.isDefault)?.model ?? data[0]?.model; + if (entry) return runEntry(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}); @@ -641,10 +835,10 @@ try { 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=(branch?outcome==='branch-completed':outcome==='turn-completed' && turnStatus==='completed' && !failure + const passed=(entry?outcome==='entry-completed':branch?outcome==='branch-completed':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,branch})); + console.log(JSON.stringify({passed,outcome,turnStatus,requestedBytes:size,artifactSize,profile,successful,inlineObserved,failure,branch,entry})); if(!passed)process.exitCode=1; } } catch(error) {console.error(error.message);process.exitCode=1;} @@ -663,6 +857,7 @@ finally { handoff?.close(); outbox?.close(); store?.close(); + for (const resource of entryResources.values()) resource.store.close(); if(fixture&&!scenario) await rm(fixture,{recursive:true,force:true}); if (worker&&process.connected) process.disconnect(); } diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index 7c64035..385fb7a 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -13,7 +13,7 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m 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','release-r5','ops-r5','branch-r5'].includes(suite)); +assert.ok(['original','followup','signing','revocation','release-r5','ops-r5','branch-r5','entry-r5'].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'), @@ -588,7 +588,12 @@ async function revocationCase() { try { let result; - if (suite==='branch-r5') { + if (suite==='entry-r5') { + const observed=await offline('entry-r5',root,'entry',{ }); + assert.equal(observed.passed,true); + assert.equal(observed.entry,true); + result={entry:observed}; + } else if (suite==='branch-r5') { const cases=[]; for (const branch of ['approved','refused','closed','missing']) { const observed=await offline('branch-'+branch,root,branch,{}); From 154fe33981a7f9b7cd7436ccc1339cb5d4437c21 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 16:30:50 +0800 Subject: [PATCH 41/72] Record dual-account Codex entry binding evidence --- .../entry-r5-live.md | 42 +++++++++++++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 9 ++-- 2 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/entry-r5-live.md diff --git a/docs/evidence/p0a-codex-isolated-20261113/entry-r5-live.md b/docs/evidence/p0a-codex-isolated-20261113/entry-r5-live.md new file mode 100644 index 0000000..73c791b --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/entry-r5-live.md @@ -0,0 +1,42 @@ +# R5 Codex 入口的主体、租户与观察时序 + +Draft 起点 `1eb68a9d6b81a464ef1f5f16fd7c6779efeb967d`,双平台实测候选 `60b745e1708f2e7c10b88254912fa074b9489d3e`。候选增加验收探针及运行入口。启动器核验普通 Model 进程的 OS 身份。Host 用启动时配置把实际 App Server 线程绑定到测试主体和命名空间,回调 JSON 仅提供 `requestId` 和结果。Host 通过私有授权表查找目标并核对当前 Pending、主体、租户以及工具开始和回调的规范参数,随后才生成签名。两租户共用一个 R5 SQLite 文件,使用独立的命名空间、签名钥及私有状态,同用 `entry-shared-run` 和 `entry-shared-start`。R5 §11 规定从受信 Host 会话固定命名空间,正式部署还须提供主体认证来源。 + +```text +node tests/security/codex-outbox-live.mjs <全新Host私有根> isolated entry-r5 +``` + +| 样本 | 入口行为和断言 | +|---|---| +| 错主体 | 同租户中 `tester-b` 访问属于 `tester-a` 的 Pending。工具返回统一拒绝。 | +| 错租户 | `tester-b` 的 `tenant-a` 会话提交 `tester-b` 在 `tenant-b` 的 Pending。工具返回同样的统一拒绝。 | +| 观察失配 | Host 故障注入改变观察中的参数值,真实回调保持原样。签前比较拒绝。 | +| 观察超时 | Host 丢弃实际开始事件的索引,收到真实回调后在测试等待上限 500 ms 拒绝。 | +| 观察迟到 | Host 延迟交付已捕获的开始观察,先按同一上限拒绝回调,再丢弃迟到观察。 | +| 两个合法请求 | 每个合法会话提交一次,分别在本租户 revision 4 接纳,终态为 `succeeded`、revision 9,Journal 各 10 行。 | + +每个拒绝分别核对 Ed25519 签名计数增量 0、两租户 `compareAndSwap` 调用次数增量 0,并逐行比较两个租户的 Checkpoint 原始 JSON、Journal 和接纳索引。合法样本各产生一份可独立复核的签名和接纳记录。 + +## Windows 实测 + +运行 `entry-20261113161559` 退出码 0。独立普通 Host 和 Model 账户,1 个真实 App Server、3 个线程、7 个模型回合和 7 次真实工具回调,样本全部通过。超时样本实际等待 **502.45 ms**,迟到样本先等待 **513.75 ms** 后拒绝,再丢弃迟到观察。独立复核通过两份签名和两条完整 Journal 链,部署探针哈希与候选相同,测试后回环监听数为 0。 + +```powershell +& C:\ProgramData\FlowKit-Isolated-Lab\public\node.exe C:\ProgramData\FlowKit-Isolated-Lab\public\source\tests\security\codex-outbox-live.mjs C:\ProgramData\FlowKit-Isolated-Lab\public\codex.exe C:\ProgramData\FlowKit-Isolated-Lab\public\model-work C:\ProgramData\FlowKit-Isolated-Lab\host-private\entry-20261113161559 isolated entry-r5 +``` + +原始归档 `FlowKit-Windows-Entry-R5-20261113.zip` 的 SHA-256 为 `bafb386a9f433a644d48f25ba81af0ae9abbe1dd82552005f67616a71aa73d92`。16 份归档文件已逐项复核,含启动脚本、原始协议、Host 观察、签名与公开验证钥和独立复核脚本。 + +## Linux 实测 + +FlowKit 先快进拉取候选,验证 64 个部署文件,再从新私有根用独立普通 Host 和 Model 账户运行。首次正式执行约 44 秒,退出码 0。1 个真实 App Server、3 个线程、7 个模型回合和 7 次真实工具回调,全部通过。独立复核了两份原始签名、20 行 Journal 与两个租户的回执索引。超时实际等待 **501.39 ms**,迟到场景 **500.74 ms**,后交付的观察被丢弃。容器和登录保留,测试进程已回收。 + +```sh +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-entry-r5-controller.mjs attempt-1 +``` + +原始归档 `FlowKit-Linux-Entry-R5-20261113.zip` 的 SHA-256 为 `c47365df7dc2385a7ff5247d289169a048343d35c67879c5f99ee5a5a27d992b`。Linux 端核对了 ZIP 完整性和 161 份文件的逐项哈希。归档由 [FlowKit 任务](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) 保管,含原始日志、SQL 快照、两份签名及公钥、精确命令和独立复核脚本。 + +## 尚待验收 + +本轮 P/M/T 证据来自真实 Codex 工具回调、测试主体绑定和 Host 侧故障注入。生产身份源、两个实际登录主体、实际审计主体、文件入口同等授权、并发租户操作及原生供应商时序异常仍为 BLOCKED。两租户同时存在且顺序提交。500 ms 仅用于测试,部署等待策略待实际运行数据确认。未改 Core、ABI 或 Store schema。Host、SQLite Store 和 Outbox 定向回归 32/32,[实测候选 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43391860952) 8/8。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index bda9a49..00b7654 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -18,6 +18,7 @@ | E8 | `d3674bca09d305f88828a77b1d3653937fc5da5e`、`5efa127cf8a26081985e454b9719619a39fdc4b2` | R5 恢复基线、validate 阶段恢复、撤销后原 Ack、两个独立 Store 分别撤销、Outbox 满额和清理。[平台裁决、命令、原始日志与 SHA-256](../p0a-codex-isolated-20261113/r5-release-live.md) | | E9 | `e0df84b23ec78b49fd8beb8e800076ce27102c74` | Windows/Linux 双账户均通过文件快照清理两处强杀、两 Store 撤销协调演练、磁盘采样和本机容量告警,各 10 个真实会话。[命令、磁盘样本、归档 SHA-256 和参数建议](../p0a-codex-isolated-20261113/ops-r5-live.md) | | E10 | `0d1da06bb3f8e56ebc54a2d32619c83d3cadea34` | Windows/Linux 双账户各 18 个真实模型回合。4 Agent+2 Conditions 多阶段续跑、受权终态读取、早拒与缺值零派发、错请求和扣留观察拒绝。[分平台结果、命令和原始归档 SHA-256](../p0a-codex-isolated-20261113/branch-r5-live.md) | +| E11 | `60b745e1708f2e7c10b88254912fa074b9489d3e` | Windows/Linux 各 7 个真实 Codex 回合,测试主体/租户绑定、同 startKey 和 RunId 隔离、观察失配/超时/迟到与两笔合法接纳。[分平台实测、原始命令与归档 SHA-256](../p0a-codex-isolated-20261113/entry-r5-live.md) | ## SEC · 生产配置与候选入口 @@ -27,7 +28,7 @@ | 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 工具通道正常。E10 补实际 stdio 工具错 requestId、Host 扣留开始观察时拒绝及零签名、零 CAS。实际启用入口的错主体、错租户和文件分支的完整授权仍待测。 | +| SEC-05 | BLOCKED,发布入口授权 | E10 的错请求和缺观察已验证。E11 用真实 stdio 工具回调实测 Host 配置的错主体/错租户,统一拒绝且零签名、零 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 和正文,核对无写入及错误脱敏。 | @@ -37,7 +38,7 @@ | SEC-12 | BLOCKED,真实资源边界 | E1 文件入口两个大小通过。E7 内联 64 KiB 通过,两档更大内联无工具调用。补实际入口异常正文、限流/超时及内联回执上限分类。 | | SEC-13 | BLOCKED,完整替换负例 | E7 的文件替换、硬链接、符号链接或目录联接点有 P 级证据。E5 的旧策略拒绝有双平台原生结果。补实际启动链下 Program 和所有已启用配置入口的替换负例。 | | SEC-14 | BLOCKED,生产轮换与撤销 | E4 验证仍受信的两把钥轮换和原 Ack。E5 验证仅信任新钥时旧 Run 拒绝。E8 验证两独立 Store 分别撤销、未接纳旧回执拒绝和受权只读返回历史 Ack。E9 演练部分完成时保留待确认 Store、授权补撤销及逐库验收。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | -| SEC-15 | BLOCKED,真实跨租户并发 | E2 是同租户、同 Pending 的两个会话。现有脚本测试有租户拒绝。补实际入口两个租户同 startKey 和 Run 标识的隔离证据。 | +| SEC-15 | BLOCKED,真实跨租户并发 | E11 使用同一 SQLite 文件,两个租户同 startKey 和 RunId,独立回执和 Journal。真实模型回合顺序提交,待补发布入口跨租户并发。 | | 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。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | @@ -62,7 +63,7 @@ | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | -| NAT-14 | BLOCKED,完整时序与死锁负例 | E1/E2/E3 正常路径匹配原生开始和回调后签收。E10 对实际回调有意扣留开始事件索引,核对拒绝、零签名和零 CAS。迟到、失配和有界超时仍待测。 | +| NAT-14 | BLOCKED,发布时序完整裁决 | E1/E2/E3 正常路径匹配原生开始和回调后签收。E10 补扣留索引。E11 对真实回调注入 Host 侧的开始观察迟到、缺失与参数失配,按有界等待拒绝且零签名、零 CAS。供应商原生故障与最终部署等待策略仍待验证。 | | NAT-15 | PASS,双平台 Codex 实测样本 | E2 用原签名、回执和同一 Run 恢复签后 CAS 前切点。E3 增补文件引用与 CAS 后 Ack 修复,E4 增补真实过期拒绝。完整 N 级随 C12/S13 的组合证据复审。 | ## C01–C24 与阶段收口 @@ -92,7 +93,7 @@ | C19 | PASS,P 级长链中途失败。 | | C20 | PASS,原 P 级双进程竞争及 E2 真实双会话竞争。 | | C21 | 同环境重开 PASS(P)。跨 Node/OS 搬移同一物理库 BLOCKED,属于增强项。发布支持的升级另在 M6 核对。 | -| C22 | BLOCKED,E1/E6 补独立身份。缺实际入口模型自报权限、伪审批及跨租户负例。 | +| C22 | BLOCKED,E1/E6 补 OS 身份,E11 补测试主体及租户错配。实际发布主体、模型自报权限、伪审批仍待取证。 | | C23 | BLOCKED,E2/E3/E4 补真实竞争和崩溃恢复。缺真实拒绝、非法输出重试耗尽和供应商失败的组合证据。 | | C24 | BLOCKED,Windows/Linux × Node 22/24、包/CLI/类型和文档消费者 CI 已通过。完整原生证据、发布支持的升级与独立复审仍待完成。 | From 2f6a6738c5ffc12459bb271823d2468c3b2731cf Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 16:45:07 +0800 Subject: [PATCH 42/72] Exercise OS-mapped operator and auditor roles on Codex entry paths --- examples/interactive/codex-os-access.mjs | 31 ++++ tests/security/codex-flowkit-probe.mjs | 197 +++++++++++++++++++++-- tests/security/codex-outbox-live.mjs | 12 +- 3 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 examples/interactive/codex-os-access.mjs diff --git a/examples/interactive/codex-os-access.mjs b/examples/interactive/codex-os-access.mjs new file mode 100644 index 0000000..5e654be --- /dev/null +++ b/examples/interactive/codex-os-access.mjs @@ -0,0 +1,31 @@ +const denied=()=>Object.assign(new Error('Host request denied'),{code:'FK_I_HOST_DENIED'}); +const validIdentity=process.platform==='win32'?/^S-1-5-(?:\d+-)+\d+$/:/^[1-9]\d*$/; +const validNamespace=/^[a-zA-Z0-9_-]{1,64}$/; + +/** The provisioner must obtain each identity from the OS and keep the map Host-private. */ +export function createCodexOsAccess(bindings) { + const policy=new Map(); + const issued=new WeakSet(); + for (const {identity,namespace,role} of bindings) { + if (typeof identity!=='string' || !validIdentity.test(identity) || typeof namespace!=='string' + || !validNamespace.test(namespace) || !['operator','auditor'].includes(role) || policy.has(identity)) { + throw new TypeError('Invalid or duplicate OS identity mapping'); + } + policy.set(identity,Object.freeze({identity,namespace,role})); + } + return Object.freeze({ + bind(identity) { + const grant=policy.get(identity); + if (!grant) throw denied(); + const session=Object.freeze({...grant}); + issued.add(session); + return session; + }, + authorize(session,action,namespace) { + if (!issued.has(session) || session.namespace!==namespace) throw denied(); + if ((session.role==='operator' && ['run:create','task:dispatch','run:complete'].includes(action)) + || (session.role==='auditor' && action==='result:read')) return true; + throw denied(); + }, + }); +} diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index eb80fd7..10b477e 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -15,13 +15,15 @@ import { 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'; +import {createCodexOsAccess} from '../../examples/interactive/codex-os-access.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','release-check','release-revoke','release-clean','ops-clean'].includes(requested); const worker=requested==='outbox-worker'; const offline=scenario&&!worker; -const entry=requested==='entry-r5'; +const osEntry=['os-stdio','os-file'].includes(requested); +const entry=requested==='entry-r5'||osEntry; const branch=requested.startsWith('branch-')?requested.slice(7):null; assert.ok(branch===null || ['approved','refused','closed','missing'].includes(branch)); const rotation=['rotate-old','rotate-new','rotate-old-next'].includes(cut); @@ -29,6 +31,7 @@ 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','release-cap-next','release-byte-next'].includes(cut)?'run-next.json' :cut==='revoked-new'?'run-new.json':'run.json'; +const osFile=requested==='os-file'; const fallback=requested.startsWith('file-') || (scenario && scenarioBytes!==undefined); const clearArtifact=cut==='ops-clear'; const artifactSize=fallback?Number(scenario?scenarioBytes:requested.slice(5)):0; @@ -60,6 +63,10 @@ const specs=size ? [tool('flowkit_inline','Measure an inline synthetic payload', ?{requestId:str,result:objectSchema(branchFields,[])}:{requestId:str,answer:str})), tool('flowkit_continue',branch?'Read the next public task or terminal frame':'Read the public terminal state of the provisioned Run',objectSchema({runId:str})), ]; +const osTools=osFile?[ + tool('flowkit_file_task','Get the current Host-provisioned file task',objectSchema({})), + tool('flowkit_submit_file','Submit the exact file reference returned by the Host',objectSchema({requestId:str,id:str})), +]:specs.filter(spec=>spec.name==='flowkit_submit'); 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) @@ -116,6 +123,9 @@ const branchReceipts=[]; const entrySessions=new Map(),entryResources=new Map(),entryObservations=new Map(); let entryCase,entryDenial,entryDone,entryDelay,entryDatabase,entryObservationReady; let entrySignatures=0; +let osAccess,osAuditSession,osFileDb; +const osTurns=new Map(); +let osOverlap; const observationWaitMs=500; let finish; const finished=new Promise(resolve=>{finish=resolve;}); @@ -154,7 +164,7 @@ terminal('done',{name:'Done',status:'succeeded'}),terminal('declined',{name:'Dec } else 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:'${cut==='ops-file'?'clear':'done'}'}), + output:{type:'object',properties:${fallback||osFile?'{artifact:{type:"object"}}':"{answer:{type:'string'}}"}},retries:0,next:'${cut==='ops-file'?'clear':'done'}'}), ${cut==='ops-file'?"node('clear',{name:'Clear completed reference',agent:'reviewer',inputs:['question'],output:{type:'object',properties:{artifact:{type:'null'}}},retries:0,next:'done'}),":''} terminal('done',{name:'Done',status:'succeeded'}) ]});`); @@ -254,7 +264,7 @@ function stopAtCleanupCut() { throw new Error('Supervisor did not stop cleanup at '+cut); } function validateArtifactResult(value) { - if (!scenario) return value.artifact?.id===staged?.id?[]:['Expected staged reference']; + if (!scenario && !osFile) 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 @@ -404,7 +414,7 @@ async function submitEntry(p) { || p.tool!=='flowkit_submit' || completed.has(p.callId)) denyEntry('SESSION_DENIED'); const target=entryResources.get(p.arguments.requestId); if (!target || target.namespace!==context.namespace || target.subject!==context.subject) denyEntry('TARGET_DENIED'); - const {store,task,host,key,publicKey}=target; + const {store,task}=target; const checkpoint=store.load(task.runId); if (checkpoint.status!=='waiting' || checkpoint.pending?.requestId!==task.requestId) denyEntry('TARGET_DENIED'); if (!entryObservations.has(p.callId)) { @@ -423,14 +433,20 @@ async function submitEntry(p) { if (observed.threadId!==p.threadId || observed.turnId!==p.turnId || observed.tool!==p.tool || !canonicalBytes(observed.arguments).equals(canonicalBytes(p.arguments))) denyEntry('OBSERVATION_MISMATCH'); assert.deepEqual(p.arguments,{requestId:task.requestId,answer:'ready'}); + return signEntry(target,p.callId,{answer:p.arguments.answer}); +} + +async function signEntry(target,callId,value) { + const {store,task,host,key,publicKey}=target; + const checkpoint=store.load(task.runId); const receipt={runId:task.runId,requestId:task.requestId,nodeId:task.nodeId,attempt:task.attempt, - kind:'agent-result',result:{answer:p.arguments.answer}}; + kind:'agent-result',result:value}; const issuedAt=Date.now(); - const statement=createAgentReceiptStatement({run:checkpoint,receipt,keyId:'proof',proofId:p.callId,issuedAt,expiresAt:issuedAt+30000}); + const statement=createAgentReceiptStatement({run:checkpoint,receipt,keyId:'proof',proofId:callId,issuedAt,expiresAt:issuedAt+30000}); const statementBytes=encodeAgentReceiptStatement(statement); const signature=sign(null,statementBytes,key); entrySignatures++; - journal('entry-signed',{callId:p.callId,subject:target.subject,namespace:target.namespace,receipt,statement, + journal('entry-signed',{callId,subject:target.subject,namespace:target.namespace,receipt,statement, signature:signature.toString('base64url'),publicKey}); const result=await host.accept({receipt,attestation:{statement,signature:signature.toString('base64url')}}); const indexed=store.getReceipt(task.runId,task.requestId); @@ -439,8 +455,63 @@ async function submitEntry(p) { assert.equal(indexed.acceptedRevision,result.acceptedRevision); assert.equal(result.status,'succeeded'); assert.equal(result.state.tenantMarker,target.namespace); - journal('entry-accepted',{callId:p.callId,subject:target.subject,namespace:target.namespace,indexed,revision:result.revision}); - return {accepted:true,revision:result.acceptedRevision}; + journal('entry-accepted',{callId,subject:target.subject,namespace:target.namespace,indexed,revision:result.revision}); + return {accepted:true,revision:result.acceptedRevision,acceptedRevision:result.acceptedRevision}; +} + +async function osEntryCallback(message) { + const p=message.params; + const turn=osTurns.get(p.threadId); + const context=entrySessions.get(p.threadId); + const started=entryObservations.get(p.callId); + assert.ok(turn && context?.modelPid===isolatedIdentity.pid && turn.id===p.turnId && started + && started.threadId===p.threadId && started.turnId===p.turnId && started.tool===p.tool + && !completed.has(p.callId) && canonicalBytes(started.arguments).equals(canonicalBytes(p.arguments))); + entryObservations.delete(p.callId); + turn.calls.push(p.tool); + const target=entryResources.get(p.arguments.requestId); + let result; + try { + if (p.tool==='flowkit_file_task') { + osAccess.authorize(context.grant,'task:dispatch',turn.target.namespace); + assert.equal(turn.target.subject,context.subject); + const {store,task}=turn.target; + const checkpoint=store.load(task.runId); + const sourceId=randomUUID(); + const bytes=canonicalBytes({answer:'A'.repeat(262144-13)}); + assert.equal(bytes.length,262144); + const source=path.join(fixture,'inbox',sourceId+'.json'); + await writeFile(source,bytes,{flag:'wx',mode:0o600}); + try { + turn.ref=await handoff.stage({sourceId,byteLength:bytes.length,sha256:sha256Bytes(bytes), + binding:{namespace:turn.target.namespace,runId:task.runId,requestId:task.requestId,nodeId:task.nodeId, + attempt:task.attempt,threadId:p.threadId,turnId:p.turnId,programHash:checkpoint.artifactHash,policyHash:checkpoint.policyHash}}); + } finally {await rm(source);} + result={accepted:true,...turn.ref}; + } else { + if (!target || target.subject!==context.subject) denyEntry('FK_I_HOST_DENIED'); + osAccess.authorize(context.grant,'run:complete',target.namespace); + assert.equal(target,turn.target); + journal('os-ready-to-accept',{namespace:target.namespace,subject:context.subject,callId:p.callId}); + await osOverlap(); + if (osFile) { + assert.equal(p.tool,'flowkit_submit_file'); + result=await handoff.consume({started:{...started,callId:p.callId},callback:{...p,arguments:p.arguments}, + loadPending:target.store.load,signAndAccept:({artifact})=>signEntry(target,p.callId,{artifact})}); + } else { + assert.equal(p.tool,'flowkit_submit'); + assert.deepEqual(p.arguments,{requestId:target.task.requestId,answer:'ready'}); + result=await signEntry(target,p.callId,{answer:p.arguments.answer}); + } + } + assert.equal(turn.denial,false); + } catch(error) { + if (!turn.denial || error.code!=='FK_I_HOST_DENIED') throw error; + result={accepted:false,code:'REQUEST_DENIED'}; + journal('os-entry-rejected',{case:turn.label,callId:p.callId,subject:context.subject,namespace:context.namespace, + targetSubject:target?.subject,targetNamespace:target?.namespace,tool:p.tool}); + } + send({id:message.id,result:{success:result.accepted,contentItems:[{type:'inputText',text:JSON.stringify(result)}]}}); } async function handleEntryCallback(message) { @@ -565,6 +636,7 @@ lines?.on('line',line=>{ } else if(message.method==='item/completed' && p.item.type==='dynamicToolCall') { completed.add(p.item.id); } else if (message.method==='item/tool/call') { + if (osEntry) {void osEntryCallback(message).catch(error=>{failure=error.message;finish('entry-error');});return;} if (entry) {void handleEntryCallback(message).catch(error=>{failure=error.message;finish('entry-error');});return;} void (async()=>{ const started=starts.get(p.callId); @@ -602,6 +674,7 @@ lines?.on('line',line=>{ } else if(message.id!==undefined && message.method) { send({id:message.id,error:{code:-32601,message:'Unexpected server request'}}); } else if(message.method==='turn/completed') { + if (osEntry) {osTurns.get(p.threadId)?.resolve(p.turn);return;} turnStatus=p.turn.status; if (p.turn.error) failure=p.turn.error.message; if (branch||entry) branchTurnDone(); @@ -657,7 +730,8 @@ function entrySnapshot() { async function openEntrySession(binding,model) { const {thread}=await request('thread/start',{cwd,model,ephemeral:true,approvalPolicy:'never',sandbox:'read-only', - multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}},dynamicTools:specs.filter(spec=>spec.name==='flowkit_submit')}); + multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}}, + dynamicTools:osEntry?osTools:specs.filter(spec=>spec.name==='flowkit_submit')}); const session=Object.freeze({...binding,threadId:thread.id,modelPid:isolatedIdentity.pid}); entrySessions.set(thread.id,session); journal('entry-session',session); @@ -665,6 +739,7 @@ async function openEntrySession(binding,model) { } async function bindEntrySubject(binding,model) { + if (osEntry) osAccess.authorize(binding.grant,'run:create',binding.namespace); const store=createSqliteCheckpointStore(entryDatabase,{namespace:binding.namespace}); let writes=0; const instrumentedStore={...store,compareAndSwap(input){writes++;return store.compareAndSwap(input);}}; @@ -672,9 +747,16 @@ async function bindEntrySubject(binding,model) { const publicKey=pair.publicKey.export({type:'spki',format:'pem'}); const host=createTrustedInteractiveHost({store:instrumentedStore, runtime:{skillResolver:async()=> 'Submit ready for the current task.', - validateResult:(_schema,value)=>value.answer==='ready'?[]:['Expected ready']}, + validateResult:(_schema,value)=>osFile?validateArtifactResult(value):value.answer==='ready'?[]:['Expected ready']}, trustedPublicKeys:{proof:pair.publicKey},trustedArtifacts:[program.programIdentity.artifactHash], - allowedInputKeys:['question'],disclosurePolicyId:'entry-r5-fixture',authorizeDisclosure:()=>true}); + allowedInputKeys:['question'],disclosurePolicyId:'entry-r5-fixture', + authorizeDisclosure:osEntry?({namespace})=>osAccess.authorize(binding.grant,'task:dispatch',namespace):()=>true, + ...(osEntry?{authorizeTerminalAccess:({action,namespace,runId,revision})=>{ + let allowed=false; + try {allowed=osAccess.authorize(osAuditSession,action,namespace);return allowed;} + finally {journal('os-result-audit',{identity:osAuditSession.identity,action,namespace,runId,revision,allowed});} + }}:{}), + }); const frame=await host.start({runId:'entry-shared-run',startKey:'entry-shared-start', programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, state:{question:'Return ready.',tenantMarker:binding.namespace}}); @@ -689,6 +771,96 @@ async function bindEntrySubject(binding,model) { return {session,target}; } +async function osTurn(session,target,label,prompt,denial=false) { + const calls=[]; + const done=new Promise(resolve=>osTurns.set(session.threadId,{label,resolve,target,denial,calls})); + const current=osTurns.get(session.threadId); + const {turn}=await request('turn/start',{threadId:session.threadId,input:[{type:'text',text:prompt}]}); + current.id=turn.id; + journal('os-turn',{label,threadId:session.threadId,turnId:turn.id,subject:session.subject,namespace:session.namespace}); + assert.equal((await done).status,'completed'); + osTurns.delete(session.threadId); + assert.deepEqual(calls,osFile&&!denial?['flowkit_file_task','flowkit_submit_file']:[osFile?'flowkit_submit_file':'flowkit_submit']); + return current.ref; +} + +function osSnapshot() { + const state=entrySnapshot(); + if (!osFile) return state; + const db=new DatabaseSync(osFileDb,{readOnly:true}); + try {return {...state,files:db.prepare('SELECT id,namespace,request_id,sha256,size,state,call_id,accepted_revision FROM file_handoff ORDER BY id').all()};} + finally {db.close();} +} + +async function runOsEntry(model) { + const policyPath=process.env.FLOWKIT_OS_ACCESS_FIXTURE; + assert.ok(policyPath && path.isAbsolute(policyPath)); + const policy=JSON.parse((await readFile(policyPath,'utf8')).replace(/^\uFEFF/,'')); + osAccess=createCodexOsAccess(policy.bindings); + const grants=policy.bindings.map(binding=>osAccess.bind(binding.identity)); + const operators=grants.filter(grant=>grant.role==='operator'); + const auditors=grants.filter(grant=>grant.role==='auditor'); + assert.equal(operators.length,2); + assert.equal(auditors.length,1); + assert.notEqual(operators[0].namespace,operators[1].namespace); + assert.equal(auditors[0].namespace,operators[0].namespace); + for (const grant of grants) assert.notEqual(grant.identity,String(isolatedIdentity.modelSid??isolatedIdentity.uid)); + journal('os-provisioned-policy',{bindings:grants}); + entryDatabase=path.join(fixture,'entry.sqlite'); + if (osFile) { + osFileDb=path.join(fixture,'handoff.sqlite'); + const inbox=path.join(fixture,'inbox'); + await mkdir(inbox,{mode:0o700}); + handoff=createFileHandoff({database:osFileDb,inbox, + validateArtifact:value=>Object.keys(value).length===1 && typeof value.answer==='string'}); + } + const a=await bindEntrySubject({subject:operators[0].identity,namespace:operators[0].namespace,grant:operators[0]},model); + const b=await bindEntrySubject({subject:operators[1].identity,namespace:operators[1].namespace,grant:operators[1]},model); + const auditor=await openEntrySession({subject:auditors[0].identity,namespace:auditors[0].namespace,grant:auditors[0]},model); + const pendingBefore=osSnapshot(); + const pendingWrites=[a.target.writes(),b.target.writes()]; + const negative=(target,ref)=>{ + const args=osFile?ref:{requestId:target.task.requestId,answer:'ready'}; + return `Synthetic negative test. Call ${osFile?'flowkit_submit_file':'flowkit_submit'} once with ${JSON.stringify(args)}. Finish after the response without retrying.`; + }; + const probeRef={requestId:a.target.task.requestId,id:randomUUID()}; + await osTurn(b.session,a.target,'wrong-tenant',negative(a.target,probeRef),true); + await osTurn(auditor,a.target,'wrong-subject',negative(a.target,probeRef),true); + assert.equal(entrySignatures,0); + assert.deepEqual(pendingWrites,[a.target.writes(),b.target.writes()]); + assert.deepEqual(pendingBefore,osSnapshot()); + journal('os-pending-denials-verified',{deniedSignatures:0,deniedCas:0,unchanged:true}); + let ready=0,release; + const overlap=new Promise(resolve=>{release=resolve;}); + osOverlap=()=>{if (++ready===2) release();return overlap;}; + const positive=({target})=>osFile + ?'Call flowkit_file_task once with {}. From its tool reply use the exact requestId and id to call flowkit_submit_file once. Then finish.' + :`Call flowkit_submit once with requestId "${target.task.requestId}" and answer "ready". Then finish.`; + const refs=await Promise.all([a,b].map(pair=>osTurn(pair.session,pair.target,'valid',positive(pair)))); + assert.equal(ready,2); + assert.equal(entrySignatures,2); + const before=osSnapshot(); + const counts=[a.target.writes(),b.target.writes()]; + if (osFile) await osTurn(b.session,a.target,'wrong-tenant-reference',negative(a.target,refs[0]),true); + const resultRef={runId:a.target.task.runId,revision:9,projectionVersion:1}; + osAuditSession=operators[0]; + await assert.rejects(a.target.host.readTrustedTerminalResult({resultRef}),{code:'FK_I_HOST_DENIED'}); + osAuditSession=auditors[0]; + const result=await a.target.host.readTrustedTerminalResult({resultRef}); + assert.equal(result.state.tenantMarker,operators[0].namespace); + await assert.rejects(b.target.host.readTrustedTerminalResult({resultRef}),{code:'FK_I_HOST_DENIED'}); + assert.equal(entrySignatures,2); + assert.deepEqual(counts,[a.target.writes(),b.target.writes()]); + assert.deepEqual(before,osSnapshot()); + assert.deepEqual(before.runs.map(row=>row.revision),[9,9]); + assert.deepEqual(before.receipts.map(row=>row.accepted_revision),[4,4]); + if (osFile) assert.deepEqual(before.files.map(row=>[row.size,row.state,row.accepted_revision]), + [[262144,'acked',4],[262144,'acked',4]]); + journal('os-entry-verified',{tool:requested,snapshot:before,signatures:entrySignatures,concurrentReady:ready, + deniedSignatures:0,deniedCas:0,unchanged:true,programHash:program.programIdentity.artifactHash}); + return 'entry-completed'; +} + async function entryTurn(model,session,target,test,expectedDenial) { threadId=session.threadId; entryCase=test; @@ -807,6 +979,7 @@ async function run() { send({method:'initialized'}); const {data}=await request('model/list',{limit:30,includeHidden:false}); const model=data.find(entry=>entry.isDefault)?.model ?? data[0]?.model; + if (osEntry) return runOsEntry(model); if (entry) return runEntry(model); const {thread}=await request('thread/start',{cwd,model,ephemeral:true,approvalPolicy:'never',sandbox:'read-only', multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}}, diff --git a/tests/security/codex-outbox-live.mjs b/tests/security/codex-outbox-live.mjs index 385fb7a..52cad40 100644 --- a/tests/security/codex-outbox-live.mjs +++ b/tests/security/codex-outbox-live.mjs @@ -13,7 +13,7 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m 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','release-r5','ops-r5','branch-r5','entry-r5'].includes(suite)); +assert.ok(['original','followup','signing','revocation','release-r5','ops-r5','branch-r5','entry-r5','os-r5'].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'), @@ -588,7 +588,15 @@ async function revocationCase() { try { let result; - if (suite==='entry-r5') { + if (suite==='os-r5') { + const cases=[]; + for (const mode of ['os-stdio','os-file']) { + const observed=await offline(mode,root,mode,{}); + assert.equal(observed.passed,true); + cases.push(observed); + } + result={cases}; + } else if (suite==='entry-r5') { const observed=await offline('entry-r5',root,'entry',{ }); assert.equal(observed.passed,true); assert.equal(observed.entry,true); From e40123fc04ebab1b94b8a558ff88af681d84e98c Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 16:56:26 +0800 Subject: [PATCH 43/72] Record dual-platform OS identity and audit acceptance evidence --- .../p0a-codex-isolated-20261113/os-r5-live.md | 46 +++++++++++++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 5 +- 2 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/os-r5-live.md diff --git a/docs/evidence/p0a-codex-isolated-20261113/os-r5-live.md b/docs/evidence/p0a-codex-isolated-20261113/os-r5-live.md new file mode 100644 index 0000000..bd3ee6c --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/os-r5-live.md @@ -0,0 +1,46 @@ +# R5 OS 操作员与审计角色验收 + +本批按已确认的首发方案取证。基线 `154fe33981a7f9b7cd7436ccc1339cb5d4437c21`,实测候选 `2f6a6738c5ffc12459bb271823d2468c3b2731cf`。候选增加 Host 参考授权模块并扩展原有 Codex 验收探针。 + +## 身份与权限 + +受信启动方分别用两名操作员和一名审计员的普通 OS 账户运行身份读取命令,核对 Windows SID 或 Linux UID 后提供 Host 私有映射。操作员 A、B 分别绑定 `tenant-a`、`tenant-b`,审计员只拥有 `tenant-a` 的 `result:read`。Model 账户独立运行。工具回调只提供业务参数,Host 从受信启动配置和实际 App Server 线程取得授权。映射器签发进程内授权对象,按引用校验授权来源,再校验租户与操作。SDK、Core、ABI 和 Store schema 沿用 R5。 + +| 角色 | 本轮授权 | +|---|---| +| 操作员 | 所属租户的 `run:create`、`task:dispatch`、`run:complete` | +| 审计员 | 授权租户的 `result:read`,结果只保留在 Host 侧 | + +```text +FLOWKIT_OS_ACCESS_FIXTURE= +node tests/security/codex-outbox-live.mjs <全新Host私有根> isolated os-r5 +``` + +## Windows 实测 + +`os-20261113164512` 退出码 0,回环监听已停止。三次普通账户进程通过 `whoami.exe /user /fo csv /nh` 核验各自 SID。SID、账户、命令及退出码保留在 `windows-os-identities.json`。两个真实 App Server 共完成 9 个模型回合。 + +| 路径 | 模型回合 | 合法接纳 | 拒绝 | +|---|---:|---:|---:| +| stdio | 4 | 2 | 2 | +| 文件引用 | 5 | 2 | 3 | + +每条路径的两个合法回调都到达 Host 后才开始签收。两个租户在同一 R5 SQLite 文件使用相同 `RunId` 与 `startKey`,各自在 revision 4 接纳,revision 9 终态。文件路径的两个独立 256 KiB 快照均成为 `acked`。两条路径都先在 Pending 存在时执行错租户和错主体负例。文件路径再补一例跨租户使用真实历史引用。各负例均返回统一拒绝,签名和 CAS 次数增量为零,Checkpoint、Journal、回执索引和快照索引保持原值。 + +每条路径在 Host 侧均验证操作员读取完整结果被拒、审计员读取 `tenant-a` 成功、同一审计员读取 `tenant-b` 被拒。日志记录身份、操作、租户、Run、revision 和裁决。独立复核通过四份 Ed25519 签名和四条 Journal 链。 + +Windows 归档 `FlowKit-Windows-OS-R5-Evidence-20261113.zip` 的 SHA-256 为 `001734197b40dbc51bb0b87b48e4e845cdaf96a656ffefaeb358d8806cd6285d`。24 份文件已逐项复核,含部署及 OS 登录脚本、原始协议、Host 观察、签名和独立复核脚本。已核对三份部署文件和私有映射 ACL。Host、SQLite Store、文件交接和 Outbox 定向回归 37/37。 + +## Linux 实测 + +FlowKit 快进拉取同一候选,在隔离容器以普通 UID `41003`、`41004`、`41005` 分别运行身份采集,供受保护配置使用。Host 与 Model 继续使用 UID `41001`、`41002`。正式运行一次,约 52 秒,退出码 0。两个 App Server、六个线程、九个模型回合、十一次工具回调。两条路径分别通过合法回调并发、五次合计拒绝和六次审计判定。独立复核了四份签名、四条共 40 行 Journal 链和两个已签收快照的字节摘要。 + +```sh +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-os-r5-controller.mjs attempt-1 +``` + +Linux 归档 `FlowKit-Linux-OS-R5-20261113.zip` 的 SHA-256 为 `536163eed158539333af38bfcbc39aa8b5ad49b282ccae55265dea7e205ceddd`,已核对 287 份文件和 ZIP 完整性。归档由 [FlowKit 任务](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) 保管,包含实际命令、签名、公钥、协议、SQL 快照和离线复核脚本。 [候选 CI 8/8](https://github.com/devcodex-labs/flowkit/actions/runs/43395694815) + +## 仍待验收 + +本批证据为 P/M/T。测试由受信启动方预绑定真实 OS 身份,模型回合由单独 Model 账户运行。审计 API 在 Host 进程内按已核验的审计身份调用。首发实际入口接入经 OS 认证的操作员和审计会话、会话失效和实际权限撤销仍为 BLOCKED。文件入口在 Pending 下用匹配目标请求和合成引用验证拒绝,随后另测真实已接纳引用。仍待补实际入口的未消费有效引用跨租户负例。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 00b7654..79731c1 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -19,6 +19,7 @@ | E9 | `e0df84b23ec78b49fd8beb8e800076ce27102c74` | Windows/Linux 双账户均通过文件快照清理两处强杀、两 Store 撤销协调演练、磁盘采样和本机容量告警,各 10 个真实会话。[命令、磁盘样本、归档 SHA-256 和参数建议](../p0a-codex-isolated-20261113/ops-r5-live.md) | | E10 | `0d1da06bb3f8e56ebc54a2d32619c83d3cadea34` | Windows/Linux 双账户各 18 个真实模型回合。4 Agent+2 Conditions 多阶段续跑、受权终态读取、早拒与缺值零派发、错请求和扣留观察拒绝。[分平台结果、命令和原始归档 SHA-256](../p0a-codex-isolated-20261113/branch-r5-live.md) | | E11 | `60b745e1708f2e7c10b88254912fa074b9489d3e` | Windows/Linux 各 7 个真实 Codex 回合,测试主体/租户绑定、同 startKey 和 RunId 隔离、观察失配/超时/迟到与两笔合法接纳。[分平台实测、原始命令与归档 SHA-256](../p0a-codex-isolated-20261113/entry-r5-live.md) | +| E12 | `2f6a6738c5ffc12459bb271823d2468c3b2731cf` | 真实普通 OS 身份预绑定,两租户 stdio 和文件入口并发,拒绝错配及独立审计权限。[分平台实测、原始命令与归档 SHA-256](../p0a-codex-isolated-20261113/os-r5-live.md) | ## SEC · 生产配置与候选入口 @@ -28,7 +29,7 @@ | 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,发布入口授权 | E10 的错请求和缺观察已验证。E11 用真实 stdio 工具回调实测 Host 配置的错主体/错租户,统一拒绝且零签名、零 CAS。发布主体认证、实际审计身份和启用的文件入口仍待实测。 | +| SEC-05 | BLOCKED,发布入口授权 | E10/E11 覆盖真实错请求、缺观察及测试主体错配。E12 补 OS 身份预绑定、两条入口的错租户/错主体拒绝及审计角色。最终入口接入经 OS 认证的会话和未消费有效文件引用的跨租户验证仍待取证。 | | 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 和正文,核对无写入及错误脱敏。 | @@ -38,7 +39,7 @@ | SEC-12 | BLOCKED,真实资源边界 | E1 文件入口两个大小通过。E7 内联 64 KiB 通过,两档更大内联无工具调用。补实际入口异常正文、限流/超时及内联回执上限分类。 | | SEC-13 | BLOCKED,完整替换负例 | E7 的文件替换、硬链接、符号链接或目录联接点有 P 级证据。E5 的旧策略拒绝有双平台原生结果。补实际启动链下 Program 和所有已启用配置入口的替换负例。 | | SEC-14 | BLOCKED,生产轮换与撤销 | E4 验证仍受信的两把钥轮换和原 Ack。E5 验证仅信任新钥时旧 Run 拒绝。E8 验证两独立 Store 分别撤销、未接纳旧回执拒绝和受权只读返回历史 Ack。E9 演练部分完成时保留待确认 Store、授权补撤销及逐库验收。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | -| SEC-15 | BLOCKED,真实跨租户并发 | E11 使用同一 SQLite 文件,两个租户同 startKey 和 RunId,独立回执和 Journal。真实模型回合顺序提交,待补发布入口跨租户并发。 | +| SEC-15 | BLOCKED,最终发布入口 | E12 实测同一 SQLite 文件内两个租户同 startKey、同 RunId 的 stdio 和文件回调并发,独立回执和 Journal。发布入口的主体认证和会话生命周期仍待验证。 | | 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。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | From 3fd1d2f9fb8a59abf74fca86136cb0bed3eef806 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 17:07:16 +0800 Subject: [PATCH 44/72] Invalidate revoked Host sessions and test live staged file isolation --- examples/interactive/codex-os-access.mjs | 28 ++++++++---- examples/interactive/codex-os-stdio.mjs | 39 ++++++++++++++++ tests/security/codex-flowkit-probe.mjs | 58 +++++++++++++++++++++++- 3 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 examples/interactive/codex-os-stdio.mjs diff --git a/examples/interactive/codex-os-access.mjs b/examples/interactive/codex-os-access.mjs index 5e654be..9e8e7e2 100644 --- a/examples/interactive/codex-os-access.mjs +++ b/examples/interactive/codex-os-access.mjs @@ -4,25 +4,35 @@ const validNamespace=/^[a-zA-Z0-9_-]{1,64}$/; /** The provisioner must obtain each identity from the OS and keep the map Host-private. */ export function createCodexOsAccess(bindings) { - const policy=new Map(); - const issued=new WeakSet(); - for (const {identity,namespace,role} of bindings) { - if (typeof identity!=='string' || !validIdentity.test(identity) || typeof namespace!=='string' - || !validNamespace.test(namespace) || !['operator','auditor'].includes(role) || policy.has(identity)) { - throw new TypeError('Invalid or duplicate OS identity mapping'); + function policyFrom(entries) { + const result=new Map(); + for (const {identity,namespace,role} of entries) { + if (typeof identity!=='string' || !validIdentity.test(identity) || typeof namespace!=='string' + || !validNamespace.test(namespace) || !['operator','auditor'].includes(role) || result.has(identity)) { + throw new TypeError('Invalid or duplicate OS identity mapping'); + } + const prior=policy.get(identity); + result.set(identity,prior?.namespace===namespace && prior.role===role + ?prior:Object.freeze({identity,namespace,role})); } - policy.set(identity,Object.freeze({identity,namespace,role})); + return result; } + let policy=new Map(); + const issued=new WeakMap(); + policy=policyFrom(bindings); return Object.freeze({ bind(identity) { const grant=policy.get(identity); if (!grant) throw denied(); const session=Object.freeze({...grant}); - issued.add(session); + issued.set(session,grant); return session; }, + close(session) {issued.delete(session);}, + replaceBindings(entries) {policy=policyFrom(entries);}, authorize(session,action,namespace) { - if (!issued.has(session) || session.namespace!==namespace) throw denied(); + const grant=issued.get(session); + if (!grant || policy.get(grant.identity)!==grant || grant.namespace!==namespace) throw denied(); if ((session.role==='operator' && ['run:create','task:dispatch','run:complete'].includes(action)) || (session.role==='auditor' && action==='result:read')) return true; throw denied(); diff --git a/examples/interactive/codex-os-stdio.mjs b/examples/interactive/codex-os-stdio.mjs new file mode 100644 index 0000000..66556bf --- /dev/null +++ b/examples/interactive/codex-os-stdio.mjs @@ -0,0 +1,39 @@ +const maxCommandBytes=4096; + +async function* commands(input) { + const line=Buffer.alloc(maxCommandBytes); + let length=0,oversize=false; + for await (const data of input) { + const chunk=Buffer.isBuffer(data)?data:Buffer.from(data); + for (const byte of chunk) { + if (byte===10) { + yield oversize?null:line.subarray(0,length).toString('utf8'); + length=0; + oversize=false; + } else if (lengthoutput.write(JSON.stringify(response)+'\n',error=>error?reject(error):resolve())); + } + } finally { + access.close(session); + } +} diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 10b477e..e6630de 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -16,6 +16,8 @@ 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'; import {createCodexOsAccess} from '../../examples/interactive/codex-os-access.mjs'; +import {serveCodexOsSession} from '../../examples/interactive/codex-os-stdio.mjs'; +import {PassThrough} from 'node:stream'; 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', @@ -126,6 +128,7 @@ let entrySignatures=0; let osAccess,osAuditSession,osFileDb; const osTurns=new Map(); let osOverlap; +let osStageCheck; const observationWaitMs=500; let finish; const finished=new Promise(resolve=>{finish=resolve;}); @@ -487,6 +490,7 @@ async function osEntryCallback(message) { binding:{namespace:turn.target.namespace,runId:task.runId,requestId:task.requestId,nodeId:task.nodeId, attempt:task.attempt,threadId:p.threadId,turnId:p.turnId,programHash:checkpoint.artifactHash,policyHash:checkpoint.policyHash}}); } finally {await rm(source);} + await osStageCheck?.(turn,turn.ref); result={accepted:true,...turn.ref}; } else { if (!target || target.subject!==context.subject) denyEntry('FK_I_HOST_DENIED'); @@ -494,6 +498,7 @@ async function osEntryCallback(message) { assert.equal(target,turn.target); journal('os-ready-to-accept',{namespace:target.namespace,subject:context.subject,callId:p.callId}); await osOverlap(); + osAccess.authorize(context.grant,'run:complete',target.namespace); if (osFile) { assert.equal(p.tool,'flowkit_submit_file'); result=await handoff.consume({started:{...started,callId:p.callId},callback:{...p,arguments:p.arguments}, @@ -836,7 +841,25 @@ async function runOsEntry(model) { const positive=({target})=>osFile ?'Call flowkit_file_task once with {}. From its tool reply use the exact requestId and id to call flowkit_submit_file once. Then finish.' :`Call flowkit_submit once with requestId "${target.task.requestId}" and answer "ready". Then finish.`; - const refs=await Promise.all([a,b].map(pair=>osTurn(pair.session,pair.target,'valid',positive(pair)))); + let releaseFile; + const fileChecked=new Promise(resolve=>{releaseFile=resolve;}); + osStageCheck=async(turn,ref)=>{ + if (turn.target!==a.target) return; + const stagedState=osSnapshot(); + assert.equal(stagedState.files.find(row=>row.id===ref.id)?.state,'staged'); + await osTurn(b.session,a.target,'wrong-tenant-staged-reference',negative(a.target,ref),true); + assert.equal(entrySignatures,0); + assert.deepEqual([a.target.writes(),b.target.writes()],pendingWrites); + assert.deepEqual(osSnapshot(),stagedState); + journal('os-staged-reference-denial',{id:ref.id,namespace:a.target.namespace,unchanged:true,signatures:0,cas:0}); + releaseFile(); + }; + const first=osTurn(a.session,a.target,'valid',positive(a)); + const second=async()=>{ + if (osFile) await fileChecked; + return osTurn(b.session,b.target,'valid',positive(b)); + }; + const refs=await Promise.all([first,second()]); assert.equal(ready,2); assert.equal(entrySignatures,2); const before=osSnapshot(); @@ -856,11 +879,44 @@ async function runOsEntry(model) { assert.deepEqual(before.receipts.map(row=>row.accepted_revision),[4,4]); if (osFile) assert.deepEqual(before.files.map(row=>[row.size,row.state,row.accepted_revision]), [[262144,'acked',4],[262144,'acked',4]]); + await verifyOsSessionRevocation(policy.bindings,a.target,auditors[0].identity,resultRef); + assert.deepEqual(before,osSnapshot()); journal('os-entry-verified',{tool:requested,snapshot:before,signatures:entrySignatures,concurrentReady:ready, deniedSignatures:0,deniedCas:0,unchanged:true,programHash:program.programIdentity.artifactHash}); return 'entry-completed'; } +async function verifyOsSessionRevocation(bindings,target,identity,resultRef) { + const input=new PassThrough(),output=new PassThrough(); + const lines=createInterface({input:output}); + const responses=lines[Symbol.asyncIterator](); + const run=serveCodexOsSession({access:osAccess,identity,input,output, + async dispatch(session,command) { + osAuditSession=session; + const result=await target.host.readTrustedTerminalResult({resultRef:command.resultRef}); + return {namespace:result.namespace,status:result.status,marker:result.state.tenantMarker}; + }}); + const sendAudit=async()=>{ + input.write(JSON.stringify({action:'result:read',namespace:target.namespace,resultRef})+'\n'); + return JSON.parse((await responses.next()).value); + }; + try { + const expected={ok:true,result:{namespace:target.namespace,status:'succeeded',marker:target.namespace}}; + assert.deepEqual(await sendAudit(),expected); + osAccess.replaceBindings(bindings.filter(item=>item.identity!==identity)); + assert.deepEqual(await sendAudit(),{ok:false,code:'FK_I_HOST_DENIED'}); + osAccess.replaceBindings(bindings); + assert.deepEqual(await sendAudit(),{ok:false,code:'FK_I_HOST_DENIED'}); + assert.equal(osAccess.authorize(osAccess.bind(identity),'result:read',target.namespace),true); + journal('os-session-revocation',{identity,namespace:target.namespace,oldSessionDenied:true,regrantDidNotRevive:true}); + } finally { + input.end(); + await run; + lines.close(); + output.end(); + } +} + async function entryTurn(model,session,target,test,expectedDenial) { threadId=session.threadId; entryCase=test; From a82c9bc5f2fc729c489ba918cb6b4321343176ed Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 17:18:23 +0800 Subject: [PATCH 45/72] Recheck audit access before reply and organize release documentation --- docs/README.md | 12 ++++ ...-PR17-Local-Verification-Final-20261112.md | 4 +- ...ve-phase2-conformance-audit-r1-20261112.md | 0 ...ve-phase2-conformance-audit-r2-20261112.md | 2 +- .../session-r5-live.md | 30 +++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 5 +- docs/host-integration.md | 2 + docs/interactive-host-security.md | 29 +++++++++ examples/interactive/codex-os-stdio.mjs | 4 +- tests/interactive/codex-os-access.test.mjs | 64 +++++++++++++++++++ website/content/guide/host-integration.mdx | 2 + 11 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 docs/README.md rename docs/{ => evidence/20261112-pr16-pr17}/FlowKit-PR16-PR17-Local-Verification-Final-20261112.md (99%) rename docs/{ => evidence/20261112-pr16-pr17}/interactive-phase2-conformance-audit-r1-20261112.md (100%) rename docs/{ => evidence/20261112-pr16-pr17}/interactive-phase2-conformance-audit-r2-20261112.md (96%) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/session-r5-live.md create mode 100644 tests/interactive/codex-os-access.test.mjs diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d64eb37 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,12 @@ +# 文档入口 + +| 用途 | 入口 | +|---|---| +| 用户指南、示例和 API | [文档站](https://devcodex-labs.github.io/flowkit/)及其[源码](../website/content/) | +| 内部 Interactive P0-A 接入 | [Interactive 接入](./interactive.md)、[可信 Host 安全](./interactive-host-security.md)、[Stage 2 契约](./interactive-stage2-contract.md) | +| 候选状态和审查 | [P0-A 发布验收矩阵](./evidence/p0a-g07-g12-20261113/release-matrix.md) | +| 正式发行与安装 | [发布维护](./releasing.md) | + +`docs/evidence/` 保存提交绑定的验收报告、探针、命令和原始日志。文件名含 `attempt` 或 `probe` 的条目可能被证据报告引用。日期化的审查记录描述当时的基线,当前状态以发布验收矩阵为准。暂存输出写到已忽略的 `.tmp/`,需要留存的日志和校验和随对应验收记录归档。 + +PR #16/#17 的[历史核验报告](./evidence/20261112-pr16-pr17/FlowKit-PR16-PR17-Local-Verification-Final-20261112.md)和 Phase 2 [R1](./evidence/20261112-pr16-pr17/interactive-phase2-conformance-audit-r1-20261112.md)/[R2](./evidence/20261112-pr16-pr17/interactive-phase2-conformance-audit-r2-20261112.md) 审查记录已归档到对应证据目录。 diff --git a/docs/FlowKit-PR16-PR17-Local-Verification-Final-20261112.md b/docs/evidence/20261112-pr16-pr17/FlowKit-PR16-PR17-Local-Verification-Final-20261112.md similarity index 99% rename from docs/FlowKit-PR16-PR17-Local-Verification-Final-20261112.md rename to docs/evidence/20261112-pr16-pr17/FlowKit-PR16-PR17-Local-Verification-Final-20261112.md index da07d68..5d4a134 100644 --- a/docs/FlowKit-PR16-PR17-Local-Verification-Final-20261112.md +++ b/docs/evidence/20261112-pr16-pr17/FlowKit-PR16-PR17-Local-Verification-Final-20261112.md @@ -31,8 +31,8 @@ PR #16 / #17 的 HEAD 与任务开始时给出的历史 SHA 一致,执行前 `git diff --stat 13d573c 90cc0de` 只有: -- `docs/interactive-phase2-conformance-audit-r1-20261112.md` -- `docs/interactive-phase2-conformance-audit-r2-20261112.md` +- [Phase 2 R1](./interactive-phase2-conformance-audit-r1-20261112.md) +- [Phase 2 R2](./interactive-phase2-conformance-audit-r2-20261112.md) 没有生产代码。`git diff ada01c9 -- tests/interactive/coordinator.test.mjs` 在修复分支上为空,说明纳入的回归测试与 PR #17 逐字相同。 diff --git a/docs/interactive-phase2-conformance-audit-r1-20261112.md b/docs/evidence/20261112-pr16-pr17/interactive-phase2-conformance-audit-r1-20261112.md similarity index 100% rename from docs/interactive-phase2-conformance-audit-r1-20261112.md rename to docs/evidence/20261112-pr16-pr17/interactive-phase2-conformance-audit-r1-20261112.md diff --git a/docs/interactive-phase2-conformance-audit-r2-20261112.md b/docs/evidence/20261112-pr16-pr17/interactive-phase2-conformance-audit-r2-20261112.md similarity index 96% rename from docs/interactive-phase2-conformance-audit-r2-20261112.md rename to docs/evidence/20261112-pr16-pr17/interactive-phase2-conformance-audit-r2-20261112.md index bad118a..1bce524 100644 --- a/docs/interactive-phase2-conformance-audit-r2-20261112.md +++ b/docs/evidence/20261112-pr16-pr17/interactive-phase2-conformance-audit-r2-20261112.md @@ -7,7 +7,7 @@ - 当前 main(2026-11-12 检查):`13d573c5ddc4a0ed3502401589c581799c96c5d2`;[main commit](https://github.com/devcodex-labs/flowkit/commit/13d573c5ddc4a0ed3502401589c581799c96c5d2)。 - R1 使用的旧 main:`ae5fc5a42c67eb6e2c229d72ff4d9ac8e3aab3cd`;对 `src/interactive/coordinator.mjs`、`trusted-host.mjs`、`sqlite-store.mjs`、`compile.mjs`、`types/interactive.d.ts` 逐一读取两版 GitHub 文件并比较 **Git blob SHA 五项完全一致**。因此 R1 的相关代码事实不因 PR #15 文档合并而失效;其他未比较文件不能套用这一结论。 - 已直接读取正式 V0.3 `FlowKit-Host-Driven-Interactive-Execution-Requirements-V0.3-Confirmed(1).md`(§§1–7、15–19 与关键段落);其历史登记身份为 56,883 bytes / SHA-256 `3a0adc3edb41869a6309bae29ec5496df00970c815f8dc16330a90cbf26e703e`。 -- 正式 V0.6-R5 的身份为 114,621 bytes / SHA-256 `09f3a5698e238d10b80efb8e26d1c41e51b663ca620efa70815dd7b52f2106ba`,由 [正式原件登记](./interactive-phase2-formal-source-register.md) 及 PR #15 的历史 9/9 校验支持。**本轮当前会话无法直接读取 Library 中该 Markdown 原始正文(原始文件读取接口返回不可访问),因此涉及 §4/§5/§8/§16 的精确规范措辞,仍引用 R1 已抽取的条款并列为独立复核门禁;不能声称本轮已独立全文复读 V0.6-R5。** 不应因此推翻此前原件已找回的事实,也无需重写原件。 +- 正式 V0.6-R5 的身份为 114,621 bytes / SHA-256 `09f3a5698e238d10b80efb8e26d1c41e51b663ca620efa70815dd7b52f2106ba`,由 [正式原件登记](../../interactive-phase2-formal-source-register.md) 及 PR #15 的历史 9/9 校验支持。**本轮当前会话无法直接读取 Library 中该 Markdown 原始正文(原始文件读取接口返回不可访问),因此涉及 §4/§5/§8/§16 的精确规范措辞,仍引用 R1 已抽取的条款并列为独立复核门禁;不能声称本轮已独立全文复读 V0.6-R5。** 不应因此推翻此前原件已找回的事实,也无需重写原件。 - 证据等级:`SOURCE-CONFIRMED` = 已直接读取具体源码及类型;`SOURCE-INFERRED` = 源码顺序推断、尚未运行反例;`CONTRACT-CONFIRMED-V03` = 已直接读取正式 V0.3;`V06-R1-EXTRACT` = 对应 V0.6 原文由 R1 转录、待独立原文复核;`RUN-BLOCKED` = 未取得该执行场景的原始运行证明。**以上不是测试 PASS 等级。** ### 1.1 当前关键源码身份 diff --git a/docs/evidence/p0a-codex-isolated-20261113/session-r5-live.md b/docs/evidence/p0a-codex-isolated-20261113/session-r5-live.md new file mode 100644 index 0000000..ae4e50f --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/session-r5-live.md @@ -0,0 +1,30 @@ +# R5 授权撤销与未消费文件引用 + +基线 `e40123fc04ebab1b94b8a558ff88af681d84e98c`,实测候选 `3fd1d2f9fb8a59abf74fca86136cb0bed3eef806`。候选增加接受已认证对端的 Host stdio 参考入口,支持按配置撤权及断流销毁授权。配置移除身份后,同一打开的会话立即失去读取权。恢复映射时,旧会话仍拒绝,须建立新会话。两个平台仍用已有 Host、Model 账户隔离以及操作员和审计身份预绑定。 + +真实文件样本由 Host 固定 262,144 B 原始字节,并与当前 Pending 和合法 Model 调用绑定。在文件处于 `staged` 时,另一租户提交同一有效引用。Host 统一拒绝,零签名、零 CAS,两个租户的 Run、Journal、接纳索引及文件索引逐项不变。原会话随后使用同一引用,文件进入 `acked`。 + +撤权样本在 Host 进程内用两条流连接 stdio 入口。审计角色先读成功,移除映射后同一会话被拒,恢复映射后旧会话仍被拒,新授权对象可通过检查。测试检查 Store 状态无写入。OS 启动身份已核验,审计流使用 Host 内 `PassThrough`。最终生产通道如何认证对端、跨 Host 撤权发布以及实际部署仍为 BLOCKED。 + +本批只读审查发现,已经开始的异步读取可能在撤权后返回结果。后续修复在返回 `result:read` 响应前再校验一次授权。定向回归覆盖撤权时仍在执行的读取、撤权后重授权及超长输入后的下一条命令。该修复的 P 级验证随最终提交 CI;下列真实 Codex 归档仍绑定上方实测候选。 + +```text +FLOWKIT_OS_ACCESS_FIXTURE= +node tests/security/codex-outbox-live.mjs <全新Host私有根> isolated os-r5 +``` + +## Windows + +运行 `os-20261113170741`,退出码 0。两条路径合计 10 个真实模型回合、6 次工具拒绝、4 份合法签名和 4 条完整 Journal 链。受信启动方分别核验三名普通账户的 SID。独立复核匹配了跨租户负例、`staged` 证据与随后合法签名中完全相同的文件 ID。监听已收回。定向回归 37/37。 + +`FlowKit-Windows-Session-R5-20261113.zip` 的 SHA-256 为 `4f5cda75780541aad5ccc22ddfbad55408fa5894b789f507960258166177f99f`,已复核 22 份文件。归档包含执行脚本、原始 App Server 协议、Host 观察和独立复核脚本。 + +## Linux + +FlowKit 快进拉取同一候选并执行 `os-r5`。首次正式运行约 61 秒,退出码 0。两个真实 App Server、六个线程、十个模型回合、十二次工具回调。独立复核四份签名、四条共 40 行 Journal 链,以及两条撤权流共十二帧原始字节。六次拒绝均符合预期。 + +```sh +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-os-revocation-r5-controller.mjs attempt-1 +``` + +`FlowKit-Linux-OS-Revocation-R5-20261113.zip` 的 SHA-256 为 `b2bb15926b6ee9f6c203f94e1e1d2de854ed62936864cc967499b6e1c3f6caf4`,已复核 227 份文件和 ZIP 完整性。归档由 [FlowKit 任务](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) 保管。 [真实样本候选 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43398681714) diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 79731c1..c91efee 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -20,6 +20,7 @@ | E10 | `0d1da06bb3f8e56ebc54a2d32619c83d3cadea34` | Windows/Linux 双账户各 18 个真实模型回合。4 Agent+2 Conditions 多阶段续跑、受权终态读取、早拒与缺值零派发、错请求和扣留观察拒绝。[分平台结果、命令和原始归档 SHA-256](../p0a-codex-isolated-20261113/branch-r5-live.md) | | E11 | `60b745e1708f2e7c10b88254912fa074b9489d3e` | Windows/Linux 各 7 个真实 Codex 回合,测试主体/租户绑定、同 startKey 和 RunId 隔离、观察失配/超时/迟到与两笔合法接纳。[分平台实测、原始命令与归档 SHA-256](../p0a-codex-isolated-20261113/entry-r5-live.md) | | E12 | `2f6a6738c5ffc12459bb271823d2468c3b2731cf` | 真实普通 OS 身份预绑定,两租户 stdio 和文件入口并发,拒绝错配及独立审计权限。[分平台实测、原始命令与归档 SHA-256](../p0a-codex-isolated-20261113/os-r5-live.md) | +| E13 | `3fd1d2f9fb8a59abf74fca86136cb0bed3eef806` | 已暂存、未消费的有效文件引用跨租户拒绝,以及 Host stdio 授权撤销后旧会话失效。[分平台实测、命令与归档 SHA-256](../p0a-codex-isolated-20261113/session-r5-live.md) | ## SEC · 生产配置与候选入口 @@ -29,7 +30,7 @@ | 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,发布入口授权 | E10/E11 覆盖真实错请求、缺观察及测试主体错配。E12 补 OS 身份预绑定、两条入口的错租户/错主体拒绝及审计角色。最终入口接入经 OS 认证的会话和未消费有效文件引用的跨租户验证仍待取证。 | +| SEC-05 | BLOCKED,发布入口授权 | E10/E11 覆盖真实错请求、缺观察及测试主体错配。E12 补 OS 身份预绑定。E13 以真实 Model 回调拒绝跨租户提交未消费有效文件引用,另在 Host 流中验证撤权。最终发布入口仍需接入经 OS 认证的对端。 | | 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 和正文,核对无写入及错误脱敏。 | @@ -113,7 +114,7 @@ - **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。 +- **G12/M6。** 由独立审查角色只读复核同一发行候选的适用 T/R/S、Checkpoint 契约、升级与回滚、发布 Host 验收和原始证据,记录裁决与剩余 BLOCKED。审查角色与实施角色分开。P0-B/P1/P2 条款按原计划阶段判定 N/A。 [冻结追踪表](../p0a-g01-20261112/traceability.md) · [兼容实现](../p0a-compat-codex-20261113/README.md) · [管理终止](../p0a-admin-codex-20261113/README.md) - **G17。** 在 G12 收口后冻结发行范围与候选,验证发行包和安装,再按独立授权办理合并、Tag、发布和部署。 ## 四项已确认的首发契约 diff --git a/docs/host-integration.md b/docs/host-integration.md index 3f1b0f1..4d467b8 100644 --- a/docs/host-integration.md +++ b/docs/host-integration.md @@ -12,6 +12,8 @@ FlowKit v1.0.0的详细中文说明已集中到[文档站](https://devcodex-labs 阅读[首次接入](https://devcodex-labs.github.io/flowkit/guide/host-integration.html)。 +内部 Interactive P0-A 部署另见[可信 Host 与 OS 身份配置](./interactive-host-security.md#os-authenticated-operator-and-auditor-sessions)。 + ### Runtime方法 diff --git a/docs/interactive-host-security.md b/docs/interactive-host-security.md index aee1bd5..f41429c 100644 --- a/docs/interactive-host-security.md +++ b/docs/interactive-host-security.md @@ -61,6 +61,35 @@ checkStep; constructing a string `provenanceRef` or JSON 5. Use separate OS users/process isolation for the untrusted model worker. A shared-UID shell agent must not be treated as a trusted approval origin. +## OS-authenticated operator and auditor sessions + +For the first internal deployment, the trusted launcher obtains the operator or +auditor identity from the Windows token SID or Linux UID of the authenticated +OS session. Store the identity-to-namespace and role mapping in Host-private +configuration. Provision the SQLite Store for the namespace assigned by that +mapping. The Model runs under a separate account and sees only task input and +submission tools. + +Use the [role mapping](../examples/interactive/codex-os-access.mjs) and +[stdio session](../examples/interactive/codex-os-stdio.mjs) as reference adapters. +The stdio adapter expects an already authenticated peer and dedicated streams. +It authorizes each command against the server-side session and closes the grant +when the stream ends. It rechecks `result:read` before returning a result. +For writes, authorize again at the mutation boundary after any asynchronous +preparation. Policy reload cannot undo an operation already committed. +An operator may start, dispatch and complete in its +namespace. Grant `result:read` separately to the auditor and route it through +`readTrustedTerminalResult`. Keep full results on the authorized audit channel. +Use a trusted configuration reload to remove or change a grant. Removing and +then restoring it requires a new session. Deployments with multiple Host +processes must apply the policy update to each affected process. + +For file submissions, the authorized Host fixes the original bytes and binds the +reference to the current namespace, Pending and observed Model call. The Host +must enforce the operator grant before claiming the snapshot. See the +[release matrix](./evidence/p0a-g07-g12-20261113/release-matrix.md) for candidate +verification and the remaining checks on the release deployment. + ## Minimal Host-side integration ~~~js diff --git a/examples/interactive/codex-os-stdio.mjs b/examples/interactive/codex-os-stdio.mjs index 66556bf..86d5bc9 100644 --- a/examples/interactive/codex-os-stdio.mjs +++ b/examples/interactive/codex-os-stdio.mjs @@ -27,7 +27,9 @@ export async function serveCodexOsSession({access,identity,input,output,dispatch const command=JSON.parse(line); const {action,namespace}=command; access.authorize(session,action,namespace); - response={ok:true,result:await dispatch(session,command)}; + const result=await dispatch(session,command); + if (action==='result:read') access.authorize(session,action,namespace); + response={ok:true,result}; } catch { response={ok:false,code:'FK_I_HOST_DENIED'}; } diff --git a/tests/interactive/codex-os-access.test.mjs b/tests/interactive/codex-os-access.test.mjs new file mode 100644 index 0000000..1252390 --- /dev/null +++ b/tests/interactive/codex-os-access.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import {createInterface} from 'node:readline'; +import {PassThrough} from 'node:stream'; +import test from 'node:test'; +import {createCodexOsAccess} from '../../examples/interactive/codex-os-access.mjs'; +import {serveCodexOsSession} from '../../examples/interactive/codex-os-stdio.mjs'; + +const identity=process.platform==='win32'?'S-1-5-21-1-2-3-1001':'1001'; +const bindings=[{identity,namespace:'tenant-a',role:'auditor'}]; +const readCommand=JSON.stringify({action:'result:read',namespace:'tenant-a'})+'\n'; +const denied={ok:false,code:'FK_I_HOST_DENIED'}; + +test('revoking a live audit session suppresses an in-flight result and blocks later reads',async()=>{ + const access=createCodexOsAccess(bindings); + const input=new PassThrough(),output=new PassThrough(); + const lines=createInterface({input:output}); + const responses=lines[Symbol.asyncIterator](); + let entered,release,session; + const ready=new Promise(resolve=>{entered=resolve;}); + const paused=new Promise(resolve=>{release=resolve;}); + const serve=serveCodexOsSession({access,identity,input,output,async dispatch(grant){ + session=grant; + entered(); + await paused; + return {private:'synthetic result'}; + }}); + const next=async()=>JSON.parse((await responses.next()).value); + try { + input.write(readCommand); + await ready; + access.replaceBindings([]); + release(); + assert.deepEqual(await next(),denied); + access.replaceBindings(bindings); + input.write(readCommand); + assert.deepEqual(await next(),denied); + assert.equal(access.authorize(access.bind(identity),'result:read','tenant-a'),true); + } finally { + release(); + input.end(); + await serve; + lines.close(); + output.end(); + } + assert.throws(()=>access.authorize(session,'result:read','tenant-a'),{code:denied.code}); +}); + +test('oversize stdio commands are discarded and the next command can be read',async()=>{ + const access=createCodexOsAccess(bindings); + const input=new PassThrough(),output=new PassThrough(); + const lines=createInterface({input:output}); + const responses=lines[Symbol.asyncIterator](); + const serve=serveCodexOsSession({access,identity,input,output,dispatch:()=>({safe:true})}); + try { + input.write(' '.repeat(8000)+readCommand+readCommand); + assert.deepEqual(JSON.parse((await responses.next()).value),denied); + assert.deepEqual(JSON.parse((await responses.next()).value),{ok:true,result:{safe:true}}); + } finally { + input.end(); + await serve; + lines.close(); + output.end(); + } +}); diff --git a/website/content/guide/host-integration.mdx b/website/content/guide/host-integration.mdx index b026f5b..676e141 100644 --- a/website/content/guide/host-integration.mdx +++ b/website/content/guide/host-integration.mdx @@ -2,6 +2,8 @@ Runtime 是 FlowKit 与模型、审批界面、业务函数之间的接缝。本页把 Agent、Skill、输出 Schema、业务检查和人工审批放在一个完整例子里。示例使用固定响应,便于先确认编排正确,再替换真实服务。 +以下示例适用于 Standard。内部 Interactive P0-A 接入由受信 Host 管理持久化、模型隔离和操作权限。部署时用经 OS 认证的操作员会话固定租户,并为完整结果读取单独授权审计角色。 + ## 准备完整目录 按[安装与环境](/guide/installation)建立 ESM 消费者项目。在项目根目录创建 sample.workflow.js、host.mjs 和 skills/writer.md。package.json 所在目录也是默认项目根目录。 From 9ba894ce702d84e20d9a51a57d015e225774b851 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 17:34:51 +0800 Subject: [PATCH 46/72] Exercise Host operator and auditor sessions through OS-launched peers --- tests/security/p0a-os-session.mjs | 162 ++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/security/p0a-os-session.mjs diff --git a/tests/security/p0a-os-session.mjs b/tests/security/p0a-os-session.mjs new file mode 100644 index 0000000..04861a2 --- /dev/null +++ b/tests/security/p0a-os-session.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import {execFileSync,spawn} from 'node:child_process'; +import {generateKeyPairSync} from 'node:crypto'; +import {mkdir,readFile,writeFile} from 'node:fs/promises'; +import {createInterface} from 'node:readline'; +import {PassThrough} from 'node:stream'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createCodexOsAccess} from '../../examples/interactive/codex-os-access.mjs'; +import {serveCodexOsSession} from '../../examples/interactive/codex-os-stdio.mjs'; +import {compileInteractive,createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../src/interactive/index.mjs'; + +const [mode,root,policyFile]=process.argv.slice(2); +const ownIdentity=()=>process.platform==='win32' + ?execFileSync(path.join(process.env.SystemRoot,'System32','whoami.exe'),['/user','/fo','csv','/nh'],{encoding:'utf8'}).match(/S-1-5-(?:\d+-)+\d+/)?.[0] + :String(process.getuid()); +const write=value=>process.stdout.write(JSON.stringify(value)+'\n'); +const incoming=createInterface({input:process.stdin,crlfDelay:Infinity}); +const identity=ownIdentity(); +assert.ok(identity); +write({identity,pid:process.pid}); + +if (mode==='linux-supervisor') { + incoming.close(); + assert.equal(process.getuid(),0); + const config=JSON.parse(await readFile(policyFile,'utf8')); + const entry=fileURLToPath(import.meta.url); + const children=[]; + const launch=async(uid,...args)=>{ + const child=spawn('/usr/bin/setpriv',['--reuid',uid,'--regid',uid,'--clear-groups','--no-new-privs', + process.execPath,entry,...args],{stdio:['pipe','pipe','inherit']}); + const closed=new Promise((resolve,reject)=>{child.once('error',reject);child.once('close',resolve);}); + children.push({child,closed}); + const output=createInterface({input:child.stdout})[Symbol.asyncIterator](); + const read=async()=>JSON.parse((await output.next()).value); + const peer=await read(); + assert.deepEqual(peer,{identity:uid,pid:child.pid}); + write({started:uid,pid:peer.pid}); + return async command=>{ + child.stdin.write(JSON.stringify(command)+'\n'); + const reply=await read(); + write({peer:uid,command,reply}); + return reply; + }; + }; + try { + const host=await launch(config.host,'host',root,policyFile); + const [a,b,auditor]=config.bindings.map(entry=>entry.identity); + const peers=new Map(); + async function connect(id,uid) { + peers.set(id,await launch(uid,'peer')); + assert.deepEqual(await host({op:'open',id,identity:uid}),{opened:id}); + } + async function command(id,value) { + const peer=peers.get(id); + const {command}=await peer(value); + return (await host({op:'data',id,command})).response; + } + for (const [id,uid] of [['a',a],['b',b],['audit',auditor]]) await connect(id,uid); + await exerciseOsSessions({host,command,connect,operator:a,auditor}); + } finally { + for (const {child} of children) child.stdin.end(); + for (const {closed} of children) assert.equal(await closed,0); + } +} else if (mode==='peer') { + // The privileged launcher reads this process's stdout and sends the bytes to + // the Host using the session ID allocated at OS process creation. + for await (const line of incoming) write({command:JSON.parse(line)}); +} else { + assert.equal(mode,'host'); + assert.ok(path.isAbsolute(root) && path.isAbsolute(policyFile)); + await mkdir(root,{mode:0o700}); + const bindings=JSON.parse((await readFile(policyFile,'utf8')).replace(/^\uFEFF/,'')).bindings; + const access=createCodexOsAccess(bindings); + const filename=path.join(root,'session.workflow.js'); + await writeFile(filename,`import {defineWorkflow,terminal} from '@devcodex-labs/flowkit/dsl'; +export default defineWorkflow({id:'os-session',version:'1.0.0',name:'OS session',nodes:[terminal('done',{name:'Done',status:'succeeded'})]});`); + const program=await compileInteractive(filename,{output:'memory',featureStage:'P0-A',skillCheck:false}); + assert.ok(program.programIdentity,JSON.stringify(program.diagnostics)); + const stores=new Map(),sessions=new Map(); + const publicKey=generateKeyPairSync('ed25519').publicKey; + function storeFor(namespace) { + if (!stores.has(namespace)) stores.set(namespace,createSqliteCheckpointStore(path.join(root,'run.sqlite'),{namespace})); + return stores.get(namespace); + } + async function dispatch(session,command) { + const {action,namespace}=command; + const store=storeFor(namespace); + const host=createTrustedInteractiveHost({store,trustedPublicKeys:{proof:publicKey}, + trustedArtifacts:[program.programIdentity.artifactHash],allowedInputKeys:[],disclosurePolicyId:'os-session-r5', + runtime:{skillResolver:async()=> 'unused',validateResult:()=>[]},authorizeDisclosure:()=>false, + authorizeTerminalAccess:request=>access.authorize(session,request.action,request.namespace)}); + if (action==='run:create') { + access.authorize(session,action,namespace); + const frame=await host.start({runId:command.runId,startKey:command.runId, + programBytes:Buffer.from(program.code),programIdentity:program.programIdentity,state:{privateMarker:namespace}}); + return {runId:frame.runId,revision:frame.revision,status:frame.status}; + } + assert.equal(action,'result:read'); + return host.readTrustedTerminalResult({resultRef:command.resultRef}); + } + try { + for await (const line of incoming) { + const message=JSON.parse(line); + const {op,id}=message; + if (op==='open') { + assert.ok(!sessions.has(id)); + const input=new PassThrough(),output=new PassThrough(); + const responses=createInterface({input:output})[Symbol.asyncIterator](); + const running=serveCodexOsSession({access,identity:message.identity,input,output,dispatch}); + sessions.set(id,{input,output,responses,running}); + write({opened:id}); + } else if (op==='data') { + const {input,responses}=sessions.get(id); + input.write(JSON.stringify(message.command)+'\n'); + const response=JSON.parse((await responses.next()).value); + write({id,response}); + } else if (op==='reload') { + access.replaceBindings(bindings.filter(binding=>!message.revoked.includes(binding.identity))); + write({reloaded:true}); + } else { + assert.equal(op,'inspect'); + const state=[...stores].map(([namespace,store])=>({namespace,runs:message.runIds.map(runId=>({ + runId,revision:store.load(runId)?.revision??null,journalRows:store.listJournal(runId).length, + }))})); + write({state}); + } + } + } finally { + for (const {input} of sessions.values()) input.end(); + for (const {running,output} of sessions.values()) {await running;output.end();} + for (const store of stores.values()) store.close(); + } +} + +async function exerciseOsSessions({host,command,connect,operator,auditor}) { + const denied={ok:false,code:'FK_I_HOST_DENIED'}; + const start=(namespace,runId)=>({action:'run:create',namespace,runId}); + const created=await command('a',start('tenant-a','shared')); + assert.equal(created.result.status,'succeeded'); + assert.deepEqual(await command('b',start('tenant-b','shared')),created); + const resultRef={runId:'shared',revision:created.result.revision,projectionVersion:1}; + const audit=namespace=>({action:'result:read',namespace,resultRef}); + assert.deepEqual(await command('a',audit('tenant-a')),denied); + assert.deepEqual(await command('audit',audit('tenant-b')),denied); + const readable=await command('audit',audit('tenant-a')); + assert.equal(readable.result.state.privateMarker,'tenant-a'); + const inspect={op:'inspect',runIds:['shared','revoked']}; + const before=await host(inspect); + await host({op:'reload',revoked:[operator,auditor]}); + assert.deepEqual(await command('a',start('tenant-a','revoked')),denied); + assert.deepEqual(await command('audit',audit('tenant-a')),denied); + await host({op:'reload',revoked:[]}); + assert.deepEqual(await command('a',start('tenant-a','revoked')),denied); + assert.deepEqual(await command('audit',audit('tenant-a')),denied); + assert.deepEqual(await host(inspect),before); + await connect('new-a',operator); + await connect('new-audit',auditor); + assert.deepEqual(await command('new-audit',audit('tenant-a')),readable); + assert.equal((await command('new-a',start('tenant-a','new'))).result.status,'succeeded'); + write({verified:true,oldOperatorDenied:true,oldAuditorDenied:true,unchanged:true}); +} From eb52a6f516d54488167481b00f2c6811fca79afb Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 17:37:14 +0800 Subject: [PATCH 47/72] Verify P0-A faults, bounded load and coordinated Store revocation --- tests/interactive/p0a-ops.test.mjs | 202 ++++++++++++++++++++++++++ tests/security/p0a-ops-codex.mjs | 218 +++++++++++++++++++++++++++++ tests/security/p0a-ops-fixture.mjs | 22 +++ 3 files changed, 442 insertions(+) create mode 100644 tests/interactive/p0a-ops.test.mjs create mode 100644 tests/security/p0a-ops-codex.mjs create mode 100644 tests/security/p0a-ops-fixture.mjs diff --git a/tests/interactive/p0a-ops.test.mjs b/tests/interactive/p0a-ops.test.mjs new file mode 100644 index 0000000..b09fb29 --- /dev/null +++ b/tests/interactive/p0a-ops.test.mjs @@ -0,0 +1,202 @@ +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {generateKeyPairSync} from 'node:crypto'; +import {once} from 'node:events'; +import {mkdtemp,rm,stat} from 'node:fs/promises'; +import {createServer} from 'node:http'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; +import {sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../src/interactive/index.mjs'; +import {compileOpsWorkflow,opsRuntime,opsStart} from '../security/p0a-ops-fixture.mjs'; + +async function fixture(t) { + const root=await mkdtemp(path.join(tmpdir(),'flowkit-p0a-ops-')); + const resources=[]; + t.after(async()=>{ + for (const resource of resources.reverse()) resource.close(); + await rm(root,{recursive:true,force:true}); + }); + return {root,resources,program:await compileOpsWorkflow(root)}; +} + +function openStore(f,namespace,keys,now,permissions={}) { + const file=path.join(f.root,namespace+'.sqlite'); + const store=createSqliteCheckpointStore(file,{namespace}); + const host=createTrustedInteractiveHost({store,runtime:opsRuntime,now, + trustedPublicKeys:{proof:keys.publicKey},trustedArtifacts:[f.program.programIdentity.artifactHash], + allowedInputKeys:['question'],disclosurePolicyId:'p0a-ops-fixture',authorizeDisclosure:()=>true,...permissions}); + f.resources.push(store); + return {file,store,host}; +} + +function inputFor(task,answer,callId) { + const {runId,requestId,nodeId,attempt}=task; + const call={threadId:'test-transport',turnId:'test-'+callId,callId,tool:'flowkit_submit',args:{requestId,answer}}; + return {evidence:{task,started:call,callback:call,modelPid:process.pid}, + receipt:{runId,requestId,nodeId,attempt,kind:'agent-result',result:{answer}}}; +} + +async function makeTask(f,host,startKey) { + const frame=await host.start(opsStart(f.program,startKey)); + assert.equal(frame.status,'waiting'); + return host.getPublicAgentTask({runId:frame.runId}); +} + +function progress(store,task) { + return {checkpoint:store.load(task.runId),journal:store.listJournal(task.runId), + receipt:store.getReceipt(task.runId,task.requestId)}; +} + +test('all entries in the test Store inventory must confirm revocation before resuming',async t=>{ + const f=await fixture(t); + const oldKeys=generateKeyPairSync('ed25519'); + const nextKeys=generateKeyPairSync('ed25519'); + const oldHash=sha256Bytes(oldKeys.publicKey.export({type:'spki',format:'der'})); + const inventory=[]; + for (const namespace of ['store-a','store-b','store-c']) { + const {file,store,host}=openStore(f,namespace,oldKeys,Date.now,{authorizeReceiptAudit:()=>true}); + let approved=namespace!=='store-b'; + const nextHost=createTrustedInteractiveHost({store,runtime:opsRuntime, + trustedPublicKeys:{proof:nextKeys.publicKey},trustedArtifacts:[f.program.programIdentity.artifactHash], + allowedInputKeys:['question'],disclosurePolicyId:'p0a-ops-fixture',authorizeDisclosure:()=>true, + authorizeKeyRevocation:()=>approved}); + const outbox=openCodexOutbox({database:path.join(f.root,namespace+'-outbox.sqlite'),store,host}); + f.resources.push(outbox); + const accepted=await makeTask(f,host,'completed'); + const done=inputFor(accepted,'ready','accepted'); + const completedProof=await outbox.record(done.evidence,done.receipt,oldKeys.privateKey); + const complete=await outbox.deliver(completedProof); + const history=outbox.describe(completedProof); + const pending=await makeTask(f,host,'in-flight'); + const deferred=inputFor(pending,'ready','in-flight'); + const signed=await outbox.record(deferred.evidence,deferred.receipt,oldKeys.privateKey); + inventory.push({namespace,file,store,host,nextHost,outbox,accepted,complete,history,pending,signed, + original:progress(store,pending),approve:()=>{approved=true;}}); + } + const rotate=item=>item.nextHost.revokeKey(oldHash); + const first=await Promise.allSettled(inventory.map(rotate)); + assert.deepEqual(first.map(item=>item.status),['fulfilled','rejected','fulfilled']); + assert.equal(first[1].reason.code,'FK_I_HOST_DENIED'); + const held=inventory.filter((_,index)=>first[index].status!=='fulfilled').map(item=>item.namespace); + assert.deepEqual(held,['store-b']); + assert.equal((await inventory[1].host.resume({runId:inventory[1].pending.runId})).status,'waiting'); + inventory[1].approve(); + assert.equal(await rotate(inventory[1]),true); + for (const item of inventory) { + assert.equal(await rotate(item),false); + await assert.rejects(item.outbox.deliver(item.signed),{code:'FK_I_PROVENANCE_REJECTED'}); + assert.deepEqual(progress(item.store,item.pending),item.original); + const {runId,requestId,nodeId,attempt}=item.accepted; + const ack=await item.host.readHistoricalAck({namespace:item.namespace,runId,requestId,nodeId,attempt, + receiptHash:item.history.receiptHash}); + assert.equal(ack.acceptedRevision,item.complete.acceptedRevision); + const newTask=await makeTask(f,item.nextHost,'new-policy'); + assert.notEqual(item.store.load(newTask.runId).policyHash,item.original.checkpoint.policyHash); + } + console.log('P0A_OPS '+JSON.stringify({case:'inventory-revocation',stores:inventory.map(item=>item.namespace), + firstAttemptHold:held,confirmed:inventory.length,rejectedDeferred:inventory.length,historicalAcks:inventory.length})); +}); + +test('bounded Outbox load sends capacity alarms to an independent local receiver',async t=>{ + const f=await fixture(t); + const clock={now:Date.now()}; + const keys=generateKeyPairSync('ed25519'); + const {file,store,host}=openStore(f,'load',keys,()=>clock.now); + const acceptedEvents=[]; + const delivery=[]; + const server=createServer(async(req,res)=>{ + let data=''; + for await (const chunk of req) data+=chunk; + if (req.method!=='POST' || req.url!=='/capacity') {res.writeHead(404).end();return;} + acceptedEvents.push(JSON.parse(data)); + res.writeHead(202).end(); + }); + server.listen(0,'127.0.0.1'); + await once(server,'listening'); + t.after(()=>new Promise(resolve=>server.close(resolve))); + const database=path.join(f.root,'load-outbox.sqlite'); + const outbox=openCodexOutbox({database,store,host,now:()=>clock.now,maxRecords:16,retentionMs:1000, + authorizeCleanup:({subjectId})=>({allowed:subjectId==='fixture-operator',heldRunIds:[]}), + onCapacity(event) { + delivery.push(fetch(`http://127.0.0.1:${server.address().port}/capacity`,{ + method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(event),signal:AbortSignal.timeout(5000), + }).then(response=>response.status)); + }}); + f.resources.push(outbox); + const physical=async()=>{ + const items=await Promise.all([file,database].flatMap(name=>[name,name+'-wal',name+'-shm']) + .map(name=>stat(name).then(info=>info.size,error=>{if(error.code!=='ENOENT')throw error;return 0;}))); + return items.reduce((sum,size)=>sum+size,0); + }; + const started=performance.now(); + const samples=[]; + for (let batch=0;batch<6;batch++) { + for (let index=0;index<16;index++) { + const id=`${batch}-${index}`; + const task=await makeTask(f,host,id); + const input=inputFor(task,'ready',id); + const proof=await outbox.record(input.evidence,input.receipt,keys.privateKey); + assert.equal((await outbox.deliver(proof)).status,'succeeded'); + } + const pending=await makeTask(f,host,'overflow-'+batch); + const before=progress(store,pending); + const input=inputFor(pending,'ready','overflow-'+batch); + await assert.rejects(outbox.record(input.evidence,input.receipt,keys.privateKey),{code:'FK_OUTBOX_CAPACITY'}); + assert.deepEqual(progress(store,pending),before); + assert.equal(await delivery.at(-1),202); + const {records,bytes}=outbox.usage(); + assert.equal(records,16); + const peakPhysical=await physical(); + clock.now+=1001; + const cleanup=await outbox.cleanup({namespace:'load',subjectId:'fixture-operator',reasonCode:'LAB_RETENTION'}); + assert.equal(cleanup.deleted,16); + assert.equal(outbox.usage().records,0); + samples.push({batch,logicalBytes:bytes,peakSamplePhysical:peakPhysical,afterCleanupPhysical:await physical()}); + } + assert.equal(acceptedEvents.length,6); + assert.ok(acceptedEvents.every(item=>item.namespace==='load'&&item.records===16&&item.maxRecords===16)); + console.log('P0A_OPS '+JSON.stringify({case:'bounded-load',accepted:96,capacityRejections:6,httpAccepted:6, + elapsedMs:Math.round(performance.now()-started),samples})); +}); + +test('real process termination immediately after durable Genesis allows idempotent reopening',async t=>{ + const f=await fixture(t); + const file=path.join(f.root,'crash.sqlite'); + const moduleUrl=new URL('../../src/interactive/index.mjs',import.meta.url).href; + const fixtureUrl=new URL('../security/p0a-ops-fixture.mjs',import.meta.url).href; + const worker=`import {writeSync} from 'node:fs'; +import {createInteractiveCoordinator,createSqliteCheckpointStore} from ${JSON.stringify(moduleUrl)}; +import {opsRuntime,opsStart} from ${JSON.stringify(fixtureUrl)}; +const [file,bytes,identity]=process.argv.slice(1); +const db=createSqliteCheckpointStore(file,{namespace:'genesis'}); +const store={...db,createRun(args){ + db.createRun(args); + writeSync(1,'GENESIS_COMMITTED\\n'); + process.kill(process.pid,'SIGKILL'); +}}; +await createInteractiveCoordinator({store,runtime:opsRuntime}).start(opsStart({programBytes:Buffer.from(bytes,'base64'),programIdentity:JSON.parse(identity)},'stable-start'));`; + const child=spawn(process.execPath,['--input-type=module','-e',worker,file,f.program.programBytes.toString('base64'), + JSON.stringify(f.program.programIdentity)],{stdio:['ignore','pipe','pipe']}); + let stdout='',stderr=''; + child.stdout.on('data',chunk=>{stdout+=chunk;}); + child.stderr.on('data',chunk=>{stderr+=chunk;}); + const [exitCode,signal]=await once(child,'close'); + assert.equal(stdout.trim(),'GENESIS_COMMITTED',stderr); + assert.ok(signal==='SIGKILL'||(process.platform==='win32'&&exitCode!==0)); + const {createInteractiveCoordinator}=await import('../../src/interactive/index.mjs'); + const store=createSqliteCheckpointStore(file,{namespace:'genesis'}); + f.resources.push(store); + const api=createInteractiveCoordinator({store,runtime:opsRuntime}); + const first=await api.start(opsStart(f.program,'stable-start')); + const second=await api.start(opsStart(f.program,'stable-start')); + assert.equal(first.status,'waiting'); + assert.equal(second.runId,first.runId); + assert.equal(second.pending.requestId,first.pending.requestId); + assert.equal(second.revision,first.revision); + assert.equal(store.load(first.runId).receiptCount,0); + console.log('P0A_OPS '+JSON.stringify({case:'genesis-after-commit',exitCode,signal,runId:first.runId, + revision:first.revision,journalRows:store.listJournal(first.runId).length})); +}); diff --git a/tests/security/p0a-ops-codex.mjs b/tests/security/p0a-ops-codex.mjs new file mode 100644 index 0000000..d055454 --- /dev/null +++ b/tests/security/p0a-ops-codex.mjs @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import {execFileSync,spawn} from 'node:child_process'; +import {generateKeyPairSync} from 'node:crypto'; +import {appendFileSync,mkdirSync,writeFileSync} from 'node:fs'; +import path from 'node:path'; +import {createInterface} from 'node:readline'; +import {fileURLToPath} from 'node:url'; +import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; +import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../src/interactive/index.mjs'; +import {compileOpsWorkflow,opsRuntime,opsStart} from './p0a-ops-fixture.mjs'; + +const [executable,cwd,out]=process.argv.slice(2); +assert.ok([executable,cwd,out].every(value=>value&&path.isAbsolute(value))); +mkdirSync(out,{recursive:false,mode:0o700}); +const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; +const launcher=process.env.FLOWKIT_CODEX_LAUNCHER; +const sshConfig=process.env.FLOWKIT_CODEX_SSH_CONFIG; +assert.ok(launcher&&path.isAbsolute(launcher)); +let args,checkIdentity; +if (process.platform==='win32') { + const helper=process.env.FLOWKIT_CODEX_WINDOWS_IDENTITY; + assert.ok(helper&&path.isAbsolute(helper)); + const hostSid=execFileSync(helper,['--host-identity'],{encoding:'utf8',timeout:5000,windowsHide:true}).trim(); + assert.equal(hostSid,process.env.FLOWKIT_HOST_SID); + assert.notEqual(hostSid,process.env.FLOWKIT_MODEL_SID); + args=[executable,...appArgs]; + checkIdentity=identity=>{ + assert.equal(identity.modelSid,process.env.FLOWKIT_MODEL_SID); + assert.equal(identity.modelElevated,false); + assert.equal(identity.stdioAllowlist,true); + return {hostSid,...identity}; + }; +} else { + assert.equal(process.platform,'linux'); + const hostUid=Number(process.env.FLOWKIT_HOST_UID),hostGid=Number(process.env.FLOWKIT_HOST_GID); + const modelUid=Number(process.env.FLOWKIT_MODEL_UID),modelGid=Number(process.env.FLOWKIT_MODEL_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(modelExec&&path.isAbsolute(modelExec)); + args=[process.execPath,fileURLToPath(new URL('./codex-model-identity.mjs',import.meta.url)), + String(modelUid),String(modelGid),modelExec,executable,...appArgs]; + checkIdentity=identity=>{ + assert.equal(identity.uid,modelUid); + assert.equal(identity.gid,modelGid); + assert.ok(identity.groups.every(group=>group===modelGid)); + assert.match(identity.capabilities,/^0+$/); + assert.equal(identity.noNewPrivs,'1'); + return {hostUid,hostGid,...identity}; + }; +} +if (sshConfig) { + assert.ok(path.isAbsolute(sshConfig)&&['ssh','ssh.exe'].includes(path.basename(launcher).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 write=(file,text)=>appendFileSync(path.join(out,file),text); +const event=(kind,details)=>write('observer.ndjson',JSON.stringify({kind,at:Date.now(),...details})+'\n'); +const program=await compileOpsWorkflow(out); +const store=createSqliteCheckpointStore(path.join(out,'run.sqlite'),{namespace:'p0a-ops'}); +const keys=generateKeyPairSync('ed25519'); +writeFileSync(path.join(out,'proof.public.pem'),keys.publicKey.export({type:'spki',format:'pem'})); +const host=createTrustedInteractiveHost({store,runtime:opsRuntime, + trustedPublicKeys:{proof:keys.publicKey},trustedArtifacts:[program.programIdentity.artifactHash], + allowedInputKeys:['question'],disclosurePolicyId:'p0a-ops',authorizeDisclosure:()=>true, + authorizeTerminalAccess:({action})=>action==='result:read'}); +const outbox=openCodexOutbox({database:path.join(out,'outbox.sqlite'),store,host}); +const child=spawn(launcher,args,{cwd,stdio:['pipe','pipe','pipe'],windowsHide:true}); +const lines=createInterface({input:child.stdout}); +let fail,identify,sequence=0,identity,active; +const failed=new Promise((_,reject)=>{fail=reject;}); +const identified=new Promise(resolve=>{identify=resolve;}); +const requests=new Map(),observations=new Map(); +const timer=setTimeout(()=>fail(new Error('App Server run timed out')),120000); +child.once('error',fail); +child.once('close',code=>fail(new Error(`App Server channel closed early (${code})`))); +child.stdin.on('error',fail); +child.stdout.on('data',chunk=>write('app-server.stdout.ndjson',chunk)); +child.stderr.on('data',chunk=>write('app-server.stderr',chunk)); + +function send(message) { + const line=JSON.stringify(message)+'\n'; + write('app-server.requests.ndjson',line); + child.stdin.write(line); +} + +function request(method,params) { + const id=++sequence; + return new Promise((resolve,reject)=>{requests.set(id,{resolve,reject});send({id,method,params});}); +} + +async function submit(message) { + const p=message.params; + const started=observations.get(p.callId); + observations.delete(p.callId); + assert.ok(active && identity && started && !active.callId); + assert.equal(p.threadId,active.threadId); + assert.equal(p.turnId,active.turnId); + assert.equal(p.tool,'flowkit_submit'); + assert.deepEqual(started,{threadId:p.threadId,turnId:p.turnId,tool:p.tool,callId:p.callId,args:p.arguments}); + const {runId,requestId,nodeId,attempt}=active.task; + assert.deepEqual(p.arguments,{requestId,answer:active.answer}); + active.callId=p.callId; + const receipt={runId,requestId,nodeId,attempt,kind:'agent-result',result:{answer:p.arguments.answer}}; + const proofId=await outbox.record({task:active.task,started,callback:started,modelPid:identity.pid},receipt,keys.privateKey); + const result=await outbox.deliver(proofId); + const ack=outbox.describe(proofId); + assert.equal(ack.state,'ACKED'); + assert.equal(ack.receiptHash,sha256Bytes(canonicalBytes(receipt))); + assert.equal(store.getReceipt(runId,requestId).acceptedRevision,ack.acceptedRevision); + active.result=result; + event('receipt',{case:active.label,modelPid:identity.pid,threadId:p.threadId,turnId:p.turnId,callId:p.callId, + runId,requestId,attempt,answer:active.answer,ack,status:result.status,revision:result.revision}); + send({id:message.id,result:{success:true,contentItems:[{type:'inputText',text:'Recorded.'}]}}); +} + +lines.on('line',line=>{ + try { + const message=JSON.parse(line),p=message.params; + if (!message.method && requests.has(message.id)) { + const waiter=requests.get(message.id); + requests.delete(message.id); + if (message.error) waiter.reject(new Error(JSON.stringify(message.error))); + else { + if (message.result?.turn && active) active.turnId=message.result.turn.id; + waiter.resolve(message.result); + } + } else if (message.method==='flowkit/profile/identity') { + assert.equal(identity,undefined); + assert.ok(Number.isSafeInteger(p.pid)&&p.pid>1); + identity=p; + event('identity',checkIdentity(p)); + identify(); + } else if (message.method==='item/started'&&p.item.type==='dynamicToolCall') { + observations.set(p.item.id,{threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,callId:p.item.id,args:p.item.arguments}); + } else if (message.method==='item/tool/call') { + void submit(message).catch(fail); + } else if (message.method==='turn/completed'&&active&&p.threadId===active.threadId) { + assert.equal(p.turn.id,active.turnId); + active.done(p.turn); + } else if (message.id!==undefined&&message.method) { + send({id:message.id,error:{code:-32601,message:'Unexpected server request'}}); + } + } catch (error) {fail(error);} +}); + +async function runCase(model,label,answers) { + const {thread}=await request('thread/start',{cwd,model,ephemeral:true,approvalPolicy:'never',sandbox:'read-only', + multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}},dynamicTools:[{type:'function',name:'flowkit_submit', + description:'Submit the exact answer for the current FlowKit fixture task',inputSchema:{type:'object', + properties:{requestId:{type:'string'},answer:{type:'string'}},required:['requestId','answer'],additionalProperties:false}}]}); + let frame=await host.start(opsStart(program,label)); + const runId=frame.runId; + const requestIds=[]; + for (const answer of answers) { + assert.equal(frame.status,'waiting'); + const task=await host.getPublicAgentTask({runId}); + assert.equal(task.attempt,requestIds.length+1); + requestIds.push(task.requestId); + const completed=new Promise(done=>{active={label,threadId:thread.id,task,answer,done};}); + const prompt=`Synthetic workflow validation test. Call the dynamic tool flowkit_submit exactly once with requestId "${task.requestId}" and answer "${answer}". Use the exact strings, then finish after the tool response without retrying.`; + await request('turn/start',{threadId:thread.id,input:[{type:'text',text:prompt}]}); + const turn=await completed; + assert.equal(turn.status,'completed'); + assert.ok(active.callId&&active.result); + frame=active.result; + active=undefined; + if (answer!=='ready') assert.equal(store.load(runId).state.answer,undefined); + } + assert.equal(new Set(requestIds).size,2); + const result=await host.readTrustedTerminalResult({resultRef:{runId,revision:frame.revision,projectionVersion:1}}); + const journal=store.listJournal(runId); + assert.equal(store.load(runId).receiptCount,2); + assert.equal(journal.length,frame.revision+1); + if (label==='recover') { + assert.equal(frame.status,'succeeded'); + assert.equal(result.state.answer,'ready'); + } else { + assert.equal(frame.status,'errored'); + assert.equal(result.causeCode,'FK_R004'); + assert.equal(result.state.answer,undefined); + } + event('case',{case:label,runId,requestIds,modelTurns:2,receiptCount:2,status:frame.status, + causeCode:result.causeCode,revision:frame.revision,journal}); + return frame.status; +} + +async function run() { + await identified; + await request('initialize',{clientInfo:{name:'flowkit-ops',title:'FlowKit operations 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; + assert.ok(model); + const recover=await runCase(model,'recover',['invalid','ready']); + const exhausted=await runCase(model,'exhausted',['invalid','invalid']); + return {passed:true,model,recover,exhausted,modelTurns:4}; +} + +try {console.log(JSON.stringify(await Promise.race([run(),failed])));} +catch (error) {console.error(error.stack);process.exitCode=1;} +finally { + clearTimeout(timer); + lines.close(); + child.stdin.end(); + child.stdout.destroy(); + child.stderr.destroy(); + child.unref(); + outbox.close(); + store.close(); +} diff --git a/tests/security/p0a-ops-fixture.mjs b/tests/security/p0a-ops-fixture.mjs new file mode 100644 index 0000000..3b53947 --- /dev/null +++ b/tests/security/p0a-ops-fixture.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import {writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import {compileInteractive} from '../../src/interactive/index.mjs'; + +export async function compileOpsWorkflow(directory) { + const file=path.join(directory,'ops.workflow.js'); + await writeFile(file,`import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl'; +export default defineWorkflow({id:'p0a-ops',version:'1.0.0',name:'P0-A operations',nodes:[ +node('agent',{name:'Agent',agent:'writer',inputs:['question'],output:{type:'object',properties:{answer:{type:'string'}}},retries:1,next:'done'}), +terminal('done',{name:'Done',status:'succeeded'})]});`,{flag:'wx'}); + const compiled=await compileInteractive(file,{output:'memory',featureStage:'P0-A',skillCheck:false}); + assert.ok(compiled.programIdentity,JSON.stringify(compiled.diagnostics)); + return {programBytes:Buffer.from(compiled.code),programIdentity:compiled.programIdentity}; +} + +export const opsRuntime={ + skillResolver:async()=> 'Follow the current task and submit the requested answer.', + validateResult(_schema,result) {return result?.answer==='ready'?[]:['Expected ready'];}, +}; + +export const opsStart=(program,startKey)=>({...program,startKey,state:{question:'Return ready.'}}); From a80cd0e41c45c94be95f8aa7ee9eb912c95e08e4 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 17:49:49 +0800 Subject: [PATCH 48/72] Record independent G12 disposition and dual-platform evidence --- .../p0a-admin-codex-20261113/README.md | 2 + .../p0a-codex-isolated-20261113/g12-ops-r5.md | 44 +++++++++++++++++++ .../g12-os-sessions.md | 21 +++++++++ .../p0a-compat-codex-20261113/README.md | 2 + .../p0a-g07-g12-20261113/g12-review.md | 16 +++++++ .../p0a-g07-g12-20261113/release-matrix.md | 22 +++++----- website/content/maintainers/releasing.mdx | 2 +- 7 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/g12-ops-r5.md create mode 100644 docs/evidence/p0a-codex-isolated-20261113/g12-os-sessions.md create mode 100644 docs/evidence/p0a-g07-g12-20261113/g12-review.md diff --git a/docs/evidence/p0a-admin-codex-20261113/README.md b/docs/evidence/p0a-admin-codex-20261113/README.md index 4536eb0..6ef0e87 100644 --- a/docs/evidence/p0a-admin-codex-20261113/README.md +++ b/docs/evidence/p0a-admin-codex-20261113/README.md @@ -1,5 +1,7 @@ # P0-A 管理终止和 Codex 实测 +> 此页记录首发方案确认前的 V2 迁移实验。当前首发初始化全新 R5 数据库,参考入口只接收 `namespace` 选项。部署和验收以 [Interactive 接入](../../interactive.md) 和 [当前发布矩阵](../p0a-g07-g12-20261113/release-matrix.md) 为准。 + Draft [#22](https://github.com/devcodex-labs/flowkit/pull/22) 的实施基线为 `e1cd5c7c4f89397fbdee50e1a430074c4c610e9d`,基线 [CI](https://github.com/devcodex-labs/flowkit/actions/runs/43331776187) 8/8 通过。 ## R5 管理终止 diff --git a/docs/evidence/p0a-codex-isolated-20261113/g12-ops-r5.md b/docs/evidence/p0a-codex-isolated-20261113/g12-ops-r5.md new file mode 100644 index 0000000..7fe785c --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/g12-ops-r5.md @@ -0,0 +1,44 @@ +# G12 故障与运维验收 + +代码候选 `eb52a6f516d54488167481b00f2c6811fca79afb`。测试使用临时数据库、合成输入和同一测试进程内的 HTTP 回环接收器。 + +## 可重复的程序级演练 + +```text +node --test tests/interactive/p0a-ops.test.mjs +``` + +Windows Node 24.22.2 三项通过。 + +| 样本 | 结果 | +|---|---| +| 三个测试 Store 的撤销协调 | 首轮 `store-b` 未授权,测试协调逻辑将它保留在待确认清单。补授权后,三个 Store 分别确认撤销。各 Store 拒绝一份旧签名的未接纳回执,原始 Checkpoint、Journal 和接纳索引保持一致,三个历史 Ack 可读取,新策略可创建 Run。 | +| 有界持续负载与告警 | 六轮各接纳 16 个 Run,共 96 次。每轮达到 16 条测试容量上限时拒绝新增回执并收到本机 HTTP `202`,原 Pending、Journal 和接纳索引未变。受权清理后本轮 16 条 Outbox 记录全部回收,再进入下一轮。 | +| Genesis 已提交、响应前强杀 | Node 子进程报告 Genesis 已提交后立即终止。新进程两次提交相同 startKey 均复用同一个 Run 和 Pending,revision 3、四行 Journal、零接纳回执。 | + +Windows 有界测试运行约 3.23 秒。每轮逻辑保留量 81,948 B。SQLite、WAL、SHM 文件长度总和在末轮满额时为 8,776,744 B,清理后采样为 8,813,824 B。生产需要按部署文件系统、压测峰值和恢复余量另行确认磁盘预算。此测试以文件长度计量,包含 Store 和 Outbox,保留 Journal 增长。 + +## Windows 双账户真实 Codex App Server + +```text +node tests/security/p0a-ops-codex.mjs <全新Host私有证据目录> +``` + +`p0a-ops-20261113173715` 退出码 0。四次真实工具提交经受信通道独立核对开始事件、线程、回合、调用及参数。Host 用真实 SQLite 和 Outbox 签收,并记录完整 Journal。 + +| 样本 | 结果 | +|---|---| +| 无效业务结果后重试恢复 | 第一次提交被业务校验拒绝,Run 保持同一 ID,产生新的 requestId,非法结果未合并。第二次合法提交成功,终态 revision 13、Journal 14 行、两份可查 Ack。 | +| 无效业务结果用尽重试 | 两次真实提交均未通过业务校验,终态 `errored`、`FK_R004`,无非法状态合并。终态 revision 9、Journal 10 行、两份可查 Ack。 | + +独立离线复核将四份签名字节、四个 App Server 原始工具回调和接纳 revision 对齐。回环监听已收回。Windows 归档 `FlowKit-Windows-G12-Ops-R5-20261113.zip` 的 SHA-256 为 `1e33cd8aef6802dcdb8c44f924449679705e53f17b708768338b92523c72c617`,18 个归档成员的哈希已逐项复核。 + +## Linux + +FlowKit 快进拉取 `eb52a6f`,相同程序级命令 3/3 通过。96 次接纳与 6 次回环 HTTP `202`,末轮满额时数据库、WAL、SHM 文件长度合计 8,768,552 B,清理后采样 8,805,632 B。Genesis 后进程退出信号为 `SIGKILL`。 + +真实 Codex 四回合首次通过,约 29 秒。两组接纳 revision 均为 4、8;分别在 revision 13 成功和 revision 9 以 `FK_R004` 终止。独立复核四份签名、两条共 24 行 Journal,Outbox 均为 `ACKED`。`FlowKit-Linux-P0A-Ops-20261113.zip` 的 SHA-256 为 `0097b047fef610ce32ece8addd3a4cde423d2594db679ee25fac7132c1841a96`,156 份文件已逐项复核。精确命令、原始日志和归档由 [FlowKit 任务](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) 保管。 + +## 证据边界 + +三个 Store 是完整的测试清单。清单的待确认状态由测试协调逻辑记录,本轮没有外部入口流量联锁。生产入口的暂停联动、生产 Store 全量清单、集中告警到达值班方、生产持续负载与备份恢复仍为 BLOCKED。`202` 来自同进程 HTTP 回环接收器。测试配额及保留时间未写入产品默认值。模型按用例提交结构有效、业务值无效的结果,真实供应商故障、非法协议正文及完整原生强杀矩阵仍待验证。 diff --git a/docs/evidence/p0a-codex-isolated-20261113/g12-os-sessions.md b/docs/evidence/p0a-codex-isolated-20261113/g12-os-sessions.md new file mode 100644 index 0000000..7e9c50e --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/g12-os-sessions.md @@ -0,0 +1,21 @@ +# G12 OS 操作员与审计员会话实测 + +实测提交 `9ba894ce702d84e20d9a51a57d015e225774b851`,承接 [E12 OS 角色](./os-r5-live.md) 和 [E13 会话撤权](./session-r5-live.md)。本次在专用环境启动普通 Host、操作员 A/B、审计员进程,由受信监督器根据其创建的 OS 进程和预期 SID/UID 建立 Host 会话。操作员和审计员经各自进程管道发送命令,监督器转发至 Host 并收集结果。审计回复由监督器核验,未通过生产审计通道送回审计员进程。生产部署的启动与配置分发仍须按实际环境验证。 + +两位操作员在各自租户用同一 `RunId` 创建 Run。两份结果均为 `succeeded`、revision 2。操作员读取私有结果及审计员跨租户读取被统一拒绝。审计员可读取获准租户的私有状态。移除操作员 A 与审计员的映射后,已打开的连接拒绝创建与读取。恢复映射后旧连接仍拒绝,前后两库的 Run revision 与 Journal 行数一致,拒绝的新 Run 不存在。重新创建 OS 对端和 Host 会话后,合法创建和读取恢复。此样本使用终态工作流,不涉及模型签名;真实 App Server 文件提交由 E13 验证。 + +## Windows + +`windows-os-session.ps1` 在受保护的测试部署中启动一个普通 Host、两名普通操作员和一名普通审计员,再为恢复后的操作员和审计员各启动一个新进程。独立读取进程 SID 和 PID,与启动方的账户配置匹配。六个进程最终均退出 0。两个早期尝试分别遇到工作目录和测试程序 ACL 拒绝,原始日志均保留。最终将只读对端测试程序放到单独的临时目录,完成后移除该目录。 + +`FlowKit-Windows-G12-OS-Sessions.zip` 已逐项复核,SHA-256 为 `9bb2481116e12bd18c6f4ca43863b491d1cef743bae51a3366e4d55327616e9f`。包含实际启动脚本、三次原始记录和通过结果。此证据为 P 级实际 OS 进程和 Host 授权。 + +## Linux + +FlowKit 快进拉取同一候选。六个普通子进程的 UID、零有效 capability 和 `NoNewPrivs` 经独立观察核对。撤权前后的完整 SQLite 快照一致,新连接恢复后才新增 Run。首次正式运行通过。 + +```text +node tests/security/p0a-os-session.mjs linux-supervisor <新Host私有目录> <映射绝对路径> +``` + +`FlowKit-Linux-OS-Process-Session-20261113.zip` 的 SHA-256 为 `49fe426a9b8f04aee21d0967c6184aabcd918d79898357da82e68ff323087973`,127 份文件已逐项复核。原始启动命令、进程记录、SQL 快照和归档由 [FlowKit 任务](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) 保管。 diff --git a/docs/evidence/p0a-compat-codex-20261113/README.md b/docs/evidence/p0a-compat-codex-20261113/README.md index 85d3730..0fe94d7 100644 --- a/docs/evidence/p0a-compat-codex-20261113/README.md +++ b/docs/evidence/p0a-compat-codex-20261113/README.md @@ -1,5 +1,7 @@ # P0-A 兼容实现与 Codex 宿主候选 +> 此页记录首发方案确认前的兼容实验。当前首发使用 R5 Checkpoint 和全新 SQLite 数据库。旧格式和旧 Run 的描述仅适用于下述历史提交。 [当前首发契约](../p0a-g07-g12-20261113/release-matrix.md#四项已确认的首发契约) + 实施基线为 Draft [#22](https://github.com/devcodex-labs/flowkit/pull/22) 的 `70143668394fc5f4e4d1d26f62788c14a66e17e3`。[该提交 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43327790322) 已完成,8/8 成功。 ## 已批准的兼容实现 diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md new file mode 100644 index 0000000..fff0f79 --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -0,0 +1,16 @@ +# G12 独立审查裁决 + +**裁决为 BLOCKED,P0-A 发布门禁未通过。** 独立 G12 审查角色核对代码候选 `eb52a6f516d54488167481b00f2c6811fca79afb`、真实命令和原始证据,并复核了 Requirements V0.3、Technical Design V0.6-R5 和 P0-A Plan V0.1 的原始文件 SHA-256。三份均与 [G01 登记](../p0a-g01-20261112/verified-inputs.txt) 一致。代码候选的 [CI](https://github.com/devcodex-labs/flowkit/actions/runs/43402857273) 8/8。审查由独立角色只读执行。 + +| 核对项 | 裁决与依据 | +|---|---| +| 冻结原件和范围 | PASS,三份原件字节与登记一致。P0-B/P1/P2 继续按首发范围判为不适用。R5 首发使用新建 SQLite 库。 | +| 代码和基础构建/消费者验证 | PASS,代码候选四矩阵、类型、包消费者、文档和 OS 隔离检查通过。独立角色复跑运维和会话回归 5/5。 | +| OS 授权样本 | 双平台样本 PASS,真实普通 OS 进程发出创建和审计命令,经受信监督器转发后检查权限、旧会话失效和新连接恢复。Windows 比较 Run revision 和 Journal 行数,Linux 比较完整 SQLite 快照。生产 OS 接入、审计结果送达实际客户端、同一发布链上的 Model 回执,以及跨 Host 撤权仍为 BLOCKED。 [实测证据](../p0a-codex-isolated-20261113/g12-os-sessions.md) | +| Codex 结果校验与恢复 | 双平台样本 PASS,真实 Codex 回调提交结构合法但业务值无效的结果,分别验证重试恢复和 `FK_R004`。真实断线、供应商失败及异常协议正文仍为 BLOCKED。 [实测证据](../p0a-codex-isolated-20261113/g12-ops-r5.md) | +| 故障与运维 | 样本 PASS,新增程序级 Genesis 已提交后强杀。既有程序级切点继续有效。完整发布环境的原生切点、生产全 Store 清单及暂停入口联动、集中告警送达、长期容量和备份恢复仍为 BLOCKED。 | +| 完整来源与部署证据 | BLOCKED,适用 SEC/NAT、C12/S13/T27 和 T29 尚未满足。NAT-13 的供应商级证据单列,按其原能力探测条款裁决。 | + +审查纠正了两处证据表述。OS 对端命令来自实际普通进程,审计结果由监督器核验;测试清单的待确认状态和同进程 HTTP `202` 用于验证协调与告警样本。历史 V2 迁移及兼容报告已标明适用提交,当前部署说明指向首发 R5 新库。 + +剩余发布门禁统一登记在 [发布矩阵](./release-matrix.md)。实际部署验收至少需要已认证的入口与客户端、实际 Host/Store 清单、告警接收端及备份恢复窗口。G17 依赖 G12 放行及用户的发布授权。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index c91efee..59d714f 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -21,6 +21,8 @@ | E11 | `60b745e1708f2e7c10b88254912fa074b9489d3e` | Windows/Linux 各 7 个真实 Codex 回合,测试主体/租户绑定、同 startKey 和 RunId 隔离、观察失配/超时/迟到与两笔合法接纳。[分平台实测、原始命令与归档 SHA-256](../p0a-codex-isolated-20261113/entry-r5-live.md) | | E12 | `2f6a6738c5ffc12459bb271823d2468c3b2731cf` | 真实普通 OS 身份预绑定,两租户 stdio 和文件入口并发,拒绝错配及独立审计权限。[分平台实测、原始命令与归档 SHA-256](../p0a-codex-isolated-20261113/os-r5-live.md) | | E13 | `3fd1d2f9fb8a59abf74fca86136cb0bed3eef806` | 已暂存、未消费的有效文件引用跨租户拒绝,以及 Host stdio 授权撤销后旧会话失效。[分平台实测、命令与归档 SHA-256](../p0a-codex-isolated-20261113/session-r5-live.md) | +| E14 | `9ba894ce702d84e20d9a51a57d015e225774b851` | 独立普通 OS 对端进程的创建和审计命令、会话撤权、新连接恢复。[OS 会话实测与证据](../p0a-codex-isolated-20261113/g12-os-sessions.md) | +| E15 | `eb52a6f516d54488167481b00f2c6811fca79afb` | 三测试 Store 撤销、96 次接纳与本机 HTTP 告警、Genesis 强杀、真实 Codex 无效结果和重试。[故障与运维实测](../p0a-codex-isolated-20261113/g12-ops-r5.md) | ## SEC · 生产配置与候选入口 @@ -30,21 +32,21 @@ | 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,发布入口授权 | E10/E11 覆盖真实错请求、缺观察及测试主体错配。E12 补 OS 身份预绑定。E13 以真实 Model 回调拒绝跨租户提交未消费有效文件引用,另在 Host 流中验证撤权。最终发布入口仍需接入经 OS 认证的对端。 | +| SEC-05 | BLOCKED,发布入口授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E14 补独立 OS 对端经受信监督器发送创建、审计命令及撤权。最终部署的操作员接入、真实 Model 回执与授权联动仍待验证。 | | 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-10 | BLOCKED,完整切点矩阵 | E2/E3/E4 的签名前、签后 CAS 前和 CAS 后强杀恢复通过。E15 增补程序级 Genesis 后强杀恢复。发布 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 拒绝。E8 验证两独立 Store 分别撤销、未接纳旧回执拒绝和受权只读返回历史 Ack。E9 演练部分完成时保留待确认 Store、授权补撤销及逐库验收。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | +| SEC-14 | BLOCKED,生产轮换与撤销 | E4/E5/E8/E9 覆盖正常轮换、原 Ack 和分别撤销。E15 增补三个测试 Store 中部分拒绝、补授权和逐库确认。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | | SEC-15 | BLOCKED,最终发布入口 | E12 实测同一 SQLite 文件内两个租户同 startKey、同 RunId 的 stdio 和文件回调并发,独立回执和 Journal。发布入口的主体认证和会话生命周期仍待验证。 | | 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。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | -| SEC-19 | BLOCKED,生产有界保留 | E8 验证真实工具触发记录数及逻辑字节拒绝、保留标记、受权清理、审计写失败回滚和重试。E9 增补真实双账户快照清理强杀、数据库分配块采样和本机满额告警。生产保留期限、持续负载下的磁盘预算及集中告警送达仍待取证。 | +| SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E15 增补 96 次接纳、六轮满额和本机 HTTP 202 告警。生产保留期限、持续负载下的磁盘预算及集中告警送达仍待取证。 | ## NAT · Codex 等价验收 @@ -76,8 +78,8 @@ |---|---| | C01 | PASS,E10 双平台 4 Agent+2 Conditions 的真实工具续跑,逐次核对声明输入、四份接纳和终态 State。 | | C02 | PASS,E10 双平台真实工具提交审查拒绝后终态 `failed`,初始 false 及缺值均为零 Agent 派发。模型按用例指定的分支值提交。 | -| C03 | BLOCKED,有受控无效结果重放。缺原生非法输出、按同一 Run 重试并恢复。 | -| C04 | BLOCKED,重试耗尽有 P 证据。缺真实持续失败及完整工具日志。 | +| C03 | PASS,E15 的真实 Codex 业务校验样本。在同一 Run 用新 requestId 重试成功,非法值未合并。异常协议正文归 NAT-10。 | +| C04 | PASS,E15 的真实 Codex 业务校验样本。两次无效结果进入 `FK_R004`,无非法状态合并,保留原始工具日志。 | | C05 | BLOCKED,E2/E3/E4 的原生强杀和恢复样本 PASS。Pending 发布及纯校验前后的完整原生故障切点归 SEC-10/T29。 | | C06 | PASS,E2 双会话竞争和 E3 已 CAS 但未记 Ack 的恢复样本。R11 的后续多阶段丢响应有 P 证据。 | | C07 | BLOCKED,E5 补实策略漂移拒绝。缺真实模型/宿主升级和重新授权。 | @@ -96,7 +98,7 @@ | C20 | PASS,原 P 级双进程竞争及 E2 真实双会话竞争。 | | C21 | 同环境重开 PASS(P)。跨 Node/OS 搬移同一物理库 BLOCKED,属于增强项。发布支持的升级另在 M6 核对。 | | C22 | BLOCKED,E1/E6 补 OS 身份,E11 补测试主体及租户错配。实际发布主体、模型自报权限、伪审批仍待取证。 | -| C23 | BLOCKED,E2/E3/E4 补真实竞争和崩溃恢复。缺真实拒绝、非法输出重试耗尽和供应商失败的组合证据。 | +| C23 | BLOCKED,E2/E3/E4 补真实竞争和崩溃恢复,E15 补结构合法但业务值无效的重试耗尽。真实供应商失败及组合证据仍待验证。 | | C24 | BLOCKED,Windows/Linux × Node 22/24、包/CLI/类型和文档消费者 CI 已通过。完整原生证据、发布支持的升级与独立复审仍待完成。 | | 冻结条款或计划项 | 后续证据对 G01 的更新 | @@ -107,14 +109,14 @@ | 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 级正反例。生产升级与恢复演练待发行候选。 | +| S12-A | 首发新建 R5 库,管理终止和专用审计的 P 级正反例通过。历史 V2 迁移只用于对应实验。部署备份恢复仍待验证。 | | 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。** 由独立审查角色只读复核同一发行候选的适用 T/R/S、Checkpoint 契约、升级与回滚、发布 Host 验收和原始证据,记录裁决与剩余 BLOCKED。审查角色与实施角色分开。P0-B/P1/P2 条款按原计划阶段判定 N/A。 [冻结追踪表](../p0a-g01-20261112/traceability.md) · [兼容实现](../p0a-compat-codex-20261113/README.md) · [管理终止](../p0a-admin-codex-20261113/README.md) +- **G12/M6。** 独立审查角色已给出 **BLOCKED** 裁决,详见 [G12 独立审查](./g12-review.md)。P0-B/P1/P2 条款按原计划阶段判定 N/A。 [冻结追踪表](../p0a-g01-20261112/traceability.md) · [历史兼容实验](../p0a-compat-codex-20261113/README.md) · [管理终止历史实验](../p0a-admin-codex-20261113/README.md) - **G17。** 在 G12 收口后冻结发行范围与候选,验证发行包和安装,再按独立授权办理合并、Tag、发布和部署。 ## 四项已确认的首发契约 @@ -139,6 +141,6 @@ Host 部署配置保留期限和容量上限。保留待投递及未对账的记 | 首发回归 | 可复核的 P 级覆盖 | 仍为 BLOCKED | |---|---|---| -| R5 Checkpoint | SQLite 原字节与异步 Store 回读一致、首次接纳、管理审计和包消费者。[版本化 SDK 回归](../../../tests/interactive/versioned.test.mjs) · E8 记录真实强杀恢复。 | G12 独立裁决及完整原生故障矩阵。 | +| R5 Checkpoint | SQLite 原字节与异步 Store 回读一致、首次接纳、管理审计和包消费者。[版本化 SDK 回归](../../../tests/interactive/versioned.test.mjs) · E8 记录真实强杀恢复。 | [G12 裁决](./g12-review.md) 的完整原生故障矩阵与发布恢复验证。 | | Ack、轮换与撤销 | 两条 SQLite 连接验证旧策略停止新建及撤销边界。[Host 回归](../../../tests/interactive/trusted-host.test.mjs) · E8 记录独立 Store 分别撤销及受权历史查询。E9 记录部分失败、补撤销和逐库确认。 | 生产所有 Host/Store 的协调、撤销后签发停止及轮换演练。 | | Outbox 容量和清理 | [Outbox 回归](../../../tests/interactive/codex-outbox.test.mjs) 覆盖文件快照回收中断重试。E8 验证真实工具满额、清理回滚及恢复。E9 增补快照清理两处强杀、分配块采样和本机告警。 | 生产容量和保留窗口、集中告警送达及持续负载下的磁盘占用。配额计入活动记录和待清理引用的逻辑字节,数据库页、WAL、文件快照和审计另计。 | diff --git a/website/content/maintainers/releasing.mdx b/website/content/maintainers/releasing.mdx index 0547dad..c77fe55 100644 --- a/website/content/maintainers/releasing.mdx +++ b/website/content/maintainers/releasing.mdx @@ -38,7 +38,7 @@ node scripts/release.mjs verify --tag v1.0.0 从私有 Registry 执行 `docs:private-package`,必须验证确切版本的 `dist.integrity`、tarball 哈希、包名和版本,再运行全部教程消费者用例,不能把无访问权限的 `404` 当作包不存在或发布成功。 -**公开文档站已获项目负责人批准**,目标地址为 GitHub Pages `/flowkit/`;FlowKit 仓库和 GitHub Packages 仍保持私有。自动部署前须核对实际 Pages 权限、环境保护和公开内容边界。网站的搜索索引和 sitemap 公开,但不得包含凭据、私有源码、内部资料或要求未授权访问者打开私有仓库的无说明链接。 +**公开文档站已获项目负责人批准**,目标地址为 GitHub Pages `/flowkit/`。GitHub 源码仓库当前公开,仓库可见性与许可证授权范围、GitHub Packages 的包权限分别核对。发行后须确认包为 Private。部署前须核对 Pages 权限、环境保护和公开内容边界。网站的搜索索引和 sitemap 公开,发布内容须排除凭据和内部资料。 ## 失败与恢复 From 00f840029d82e069f844aec68732d0989a3d0f6e Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 17:52:57 +0800 Subject: [PATCH 49/72] Use explicit loopback HTTP transport in capacity acceptance --- .../p0a-codex-isolated-20261113/g12-ops-r5.md | 2 ++ tests/interactive/p0a-ops.test.mjs | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/g12-ops-r5.md b/docs/evidence/p0a-codex-isolated-20261113/g12-ops-r5.md index 7fe785c..ac1856f 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/g12-ops-r5.md +++ b/docs/evidence/p0a-codex-isolated-20261113/g12-ops-r5.md @@ -42,3 +42,5 @@ FlowKit 快进拉取 `eb52a6f`,相同程序级命令 3/3 通过。96 次接纳 ## 证据边界 三个 Store 是完整的测试清单。清单的待确认状态由测试协调逻辑记录,本轮没有外部入口流量联锁。生产入口的暂停联动、生产 Store 全量清单、集中告警到达值班方、生产持续负载与备份恢复仍为 BLOCKED。`202` 来自同进程 HTTP 回环接收器。测试配额及保留时间未写入产品默认值。模型按用例提交结构有效、业务值无效的结果,真实供应商故障、非法协议正文及完整原生强杀矩阵仍待验证。 + +证据回填后的 Windows Node 22 CI 曾在回环告警样本中报 `fetch failed`。后续提交只调整测试发送端,使用显式的回环 HTTP 请求、每次独立连接和 15 秒上限,继续核对真实 `202` 和原有数据库断言。真实 Codex 探针及已归档的运行保持原提交身份。 [首次失败的 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43404514044) diff --git a/tests/interactive/p0a-ops.test.mjs b/tests/interactive/p0a-ops.test.mjs index b09fb29..22eb0cd 100644 --- a/tests/interactive/p0a-ops.test.mjs +++ b/tests/interactive/p0a-ops.test.mjs @@ -3,7 +3,7 @@ import {spawn} from 'node:child_process'; import {generateKeyPairSync} from 'node:crypto'; import {once} from 'node:events'; import {mkdtemp,rm,stat} from 'node:fs/promises'; -import {createServer} from 'node:http'; +import {createServer,request} from 'node:http'; import {tmpdir} from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -100,7 +100,7 @@ test('all entries in the test Store inventory must confirm revocation before res firstAttemptHold:held,confirmed:inventory.length,rejectedDeferred:inventory.length,historicalAcks:inventory.length})); }); -test('bounded Outbox load sends capacity alarms to an independent local receiver',async t=>{ +test('bounded Outbox load sends capacity alarms to a loopback HTTP receiver',async t=>{ const f=await fixture(t); const clock={now:Date.now()}; const keys=generateKeyPairSync('ed25519'); @@ -121,9 +121,18 @@ test('bounded Outbox load sends capacity alarms to an independent local receiver const outbox=openCodexOutbox({database,store,host,now:()=>clock.now,maxRecords:16,retentionMs:1000, authorizeCleanup:({subjectId})=>({allowed:subjectId==='fixture-operator',heldRunIds:[]}), onCapacity(event) { - delivery.push(fetch(`http://127.0.0.1:${server.address().port}/capacity`,{ - method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(event),signal:AbortSignal.timeout(5000), - }).then(response=>response.status)); + delivery.push(new Promise((resolve,reject)=>{ + const body=JSON.stringify(event); + const req=request({hostname:'127.0.0.1',port:server.address().port,path:'/capacity',method:'POST',agent:false, + headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}, + signal:AbortSignal.timeout(15000)},response=>{ + response.on('error',reject); + response.on('end',()=>resolve(response.statusCode)); + response.resume(); + }); + req.on('error',reject); + req.end(body); + })); }}); f.resources.push(outbox); const physical=async()=>{ From 8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 18:01:10 +0800 Subject: [PATCH 50/72] Exercise authenticated peer replies, App Server EOF and R5 backups --- tests/interactive/p0a-staging-backup.test.mjs | 80 +++++++++++++++++++ tests/security/p0a-ops-codex.mjs | 28 ++++++- tests/security/p0a-os-session.mjs | 12 ++- 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 tests/interactive/p0a-staging-backup.test.mjs diff --git a/tests/interactive/p0a-staging-backup.test.mjs b/tests/interactive/p0a-staging-backup.test.mjs new file mode 100644 index 0000000..c9b9699 --- /dev/null +++ b/tests/interactive/p0a-staging-backup.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import {createHash,generateKeyPairSync} from 'node:crypto'; +import {copyFile,mkdtemp,readFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {DatabaseSync} from 'node:sqlite'; +import test from 'node:test'; +import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; +import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../src/interactive/index.mjs'; +import {compileOpsWorkflow,opsRuntime,opsStart} from '../security/p0a-ops-fixture.mjs'; + +test('quiesced R5 Store and Outbox backups reopen together and recover a signed receipt',async t=>{ + const root=await mkdtemp(path.join(tmpdir(),'flowkit-staging-backup-')); + const resources=[]; + t.after(async()=>{ + for (const db of resources.reverse()) db.close(); + await rm(root,{recursive:true,force:true}); + }); + const program=await compileOpsWorkflow(root); + const key=generateKeyPairSync('ed25519'); + const clock=Date.now(); + const open=prefix=>{ + const storeFile=path.join(root,prefix+'-store.sqlite'); + const outboxFile=path.join(root,prefix+'-outbox.sqlite'); + const store=createSqliteCheckpointStore(storeFile,{namespace:'staging-backup'}); + const host=createTrustedInteractiveHost({store,runtime:opsRuntime,now:()=>clock, + trustedPublicKeys:{proof:key.publicKey},trustedArtifacts:[program.programIdentity.artifactHash], + allowedInputKeys:['question'],disclosurePolicyId:'staging-backup',authorizeDisclosure:()=>true, + authorizeReceiptAudit:()=>true}); + const outbox=openCodexOutbox({database:outboxFile,store,host,now:()=>clock}); + resources.push(store,outbox); + return {store,outbox,host,storeFile,outboxFile}; + }; + const live=open('live'); + async function signed(label) { + const {runId}=await live.host.start(opsStart(program,label)); + const task=await live.host.getPublicAgentTask({runId}); + const {requestId,nodeId,attempt}=task; + const call={tool:'flowkit_submit',callId:label,threadId:'test-thread',turnId:label,args:{requestId,answer:'ready'}}; + const proofId=await live.outbox.record({task,started:call,callback:call,modelPid:process.pid}, + {runId,requestId,nodeId,attempt,kind:'agent-result',result:{answer:'ready'}},key.privateKey); + return {task,proofId}; + } + const accepted=await signed('acknowledged'); + await live.outbox.deliver(accepted.proofId); + const pending=await signed('pending'); + const state=(store,{task})=>({checkpoint:store.load(task.runId),journal:store.listJournal(task.runId), + receipt:store.getReceipt(task.runId,task.requestId)}); + const before=[accepted,pending].map(item=>state(live.store,item)); + const [ackBefore,pendingBefore]=[accepted,pending].map(item=>live.outbox.describe(item.proofId)); + const hashes={}; + // There are no active writers between the two snapshots. + for (const [label,file] of [['store',live.storeFile],['outbox',live.outboxFile]]) { + const snapshot=path.join(root,`backup-${label}.sqlite`); + const db=new DatabaseSync(file); + try {db.prepare('VACUUM INTO ?').run(snapshot);} finally {db.close();} + const checked=new DatabaseSync(snapshot,{readOnly:true}); + try {assert.equal(checked.prepare('PRAGMA integrity_check').get().integrity_check,'ok');} + finally {checked.close();} + hashes[label]=createHash('sha256').update(await readFile(snapshot)).digest('hex'); + await copyFile(snapshot,path.join(root,`restored-${label}.sqlite`)); + } + const restored=open('restored'); + assert.deepEqual([accepted,pending].map(item=>state(restored.store,item)),before); + assert.deepEqual(restored.outbox.describe(accepted.proofId),ackBefore); + assert.deepEqual(restored.outbox.describe(pending.proofId),pendingBefore); + const {runId,requestId,nodeId,attempt}=accepted.task; + const ack=await restored.host.readHistoricalAck({namespace:'staging-backup',runId,requestId,nodeId,attempt, + receiptHash:ackBefore.receiptHash}); + assert.equal(ack.acceptedRevision,ackBefore.acceptedRevision); + const recovered=await restored.outbox.deliver(pending.proofId); + assert.equal(recovered.status,'succeeded'); + const restoredProof=restored.outbox.describe(pending.proofId); + assert.equal(restoredProof.signatureHash,pendingBefore.signatureHash); + assert.equal(restoredProof.state,'ACKED'); + assert.deepEqual(state(live.store,pending),before[1]); + assert.deepEqual(live.outbox.describe(pending.proofId),pendingBefore); + console.log('P0A_BACKUP '+JSON.stringify({integrity:'ok',hashes,originalAck:ack.acceptedRevision, + recoveredAck:restoredProof.acceptedRevision,signatureReused:true,sourceUnchanged:true})); +}); diff --git a/tests/security/p0a-ops-codex.mjs b/tests/security/p0a-ops-codex.mjs index d055454..c6e0b2e 100644 --- a/tests/security/p0a-ops-codex.mjs +++ b/tests/security/p0a-ops-codex.mjs @@ -10,7 +10,8 @@ import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../src/interactive/index.mjs'; import {compileOpsWorkflow,opsRuntime,opsStart} from './p0a-ops-fixture.mjs'; -const [executable,cwd,out]=process.argv.slice(2); +const [executable,cwd,out,mode='retry']=process.argv.slice(2); +assert.ok(['retry','disconnect'].includes(mode)); assert.ok([executable,cwd,out].every(value=>value&&path.isAbsolute(value))); mkdirSync(out,{recursive:false,mode:0o700}); const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; @@ -79,7 +80,10 @@ const identified=new Promise(resolve=>{identify=resolve;}); const requests=new Map(),observations=new Map(); const timer=setTimeout(()=>fail(new Error('App Server run timed out')),120000); child.once('error',fail); -child.once('close',code=>fail(new Error(`App Server channel closed early (${code})`))); +let closingForTest=false; +child.once('close',code=>{ + if (!closingForTest) fail(new Error(`App Server channel closed early (${code})`)); +}); child.stdin.on('error',fail); child.stdout.on('data',chunk=>write('app-server.stdout.ndjson',chunk)); child.stderr.on('data',chunk=>write('app-server.stderr',chunk)); @@ -199,6 +203,26 @@ async function run() { const {data}=await request('model/list',{limit:30,includeHidden:false}); const model=data.find(entry=>entry.isDefault)?.model??data[0]?.model; assert.ok(model); + if (mode==='disconnect') { + const {thread}=await request('thread/start',{cwd,model,ephemeral:true,approvalPolicy:'never',sandbox:'read-only', + multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}}}); + const {runId}=await host.start(opsStart(program,'transport-disconnect')); + const snapshot=()=>({checkpoint:store.load(runId),journal:store.listJournal(runId),outbox:outbox.usage()}); + const before=snapshot(); + assert.equal(before.checkpoint.status,'waiting'); + assert.equal(before.checkpoint.receiptCount,0); + const closed=new Promise(resolve=>child.once('close',(code,signal)=>resolve({code,signal}))); + closingForTest=true; + child.stdin.end(); + const exit=await closed; + assert.deepEqual(snapshot(),before); + const resumed=await host.resume({runId}); + assert.equal(resumed.pending.requestId,before.checkpoint.pending.requestId); + assert.deepEqual(snapshot(),before); + const result={passed:true,mode,threadId:thread.id,runId,exit,unchanged:true,receiptCount:0}; + event('transport-closed',result); + return result; + } const recover=await runCase(model,'recover',['invalid','ready']); const exhausted=await runCase(model,'exhausted',['invalid','invalid']); return {passed:true,model,recover,exhausted,modelTurns:4}; diff --git a/tests/security/p0a-os-session.mjs b/tests/security/p0a-os-session.mjs index 04861a2..ded568c 100644 --- a/tests/security/p0a-os-session.mjs +++ b/tests/security/p0a-os-session.mjs @@ -53,8 +53,10 @@ if (mode==='linux-supervisor') { } async function command(id,value) { const peer=peers.get(id); - const {command}=await peer(value); - return (await host({op:'data',id,command})).response; + const {command}=await peer({command:value}); + const {response}=await host({op:'data',id,command}); + assert.deepEqual(await peer({response}),{received:response}); + return response; } for (const [id,uid] of [['a',a],['b',b],['audit',auditor]]) await connect(id,uid); await exerciseOsSessions({host,command,connect,operator:a,auditor}); @@ -65,7 +67,11 @@ if (mode==='linux-supervisor') { } else if (mode==='peer') { // The privileged launcher reads this process's stdout and sends the bytes to // the Host using the session ID allocated at OS process creation. - for await (const line of incoming) write({command:JSON.parse(line)}); + for await (const line of incoming) { + const message=JSON.parse(line); + if (Object.hasOwn(message,'response')) write({received:message.response}); + else write({command:message.command}); + } } else { assert.equal(mode,'host'); assert.ok(path.isAbsolute(root) && path.isAbsolute(policyFile)); From 3847277e5d6339a06e31f20fef576985e3bd1933 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 18:04:14 +0800 Subject: [PATCH 51/72] Exercise staging admission and alert delivery --- tests/interactive/p0a-staging-ops.test.mjs | 177 ++++++++++++++++++ tests/security/p0a-staging-alert-receiver.mjs | 27 +++ 2 files changed, 204 insertions(+) create mode 100644 tests/interactive/p0a-staging-ops.test.mjs create mode 100644 tests/security/p0a-staging-alert-receiver.mjs diff --git a/tests/interactive/p0a-staging-ops.test.mjs b/tests/interactive/p0a-staging-ops.test.mjs new file mode 100644 index 0000000..24bb969 --- /dev/null +++ b/tests/interactive/p0a-staging-ops.test.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict'; +import {fork} from 'node:child_process'; +import {generateKeyPairSync} from 'node:crypto'; +import {once} from 'node:events'; +import {mkdtemp,readFile,rm,stat} from 'node:fs/promises'; +import {request} from 'node:http'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import {setTimeout as sleep} from 'node:timers/promises'; +import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; +import {sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../src/interactive/index.mjs'; +import {compileOpsWorkflow,opsRuntime,opsStart} from '../security/p0a-ops-fixture.mjs'; + +async function fixture(t) { + const root=await mkdtemp(path.join(tmpdir(),'flowkit-staging-ops-')); + const resources=[]; + t.after(async()=>{ + for (const resource of resources.reverse()) resource.close(); + await rm(root,{recursive:true,force:true}); + }); + return {root,resources,program:await compileOpsWorkflow(root)}; +} + +function makeHost(f,store,keys,overrides={}) { + return createTrustedInteractiveHost({store,runtime:opsRuntime,allowedInputKeys:['question'], + trustedPublicKeys:{proof:keys.publicKey},trustedArtifacts:[f.program.programIdentity.artifactHash], + disclosurePolicyId:'p0a-staging-ops',authorizeDisclosure:()=>true,...overrides}); +} + +async function makeTask(f,host,startKey) { + const frame=await host.start(opsStart(f.program,startKey)); + assert.equal(frame.status,'waiting'); + return host.getPublicAgentTask({runId:frame.runId}); +} + +function signTask(task,outbox,keys,startKey) { + const {runId,requestId,nodeId,attempt}=task; + const answer='ready'; + const call={threadId:'fixture',turnId:'turn-'+startKey,callId:'call-'+startKey,tool:'flowkit_submit',args:{requestId,answer}}; + return outbox.record({task,started:call,callback:call,modelPid:process.pid}, + {runId,requestId,nodeId,attempt,kind:'agent-result',result:{answer}},keys.privateKey); +} + +function snapshot(store,{runId,requestId}) { + return {checkpoint:store.load(runId),journal:store.listJournal(runId),receipt:store.getReceipt(runId,requestId)}; +} + +test('test admission stays paused for every Store until the inventory has confirmed revocation',async t=>{ + const f=await fixture(t); + const oldKeys=generateKeyPairSync('ed25519'),nextKeys=generateKeyPairSync('ed25519'); + const oldHash=sha256Bytes(oldKeys.publicKey.export({type:'spki',format:'der'})); + const names=['store-a','store-b','store-c']; + const approval=new Set(['store-a','store-c']); + const confirmed=new Set(); + const inventory=new Map(); + for (const namespace of names) { + const store=createSqliteCheckpointStore(path.join(f.root,namespace+'.sqlite'),{namespace}); + const oldHost=makeHost(f,store,oldKeys); + const newHost=makeHost(f,store,nextKeys,{authorizeKeyRevocation:()=>approval.has(namespace)}); + const outbox=openCodexOutbox({database:path.join(f.root,namespace+'-outbox.sqlite'),store,host:oldHost}); + f.resources.push(store,outbox); + const task=await makeTask(f,oldHost,'old-in-flight'); + const proof=await signTask(task,outbox,oldKeys,'old-in-flight'); + inventory.set(namespace,{store,newHost,outbox,task,proof}); + } + const revoke=async namespace=>{ + assert.equal(await inventory.get(namespace).newHost.revokeKey(oldHash),true); + confirmed.add(namespace); + }; + // Every test admission goes through this gate. It represents one deployed inventory only. + const admit=(namespace,key)=>{ + if (confirmed.size!==inventory.size) throw Object.assign(new Error('Test admission paused'),{code:'STAGING_PAUSED'}); + return inventory.get(namespace).newHost.start(opsStart(f.program,key)); + }; + const snapshots=()=>[...inventory.values()].map(({store,task})=>snapshot(store,task)); + const before=snapshots(); + const first=await Promise.allSettled(names.map(revoke)); + assert.deepEqual(first.map(result=>result.status),['fulfilled','rejected','fulfilled']); + assert.equal(first[1].reason.code,'FK_I_HOST_DENIED'); + for (const namespace of names) assert.throws(()=>admit(namespace,'before-resume'),{code:'STAGING_PAUSED'}); + assert.deepEqual(snapshots(),before); + approval.add('store-b'); + await revoke('store-b'); + for (const namespace of names) { + const {outbox,proof}=inventory.get(namespace); + await assert.rejects(outbox.deliver(proof),{code:'FK_I_PROVENANCE_REJECTED'}); + assert.equal((await admit(namespace,'after-resume')).status,'waiting'); + } + assert.deepEqual(snapshots(),before); + console.log('P0A_STAGING '+JSON.stringify({case:'admission-pause',inventory:names, + firstRoundDenied:['store-b'],blockedAdmissions:names.length,reopenedAdmissions:names.length,rejectedOldProofs:names.length})); +}); + +function post(port,event) { + return new Promise((resolve,reject)=>{ + const body=JSON.stringify(event); + const req=request({hostname:'127.0.0.1',port,path:'/capacity',method:'POST',agent:false, + headers:{'content-type':'application/json','content-length':Buffer.byteLength(body)}, + signal:AbortSignal.timeout(15000)},res=>{ + let data=''; + res.setEncoding('utf8').on('data',part=>{data+=part;}); + res.on('error',reject); + res.on('end',()=>{ + try {resolve({status:res.statusCode,body:JSON.parse(data)});} + catch(error) {reject(error);} + }); + }); + req.on('error',reject); + req.end(body); + }); +} + +test('paced capacity load delivers recorded alerts to a separate loopback process',async t=>{ + const f=await fixture(t); + const alertFile=path.join(f.root,'alerts.ndjson'); + const receiver=fork(new URL('../security/p0a-staging-alert-receiver.mjs',import.meta.url),[alertFile],{execArgv:[]}); + const closed=once(receiver,'exit'); + t.after(async()=>{ + if (receiver.connected) receiver.disconnect(); + const timer=setTimeout(()=>receiver.kill(),5000); + try {assert.deepEqual(await closed,[0,null]);} + finally {clearTimeout(timer);} + }); + const [{pid,port}]=await Promise.race([once(receiver,'message',{signal:AbortSignal.timeout(15000)}), + closed.then(()=>{throw new Error('Alert receiver exited before ready');})]); + assert.equal(pid,receiver.pid); + assert.notEqual(pid,process.pid); + const keys=generateKeyPairSync('ed25519'); + const storeFile=path.join(f.root,'load.sqlite'); + const outboxFile=path.join(f.root,'load-outbox.sqlite'); + const store=createSqliteCheckpointStore(storeFile,{namespace:'load'}); + let now=Date.now(),delivery,sequence=0; + const host=makeHost(f,store,keys,{now:()=>now}); + const cap=16,batches=8; + const outbox=openCodexOutbox({database:outboxFile,store,host,now:()=>now,maxRecords:cap,retentionMs:1000, + authorizeCleanup:()=>({allowed:true,heldRunIds:[]}), + onCapacity(event) {delivery=post(port,{...event,sequence:++sequence});}, + }); + f.resources.push(store,outbox); + async function sampleDisk() { + const sizes=await Promise.all([storeFile,outboxFile].flatMap(file=>['','-wal','-shm'].map(suffix=> + stat(file+suffix).then(info=>info.size,error=>{if(error.code==='ENOENT')return 0;throw error;})))); + return sizes.reduce((sum,size)=>sum+size,0); + } + const expected=[]; + const samples=[]; + const started=performance.now(); + for (let batch=1;batch<=batches;batch++) { + for (let index=0;indexJSON.parse(line)); + assert.deepEqual(saved,expected); + console.log('P0A_STAGING '+JSON.stringify({case:'recorded-alert-load',senderPid:process.pid,receiverPid:pid, + accepted:batches*cap,elapsedMs:Math.round(performance.now()-started),receipts:saved,samples})); +}); diff --git a/tests/security/p0a-staging-alert-receiver.mjs b/tests/security/p0a-staging-alert-receiver.mjs new file mode 100644 index 0000000..8ec0429 --- /dev/null +++ b/tests/security/p0a-staging-alert-receiver.mjs @@ -0,0 +1,27 @@ +import {appendFileSync} from 'node:fs'; +import {createServer} from 'node:http'; + +const [receiptFile]=process.argv.slice(2); +const server=createServer(async(req,res)=>{ + if (req.method!=='POST' || req.url!=='/capacity') {res.writeHead(404).end();return;} + try { + let body=''; + for await (const part of req) { + body+=part; + if (body.length>4096) {res.writeHead(413).end();return;} + } + const event=JSON.parse(body); + const receipt={receiverPid:process.pid,sequence:event.sequence,namespace:event.namespace, + records:event.records,maxRecords:event.maxRecords}; + appendFileSync(receiptFile,JSON.stringify(receipt)+'\n',{encoding:'utf8',flush:true}); + res.writeHead(202,{'content-type':'application/json'}).end(JSON.stringify(receipt)); + } catch { + res.writeHead(400).end(); + } +}); + +server.listen(0,'127.0.0.1',()=>process.send({pid:process.pid,port:server.address().port})); +process.once('disconnect',()=>{ + server.close(); + server.closeAllConnections(); +}); From 5980872f0ad527d0979e95a42aaa7102e464d535 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 18:11:55 +0800 Subject: [PATCH 52/72] Record G12 joint staging acceptance and release blockers --- .../ops-r5-live.md | 6 ++++ .../staging-joint-r5.md | 35 +++++++++++++++++++ .../p0a-g07-g12-20261113/g12-review.md | 6 ++++ .../p0a-g07-g12-20261113/release-matrix.md | 11 +++--- 4 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/staging-joint-r5.md diff --git a/docs/evidence/p0a-codex-isolated-20261113/ops-r5-live.md b/docs/evidence/p0a-codex-isolated-20261113/ops-r5-live.md index e629ff6..11ad6fb 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/ops-r5-live.md +++ b/docs/evidence/p0a-codex-isolated-20261113/ops-r5-live.md @@ -75,3 +75,9 @@ Linux 原始归档 `FlowKit-Linux-Ops-R5-20261113.zip` 由 [FlowKit 任务](thre 生产全量 Store 清单、暂停入口的外部控制、集中告警送达、最终磁盘预算及保留策略仍待部署验证。 正式协调时,以批准的 Host/Store 清单为撤销目标并记录逐项结果。先暂停仍可能用旧钥签发的入口,逐库执行并验证,全部确认后才恢复入口。本轮仅演练确认清单和部分失败处理,生产入口联动为 BLOCKED。 + +## 备份恢复预演 + +把 Run Store、Outbox 及启用时的文件交接库和快照纳入同一恢复点。对使用 SQLite 的部署,先停止相关写入,再分别生成一致副本,记录文件 SHA-256 及受保护的策略和密钥版本引用。恢复到新路径,按 R5 校验数据库完整性、Checkpoint、Journal、程序字节与接纳索引,并对照原 Ack 和待对账记录。将已撤销密钥的状态纳入恢复后的信任核对。 + +实验环境已验证停写后的 Store 和 Outbox 成对恢复。实际备份系统、文件交接与快照、部署密钥策略及恢复窗口仍待验。 [成对恢复命令与日志](./staging-joint-r5.md) diff --git a/docs/evidence/p0a-codex-isolated-20261113/staging-joint-r5.md b/docs/evidence/p0a-codex-isolated-20261113/staging-joint-r5.md new file mode 100644 index 0000000..9b5787e --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/staging-joint-r5.md @@ -0,0 +1,35 @@ +# G12 联合预演与证据 + +本轮使用现有隔离实验环境。基线为 `00f840029d82e069f844aec68732d0989a3d0f6e`。操作员、审计员、App Server 及成对备份探针对应 `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9`。测试入口撤销联动与告警探针对应 `3847277e5d6339a06e31f20fef576985e3bd1933`。两个候选之间仅增补后两项测试。增量代码 diff SHA-256 为 `d32eccf4dcf7b2bc7db0260cb2332b3cf356fb93b6585f14b6ba3b376a549305`。本轮复核三份冻结原件及附属文件的字节数和 SHA-256,与 [G01 登记](../p0a-g01-20261112/verified-inputs.txt) 一致。 [代码候选 CI 8/8](https://github.com/devcodex-labs/flowkit/actions/runs/43406489736) + +## Windows 实测 + +| 场景 | 结果与证据等级 | +| --- | --- | +| 客户端收件与撤权 | P。Host、操作员 A/B、审计员以普通账户实际启动。操作员创建各自租户的同 RunId,审计员进程通过其输入管道收到本租户私有状态。跨租户拒绝。撤权后旧会话拒绝,恢复授权也无法复用旧连接。新普通进程重连成功,拒绝前后 Store 观测值一致。 | +| App Server 传输关闭 | P/T。两个普通账户经现有固定启动链启动真实 App Server,初始化并创建临时 thread。客户端在 `turn/start` 前关闭标准输入,观察到进程以 0 退出。Checkpoint、Journal 和 Outbox 观测值不变,恢复同一 Pending,零新回执。收尾时测试监听数为 0。 | +| R5 成对备份 | P。停止写入后,对 Store 与 Outbox 分别执行 SQLite `VACUUM INTO`,检查完整性并计算各自 SHA-256。副本在新路径恢复,原 Ack 和已签名未接纳回执均在 revision 4 得到对应 Ack,保留原签名,源库观测值不变。使用测试签名和有效期内的固定时钟。 | +| 三库撤销联动 | P。部分撤销未确认时,测试入口拒绝三次接纳。全部确认后,三个 Store 均拒绝旧签名待接纳回执,并允许新策略接纳。拒绝前后 Checkpoint、Journal 与回执索引一致。 | +| 满额和告警 | P。Windows Node 22.13.0 和 24.22.2 分别完成 128 次接纳及 8 轮清理。独立回环 HTTP 进程先把 8 份告警持久化,再分别返回 202。Node 22 样本耗时约 7.19 秒,最后满额时 SQLite/WAL/SHM 文件合计 10,236,104 B,清理后 10,273,184 B。该值为短时样本的文件长度。 | + +实际启动与协议参数收录在归档脚本和原始记录。Windows 先用管理员 PowerShell 启动实验监督器,业务 Host 和各对端保持普通身份。 + +```powershell +git -c core.autocrlf=false -c core.eol=lf archive --format=tar --output=<证据目录>/source.tar 8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9 +powershell.exe -NoProfile -File <证据目录>/windows-os-session.ps1 -Output <证据目录> +powershell.exe -NoProfile -File <证据目录>/windows-deploy-and-run.ps1 -Output <证据目录> +node --test tests/interactive/p0a-staging-backup.test.mjs +node --test tests/interactive/p0a-staging-ops.test.mjs +``` + +`FlowKit-Windows-Staging-Joint-R5.zip` SHA-256 为 `5eccd71886d1e4c2681666a4978b93fc50841cce6df14ca21e25a90e8d3f9f88`,18 项逐项核对。包含候选 SHA、启动脚本、OS 进程交付记录、App Server 原始请求和响应、监听收尾及定向测试日志。`WINDOWS-SHA256SUMS.txt` 自身 SHA-256 为 `55cf0816904b90af793cb6d74ecf8f4b048c995baa70fb748180eabedb87afd2`。 + +## Linux + +独立普通 OS 账户实测证据待收。GitHub Actions 中的程序测试与双账户 Codex 实测分别登记。 + +## 发布待验 + +- 在实际入口核对身份、租户、审计客户端与真实 Model 回执的同链联动,并验证实际 Host/Store 清单上的跨 Host 撤权和暂停接纳。 +- 使用生产等价告警通道,核对值班端收件、容量和保留窗口。备份恢复使用实际备份系统、密钥配置和纳入文件回退的备份集。维护窗口与容量参数待确认。 +- 补正在执行的模型回合中的连接中断、实际供应商失败及剩余适用原生安全与故障切点。受控 EOF 在任何模型回合开始前执行,证据等级为 P/T。 diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index fff0f79..f406eff 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -14,3 +14,9 @@ 审查纠正了两处证据表述。OS 对端命令来自实际普通进程,审计结果由监督器核验;测试清单的待确认状态和同进程 HTTP `202` 用于验证协调与告警样本。历史 V2 迁移及兼容报告已标明适用提交,当前部署说明指向首发 R5 新库。 剩余发布门禁统一登记在 [发布矩阵](./release-matrix.md)。实际部署验收至少需要已认证的入口与客户端、实际 Host/Store 清单、告警接收端及备份恢复窗口。G17 依赖 G12 放行及用户的发布授权。 + +## 联合预演复审 + +**裁决维持 BLOCKED。** 独立角色核对 `3847277e5d6339a06e31f20fef576985e3bd1933` 的五个测试文件和 Windows 原始记录,复跑备份及运维测试 3/3 通过。当前证据覆盖普通审计员测试进程收件与会话撤权、三个测试 Store 的入口联动、独立回环进程持久化告警,以及停写后 R5 Store/Outbox 成对恢复。App Server 的 EOF 发生于创建 thread 后、启动模型回合前。 [实测证据与范围](../p0a-codex-isolated-20261113/staging-joint-r5.md) + +待验范围包括实际发布入口的身份及租户绑定和同链 Model 回执、适用 SEC/NAT/T29 的剩余原生证据、进行中模型回合断线与供应商故障、实际全清单撤权联动、值班告警收件、长时容量及包含文件快照的实际备份恢复。G17 继续等待 G12 放行。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 59d714f..fee27bb 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -23,6 +23,7 @@ | E13 | `3fd1d2f9fb8a59abf74fca86136cb0bed3eef806` | 已暂存、未消费的有效文件引用跨租户拒绝,以及 Host stdio 授权撤销后旧会话失效。[分平台实测、命令与归档 SHA-256](../p0a-codex-isolated-20261113/session-r5-live.md) | | E14 | `9ba894ce702d84e20d9a51a57d015e225774b851` | 独立普通 OS 对端进程的创建和审计命令、会话撤权、新连接恢复。[OS 会话实测与证据](../p0a-codex-isolated-20261113/g12-os-sessions.md) | | E15 | `eb52a6f516d54488167481b00f2c6811fca79afb` | 三测试 Store 撤销、96 次接纳与本机 HTTP 告警、Genesis 强杀、真实 Codex 无效结果和重试。[故障与运维实测](../p0a-codex-isolated-20261113/g12-ops-r5.md) | +| E16 | `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9`、`3847277e5d6339a06e31f20fef576985e3bd1933` | 隔离实验室联合预演。普通审计员进程收件、受控 App Server EOF、R5 成对备份、三库撤销联动与独立进程持久化告警。[命令、场景边界和归档哈希](../p0a-codex-isolated-20261113/staging-joint-r5.md) | ## SEC · 生产配置与候选入口 @@ -32,7 +33,7 @@ | 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,发布入口授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E14 补独立 OS 对端经受信监督器发送创建、审计命令及撤权。最终部署的操作员接入、真实 Model 回执与授权联动仍待验证。 | +| SEC-05 | BLOCKED,发布入口授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E14 补独立 OS 对端发送命令及撤权,E16 验证审计结果实际返回普通对端进程。最终部署的操作员接入、真实 Model 回执与授权联动仍待验证。 | | 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 和正文,核对无写入及错误脱敏。 | @@ -41,12 +42,12 @@ | SEC-11 | BLOCKED,完整披露矩阵 | E1 使用含 Host 私有标记的任务并走公开投影,现有 P 级回归独立检查结果读取权限。补任务、工具回复、终态和生产日志全路径标记检查。 | | SEC-12 | BLOCKED,真实资源边界 | E1 文件入口两个大小通过。E7 内联 64 KiB 通过,两档更大内联无工具调用。补实际入口异常正文、限流/超时及内联回执上限分类。 | | SEC-13 | BLOCKED,完整替换负例 | E7 的文件替换、硬链接、符号链接或目录联接点有 P 级证据。E5 的旧策略拒绝有双平台原生结果。补实际启动链下 Program 和所有已启用配置入口的替换负例。 | -| SEC-14 | BLOCKED,生产轮换与撤销 | E4/E5/E8/E9 覆盖正常轮换、原 Ack 和分别撤销。E15 增补三个测试 Store 中部分拒绝、补授权和逐库确认。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | -| SEC-15 | BLOCKED,最终发布入口 | E12 实测同一 SQLite 文件内两个租户同 startKey、同 RunId 的 stdio 和文件回调并发,独立回执和 Journal。发布入口的主体认证和会话生命周期仍待验证。 | +| SEC-14 | BLOCKED,生产轮换与撤销 | E4/E5/E8/E9 覆盖正常轮换、原 Ack 和分别撤销。E15/E16 补三个测试 Store 的部分撤销、测试入口阻断及逐库确认后的恢复。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | +| SEC-15 | BLOCKED,最终发布入口 | E12 实测同一 SQLite 文件内两个租户同 startKey、同 RunId 的 stdio 和文件回调并发,独立回执和 Journal。E16 复验普通客户端收件、撤权、重连。发布入口的主体认证和会话生命周期仍待验证。 | | 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。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | -| SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E15 增补 96 次接纳、六轮满额和本机 HTTP 202 告警。生产保留期限、持续负载下的磁盘预算及集中告警送达仍待取证。 | +| SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E16 补 128 次接纳、八轮满额与清理、独立回环进程持久化告警和停写后 Store/Outbox 恢复。生产保留期限、持续容量、值班端送达及完整备份恢复仍待取证。 | ## NAT · Codex 等价验收 @@ -63,7 +64,7 @@ | 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-10 | BLOCKED,真实失败与传输边界 | E7 的大内联未发出工具调用,E1 已通过文件回退。E15 补业务非法值和重试耗尽,E16 补真实 App Server 在模型回合前收到受控 EOF 后保持同一 Pending。仍需验证进行中模型回合中断、供应商失败、异常协议正文和相应的有界退出。 | | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | From fc7fd69fb4951ff35206b1825302fed49341107f Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 18:16:53 +0800 Subject: [PATCH 53/72] Include Linux staging proof and independent adjudication --- .../staging-joint-r5.md | 15 ++++++++++++++- docs/evidence/p0a-g07-g12-20261113/g12-review.md | 2 +- .../p0a-g07-g12-20261113/release-matrix.md | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/staging-joint-r5.md b/docs/evidence/p0a-codex-isolated-20261113/staging-joint-r5.md index 9b5787e..de67c17 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/staging-joint-r5.md +++ b/docs/evidence/p0a-codex-isolated-20261113/staging-joint-r5.md @@ -26,7 +26,20 @@ node --test tests/interactive/p0a-staging-ops.test.mjs ## Linux -独立普通 OS 账户实测证据待收。GitHub Actions 中的程序测试与双账户 Codex 实测分别登记。 +FlowKit 以 `git pull --ff-only origin feat/p0a-interactive-20261112` 快进并核对 `HEAD` 和 `FETCH_HEAD` 均为 `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9`。专用容器的普通对端进程回报 11 次收件,逐条与 Host 回复匹配,撤权后旧会话拒绝且新会话恢复。成对备份 1/1 通过,原 Ack 和待对账回执恢复均为 revision 4,原签名复用。两项为 P 级。 + +真实 App Server 样本通过固定启动链,Host UID 41001、Model UID 41002。创建 thread 后、启动模型回合前关闭 stdin,进程退出 0。复核同一 Pending requestId、Checkpoint 和 Journal 保持一致,回执索引及 Outbox 记录为零。此项为 P/T 级。首次尝试在 App Server 启动前遇到 SQLite error 14。原始失败保留。第二次在新目录延后观察包装的 SQL 旁读,候选代码字节保持一致,复测通过。首次失败根因未完全确认。 + +```sh +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-staging-os-supervisor.mjs +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-staging-transport-controller.mjs local +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-staging-transport-controller.mjs codex +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-staging-transport-controller.mjs codex attempt-2 +``` + +`FlowKit-Linux-Staging-Session-Disconnect-20261113.zip` SHA-256 为 `31d5420df20f77ecdd29b4b9d515839fe242e1ccb32f520a8431533c3ad87d23`。205 份载荷文件由 Linux 任务核对归档完整性和逐项哈希,包含两次断流尝试、进程身份和 SQL 观测、原始命令及独立离线校验。 [FlowKit 任务及归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +另核对了 [代码候选 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43406489736) 的 Ubuntu Node 22.13.0 与 24.22.1 两套日志。三个备份及运维程序用例均通过,两套各完成 128 次接纳、8 次独立进程告警和 8 轮清理。此项为 P 级 CI 回归。 ## 发布待验 diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index f406eff..621be4f 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -17,6 +17,6 @@ ## 联合预演复审 -**裁决维持 BLOCKED。** 独立角色核对 `3847277e5d6339a06e31f20fef576985e3bd1933` 的五个测试文件和 Windows 原始记录,复跑备份及运维测试 3/3 通过。当前证据覆盖普通审计员测试进程收件与会话撤权、三个测试 Store 的入口联动、独立回环进程持久化告警,以及停写后 R5 Store/Outbox 成对恢复。App Server 的 EOF 发生于创建 thread 后、启动模型回合前。 [实测证据与范围](../p0a-codex-isolated-20261113/staging-joint-r5.md) +**裁决维持 BLOCKED。** 独立角色核对 `3847277e5d6339a06e31f20fef576985e3bd1933` 的五个测试文件和 Windows 原始记录,复跑备份及运维测试 3/3 通过。另核对 Linux `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9` 的 11 次真实普通客户端收件、R5 成对备份恢复和受控 EOF。两个平台的 App Server EOF 均发生于创建 thread 后、启动模型回合前。Linux 首次启动前的 SQLite error 14 已归档。仅调整观察包装后复测通过,根因未完全确认。三库入口联动和独立回环告警已通过 Windows 实测及 Ubuntu CI。 [实测证据与范围](../p0a-codex-isolated-20261113/staging-joint-r5.md) 待验范围包括实际发布入口的身份及租户绑定和同链 Model 回执、适用 SEC/NAT/T29 的剩余原生证据、进行中模型回合断线与供应商故障、实际全清单撤权联动、值班告警收件、长时容量及包含文件快照的实际备份恢复。G17 继续等待 G12 放行。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index fee27bb..2aa6654 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -23,7 +23,7 @@ | E13 | `3fd1d2f9fb8a59abf74fca86136cb0bed3eef806` | 已暂存、未消费的有效文件引用跨租户拒绝,以及 Host stdio 授权撤销后旧会话失效。[分平台实测、命令与归档 SHA-256](../p0a-codex-isolated-20261113/session-r5-live.md) | | E14 | `9ba894ce702d84e20d9a51a57d015e225774b851` | 独立普通 OS 对端进程的创建和审计命令、会话撤权、新连接恢复。[OS 会话实测与证据](../p0a-codex-isolated-20261113/g12-os-sessions.md) | | E15 | `eb52a6f516d54488167481b00f2c6811fca79afb` | 三测试 Store 撤销、96 次接纳与本机 HTTP 告警、Genesis 强杀、真实 Codex 无效结果和重试。[故障与运维实测](../p0a-codex-isolated-20261113/g12-ops-r5.md) | -| E16 | `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9`、`3847277e5d6339a06e31f20fef576985e3bd1933` | 隔离实验室联合预演。普通审计员进程收件、受控 App Server EOF、R5 成对备份、三库撤销联动与独立进程持久化告警。[命令、场景边界和归档哈希](../p0a-codex-isolated-20261113/staging-joint-r5.md) | +| E16 | `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9`、`3847277e5d6339a06e31f20fef576985e3bd1933` | Windows/Linux 隔离实验室联合预演。双平台普通审计员进程收件、受控 App Server EOF 和 R5 成对备份。三库撤销联动与独立进程持久化告警通过 Windows 实测和 Ubuntu CI。[命令、场景边界和归档哈希](../p0a-codex-isolated-20261113/staging-joint-r5.md) | ## SEC · 生产配置与候选入口 From a43ff75377ebe0938d8933074450feb62aba4c67 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 18:49:14 +0800 Subject: [PATCH 54/72] Verify live turn interruption and recovery of file-backed R5 receipts --- tests/interactive/p0a-staging-backup.test.mjs | 91 +++++++++++++++---- tests/security/p0a-ops-codex.mjs | 44 ++++++++- tests/security/p0a-ops-fixture.mjs | 5 +- 3 files changed, 117 insertions(+), 23 deletions(-) diff --git a/tests/interactive/p0a-staging-backup.test.mjs b/tests/interactive/p0a-staging-backup.test.mjs index c9b9699..c21d629 100644 --- a/tests/interactive/p0a-staging-backup.test.mjs +++ b/tests/interactive/p0a-staging-backup.test.mjs @@ -1,41 +1,80 @@ import assert from 'node:assert/strict'; -import {createHash,generateKeyPairSync} from 'node:crypto'; -import {copyFile,mkdtemp,readFile,rm} from 'node:fs/promises'; +import {createHash,generateKeyPairSync,randomUUID} from 'node:crypto'; +import {copyFile,mkdir,mkdtemp,readFile,rm,unlink,writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import path from 'node:path'; import {DatabaseSync} from 'node:sqlite'; import test from 'node:test'; import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; +import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.mjs'; +import {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../src/interactive/index.mjs'; import {compileOpsWorkflow,opsRuntime,opsStart} from '../security/p0a-ops-fixture.mjs'; -test('quiesced R5 Store and Outbox backups reopen together and recover a signed receipt',async t=>{ +async function testBackup(t,fileHandoff) { const root=await mkdtemp(path.join(tmpdir(),'flowkit-staging-backup-')); const resources=[]; t.after(async()=>{ for (const db of resources.reverse()) db.close(); await rm(root,{recursive:true,force:true}); }); - const program=await compileOpsWorkflow(root); + const program=await compileOpsWorkflow(root,{fileHandoff}); const key=generateKeyPairSync('ed25519'); const clock=Date.now(); - const open=prefix=>{ + const bytes=fileHandoff?canonicalBytes({answer:'A'.repeat(262144-13)}):null; + if (fileHandoff) assert.equal(bytes.length,262144); + const open=async prefix=>{ const storeFile=path.join(root,prefix+'-store.sqlite'); const outboxFile=path.join(root,prefix+'-outbox.sqlite'); + const handoffFile=path.join(root,prefix+'-handoff.sqlite'); + const inbox=path.join(root,prefix+'-inbox'); + let handoff; + if (fileHandoff) { + await mkdir(inbox); + handoff=createFileHandoff({database:handoffFile,inbox,now:()=>clock, + validateArtifact:value=>canonicalBytes(value).equals(bytes)}); + resources.push(handoff); + } const store=createSqliteCheckpointStore(storeFile,{namespace:'staging-backup'}); - const host=createTrustedInteractiveHost({store,runtime:opsRuntime,now:()=>clock, + resources.push(store); + const runtime=fileHandoff?{...opsRuntime,validateResult(_schema,{artifact}) { + const saved=artifact&&handoff.readSnapshot(artifact.id); + return saved?.bytes.equals(bytes)&&saved.sha256===artifact.sha256?[]:['Expected original file bytes']; + }}:opsRuntime; + const host=createTrustedInteractiveHost({store,runtime,now:()=>clock, trustedPublicKeys:{proof:key.publicKey},trustedArtifacts:[program.programIdentity.artifactHash], allowedInputKeys:['question'],disclosurePolicyId:'staging-backup',authorizeDisclosure:()=>true, authorizeReceiptAudit:()=>true}); - const outbox=openCodexOutbox({database:outboxFile,store,host,now:()=>clock}); - resources.push(store,outbox); - return {store,outbox,host,storeFile,outboxFile}; + const outbox=openCodexOutbox({database:outboxFile,store,host,handoff,now:()=>clock}); + resources.push(outbox); + return {store,outbox,host,handoff,inbox,storeFile,outboxFile,handoffFile}; }; - const live=open('live'); + const live=await open('live'); async function signed(label) { const {runId}=await live.host.start(opsStart(program,label)); const task=await live.host.getPublicAgentTask({runId}); const {requestId,nodeId,attempt}=task; + if (fileHandoff) { + const cp=live.store.load(runId); + const sourceId=randomUUID(); + const file=path.join(live.inbox,sourceId+'.json'); + await writeFile(file,bytes,{flag:'wx'}); + const binding={runId,requestId,nodeId,attempt,namespace:live.store.namespace,threadId:'test-thread', + turnId:label,programHash:cp.artifactHash,policyHash:cp.policyHash}; + const reference=await live.handoff.stage({sourceId,binding,byteLength:bytes.length,sha256:sha256Bytes(bytes)}); + await unlink(file); + const call={tool:'flowkit_submit_file',callId:label,threadId:binding.threadId,turnId:label,arguments:reference}; + let proofId; + const interrupted=new Error('Test stopped after durable signing'); + await assert.rejects(live.handoff.consume({started:call,callback:call,loadPending:live.store.load, + async signAndAccept({artifact}) { + const observed={...call,args:reference}; + proofId=await live.outbox.record({task,started:observed,callback:observed,modelPid:process.pid}, + {runId,requestId,nodeId,attempt,kind:'agent-result',result:{artifact}},key.privateKey); + throw interrupted; + }}),error=>error===interrupted); + return {task,proofId,fileId:reference.id}; + } const call={tool:'flowkit_submit',callId:label,threadId:'test-thread',turnId:label,args:{requestId,answer:'ready'}}; const proofId=await live.outbox.record({task,started:call,callback:call,modelPid:process.pid}, {runId,requestId,nodeId,attempt,kind:'agent-result',result:{answer:'ready'}},key.privateKey); @@ -49,8 +88,10 @@ test('quiesced R5 Store and Outbox backups reopen together and recover a signed const before=[accepted,pending].map(item=>state(live.store,item)); const [ackBefore,pendingBefore]=[accepted,pending].map(item=>live.outbox.describe(item.proofId)); const hashes={}; - // There are no active writers between the two snapshots. - for (const [label,file] of [['store',live.storeFile],['outbox',live.outboxFile]]) { + // There are no active writers across the snapshot set. + const databases=[['store',live.storeFile],['outbox',live.outboxFile]]; + if (fileHandoff) databases.push(['handoff',live.handoffFile]); + for (const [label,file] of databases) { const snapshot=path.join(root,`backup-${label}.sqlite`); const db=new DatabaseSync(file); try {db.prepare('VACUUM INTO ?').run(snapshot);} finally {db.close();} @@ -60,10 +101,16 @@ test('quiesced R5 Store and Outbox backups reopen together and recover a signed hashes[label]=createHash('sha256').update(await readFile(snapshot)).digest('hex'); await copyFile(snapshot,path.join(root,`restored-${label}.sqlite`)); } - const restored=open('restored'); + const restored=await open('restored'); assert.deepEqual([accepted,pending].map(item=>state(restored.store,item)),before); assert.deepEqual(restored.outbox.describe(accepted.proofId),ackBefore); assert.deepEqual(restored.outbox.describe(pending.proofId),pendingBefore); + if (fileHandoff) { + const saved=restored.handoff.readSnapshot(pending.fileId); + assert.equal(saved.state,'claimed'); + assert.deepEqual(saved.bytes,bytes); + assert.deepEqual(restored.handoff.readSnapshot(accepted.fileId),live.handoff.readSnapshot(accepted.fileId)); + } const {runId,requestId,nodeId,attempt}=accepted.task; const ack=await restored.host.readHistoricalAck({namespace:'staging-backup',runId,requestId,nodeId,attempt, receiptHash:ackBefore.receiptHash}); @@ -73,8 +120,20 @@ test('quiesced R5 Store and Outbox backups reopen together and recover a signed const restoredProof=restored.outbox.describe(pending.proofId); assert.equal(restoredProof.signatureHash,pendingBefore.signatureHash); assert.equal(restoredProof.state,'ACKED'); - assert.deepEqual(state(live.store,pending),before[1]); + if (fileHandoff) { + const saved=restored.handoff.readSnapshot(pending.fileId); + assert.deepEqual(saved.bytes,bytes); + assert.equal(saved.state,'acked'); + assert.equal(saved.acceptedRevision,restoredProof.acceptedRevision); + assert.equal(live.handoff.readSnapshot(pending.fileId).state,'claimed'); + } + assert.deepEqual([accepted,pending].map(item=>state(live.store,item)),before); + assert.deepEqual(live.outbox.describe(accepted.proofId),ackBefore); assert.deepEqual(live.outbox.describe(pending.proofId),pendingBefore); console.log('P0A_BACKUP '+JSON.stringify({integrity:'ok',hashes,originalAck:ack.acceptedRevision, - recoveredAck:restoredProof.acceptedRevision,signatureReused:true,sourceUnchanged:true})); -}); + recoveredAck:restoredProof.acceptedRevision,signatureReused:true,sourceUnchanged:true, + ...(fileHandoff?{fileBytes:bytes.length,fileSha256:sha256Bytes(bytes)}:{})})); +} + +test('quiesced R5 Store and Outbox backups reopen together and recover a signed receipt',t=>testBackup(t,false)); +test('quiesced R5 backup recovers a signed file receipt from its private snapshot',t=>testBackup(t,true)); diff --git a/tests/security/p0a-ops-codex.mjs b/tests/security/p0a-ops-codex.mjs index c6e0b2e..05464ff 100644 --- a/tests/security/p0a-ops-codex.mjs +++ b/tests/security/p0a-ops-codex.mjs @@ -11,7 +11,7 @@ import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../sr import {compileOpsWorkflow,opsRuntime,opsStart} from './p0a-ops-fixture.mjs'; const [executable,cwd,out,mode='retry']=process.argv.slice(2); -assert.ok(['retry','disconnect'].includes(mode)); +assert.ok(['retry','disconnect','interrupt'].includes(mode)); assert.ok([executable,cwd,out].every(value=>value&&path.isAbsolute(value))); mkdirSync(out,{recursive:false,mode:0o700}); const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; @@ -144,7 +144,19 @@ lines.on('line',line=>{ } else if (message.method==='item/started'&&p.item.type==='dynamicToolCall') { observations.set(p.item.id,{threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,callId:p.item.id,args:p.item.arguments}); } else if (message.method==='item/tool/call') { - void submit(message).catch(fail); + if (mode==='interrupt') { + const started=observations.get(p.callId); + assert.ok(active && identity && started && !active.callId); + assert.equal(p.threadId,active.threadId); + assert.equal(p.turnId,active.turnId); + assert.equal(p.tool,'probe_hold'); + assert.deepEqual(started,{threadId:p.threadId,turnId:p.turnId,tool:p.tool,callId:p.callId,args:p.arguments}); + assert.deepEqual(p.arguments,{}); + active.callId=p.callId; + // Keep the real model turn waiting on a read-only probe until the client interrupts it. + event('turn-held',{threadId:p.threadId,turnId:p.turnId,callId:p.callId}); + active.held(); + } else void submit(message).catch(fail); } else if (message.method==='turn/completed'&&active&&p.threadId===active.threadId) { assert.equal(p.turn.id,active.turnId); active.done(p.turn); @@ -203,14 +215,34 @@ async function run() { const {data}=await request('model/list',{limit:30,includeHidden:false}); const model=data.find(entry=>entry.isDefault)?.model??data[0]?.model; assert.ok(model); - if (mode==='disconnect') { + if (mode==='disconnect' || mode==='interrupt') { + const interrupt=mode==='interrupt'; const {thread}=await request('thread/start',{cwd,model,ephemeral:true,approvalPolicy:'never',sandbox:'read-only', - multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}}}); + multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}},...(interrupt?{dynamicTools:[{ + type:'function',name:'probe_hold',description:'Read-only synthetic test barrier', + inputSchema:{type:'object',properties:{},additionalProperties:false}, + }]}:{})}); const {runId}=await host.start(opsStart(program,'transport-disconnect')); const snapshot=()=>({checkpoint:store.load(runId),journal:store.listJournal(runId),outbox:outbox.usage()}); const before=snapshot(); assert.equal(before.checkpoint.status,'waiting'); assert.equal(before.checkpoint.receiptCount,0); + assert.equal(before.outbox.records,0); + let turn; + if (interrupt) { + active={threadId:thread.id}; + const held=new Promise(resolve=>{active.held=resolve;}); + const completed=new Promise(resolve=>{active.done=resolve;}); + await request('turn/start',{threadId:thread.id,input:[{type:'text', + text:'Synthetic integration test. Immediately call the probe_hold tool exactly once with an empty object. Wait for its reply.'}]}); + await Promise.race([held,completed.then(()=>{throw new Error('Model turn completed before the test barrier');})]); + assert.deepEqual(snapshot(),before); + await request('turn/interrupt',{threadId:thread.id,turnId:active.turnId}); + turn=await completed; + assert.equal(turn.status,'interrupted'); + event('turn-interrupted',{threadId:thread.id,turnId:turn.id,callId:active.callId,status:turn.status}); + active=undefined; + } const closed=new Promise(resolve=>child.once('close',(code,signal)=>resolve({code,signal}))); closingForTest=true; child.stdin.end(); @@ -219,7 +251,9 @@ async function run() { const resumed=await host.resume({runId}); assert.equal(resumed.pending.requestId,before.checkpoint.pending.requestId); assert.deepEqual(snapshot(),before); - const result={passed:true,mode,threadId:thread.id,runId,exit,unchanged:true,receiptCount:0}; + assert.equal(exit.code,0); + const result={passed:true,mode,threadId:thread.id,runId,exit,unchanged:true,receiptCount:0, + ...(turn?{turnId:turn.id,turnStatus:turn.status}:{})}; event('transport-closed',result); return result; } diff --git a/tests/security/p0a-ops-fixture.mjs b/tests/security/p0a-ops-fixture.mjs index 3b53947..d76dd2c 100644 --- a/tests/security/p0a-ops-fixture.mjs +++ b/tests/security/p0a-ops-fixture.mjs @@ -3,11 +3,12 @@ import {writeFile} from 'node:fs/promises'; import path from 'node:path'; import {compileInteractive} from '../../src/interactive/index.mjs'; -export async function compileOpsWorkflow(directory) { +export async function compileOpsWorkflow(directory,{fileHandoff=false}={}) { const file=path.join(directory,'ops.workflow.js'); + const properties=fileHandoff?"{artifact:{type:'object'}}":"{answer:{type:'string'}}"; await writeFile(file,`import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl'; export default defineWorkflow({id:'p0a-ops',version:'1.0.0',name:'P0-A operations',nodes:[ -node('agent',{name:'Agent',agent:'writer',inputs:['question'],output:{type:'object',properties:{answer:{type:'string'}}},retries:1,next:'done'}), +node('agent',{name:'Agent',agent:'writer',inputs:['question'],output:{type:'object',properties:${properties}},retries:1,next:'done'}), terminal('done',{name:'Done',status:'succeeded'})]});`,{flag:'wx'}); const compiled=await compileInteractive(file,{output:'memory',featureStage:'P0-A',skillCheck:false}); assert.ok(compiled.programIdentity,JSON.stringify(compiled.diagnostics)); From 0a2b5fca993d615048c517cbe0a9e3fc527f7650 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 19:00:44 +0800 Subject: [PATCH 55/72] Record G12 dual-platform interruption and release prerequisites --- .../g12-first-release-readiness.md | 45 +++++++++++++++++++ .../p0a-g07-g12-20261113/g12-review.md | 4 ++ .../p0a-g07-g12-20261113/release-matrix.md | 5 ++- 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/g12-first-release-readiness.md diff --git a/docs/evidence/p0a-codex-isolated-20261113/g12-first-release-readiness.md b/docs/evidence/p0a-codex-isolated-20261113/g12-first-release-readiness.md new file mode 100644 index 0000000..aa5f8f0 --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/g12-first-release-readiness.md @@ -0,0 +1,45 @@ +# G12 首发环境核查与补充实测 + +起始候选 `fc7fd69fb4951ff35206b1825302fed49341107f` 的 CI 8/8 通过。补充实测候选为 `a43ff75377ebe0938d8933074450feb62aba4c67`。代码增量限于三个测试文件,diff SHA-256 为 `b980cd0b251eca8330cec11d232790352d0b4ab61f389a3a4e26a199b3eb353a`。三份冻结原件及其附属文件的字节数和 SHA-256 与 [G01 登记](../p0a-g01-20261112/verified-inputs.txt) 一致。 [起始 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43407985499) · [实测候选 CI](https://github.com/devcodex-labs/flowkit/actions/runs/43412068455) + +## Windows 实测 + +- **正在运行的模型回合主动中断,P/M/T。** 独立普通 Host 与 Model 由本机固定启动链启动。真实模型调用只读探针 `probe_hold`,Host 核对同一 thread、turn 和 call 的开始事件与回调,在工具等待期间发送 `turn/interrupt`。同一回合报告 `interrupted`,随后 App Server 收到 EOF 并退出 0。Checkpoint、Journal 和 Outbox 保持一致,恢复得到相同 Pending,零回执。回环监听在测试后关闭。首次 Windows PowerShell 脚本启动被本机默认执行策略拒绝,在启动本次已核对的本地脚本时使用进程级 RemoteSigned 后通过。 +- **含文件快照的 R5 恢复,P。** Windows Node 22.13.0 与 24.22.2 各 2/2。停写后分别以 `VACUUM INTO` 备份 Store、Outbox、文件交接库,逐库校验完整性与 SHA-256,在新路径重开。两份 262,144 B 合成文件的入口原件在暂存后已删除。原 Ack 保持 revision 4。显式按 proof ID 重新投递已签待接纳的文件回执,原签名和私有快照字节保持一致,目标快照从 claimed 转为 acked,源库保持原状。测试用同一密钥和有效期内的固定时钟。 + +Windows 归档 `FlowKit-Windows-G12-Interrupt-Backup.zip` SHA-256 为 `4a50c9744e7525a67a37559144257ff3d0ffdc3fa1fd7193968cee566bfd5883`,21 项逐一核对。含双账户启动脚本、失败启动记录、完整协议请求和响应、Node 22/24 原始日志、启动后监听状态及只读环境清点。 + +```powershell +volta run --node 22.13.0 node --test tests/interactive/p0a-staging-backup.test.mjs +node --test tests/interactive/p0a-staging-backup.test.mjs +# 由归档中的 windows-run-ops.ps1 以普通 Host 身份启动 +node tests/security/p0a-ops-codex.mjs <新Host私有目录> interrupt +``` + +## Linux + +FlowKit 快进拉取并核对同一候选。独立普通 Host/Model 在固定启动链下首次通过主动取消,耗时约 5.13 秒。原始协议中 `probe_hold({})`、中断请求与完成事件对应同一回合,状态为 `interrupted`。独立 SQL 观察无读取错误,零签名、零回执,恢复同一 revision 3 Pending。证据等级 P/M/T。 + +三库停写备份和恢复首次 2/2 通过。私有快照 262,144 B,原 Ack 及恢复 Ack 均为 revision 4,复用原签名,源库保持原状。另核对代码候选的 Ubuntu Node 22.13.0 与 24.22.1 原始 CI 日志,两项备份回归均通过。证据等级 P。 + +```sh +git pull --ff-only origin feat/p0a-interactive-20261112 +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-interrupt-backup-controller.mjs local attempt-1 +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-interrupt-backup-controller.mjs codex attempt-1 +``` + +`FlowKit-Linux-AppServer-Interrupt-Backup-20261113.zip` SHA-256 为 `c259575c3911b1f4750234cea84c0cc010d2b401a609a30a2ac0cc4fc414afbc`。Linux 端核对了 ZIP 完整性、160 个载荷文件的哈希及敏感内容扫描结果。归档含精确命令与退出码、原始协议、快照、进程观察和离线校验。 [Linux 执行记录与归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +## 首发环境缺口 + +Windows 只读清点检查了 FlowKit 专用实验目录、公开配置文件名、计划任务和 FlowKit 服务。可用配置是隔离实验室的 `host-config.json` 与 `FlowKit-Isolated-SSH`。Linux 快进拉取起始候选后,确认既有专用容器和普通账号可用,现场只有测试身份映射。仓库和两端检查范围内均未找到本次可用的实际首发配置。以下验收保持 **BLOCKED**。 [Linux 核查记录](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +| 验收项 | 需要的环境、授权或证据 | +| --- | --- | +| 认证入口和结果交付 | 首发入口或生产等价实例、身份源配置、两个测试主体及其租户绑定、审计客户端。验证真实 Model 回执与操作员/审计员授权处于同一部署链。 | +| 跨 Store 撤权 | 首发 Host/Store 全量清单、入口暂停控制及测试钥撤销权限。在批准的测试窗口逐库确认,检查暂停期间的拒绝与全量确认后的恢复。 | +| 值班告警和长时容量 | 批准的测试值班接收端及收件确认,目标负载曲线、持续时间、容量和保留预算。现有本机回环告警和短时容量样本见 E16。 | +| 完整备份恢复 | 目标备份系统、保留范围、恢复到隔离位置的窗口及恢复时间和数据损失目标,包含文件快照、部署密钥和策略。核对停写边界、完整性、原 Ack 和待对账记录。 | +| 连接与供应商故障 | 进行中回合的意外传输中断,以及供应商的真实故障事件或经批准的供应商故障验证方式和原始来源证据。主动取消样本适用于 `turn/interrupt`。 | + +实际首发配置到位后集中完成联合验收。G12 放行后进入 G17,冻结候选并验证发行包与安装,合并、Tag、发布和部署按原授权边界办理。 diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index 621be4f..b1d926a 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -20,3 +20,7 @@ **裁决维持 BLOCKED。** 独立角色核对 `3847277e5d6339a06e31f20fef576985e3bd1933` 的五个测试文件和 Windows 原始记录,复跑备份及运维测试 3/3 通过。另核对 Linux `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9` 的 11 次真实普通客户端收件、R5 成对备份恢复和受控 EOF。两个平台的 App Server EOF 均发生于创建 thread 后、启动模型回合前。Linux 首次启动前的 SQLite error 14 已归档。仅调整观察包装后复测通过,根因未完全确认。三库入口联动和独立回环告警已通过 Windows 实测及 Ubuntu CI。 [实测证据与范围](../p0a-codex-isolated-20261113/staging-joint-r5.md) 待验范围包括实际发布入口的身份及租户绑定和同链 Model 回执、适用 SEC/NAT/T29 的剩余原生证据、进行中模型回合断线与供应商故障、实际全清单撤权联动、值班告警收件、长时容量及包含文件快照的实际备份恢复。G17 继续等待 G12 放行。 + +## 首发环境核查复审 + +**G12 维持 BLOCKED。** 独立角色审查 `a43ff75377ebe0938d8933074450feb62aba4c67` 的三个测试文件、Windows 原始日志和已部署探针哈希,并核对 Linux 的原始协议、普通进程身份、离线复核和归档结果,未发现阻断性问题。双平台实测了正在等待只读工具的真实回合被主动取消,以及停写备份恢复后按 proof ID 用原签名和 256 KiB 文件快照重新投递。启动时自动发现、非预期传输断开及真实供应商故障各自待验。实际首发入口、完整清单、值班接收端、负载及备份窗口仍缺。 [本轮实测与待验条件](../p0a-codex-isolated-20261113/g12-first-release-readiness.md) diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 2aa6654..aea8fce 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -24,6 +24,7 @@ | E14 | `9ba894ce702d84e20d9a51a57d015e225774b851` | 独立普通 OS 对端进程的创建和审计命令、会话撤权、新连接恢复。[OS 会话实测与证据](../p0a-codex-isolated-20261113/g12-os-sessions.md) | | E15 | `eb52a6f516d54488167481b00f2c6811fca79afb` | 三测试 Store 撤销、96 次接纳与本机 HTTP 告警、Genesis 强杀、真实 Codex 无效结果和重试。[故障与运维实测](../p0a-codex-isolated-20261113/g12-ops-r5.md) | | E16 | `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9`、`3847277e5d6339a06e31f20fef576985e3bd1933` | Windows/Linux 隔离实验室联合预演。双平台普通审计员进程收件、受控 App Server EOF 和 R5 成对备份。三库撤销联动与独立进程持久化告警通过 Windows 实测和 Ubuntu CI。[命令、场景边界和归档哈希](../p0a-codex-isolated-20261113/staging-joint-r5.md) | +| E17 | `a43ff75377ebe0938d8933074450feb62aba4c67` | Windows/Linux 隔离环境中主动取消正在运行的 Codex 模型回合,以及含 256 KiB 私有文件快照的三库恢复。[实测与首发环境缺口](../p0a-codex-isolated-20261113/g12-first-release-readiness.md) | ## SEC · 生产配置与候选入口 @@ -47,7 +48,7 @@ | 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。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | -| SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E16 补 128 次接纳、八轮满额与清理、独立回环进程持久化告警和停写后 Store/Outbox 恢复。生产保留期限、持续容量、值班端送达及完整备份恢复仍待取证。 | +| SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E16 补 128 次接纳、八轮满额与清理、独立回环进程持久化告警。E17 补停写后的 Store、Outbox 和文件交接库联合恢复。生产保留期限、持续容量、值班端送达及实际备份系统的恢复仍待取证。 | ## NAT · Codex 等价验收 @@ -64,7 +65,7 @@ | 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 已通过文件回退。E15 补业务非法值和重试耗尽,E16 补真实 App Server 在模型回合前收到受控 EOF 后保持同一 Pending。仍需验证进行中模型回合中断、供应商失败、异常协议正文和相应的有界退出。 | +| NAT-10 | BLOCKED,真实失败与传输边界 | E7 的大内联未发出工具调用,E1 已通过文件回退。E15 补业务非法值和重试耗尽,E16 补回合前受控 EOF,E17 补真实模型回合主动取消并保留同一 Pending。仍需验证意外传输断开、供应商失败、异常协议正文和相应的有界退出。 | | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | From cfe47768194fb8bbaf7e817ee0efddb0e493116b Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 19:33:41 +0800 Subject: [PATCH 56/72] Exercise recovery discovery and abrupt active-turn transport loss --- tests/interactive/p0a-staging-backup.test.mjs | 18 +++++-- tests/security/p0a-ops-codex.mjs | 47 ++++++++++++------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/tests/interactive/p0a-staging-backup.test.mjs b/tests/interactive/p0a-staging-backup.test.mjs index c21d629..999c543 100644 --- a/tests/interactive/p0a-staging-backup.test.mjs +++ b/tests/interactive/p0a-staging-backup.test.mjs @@ -115,11 +115,20 @@ async function testBackup(t,fileHandoff) { const ack=await restored.host.readHistoricalAck({namespace:'staging-backup',runId,requestId,nodeId,attempt, receiptHash:ackBefore.receiptHash}); assert.equal(ack.acceptedRevision,ackBefore.acceptedRevision); - const recovered=await restored.outbox.deliver(pending.proofId); - assert.equal(recovered.status,'succeeded'); - const restoredProof=restored.outbox.describe(pending.proofId); + assert.deepEqual(await restored.outbox.recover(runId),[]); + const recovered=await restored.outbox.recover(pending.task.runId); + assert.equal(recovered.length,1); + const [restoredProof]=recovered; + assert.equal(restoredProof.proofId,pending.proofId); + assert.equal(restored.store.load(pending.task.runId).status,'succeeded'); + assert.equal(restored.store.getReceipt(pending.task.runId,pending.task.requestId).acceptedRevision, + restoredProof.acceptedRevision); assert.equal(restoredProof.signatureHash,pendingBefore.signatureHash); assert.equal(restoredProof.state,'ACKED'); + const afterRecovery=state(restored.store,pending); + assert.deepEqual(await restored.outbox.recover(pending.task.runId),[]); + assert.deepEqual(state(restored.store,pending),afterRecovery); + assert.deepEqual(state(restored.store,accepted),before[0]); if (fileHandoff) { const saved=restored.handoff.readSnapshot(pending.fileId); assert.deepEqual(saved.bytes,bytes); @@ -131,7 +140,8 @@ async function testBackup(t,fileHandoff) { assert.deepEqual(live.outbox.describe(accepted.proofId),ackBefore); assert.deepEqual(live.outbox.describe(pending.proofId),pendingBefore); console.log('P0A_BACKUP '+JSON.stringify({integrity:'ok',hashes,originalAck:ack.acceptedRevision, - recoveredAck:restoredProof.acceptedRevision,signatureReused:true,sourceUnchanged:true, + recoveredAck:restoredProof.acceptedRevision,autoDiscovered:recovered.length,secondScan:0, + signatureReused:true,sourceUnchanged:true, ...(fileHandoff?{fileBytes:bytes.length,fileSha256:sha256Bytes(bytes)}:{})})); } diff --git a/tests/security/p0a-ops-codex.mjs b/tests/security/p0a-ops-codex.mjs index 05464ff..3eef941 100644 --- a/tests/security/p0a-ops-codex.mjs +++ b/tests/security/p0a-ops-codex.mjs @@ -11,7 +11,9 @@ import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../sr import {compileOpsWorkflow,opsRuntime,opsStart} from './p0a-ops-fixture.mjs'; const [executable,cwd,out,mode='retry']=process.argv.slice(2); -assert.ok(['retry','disconnect','interrupt'].includes(mode)); +assert.ok(['retry','disconnect','interrupt','transport-loss'].includes(mode)); +const holdTurn=mode==='interrupt'||mode==='transport-loss'; +const abrupt=mode==='transport-loss'; assert.ok([executable,cwd,out].every(value=>value&&path.isAbsolute(value))); mkdirSync(out,{recursive:false,mode:0o700}); const appArgs=['app-server','--listen','stdio://','-c','web_search="disabled"']; @@ -144,7 +146,7 @@ lines.on('line',line=>{ } else if (message.method==='item/started'&&p.item.type==='dynamicToolCall') { observations.set(p.item.id,{threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,callId:p.item.id,args:p.item.arguments}); } else if (message.method==='item/tool/call') { - if (mode==='interrupt') { + if (holdTurn) { const started=observations.get(p.callId); assert.ok(active && identity && started && !active.callId); assert.equal(p.threadId,active.threadId); @@ -153,7 +155,7 @@ lines.on('line',line=>{ assert.deepEqual(started,{threadId:p.threadId,turnId:p.turnId,tool:p.tool,callId:p.callId,args:p.arguments}); assert.deepEqual(p.arguments,{}); active.callId=p.callId; - // Keep the real model turn waiting on a read-only probe until the client interrupts it. + // Hold the real model turn at a read-only tool call before testing interruption or channel loss. event('turn-held',{threadId:p.threadId,turnId:p.turnId,callId:p.callId}); active.held(); } else void submit(message).catch(fail); @@ -215,10 +217,9 @@ async function run() { const {data}=await request('model/list',{limit:30,includeHidden:false}); const model=data.find(entry=>entry.isDefault)?.model??data[0]?.model; assert.ok(model); - if (mode==='disconnect' || mode==='interrupt') { - const interrupt=mode==='interrupt'; + if (mode==='disconnect' || holdTurn) { const {thread}=await request('thread/start',{cwd,model,ephemeral:true,approvalPolicy:'never',sandbox:'read-only', - multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}},...(interrupt?{dynamicTools:[{ + multiAgentMode:'explicitRequestOnly',config:{mcp_servers:{}},...(holdTurn?{dynamicTools:[{ type:'function',name:'probe_hold',description:'Read-only synthetic test barrier', inputSchema:{type:'object',properties:{},additionalProperties:false}, }]}:{})}); @@ -228,31 +229,45 @@ async function run() { assert.equal(before.checkpoint.status,'waiting'); assert.equal(before.checkpoint.receiptCount,0); assert.equal(before.outbox.records,0); + const requestId=before.checkpoint.pending.requestId; + const receiptBefore=store.getReceipt(runId,requestId); + assert.ok(!receiptBefore); let turn; - if (interrupt) { + let terminalTurn; + if (holdTurn) { active={threadId:thread.id}; const held=new Promise(resolve=>{active.held=resolve;}); - const completed=new Promise(resolve=>{active.done=resolve;}); + const completed=new Promise(resolve=>{active.done=value=>{terminalTurn=value;resolve(value);};}); await request('turn/start',{threadId:thread.id,input:[{type:'text', text:'Synthetic integration test. Immediately call the probe_hold tool exactly once with an empty object. Wait for its reply.'}]}); await Promise.race([held,completed.then(()=>{throw new Error('Model turn completed before the test barrier');})]); assert.deepEqual(snapshot(),before); - await request('turn/interrupt',{threadId:thread.id,turnId:active.turnId}); - turn=await completed; - assert.equal(turn.status,'interrupted'); - event('turn-interrupted',{threadId:thread.id,turnId:turn.id,callId:active.callId,status:turn.status}); - active=undefined; + assert.equal(terminalTurn,undefined); + if (!abrupt) { + await request('turn/interrupt',{threadId:thread.id,turnId:active.turnId}); + turn=await completed; + assert.equal(turn.status,'interrupted'); + event('turn-interrupted',{threadId:thread.id,turnId:turn.id,callId:active.callId,status:turn.status}); + active=undefined; + } } const closed=new Promise(resolve=>child.once('close',(code,signal)=>resolve({code,signal}))); closingForTest=true; - child.stdin.end(); + if (abrupt) { + event('transport-cut',{threadId:thread.id,turnId:active.turnId,callId:active.callId,launcherPid:child.pid}); + // Stop only this probe's own transport subprocess. No graceful RPC or EOF is sent. + assert.ok(child.kill('SIGKILL')); + } else child.stdin.end(); const exit=await closed; assert.deepEqual(snapshot(),before); const resumed=await host.resume({runId}); - assert.equal(resumed.pending.requestId,before.checkpoint.pending.requestId); + assert.equal(resumed.pending.requestId,requestId); assert.deepEqual(snapshot(),before); - assert.equal(exit.code,0); + assert.deepEqual(store.getReceipt(runId,requestId),receiptBefore); + if (abrupt) assert.equal(exit.signal,'SIGKILL'); + else assert.equal(exit.code,0); const result={passed:true,mode,threadId:thread.id,runId,exit,unchanged:true,receiptCount:0, + ...(abrupt?{turnId:active.turnId,requestId,terminalBeforeCut:false}:{}), ...(turn?{turnId:turn.id,turnStatus:turn.status}:{})}; event('transport-closed',result); return result; From 585ad5feaa619e961a37009e3ac57d0006797601 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 19:45:51 +0800 Subject: [PATCH 57/72] Record G12 recovery and transport-loss rehearsal --- .../g12-recovery-transport-loss.md | 42 +++++++++++++++++++ .../p0a-g07-g12-20261113/g12-review.md | 4 ++ .../p0a-g07-g12-20261113/release-matrix.md | 5 ++- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/p0a-codex-isolated-20261113/g12-recovery-transport-loss.md diff --git a/docs/evidence/p0a-codex-isolated-20261113/g12-recovery-transport-loss.md b/docs/evidence/p0a-codex-isolated-20261113/g12-recovery-transport-loss.md new file mode 100644 index 0000000..6273097 --- /dev/null +++ b/docs/evidence/p0a-codex-isolated-20261113/g12-recovery-transport-loss.md @@ -0,0 +1,42 @@ +# G12 恢复扫描与回合中传输骤断 + +起始候选 `0a2b5fca993d615048c517cbe0a9e3fc527f7650` 的 [CI 8/8 通过](https://github.com/devcodex-labs/flowkit/actions/runs/43413583007)。实测候选为 `cfe47768194fb8bbaf7e817ee0efddb0e493116b`,其 [CI 8/8 通过](https://github.com/devcodex-labs/flowkit/actions/runs/43418066780)。两份测试文件的增量 diff SHA-256 为 `cad8aa949cd86dc4e474efad9a90f6f1878174434583077324d43718ae557d98`。复核三份冻结原件及附属文件的字节数与 SHA-256,均与 [G01 登记](../p0a-g01-20261112/verified-inputs.txt) 一致。 + +## Windows 实测 + +- **恢复后按 Run 发现已签回执,P。** 停写后备份并重开 Store、Outbox 和启用时的文件交接库。只传 Run ID 给 `outbox.recover(runId)`,原已确认 Run 无待投递记录,目标 Run 恰好发现一份已签回执。恢复后为 `ACKED`,复用原签名,文件变体保留 262,144 B 私有快照,原 Ack 和恢复 Ack 均为 revision 4。再次扫描返回空,Checkpoint、Journal、接纳索引及源库保持原状。扫描由测试显式调用,发布服务的启动编排和全 Run 清单待验。 +- **进行中真实回合的本地传输骤断,P/M/T。** 专用普通 Host、Model 以固定回环 SSH 启动真实 App Server。收到同一 thread/turn/call 的 `probe_hold({})` 开始事件和工具回调后,在工具仍未应答、回合终态尚未到达时强停探针自己的 SSH 子进程,退出信号为 `SIGKILL`。协议未发送取消请求,未收到回合完成事件。恢复仍是原 Pending,零签名、零新增回执,Checkpoint、Journal、Outbox 与回执索引保持一致。收尾时 Model 和传输 PID 已不在,测试监听数为零。 +- **首发等价配置预演,P。** 复用既有固定启动器、两个测试租户和普通操作员/审计员。6 次进程启动及退出通过,11 个回复由对端实际收到,撤权后旧连接拒绝,新连接可用。Node 22.13.0 与 24.22.2 各通过备份和运维测试 4/4,包含三测试 Store 的撤权联动,以及独立回环进程收件的 8 轮容量告警和清理。测试参数为 16 条硬上限、128 次接纳,作为短时预演。 + +```powershell +volta run --node 22.13.0 node --test tests/interactive/p0a-staging-backup.test.mjs tests/interactive/p0a-staging-ops.test.mjs +node --test tests/interactive/p0a-staging-backup.test.mjs tests/interactive/p0a-staging-ops.test.mjs +# 由归档中的 windows-run-ops.ps1 以普通 Host 启动 +node tests/security/p0a-ops-codex.mjs <新Host私有目录> transport-loss +``` + +`FlowKit-Windows-G12-Recovery-Transport-Loss.zip` SHA-256 为 `5b8a42a15254f405b0a38d0ac6cb3f7153f56d5246a3c7c8a722962c54cc9cd5`。23 项逐一核对,含命令与退出、完整协议、回合及收尾观察、配置范围、普通对端收件和原始测试日志。 + +## Linux + +同一候选的 Ubuntu CI 在 Node 22.13.0 和 24.22.1 分别通过备份与运维测试 4/4。CI 日志含恢复扫描、三 Store 联动和回环接收端落盘。已派发同一候选的快进拉取、隔离环境复用及实测。独立账户原始记录待取。 + +## 可复用的首发预演配置 + +| 项目 | 已准备的隔离配置 | 交接检查 | +| --- | --- | --- | +| 认证入口 | 固定 stdio 启动、两个普通操作员、普通审计员、两个测试租户 | 先核对 OS 身份映射,再核对客户端收件、跨租户拒绝和撤权后新会话。 | +| 撤权联动 | 三个可丢弃 SQLite Store 和测试签名钥 | 停止接纳,模拟部分确认,核对三库均拒绝,全部确认后恢复。 | +| 告警与负载 | 单独回环接收进程,16 条硬上限,8 轮采样 | 保存接收端落盘凭证、满额前后数据库文件长度和清理结果。 | +| 恢复与传输 | 停写快照、全新的恢复目录、固定普通 Host/Model 启动器 | 恢复后传 Run ID 扫描;本地断流只切断本次探针创建的传输进程。 | + +## 实际首发仍需的四项输入 + +| 项目 | 需提供或授权 | +| --- | --- | +| 身份与入口 | 生产等价首发入口和受信身份源的配置位置,两个可用测试主体及各自租户绑定,审计客户端与对应测试权限。 | +| 撤权联动 | 实际 Host/Store 全量清单、暂停与恢复接纳的入口控制,以及在批准窗口撤销测试钥和核对逐库确认的权限。 | +| 告警、容量和恢复 | 测试值班接收端和收件确认人,允许发送测试告警的范围,负载曲线、时长、磁盘与保留目标,以及备份系统、隔离恢复位置和窗口。 | +| 供应商与部署传输故障 | 真实供应商故障的原始事件或供应商提供的批准测试方式,以及在实际发布链路上验证断线和重连的测试窗口。当前隔离样本覆盖等待工具回复时的本地传输骤断。 | + +以上条款保持 **BLOCKED**。首发预演使用专用本机回环与容器网络,测试租户、测试 Store 和告警接收端可复用。实际部署的恢复启动编排、剩余 SEC/NAT/T29 证据随同一批验收。G12 放行后才能进入 G17。 diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index b1d926a..4470d97 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -24,3 +24,7 @@ ## 首发环境核查复审 **G12 维持 BLOCKED。** 独立角色审查 `a43ff75377ebe0938d8933074450feb62aba4c67` 的三个测试文件、Windows 原始日志和已部署探针哈希,并核对 Linux 的原始协议、普通进程身份、离线复核和归档结果,未发现阻断性问题。双平台实测了正在等待只读工具的真实回合被主动取消,以及停写备份恢复后按 proof ID 用原签名和 256 KiB 文件快照重新投递。启动时自动发现、非预期传输断开及真实供应商故障各自待验。实际首发入口、完整清单、值班接收端、负载及备份窗口仍缺。 [本轮实测与待验条件](../p0a-codex-isolated-20261113/g12-first-release-readiness.md) + +## 恢复扫描与传输骤断复审 + +**G12 维持 BLOCKED。** 独立角色核对 `cfe47768194fb8bbaf7e817ee0efddb0e493116b` 的两个测试文件和 Windows 原始证据。已恢复库按已知 Run ID 发现一份待投递回执并复用原签名,复扫为空。真实模型工具回调已到达且尚未应答时,本地传输突然终止。Host 核对同一 Pending 和零新增回执。实际首发的启动编排、身份与跨 Store 联动、外部值班、长时容量与备份系统,以及部署链路断线重连和供应商故障仍待验。 [本批证据与四项待提供输入](../p0a-codex-isolated-20261113/g12-recovery-transport-loss.md) diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index aea8fce..52d899b 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -25,6 +25,7 @@ | E15 | `eb52a6f516d54488167481b00f2c6811fca79afb` | 三测试 Store 撤销、96 次接纳与本机 HTTP 告警、Genesis 强杀、真实 Codex 无效结果和重试。[故障与运维实测](../p0a-codex-isolated-20261113/g12-ops-r5.md) | | E16 | `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9`、`3847277e5d6339a06e31f20fef576985e3bd1933` | Windows/Linux 隔离实验室联合预演。双平台普通审计员进程收件、受控 App Server EOF 和 R5 成对备份。三库撤销联动与独立进程持久化告警通过 Windows 实测和 Ubuntu CI。[命令、场景边界和归档哈希](../p0a-codex-isolated-20261113/staging-joint-r5.md) | | E17 | `a43ff75377ebe0938d8933074450feb62aba4c67` | Windows/Linux 隔离环境中主动取消正在运行的 Codex 模型回合,以及含 256 KiB 私有文件快照的三库恢复。[实测与首发环境缺口](../p0a-codex-isolated-20261113/g12-first-release-readiness.md) | +| E18 | `cfe47768194fb8bbaf7e817ee0efddb0e493116b` | 恢复后按 Run ID 自动发现已签回执,真实模型回合等待工具时传输骤断,以及首发等价配置预演。[实测、命令和首发输入](../p0a-codex-isolated-20261113/g12-recovery-transport-loss.md) | ## SEC · 生产配置与候选入口 @@ -48,7 +49,7 @@ | 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。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | -| SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E16 补 128 次接纳、八轮满额与清理、独立回环进程持久化告警。E17 补停写后的 Store、Outbox 和文件交接库联合恢复。生产保留期限、持续容量、值班端送达及实际备份系统的恢复仍待取证。 | +| SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E16 补 128 次接纳、八轮满额与清理、独立回环进程持久化告警。E17/E18 补三库联合恢复、按 Run 自动发现及二次扫描幂等。生产保留期限、持续容量、值班端送达及实际备份系统和启动编排仍待取证。 | ## NAT · Codex 等价验收 @@ -65,7 +66,7 @@ | 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 已通过文件回退。E15 补业务非法值和重试耗尽,E16 补回合前受控 EOF,E17 补真实模型回合主动取消并保留同一 Pending。仍需验证意外传输断开、供应商失败、异常协议正文和相应的有界退出。 | +| NAT-10 | BLOCKED,真实失败与传输边界 | E7 的大内联未发出工具调用,E1 已通过文件回退。E15 补业务非法值和重试耗尽,E16 补回合前受控 EOF,E17 补主动取消,E18 补真实模型回合等待工具时的本地传输骤断。两种回合中断均保留同一 Pending。仍需验证实际部署链路上的断开和重连、供应商失败、异常协议正文及有界退出。 | | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | From cb55668e8b22a9753823399f54d7c58d050508ef Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 19:48:14 +0800 Subject: [PATCH 58/72] Record Linux recovery and transport-loss evidence --- .../g12-recovery-transport-loss.md | 10 +++++++++- docs/evidence/p0a-g07-g12-20261113/g12-review.md | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/evidence/p0a-codex-isolated-20261113/g12-recovery-transport-loss.md b/docs/evidence/p0a-codex-isolated-20261113/g12-recovery-transport-loss.md index 6273097..f7d2796 100644 --- a/docs/evidence/p0a-codex-isolated-20261113/g12-recovery-transport-loss.md +++ b/docs/evidence/p0a-codex-isolated-20261113/g12-recovery-transport-loss.md @@ -19,7 +19,15 @@ node tests/security/p0a-ops-codex.mjs <新Host私有 ## Linux -同一候选的 Ubuntu CI 在 Node 22.13.0 和 24.22.1 分别通过备份与运维测试 4/4。CI 日志含恢复扫描、三 Store 联动和回环接收端落盘。已派发同一候选的快进拉取、隔离环境复用及实测。独立账户原始记录待取。 +FlowKit 快进拉取并核对同一候选,复用固定启动器、普通 Host/Model 和测试环境。按 Run 恢复测试首次 2/2 通过,首次扫描一份,复扫为空。实际回合等待只读工具期间,探针以 SIGKILL 停止 Host UID 41001 持有的字节转发进程,固定启动器哈希不变。未发送取消 RPC,未收到回合终态,Checkpoint、Journal、Outbox、接纳索引与 Pending 保持一致。独立进程回收记录确认 Model App Server 随后退出 0。SQL 旁读保留瞬时 `database is locked` 日志,四份最终快照与离线校验通过。备份为 P 级,真实回合与传输为 P/M/T 级。 + +```sh +git pull --ff-only origin feat/p0a-interactive-20261112 +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-transport-loss-controller.mjs local attempt-1 +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-transport-loss-controller.mjs codex attempt-1 +``` + +同一候选的 Ubuntu CI 在 Node 22.13.0 和 24.22.1 分别通过备份与运维测试 4/4。CI 日志含恢复扫描、三 Store 联动和回环接收端落盘。`FlowKit-Linux-Transport-Loss-Autorecover-20261113.zip` 的 SHA-256 为 `da9dbf6c061c5c3c34428d4689717934638c4f3446eb217672d27ff4980f7d61`,173 个载荷文件的哈希、归档完整性与敏感内容扫描由 Linux 端核对。 [Linux 原始执行记录与归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) ## 可复用的首发预演配置 diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index 4470d97..c3b2488 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -27,4 +27,4 @@ ## 恢复扫描与传输骤断复审 -**G12 维持 BLOCKED。** 独立角色核对 `cfe47768194fb8bbaf7e817ee0efddb0e493116b` 的两个测试文件和 Windows 原始证据。已恢复库按已知 Run ID 发现一份待投递回执并复用原签名,复扫为空。真实模型工具回调已到达且尚未应答时,本地传输突然终止。Host 核对同一 Pending 和零新增回执。实际首发的启动编排、身份与跨 Store 联动、外部值班、长时容量与备份系统,以及部署链路断线重连和供应商故障仍待验。 [本批证据与四项待提供输入](../p0a-codex-isolated-20261113/g12-recovery-transport-loss.md) +**G12 维持 BLOCKED。** 独立角色核对 `cfe47768194fb8bbaf7e817ee0efddb0e493116b` 的两个测试文件及 Windows/Linux 原始证据。已恢复库按已知 Run ID 发现一份待投递回执并复用原签名,复扫为空。真实模型工具回调已到达且尚未应答时,本地传输突然终止。Host 核对同一 Pending 和零新增回执。Linux 另核对 Model 进程退出码和 SQLite 旁读锁错误记录。实际首发的启动编排、身份与跨 Store 联动、外部值班、长时容量与备份系统,以及部署链路断线重连和供应商故障仍待验。 [本批证据与四项待提供输入](../p0a-codex-isolated-20261113/g12-recovery-transport-loss.md) From c338cd46261757e598ae63b2738c9f9d03f08be8 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 20:11:04 +0800 Subject: [PATCH 59/72] Validate R5 proposal shapes and verify both Genesis crash boundaries --- src/interactive/coordinator.mjs | 92 +++++++++++++++++++++----- tests/interactive/coordinator.test.mjs | 28 +++++++- tests/interactive/p0a-ops.test.mjs | 44 +++++++++--- 3 files changed, 138 insertions(+), 26 deletions(-) diff --git a/src/interactive/coordinator.mjs b/src/interactive/coordinator.mjs index 4dd96f5..034228c 100644 --- a/src/interactive/coordinator.mjs +++ b/src/interactive/coordinator.mjs @@ -2,10 +2,25 @@ import {randomUUID} from 'node:crypto'; import {MAX_INLINE_AGENT_RESULT_BYTES, canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs'; import {loadInteractiveProgram} from './load.mjs'; import {checkpointPolicy} from './checkpoint.mjs'; +import {agentFrameRule} from '../compiler/interactive/validate-ir.mjs'; const FINISHED = new Set(['succeeded','failed','errored','cancelled']); const MAX_REASONS = 32; const MAX_REASON_LENGTH = 512; +const PROPOSAL_COMMON = ['kind','fromBlockId','nodeId','stackEffect','mergeIntent']; +const PROPOSAL_TARGET = [...PROPOSAL_COMMON,'toBlockId']; +const STACK_FIELDS = { + none:['kind'], + push:['kind','frame'], + pop:['kind','expectedKind','expectedNodeId'], + update:['kind','expectedKind','expectedNodeId','field','value'], +}; + +function hasFields(value, required, optional = []) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && required.every(field=>Object.hasOwn(value,field)) + && Reflect.ownKeys(value).every(field=>required.includes(field)||optional.includes(field)); +} function failure(code, message) { return Object.assign(new Error(code + ': ' + message), {code}); @@ -82,31 +97,74 @@ function confirmCurrentBlock(cp, program) { } return {block,record}; } -function certifiedProposal(cp, program, event, proposal) { - const {block} = confirmCurrentBlock(cp,program); - if (!block.eventKinds.includes(event.kind) - || !proposal || proposal.fromBlockId !== cp.blockId - || proposal.nodeId !== block.nodeId - || !block.proposalKinds.includes(proposal.kind)) { - throw failure('FK_I_INVALID_TRANSITION','Program returned uncertified proposal'); - } - if (proposal.kind === 'terminal' || proposal.kind === 'raise-error') { - if (proposal.toBlockId != null) { - throw failure('FK_I_INVALID_TRANSITION','terminal and error must have no successor'); - } - } else if (!block.legalTargets.includes(proposal.toBlockId)) { +/** Internal runtime validation of the current P0-A proposal contract. */ +export function validateProposalFields(rawProposal, block, record) { + let proposal; + try { proposal=copy(rawProposal); } + catch { throw failure('FK_I_INVALID_TRANSITION','Program proposal is not canonical data'); } + if (!proposal || !block.proposalKinds.includes(proposal.kind)) { + throw failure('FK_I_INVALID_TRANSITION','Program returned uncertified proposal kind'); + } + if (proposal.kind === 'terminal') { + if (!hasFields(proposal,[...PROPOSAL_COMMON,'terminal']) + || !hasFields(proposal.terminal,['status','nodeId']) + || proposal.terminal.nodeId!==block.nodeId + || proposal.terminal.status!==record.metadata.status) { + throw failure('FK_I_INVALID_TRANSITION','invalid terminal proposal fields'); + } + } else if (proposal.kind === 'raise-error') { + if (!hasFields(proposal,[...PROPOSAL_COMMON,'causeCode'],['diagnosticCode']) + || typeof proposal.causeCode!=='string' || !proposal.causeCode.length + || (Object.hasOwn(proposal,'diagnosticCode') && typeof proposal.diagnosticCode!=='string')) { + throw failure('FK_I_INVALID_TRANSITION','invalid error proposal fields'); + } + } else if (!hasFields(proposal,PROPOSAL_TARGET,['requestId']) + || (Object.hasOwn(proposal,'requestId') && typeof proposal.requestId!=='string') + || !block.legalTargets.includes(proposal.toBlockId)) { throw failure('FK_I_INVALID_TRANSITION','Program target not in legalTargets'); } - if (proposal.stackEffect?.kind !== 'none' - && !['push','pop','update'].includes(proposal.stackEffect?.kind)) { + if ((proposal.kind==='retry-agent' && proposal.toBlockId!==block.nodeId+'::prepare') + || (proposal.kind==='commit-candidate' && proposal.toBlockId!==block.nodeId+'::commit')) { + throw failure('FK_I_INVALID_TRANSITION','proposal kind does not match its successor phase'); + } + const {stackEffect,mergeIntent}=proposal; + const stackKind=stackEffect?.kind; + let expectedStack='none'; + if (proposal.kind==='retry-agent') expectedStack='update'; + if (proposal.kind==='move' && record.kind==='agent') { + if (block.phase==='enter') expectedStack='push'; + if (block.phase==='route') expectedStack='pop'; + } + if (stackKind!==expectedStack || !hasFields(stackEffect,STACK_FIELDS[expectedStack]) + || (stackKind==='push' && !hasFields(stackEffect.frame,agentFrameRule(block.nodeId).fields))) { throw failure('FK_I_INVALID_TRANSITION','invalid control stack effect'); } - if (proposal.mergeIntent?.kind !== 'none' - && proposal.mergeIntent?.kind !== 'commit-candidate') { + const commits=proposal.kind==='commit-state'; + if (!hasFields(mergeIntent,commits?['kind','requestId']:['kind']) + || mergeIntent.kind!==(commits?'commit-candidate':'none') + || (commits && typeof mergeIntent.requestId!=='string')) { throw failure('FK_I_INVALID_TRANSITION','invalid merge intent'); } return proposal; } +function certifiedProposal(cp, program, event, rawProposal) { + const {block,record} = confirmCurrentBlock(cp,program); + const proposal=validateProposalFields(rawProposal,block,record); + if (!block.eventKinds.includes(event.kind) + || proposal.fromBlockId !== cp.blockId || proposal.nodeId !== block.nodeId) { + throw failure('FK_I_INVALID_TRANSITION','Program returned uncertified proposal'); + } + if (proposal.stackEffect.kind==='push') { + const {frame}=proposal.stackEffect; + if (frame.kind!=='agent' || frame.nodeId!==block.nodeId || frame.phase!=='prepare' + || frame.attempt!==agentFrameRule(block.nodeId).initialAttempt + || frame.activation!==event.activation || frame.overviewStep!==event.activation + || !Array.isArray(frame.reasons) || frame.reasons.length) { + throw failure('FK_I_INVALID_TRANSITION','invalid initial Agent frame'); + } + } + return proposal; +} function applyStack(cp, effect) { const frames = copy(cp.controlStack); if (effect.kind === 'none') return frames; diff --git a/tests/interactive/coordinator.test.mjs b/tests/interactive/coordinator.test.mjs index f1c81cd..1d21304 100644 --- a/tests/interactive/coordinator.test.mjs +++ b/tests/interactive/coordinator.test.mjs @@ -8,7 +8,7 @@ import {analyzeSource} from '../../src/compiler/analyze.mjs'; import {lowerInteractive} from '../../src/compiler/interactive/lower.mjs'; import {emitInteractive} from '../../src/compiler/interactive/emit.mjs'; import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; -import {createInteractiveCoordinator} from '../../src/interactive/coordinator.mjs'; +import {createInteractiveCoordinator,validateProposalFields} from '../../src/interactive/coordinator.mjs'; import {canonicalBytes} from '../../src/interactive/canonical.mjs'; const dirs=[]; @@ -47,6 +47,32 @@ const bindings={ && typeof result.answer==='string' ? [] : ['answer must be string'], }; const check=(result, expected)=>assert.equal(result.status,expected,JSON.stringify(result)); + +test('proposal field validation enforces the P0-A discriminated shapes',()=>{ + const block={nodeId:'done',legalTargets:['next::enter'],proposalKinds:['move','terminal','raise-error']}; + const record={metadata:{status:'succeeded'}}; + const common={fromBlockId:'done::route',nodeId:'done',stackEffect:{kind:'none'},mergeIntent:{kind:'none'}}; + const move={...common,kind:'move',toBlockId:'next::enter'}; + const terminal={...common,kind:'terminal',terminal:{status:'succeeded',nodeId:'done'}}; + const raised={...common,kind:'raise-error',causeCode:'FK_R002'}; + for (const proposal of [move,terminal,raised]) { + assert.deepEqual(validateProposalFields(proposal,block,record),proposal); + } + for (const proposal of [ + {...move,unexpected:true}, + {...move,mergeIntent:{kind:'none',requestId:'unexpected'}}, + {...terminal,terminal:{status:'succeeded'}}, + {...raised,causeCode:null}, + ]) { + assert.throws(()=>validateProposalFields(proposal,block,record),{code:'FK_I_INVALID_TRANSITION'}); + } + const validateBlock={nodeId:'agent',phase:'validate',legalTargets:['agent::prepare','agent::commit'], + proposalKinds:['retry-agent','commit-candidate']}; + const commit={...common,nodeId:'agent',fromBlockId:'agent::validate',kind:'commit-candidate',toBlockId:'agent::commit'}; + assert.deepEqual(validateProposalFields(commit,validateBlock,{kind:'agent'}),commit); + assert.throws(()=>validateProposalFields({...commit,toBlockId:'agent::prepare'},validateBlock,{kind:'agent'}), + {code:'FK_I_INVALID_TRANSITION'}); +}); function coordinator(store,{runtime=bindings,policy={},maxStagesPerCall}={}) { return createInteractiveCoordinator({store,runtime,policy, ...(maxStagesPerCall===undefined?{}:{maxStagesPerCall})}); diff --git a/tests/interactive/p0a-ops.test.mjs b/tests/interactive/p0a-ops.test.mjs index 22eb0cd..9a19e61 100644 --- a/tests/interactive/p0a-ops.test.mjs +++ b/tests/interactive/p0a-ops.test.mjs @@ -7,6 +7,7 @@ import {createServer,request} from 'node:http'; 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 {sha256Bytes} from '../../src/interactive/canonical.mjs'; import {createSqliteCheckpointStore,createTrustedInteractiveHost} from '../../src/interactive/index.mjs'; @@ -171,7 +172,7 @@ test('bounded Outbox load sends capacity alarms to a loopback HTTP receiver',asy elapsedMs:Math.round(performance.now()-started),samples})); }); -test('real process termination immediately after durable Genesis allows idempotent reopening',async t=>{ +async function verifyGenesisCut(t,side) { const f=await fixture(t); const file=path.join(f.root,'crash.sqlite'); const moduleUrl=new URL('../../src/interactive/index.mjs',import.meta.url).href; @@ -179,25 +180,47 @@ test('real process termination immediately after durable Genesis allows idempote const worker=`import {writeSync} from 'node:fs'; import {createInteractiveCoordinator,createSqliteCheckpointStore} from ${JSON.stringify(moduleUrl)}; import {opsRuntime,opsStart} from ${JSON.stringify(fixtureUrl)}; -const [file,bytes,identity]=process.argv.slice(1); +const [file,bytes,identity,side]=process.argv.slice(1); const db=createSqliteCheckpointStore(file,{namespace:'genesis'}); const store={...db,createRun(args){ - db.createRun(args); - writeSync(1,'GENESIS_COMMITTED\\n'); + if (side==='after') db.createRun(args); + writeSync(1,JSON.stringify({side,runId:args.checkpoint.runId})+'\\n'); process.kill(process.pid,'SIGKILL'); }}; await createInteractiveCoordinator({store,runtime:opsRuntime}).start(opsStart({programBytes:Buffer.from(bytes,'base64'),programIdentity:JSON.parse(identity)},'stable-start'));`; const child=spawn(process.execPath,['--input-type=module','-e',worker,file,f.program.programBytes.toString('base64'), - JSON.stringify(f.program.programIdentity)],{stdio:['ignore','pipe','pipe']}); + JSON.stringify(f.program.programIdentity),side],{stdio:['ignore','pipe','pipe']}); let stdout='',stderr=''; child.stdout.on('data',chunk=>{stdout+=chunk;}); child.stderr.on('data',chunk=>{stderr+=chunk;}); const [exitCode,signal]=await once(child,'close'); - assert.equal(stdout.trim(),'GENESIS_COMMITTED',stderr); + assert.ok(stdout.trim(),stderr); + const cut=JSON.parse(stdout); + assert.equal(cut.side,side); + assert.ok(cut.runId); assert.ok(signal==='SIGKILL'||(process.platform==='win32'&&exitCode!==0)); const {createInteractiveCoordinator}=await import('../../src/interactive/index.mjs'); const store=createSqliteCheckpointStore(file,{namespace:'genesis'}); f.resources.push(store); + const checkpoint=store.load(cut.runId); + const journal=store.listJournal(cut.runId); + // R5 SQLite pins the artifact through the Run's foreign key. + const readback=new DatabaseSync(file,{readOnly:true}); + let retains; + try { + retains=readback.prepare('SELECT artifact_hash FROM fk_i_runs WHERE namespace=? AND run_id=?') + .all('genesis',cut.runId).map(row=>row.artifact_hash); + } finally {readback.close();} + if (side==='before') { + assert.ok(!checkpoint); + assert.deepEqual(journal,[]); + assert.deepEqual(retains,[]); + } else { + assert.equal(checkpoint.revision,0); + assert.equal(journal.length,1); + assert.equal(journal[0].revision,0); + assert.deepEqual(retains,[f.program.programIdentity.artifactHash]); + } const api=createInteractiveCoordinator({store,runtime:opsRuntime}); const first=await api.start(opsStart(f.program,'stable-start')); const second=await api.start(opsStart(f.program,'stable-start')); @@ -206,6 +229,11 @@ await createInteractiveCoordinator({store,runtime:opsRuntime}).start(opsStart({p assert.equal(second.pending.requestId,first.pending.requestId); assert.equal(second.revision,first.revision); assert.equal(store.load(first.runId).receiptCount,0); - console.log('P0A_OPS '+JSON.stringify({case:'genesis-after-commit',exitCode,signal,runId:first.runId, + if (side==='after') assert.equal(first.runId,cut.runId); + else assert.ok(!store.load(cut.runId)); + console.log('P0A_OPS '+JSON.stringify({case:'genesis-'+side+'-commit',exitCode,signal,runId:first.runId, revision:first.revision,journalRows:store.listJournal(first.runId).length})); -}); +} + +test('real process termination before Genesis leaves no Run or Journal',t=>verifyGenesisCut(t,'before')); +test('real process termination immediately after durable Genesis allows idempotent reopening',t=>verifyGenesisCut(t,'after')); From 90e20554ef5ae443fd56a662cea616723c956170 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 20:18:21 +0800 Subject: [PATCH 60/72] Record joint G12 adjudication and v1.0.0 package preflight --- .../p0a-g07-g12-20261113/g12-review.md | 4 ++ .../release-candidate-review.md | 49 +++++++++++++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 9 ++-- 3 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/p0a-g07-g12-20261113/release-candidate-review.md diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index c3b2488..14dd274 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -28,3 +28,7 @@ ## 恢复扫描与传输骤断复审 **G12 维持 BLOCKED。** 独立角色核对 `cfe47768194fb8bbaf7e817ee0efddb0e493116b` 的两个测试文件及 Windows/Linux 原始证据。已恢复库按已知 Run ID 发现一份待投递回执并复用原签名,复扫为空。真实模型工具回调已到达且尚未应答时,本地传输突然终止。Host 核对同一 Pending 和零新增回执。Linux 另核对 Model 进程退出码和 SQLite 旁读锁错误记录。实际首发的启动编排、身份与跨 Store 联动、外部值班、长时容量与备份系统,以及部署链路断线重连和供应商故障仍待验。 [本批证据与四项待提供输入](../p0a-codex-isolated-20261113/g12-recovery-transport-loss.md) + +## 首发候选联合裁决 + +**G12 维持 BLOCKED。** 独立角色复核 `c338cd46261757e598ae63b2738c9f9d03f08be8`,确认本批已修复提案字段白名单、分支阶段约束和 Agent 初始帧绑定,补了 Genesis 提交前的实测及提交后的 Program 引用断言。尚缺的门禁限于适用 P0 与冻结计划的未齐证据。供应商签名、可选 IPC 和额外运维能力按各自原条款及启用范围裁决。 [首发候选联合验收与 G17 预检](./release-candidate-review.md) diff --git a/docs/evidence/p0a-g07-g12-20261113/release-candidate-review.md b/docs/evidence/p0a-g07-g12-20261113/release-candidate-review.md new file mode 100644 index 0000000..1c59cce --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/release-candidate-review.md @@ -0,0 +1,49 @@ +# P0-A 首发候选联合验收 + +基线为 `cb55668e8b22a9753823399f54d7c58d050508ef`,代码候选为 `c338cd46261757e598ae63b2738c9f9d03f08be8`。代码增量 diff SHA-256 为 `7508c81c6d43acee1e09493e6fb46cb16f4a01ea2bcb03aef2c8cb7f91e2cb0d`。复核了 Requirements V0.3、Technical Design V0.6-R5、P0-A Plan V0.1 原件的字节数和 SHA-256,均符合 [G01 登记](../p0a-g01-20261112/verified-inputs.txt)。使用已确认的 Codex App Server 参考宿主范围。 [代码候选 CI 8/8](https://github.com/devcodex-labs/flowkit/actions/runs/43423047730) + +## 本批通过的项目 + +- **原生正向链。** E1/E10 的 Windows/Linux 普通双账户样本证明真实 JS、Model 工具调用与 JS 状态推进,含同一 Run 的四次 Agent 接纳。E2–E4/E8 证明双会话竞争、签名与 CAS 边界恢复。E11 已核对模型开始事件、回调及迟到、失配、超时的无写入行为。E18 证明回合中本地传输骤断与按 Run 扫描恢复。以上实测沿用各自记录的提交及环境,当前批次以程序回归和独立证据复核覆盖改动。 +- **R5 提案契约。** Coordinator 对生成提案先做纯数据快照,再按提案种类限制必需与允许字段,检查栈和合并形状。候选提交和重试分别绑定既有 `commit`、`prepare` 阶段,Agent 入栈初值绑定认证事件。包导出、生成 Program ABI、Store schema 和 Standard 入口沿用现有契约。 +- **T29 Genesis 前后。** Windows Node 22.13.0 与 24.22.2 的定向回归各 51/51。F01 在 Program 注册后、Genesis 写入前强停,SQLite 中无 Run、Journal 或 Run 的 Program 引用。同 startKey 随后只创建一个 Run。F02 在 Genesis 事务提交后强停,保留 revision 0、单条 Journal 和 Program 引用,同 startKey 复用 Run 与 Pending。已有程序测试另覆盖准备、发布、接纳、验证、提交、路由前后 12 个切点。此项为 P 级。 + +```powershell +$files = @('tests/interactive/coordinator.test.mjs','tests/interactive/crash.test.mjs','tests/interactive/p0a-ops.test.mjs','tests/interactive/p0a-staging-backup.test.mjs','tests/interactive/p0a-staging-ops.test.mjs','tests/interactive/complex-c01-c12.test.mjs') +volta run --node 22.13.0 node --test @files +node --test @files +``` + +Linux 先快进拉取 `cb55668e8b22a9753823399f54d7c58d050508ef`,既有专用环境的故障、并发和备份回归 8/8。随后快进至 `c338cd46261757e598ae63b2738c9f9d03f08be8`,使用普通 UID 1000 和 Node 24.22.1,新增回归 3/3、切点和并发回归 3/3。新增候选验证为 P 级程序回归。`FlowKit-Linux-Unified-Defensive-20261113.zip` SHA-256 为 `fee340a675f7f3d31f36305c40f8ca45d498bc5e943179688aa55ad82b0ceabf`,Linux 端核对归档完整性和 212 个载荷文件的哈希。 [Linux 命令、原始日志与归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +```sh +git pull --ff-only origin feat/p0a-interactive-20261112 +node --test --test-name-pattern 'Genesis|proposal field' tests/interactive/coordinator.test.mjs tests/interactive/p0a-ops.test.mjs +node --test tests/interactive/crash.test.mjs +``` + +## G12 独立裁决 + +**BLOCKED。** 独立角色复核了代码候选、冻结要求和原始证据。提案字段、分阶段目标、Agent 入栈与 Genesis 修正均经复核。发布时实际选择的 Windows/Linux 配置仍须覆盖下列适用 P0。 + +| 剩余门禁 | 收口条件 | +| --- | --- | +| SEC-02–06、08–09、11、15、17 | 在同一参考启动链完成真实 Model 隔离、启用入口的主体和租户授权、来源拒绝以及最小披露。Linux 需实际受限 Model 进程证据。已有测试进程与原生子样本可复用。 | +| SEC-10/T29、S02,T28/T30 | 核对实际选定宿主的组合恢复断言与完整符合性,保留 E19 的新增程序切点与提案校验。 | +| C12/S13/T27、NAT-01、06、07、10、11、12、14 | 真实正向闭环已证,完整来源、实际启用入口的授权、会话变更和故障语义仍待验证。真实供应商故障缺独立事件证据。 | + +当前参考配置使用受控 stdio,不启用 TLS/mTLS IPC 或 Named Pipe,对应的 SEC-07/16 为 N/A。NAT-13 的供应商可验证签名是能力探测。SEC-12/13/14/19 的 P1 运维项按首发声明和实际部署范围审查,适用的 P0 拒绝、隔离和恢复仍须通过。ChatGPT/Grok 专项和跨 OS 搬移 SQLite 物理库按各自范围登记。 + +## G17 发布准备 + +本地 `v1.0.0` 元数据预检、构建、打包和独立验包通过。候选 `devcodex-labs-flowkit-1.0.0.tgz` SHA-256 为 `9101a8c173aa2c3405a3c693dcf2c6e685954f28255c60669eb1af4ac3597934`。在新目录从同一 tarball 用 npm 缓存离线安装成功,Standard/Interactive 入口导入通过,安装后的 63 个文件哈希逐项与清单一致。原始在线文档消费者安装在本机超时并留档。CI 四矩阵各通过 13 个文档消费者场景,Windows 两份报告的 tarball SHA-256 与本地候选一致,Ubuntu 两份均为 `e47d0f2aa2db1fb0e0614e74f3326597baa61e2449651fe08d0fd111018e078d`。正式发布工作流以它构建并验证的包为准。 + +```powershell +npm run release:check -- --tag v1.0.0 +npm run build +npm run release:pack -- --tag v1.0.0 +node scripts/release.mjs verify --tag v1.0.0 +npm install --offline --ignore-scripts --no-audit --no-fund --save-exact ./devcodex-labs-flowkit-1.0.0.tgz +``` + +正式 G17 等 G12 放行及明确授权,届时按目标提交重新确认包身份并运行发布工作流和私有包安装验证。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 52d899b..cb7581a 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -26,6 +26,7 @@ | E16 | `8324e0c9006793cd89eb7c9f2df0db9f9cb9a6c9`、`3847277e5d6339a06e31f20fef576985e3bd1933` | Windows/Linux 隔离实验室联合预演。双平台普通审计员进程收件、受控 App Server EOF 和 R5 成对备份。三库撤销联动与独立进程持久化告警通过 Windows 实测和 Ubuntu CI。[命令、场景边界和归档哈希](../p0a-codex-isolated-20261113/staging-joint-r5.md) | | E17 | `a43ff75377ebe0938d8933074450feb62aba4c67` | Windows/Linux 隔离环境中主动取消正在运行的 Codex 模型回合,以及含 256 KiB 私有文件快照的三库恢复。[实测与首发环境缺口](../p0a-codex-isolated-20261113/g12-first-release-readiness.md) | | E18 | `cfe47768194fb8bbaf7e817ee0efddb0e493116b` | 恢复后按 Run ID 自动发现已签回执,真实模型回合等待工具时传输骤断,以及首发等价配置预演。[实测、命令和首发输入](../p0a-codex-isolated-20261113/g12-recovery-transport-loss.md) | +| E19 | `c338cd46261757e598ae63b2738c9f9d03f08be8` | 核对现有双平台证据、R5 提案字段与阶段校验、Genesis 前后强停、G17 本地包预检。[同批裁决](./release-candidate-review.md) | ## SEC · 生产配置与候选入口 @@ -40,7 +41,7 @@ | 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 后强杀恢复通过。E15 增补程序级 Genesis 后强杀恢复。发布 Pending 前后等其余适用切点按 T29 和故障矩阵核对,缺原生证据的切点保持 BLOCKED。 | +| SEC-10 | BLOCKED,选定宿主完整切点 | E2/E3/E4 的签名前、签后 CAS 前和 CAS 后强杀恢复通过,E8 覆盖真实接纳后校验恢复。E19 与既有进程测试覆盖 Genesis 前后及准备、发布 Pending、接纳、校验和提交前后的 SQLite 切点。选定宿主上的组合证据与剩余断言随 T29 裁决。 | | SEC-11 | BLOCKED,完整披露矩阵 | E1 使用含 Host 私有标记的任务并走公开投影,现有 P 级回归独立检查结果读取权限。补任务、工具回复、终态和生产日志全路径标记检查。 | | SEC-12 | BLOCKED,真实资源边界 | E1 文件入口两个大小通过。E7 内联 64 KiB 通过,两档更大内联无工具调用。补实际入口异常正文、限流/超时及内联回执上限分类。 | | SEC-13 | BLOCKED,完整替换负例 | E7 的文件替换、硬链接、符号链接或目录联接点有 P 级证据。E5 的旧策略拒绝有双平台原生结果。补实际启动链下 Program 和所有已启用配置入口的替换负例。 | @@ -94,19 +95,19 @@ | C13 | PASS,P 级分支与预算。 | | C14 | PASS,P 级链中错误与超限拒绝。原生入口负例归 SEC-08/12。 | | C15 | PASS,P 级历史重传、E3/E4 原生历史 Ack。E8 增补真实回执撤销后及清理后的受权历史对账样本。E9 文件快照清理强杀恢复后仍返回原 Ack。 | -| C16 | BLOCKED,P 级可信失败路径已有。缺真实供应商失败。 | +| C16 | PASS,按冻结场景的 P 级断言。可信 Host 签名失败回执、重试、原 Ack 和第二次响应成功已有回归。真实供应商失败另归 NAT-10。 | | 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 补 OS 身份,E11 补测试主体及租户错配。实际发布主体、模型自报权限、伪审批仍待取证。 | -| C23 | BLOCKED,E2/E3/E4 补真实竞争和崩溃恢复,E15 补结构合法但业务值无效的重试耗尽。真实供应商失败及组合证据仍待验证。 | +| C23 | BLOCKED,完整 N 级。真实拒绝分支 E10、重试耗尽 E15、强停恢复 E2–E4/E8、双会话竞争 E2 均有分项实测。完整原生故障与来源链按 C12 和 NAT 裁决。 | | C24 | BLOCKED,Windows/Linux × Node 22/24、包/CLI/类型和文档消费者 CI 已通过。完整原生证据、发布支持的升级与独立复审仍待完成。 | | 冻结条款或计划项 | 后续证据对 G01 的更新 | |---|---| -| T03、S02、T29 | E2–E4 补真实 Host 进程退出、同 Run 与原回执恢复。T29 的适用切点及 S02 的完整裁决仍待复审。 | +| T03、S02、T29 | E2–E4/E8 补真实 Host 进程退出、同 Run、原回执和已接纳候选续跑。E19 与既有切点测试记录 F01–F05、F07–F10 的程序级实测。实际选定宿主的组合验证与完整断言仍待裁决。 | | 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 升级组合仍待逐一裁决。 | From f391fd07ff777a72360726ba7fd091f9aad623f5 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 20:40:06 +0800 Subject: [PATCH 61/72] Assert each durable Interactive crash boundary precisely --- tests/interactive/crash.test.mjs | 75 ++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/tests/interactive/crash.test.mjs b/tests/interactive/crash.test.mjs index 49d8531..27b0e41 100644 --- a/tests/interactive/crash.test.mjs +++ b/tests/interactive/crash.test.mjs @@ -87,37 +87,49 @@ async function spawnAndKill({file,compiled,mode,phase,side,runId,receipt}) { } test('M4 real process SIGKILL before/after Agent enter, prepare and durable publish resumes once',async t=>{ - for(const phase of ['prepare','dispatch-ready','awaiting']){ - for(const side of ['before','after']){ + const phases=['enter','prepare','dispatch-ready','awaiting']; + for (const [index,phase] of phases.slice(1).entries()) { + for (const side of ['before','after']) { const {file,compiled,db,api}=await fixture(t); const runId='start-'+phase+'-'+side; await spawnAndKill({file,compiled,mode:'start',phase,side,runId}); const checkpoint=db.load(runId); - assert.ok(checkpoint); - assert.ok(['enter','prepare','dispatch-ready','awaiting'].includes(checkpoint.phase)); - const waiting=await api.resume({runId}); - assert.equal(waiting.status,'waiting'); - assert.equal(waiting.pending.attempt,1); - assert.equal(waiting.pending.nodeId,'worker'); - const again=await api.resume({runId}); - assert.equal(again.pending.requestId,waiting.pending.requestId); - assert.equal(again.revision,waiting.revision); - const done=await api.complete({ - runId,requestId:waiting.pending.requestId,nodeId:'worker',attempt:1, - result:{answer:'complete'}, - }); - assert.equal(done.status,'succeeded'); - assert.equal(done.state.answer,'complete'); - assert.equal(db.load(runId).receiptCount,1); - assert.deepEqual(db.listJournal(runId).map(x=>x.revision), - Array.from({length:done.revision+1},(_,i)=>i)); + const revision=index+(side==='after'?1:0); + assert.equal(checkpoint.phase,phases[revision]); + assert.equal(checkpoint.revision,revision); + assert.equal(checkpoint.receiptCount,0); + assert.equal(db.listJournal(runId).length,revision+1); + const published=checkpoint.phase==='awaiting'; + assert.equal(checkpoint.status,published?'waiting':'running'); + assert.equal(Boolean(checkpoint.pending),published); + const waiting=await api.resume({runId}); + assert.equal(waiting.status,'waiting'); + assert.equal(waiting.pending.attempt,1); + assert.equal(waiting.pending.nodeId,'worker'); + if (published) { + assert.equal(waiting.pending.requestId,checkpoint.pending.requestId); + assert.equal(waiting.revision,checkpoint.revision); + } + const again=await api.resume({runId}); + assert.equal(again.pending.requestId,waiting.pending.requestId); + assert.equal(again.revision,waiting.revision); + const done=await api.complete({ + runId,requestId:waiting.pending.requestId,nodeId:'worker',attempt:1, + result:{answer:'complete'}, + }); + assert.equal(done.status,'succeeded'); + assert.equal(done.state.answer,'complete'); + assert.equal(db.load(runId).receiptCount,1); + assert.deepEqual(db.listJournal(runId).map(x=>x.revision), + Array.from({length:done.revision+1},(_,i)=>i)); } } }); test('M4 real process SIGKILL before/after receipt acceptance, validation and state commit',async t=>{ - for(const phase of ['validate','commit','route']){ - for(const side of ['before','after']){ + const phases=['awaiting','validate','commit','route']; + for (const [index,phase] of phases.slice(1).entries()) { + for (const side of ['before','after']) { const {file,compiled,db,api}=await fixture(t); const runId='complete-'+phase+'-'+side; const waiting=await api.start({ @@ -131,7 +143,24 @@ test('M4 real process SIGKILL before/after receipt acceptance, validation and st }; await spawnAndKill({file,compiled,mode:'complete',phase,side,runId,receipt}); const observed=db.load(runId); - assert.ok(['waiting','running'].includes(observed.status)); + const stage=index+(side==='after'?1:0); + const revision=waiting.revision+stage; + assert.equal(observed.phase,phases[stage]); + assert.equal(observed.revision,revision); + assert.equal(observed.status,stage===0?'waiting':'running'); + assert.equal(db.listJournal(runId).length,revision+1); + assert.equal(observed.receiptCount,stage===0?0:1); + const indexed=db.getReceipt(runId,waiting.pending.requestId); + assert.equal(indexed?.acceptedRevision,stage===0?undefined:waiting.revision+1); + if (stage===0) assert.equal(observed.pending.requestId,waiting.pending.requestId); + else { + assert.equal(observed.pending,null); + if (stage===1) { + assert.equal(observed.receivedSubmission.requestId,waiting.pending.requestId); + assert.equal(observed.receivedSubmission.acceptedRevision,indexed.acceptedRevision); + } + } + assert.equal(observed.state.answer,stage===3?'once':undefined); let finished; if(observed.status==='waiting') finished=await api.complete(receipt); else finished=await api.resume({runId}); From 88fd1324716f819a5f02feb2926f45c1bc3995e1 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 20:42:27 +0800 Subject: [PATCH 62/72] Record selected stdio P0 acceptance and residual G12 gates --- .../p0a-g07-g12-20261113/g12-review.md | 4 ++ .../p0a-g07-g12-20261113/release-matrix.md | 11 ++-- .../p0a-g07-g12-20261113/selected-stdio-p0.md | 53 +++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 docs/evidence/p0a-g07-g12-20261113/selected-stdio-p0.md diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index 14dd274..3886a15 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -32,3 +32,7 @@ ## 首发候选联合裁决 **G12 维持 BLOCKED。** 独立角色复核 `c338cd46261757e598ae63b2738c9f9d03f08be8`,确认本批已修复提案字段白名单、分支阶段约束和 Agent 初始帧绑定,补了 Genesis 提交前的实测及提交后的 Program 引用断言。尚缺的门禁限于适用 P0 与冻结计划的未齐证据。供应商签名、可选 IPC 和额外运维能力按各自原条款及启用范围裁决。 [首发候选联合验收与 G17 预检](./release-candidate-review.md) + +## 首发受控 stdio P0 复审 + +**G12 维持 BLOCKED。** 已按用户选定的 Windows/Linux 受控 stdio 配置审查。独立角色核对 `90e20554ef5ae443fd56a662cea616723c956170` 的双平台原生回调、签名、两租户并发及拒绝记录,SEC-15 通过。复核 `f391fd07ff777a72360726ba7fd091f9aad623f5` 的 T29 测试增量,确认接纳前后按同一请求核对持久状态。实际 Model 完整权限、同链入口授权和披露、原生异常时序及供应商故障仍待取证。 [实测、原始归档与剩余 P0](./selected-stdio-p0.md) diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index cb7581a..74b1c94 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -27,6 +27,7 @@ | E17 | `a43ff75377ebe0938d8933074450feb62aba4c67` | Windows/Linux 隔离环境中主动取消正在运行的 Codex 模型回合,以及含 256 KiB 私有文件快照的三库恢复。[实测与首发环境缺口](../p0a-codex-isolated-20261113/g12-first-release-readiness.md) | | E18 | `cfe47768194fb8bbaf7e817ee0efddb0e493116b` | 恢复后按 Run ID 自动发现已签回执,真实模型回合等待工具时传输骤断,以及首发等价配置预演。[实测、命令和首发输入](../p0a-codex-isolated-20261113/g12-recovery-transport-loss.md) | | E19 | `c338cd46261757e598ae63b2738c9f9d03f08be8` | 核对现有双平台证据、R5 提案字段与阶段校验、Genesis 前后强停、G17 本地包预检。[同批裁决](./release-candidate-review.md) | +| E20 | `90e20554ef5ae443fd56a662cea616723c956170`、`f391fd07ff777a72360726ba7fd091f9aad623f5` | Windows/Linux 首发受控 stdio 的原生租户绑定与回执复核,以及 12 个恢复切点的逐点持久化断言。[首发 P0 验收](./selected-stdio-p0.md) | ## SEC · 生产配置与候选入口 @@ -36,17 +37,17 @@ | 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,发布入口授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E14 补独立 OS 对端发送命令及撤权,E16 验证审计结果实际返回普通对端进程。最终部署的操作员接入、真实 Model 回执与授权联动仍待验证。 | +| SEC-05 | BLOCKED,启用入口完整授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E14/E16 补普通 OS 客户端与审计收件,E20 在选定 stdio 验证实际模型身份及回调授权。操作员创建、同一 Run 的真实 Model 回执及审计客户端交付仍需联证。 | | 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 后强杀恢复通过,E8 覆盖真实接纳后校验恢复。E19 与既有进程测试覆盖 Genesis 前后及准备、发布 Pending、接纳、校验和提交前后的 SQLite 切点。选定宿主上的组合证据与剩余断言随 T29 裁决。 | -| SEC-11 | BLOCKED,完整披露矩阵 | E1 使用含 Host 私有标记的任务并走公开投影,现有 P 级回归独立检查结果读取权限。补任务、工具回复、终态和生产日志全路径标记检查。 | +| SEC-10 | BLOCKED,选定宿主完整切点 | E2/E3/E4 的签名前、签后 CAS 前和 CAS 后强杀恢复通过,E8 覆盖真实接纳后校验恢复。E19/E20 补 Genesis 前后与 12 个 SQLite 切点逐点验证。选定宿主上的组合证据随 T29 裁决。 | +| SEC-11 | BLOCKED,完整披露矩阵 | E1/E10 覆盖任务、工具回复、终态投影,E20 复核 E10 原始 Model 协议无 Host 私有标记。完整日志可见性与授权审计的同链覆盖仍缺。 | | SEC-12 | BLOCKED,真实资源边界 | E1 文件入口两个大小通过。E7 内联 64 KiB 通过,两档更大内联无工具调用。补实际入口异常正文、限流/超时及内联回执上限分类。 | | SEC-13 | BLOCKED,完整替换负例 | E7 的文件替换、硬链接、符号链接或目录联接点有 P 级证据。E5 的旧策略拒绝有双平台原生结果。补实际启动链下 Program 和所有已启用配置入口的替换负例。 | | SEC-14 | BLOCKED,生产轮换与撤销 | E4/E5/E8/E9 覆盖正常轮换、原 Ack 和分别撤销。E15/E16 补三个测试 Store 的部分撤销、测试入口阻断及逐库确认后的恢复。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | -| SEC-15 | BLOCKED,最终发布入口 | E12 实测同一 SQLite 文件内两个租户同 startKey、同 RunId 的 stdio 和文件回调并发,独立回执和 Journal。E16 复验普通客户端收件、撤权、重连。发布入口的主体认证和会话生命周期仍待验证。 | +| SEC-15 | PASS,已选 Windows/Linux 受控 stdio | E20 两个经 OS 核验的主体与租户使用同 startKey、同 RunId 并发接纳,独立回执和 Journal,错租户拒绝且状态不变。完整同链授权与审计交付归 SEC-05/11。 | | 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。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | @@ -71,7 +72,7 @@ | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | -| NAT-14 | BLOCKED,发布时序完整裁决 | E1/E2/E3 正常路径匹配原生开始和回调后签收。E10 补扣留索引。E11 对真实回调注入 Host 侧的开始观察迟到、缺失与参数失配,按有界等待拒绝且零签名、零 CAS。供应商原生故障与最终部署等待策略仍待验证。 | +| NAT-14 | BLOCKED,选定入口的异常时序 | E20 证实选定 OS 绑定 stdio 的两份原生签前事件匹配。E11 在独立回调路径验证迟到、缺失、失配和有界等待拒绝,零签名、零 CAS。仍需验证选定入口同类异常的有界退出和恢复。 | | NAT-15 | PASS,双平台 Codex 实测样本 | E2 用原签名、回执和同一 Run 恢复签后 CAS 前切点。E3 增补文件引用与 CAS 后 Ack 修复,E4 增补真实过期拒绝。完整 N 级随 C12/S13 的组合证据复审。 | ## C01–C24 与阶段收口 diff --git a/docs/evidence/p0a-g07-g12-20261113/selected-stdio-p0.md b/docs/evidence/p0a-g07-g12-20261113/selected-stdio-p0.md new file mode 100644 index 0000000..3a298d0 --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/selected-stdio-p0.md @@ -0,0 +1,53 @@ +# 首发受控 stdio 配置的 G12 P0 验收 + +首发采用现有 Windows/Linux Codex App Server 受控 stdio,Host 和 Model 以独立普通账户运行。Windows 通过固定回环 SSH 命令和标准流句柄白名单启动,Linux 使用固定启动链和受限 UID/GID。操作员和审计员由受信监督器依据实际 OS 身份绑定。当前配置没有候选 TLS/mTLS IPC 或 Named Pipe,SEC-07/16 为 N/A。启用的 stdio 路径仍按 SEC-05/06 验收。 + +原生实测提交为 `90e20554ef5ae443fd56a662cea616723c956170`,[起始 CI 8/8](https://github.com/devcodex-labs/flowkit/actions/runs/43424055261)。三份冻结原件与附属材料的字节数和 SHA-256 已按 [G01 登记](../p0a-g01-20261112/verified-inputs.txt) 复核。 + +## Windows 实测 + +- `os-stdio` 在固定启动链完成 4 个真实 Codex 回合。核对不同 Host/Model SID、Model 非提升权限及继承的标准流白名单。两个操作员的租户共享 RunId 和 startKey,合法回执分别在 revision 4 接纳,两个 Run 均到达 revision 9。错主体和错租户均返回统一拒绝,零新增签名和 CAS,Checkpoint、Journal、接纳索引不变。 +- 离线复核两份原生 tool start/callback、规范参数和 Ed25519 签名,关联两条共 20 行 Journal。该探针中的审计操作由 Host 依据预先核对的身份执行。另用真实普通操作员和审计员的 stdio 会话测试了回复交付、跨租户拒绝、撤权后旧会话失效及新会话恢复,6 个子进程退出 0。两项实测覆盖各自路径的授权,完整的同一 Run 从操作员创建、Model 回执到审计客户端收件仍待证明。 +- 复核 E10 四个场景的 8 份 Model 侧原始协议文件,Host 私有合成标记均未出现。完整日志可见性与授权审计路径仍按 SEC-11 裁决。回环监听收尾计数为 0。 + +```powershell +# windows-run-os.ps1 在普通 Host 身份下启动 +node tests/security/codex-flowkit-probe.mjs <新Host私有目录> os-stdio isolated +node <归档目录>/verify-windows.mjs <归档目录> +``` + +`FlowKit-Windows-G12-Selected-Stdio.zip` SHA-256 为 `f5465f52b803e0acaca9b4ac3ff67dd885c9acf9976d2bfd4981e62d9a6404c0`,18 项逐一核对。归档包含实际启动、原始协议、监督器与客户端收件、离线验签及退出状态。证据为 P/M/T。 + +## 程序级故障恢复 + +测试提交 `f391fd07ff777a72360726ba7fd091f9aad623f5`,相对原生实测提交的测试 Diff SHA-256 为 `562e1430017b1cbc1cc2698be6b1adb34229fd20fae16086adbd2362985ffc1`。现有 T29 的 12 个真实子进程强停切点增加逐点断言。发布前无已发布 Pending,发布后复用原 requestId 和 revision。接纳前保留原 Pending,接纳后按原 requestId、receivedSubmission 和接纳 revision 核对回执索引。候选提交前后核对 State,每个切点检查 revision 和 Journal 数。Windows Node 22.13.0 和 24.22.2 各通过 9/9,包括既有 Genesis 前后与会话回归。增强范围为 P 级,Host/Model 组合证据沿用 E2–E4、E8、E18。 + +```powershell +$files = @('tests/interactive/crash.test.mjs','tests/interactive/p0a-ops.test.mjs','tests/interactive/codex-os-access.test.mjs') +volta run --node 22.13.0 node --test @files +node --test @files +``` + +## Linux 实测 + +快进拉取同一提交后,固定启动链上的 `os-stdio isolated` 首次退出 0,耗时 16.86 秒。1 个真实 App Server、3 个线程、4 个回合,Host/Model UID 分别为 41001/41002。两租户的有效签名各在 revision 4 接纳,终态均为 revision 9。错主体和错租户均零新增签名、零 CAS;已核对 Journal、审计投影和 EOF 后无遗留进程。离线核验首次把登录状态误判为仅在 stdout,改为检查归档的两路输出后通过,模型验收未重跑。归档保留 bubblewrap/用户命名空间诊断,Codex 内部沙箱和实际 Shell 权限仍待验证。 + +```sh +git pull --ff-only origin feat/p0a-interactive-20261112 +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-release-stdio-controller.mjs attempt-1 +``` + +`FlowKit-Linux-Release-Stdio-20261113.zip` SHA-256 为 `05ea40389d319811720f9d5450995744694a9b2be7b7ada16bf839a0a400418f`。Linux 端核对 165 个载荷文件、ZIP 完整性及逐项哈希。证据为 P/M/T。 [Linux 原始命令、日志和归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +## 独立复审与尚缺 P0 + +**G12 维持 BLOCKED。** 独立复审核对了两端的原生证据和新增恢复断言。SEC-15 按选定 stdio 的两个 OS 主体/两个租户、同 RunId 与 startKey、并发接纳和错租户拒绝通过。供应商故障属于 NAT-10;缺可验证事件时保持 BLOCKED。NAT-13 的供应商签名为可选探测。此次裁决按已选的受控 stdio 与 R5 SQLite 参考配置,外部身份系统、监听 IPC 和额外生产运维能力无需成为该配置的前置条件。 + +| 仍缺的条款 | 本批已补与待验证 | +| --- | --- | +| SEC-02–04、Linux SEC-17 | E6 与既有 CI 提供普通进程隔离证据。本批记录真实 Model 身份与标准流白名单。完整的实际 Model 句柄/描述符和权限裁决仍待证据。 | +| SEC-05/06、08/09、11;NAT-01/06/07/11 | 两租户的原生回调授权、无写入拒绝和 Model 协议最小披露有证据。完整的启用入口、实际来源拒绝、授权审计交付与披露矩阵仍缺。 | +| SEC-10/T29、S02;T28/T30 | 本批补齐逐切点程序断言。正式选定宿主的组合恢复证据和冻结条款的完整裁决仍开放。 | +| NAT-10/12/14、C12/S13/T27 | 有正向签前事件顺序、受控骤断和同一 Pending 恢复。E11 的迟到、缺失、失配及超时使用单独的回调路径,所选 OS 绑定路径仍待有界拒绝证据。实际会话变更和重新授权、供应商故障与完整来源链仍待证。 | + +若能提供一次真实 Codex 供应商故障的原始记录或供应商支持的验证入口,可用于独立复核 NAT-10。首发 stdio 选择已确认。正式发布等适用的 G12 门禁放行与明确授权。 From 20a621ba2ecff3ba574804933571027ac13c0321 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 21:18:43 +0800 Subject: [PATCH 63/72] Reject unobserved stdio callbacks without stopping the acceptance probe --- .../codex-os-tool-observations.test.mjs | 37 +++++++++++++++++++ tests/security/codex-flowkit-probe.mjs | 22 ++++++++--- tests/security/codex-os-tool-observations.mjs | 24 ++++++++++++ 3 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 tests/interactive/codex-os-tool-observations.test.mjs create mode 100644 tests/security/codex-os-tool-observations.mjs diff --git a/tests/interactive/codex-os-tool-observations.test.mjs b/tests/interactive/codex-os-tool-observations.test.mjs new file mode 100644 index 0000000..eff5b36 --- /dev/null +++ b/tests/interactive/codex-os-tool-observations.test.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {createOsToolObservations} from '../security/codex-os-tool-observations.mjs'; + +const turn={threadId:'thread',turnId:'turn'}; +const args={requestId:'request',answer:'ready'}; +const tool='flowkit_submit'; +const start=(id,input=args)=>({...turn,item:{id,tool,arguments:input}}); +const callback=(callId,input=args)=>({...turn,callId,tool,arguments:input}); + +test('OS-bound tool calls require a matching start recorded before the callback',()=>{ + const observations=createOsToolObservations(); + assert.equal(observations.consume(callback('missing')),null); + observations.record(start('missing')); + assert.equal(observations.consume(callback('missing')),null); + + observations.record(start('different',{...args,answer:'other'})); + assert.equal(observations.consume(callback('different')),null); + + observations.record(start('valid')); + assert.deepEqual(observations.consume(callback('valid',{answer:'ready',requestId:'request'})), + {...turn,tool,arguments:args}); + assert.equal(observations.consume(callback('valid')),null); +}); + +test('observation state belongs to one turn and preserves the first start',()=>{ + const observations=createOsToolObservations(); + observations.record(start('call')); + observations.record(start('call',{...args,answer:'other'})); + assert.deepEqual(observations.consume(callback('call')),{...turn,tool,arguments:args}); + + observations.record(start('other-turn')); + assert.equal(observations.consume({...callback('other-turn'),turnId:'different'}),null); + const next=createOsToolObservations(); + next.record(start('call')); + assert.ok(next.consume(callback('call'))); +}); diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index e6630de..1ea0a6c 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -17,6 +17,7 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; import {createCodexOsAccess} from '../../examples/interactive/codex-os-access.mjs'; import {serveCodexOsSession} from '../../examples/interactive/codex-os-stdio.mjs'; +import {createOsToolObservations} from './codex-os-tool-observations.mjs'; import {PassThrough} from 'node:stream'; const [exe,cwd,out,requested='loop',profile='direct',scenarioDir,answer,cut,scenarioBytes]=process.argv.slice(2); @@ -396,6 +397,11 @@ function denyEntry(code) { } function captureEntryStart(p) { + if (osEntry) { + const turn=osTurns.get(p.threadId); + if (turn && (turn.id===undefined || turn.id===p.turnId)) turn.observations.record(p); + return; + } const observation={threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,arguments:p.item.arguments}; const callId=p.item.id; if (entryCase==='timeout') return; @@ -466,11 +472,14 @@ async function osEntryCallback(message) { const p=message.params; const turn=osTurns.get(p.threadId); const context=entrySessions.get(p.threadId); - const started=entryObservations.get(p.callId); - assert.ok(turn && context?.modelPid===isolatedIdentity.pid && turn.id===p.turnId && started - && started.threadId===p.threadId && started.turnId===p.turnId && started.tool===p.tool - && !completed.has(p.callId) && canonicalBytes(started.arguments).equals(canonicalBytes(p.arguments))); - entryObservations.delete(p.callId); + const sameTurn=turn && isolatedIdentity && context?.modelPid===isolatedIdentity.pid && turn.id===p.turnId; + const started=sameTurn && !completed.has(p.callId) ? turn.observations.consume(p) : null; + if (!started) { + journal('os-observation-denied',{callId:p.callId,threadId:p.threadId,turnId:p.turnId}); + send({id:message.id,result:{success:false,contentItems:[{type:'inputText', + text:JSON.stringify({accepted:false,code:'REQUEST_DENIED'})}]}}); + return; + } turn.calls.push(p.tool); const target=entryResources.get(p.arguments.requestId); let result; @@ -778,7 +787,8 @@ async function bindEntrySubject(binding,model) { async function osTurn(session,target,label,prompt,denial=false) { const calls=[]; - const done=new Promise(resolve=>osTurns.set(session.threadId,{label,resolve,target,denial,calls})); + const done=new Promise(resolve=>osTurns.set(session.threadId, + {label,resolve,target,denial,calls,observations:createOsToolObservations()})); const current=osTurns.get(session.threadId); const {turn}=await request('turn/start',{threadId:session.threadId,input:[{type:'text',text:prompt}]}); current.id=turn.id; diff --git a/tests/security/codex-os-tool-observations.mjs b/tests/security/codex-os-tool-observations.mjs new file mode 100644 index 0000000..de09722 --- /dev/null +++ b/tests/security/codex-os-tool-observations.mjs @@ -0,0 +1,24 @@ +import {canonicalBytes} from '../../src/interactive/canonical.mjs'; + +/** Track trusted start events for one OS-bound model turn. */ +export function createOsToolObservations() { + const entries=new Map(); + return { + record({threadId,turnId,item}) { + if (!entries.has(item.id)) { + entries.set(item.id,{threadId,turnId,tool:item.tool,arguments:item.arguments}); + } + }, + consume({threadId,turnId,tool,callId,arguments:args}) { + const started=entries.get(callId); + // A callback without a trusted start cannot become valid later. + entries.set(callId,null); + if (!started || started.threadId!==threadId || started.turnId!==turnId || started.tool!==tool) return null; + try { + return canonicalBytes(started.arguments).equals(canonicalBytes(args))?started:null; + } catch { + return null; + } + }, + }; +} From d6bd8d839fe33d344748ee5d889f4bd901cb958f Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 21:22:22 +0800 Subject: [PATCH 64/72] Bind OS probe turn before processing subsequent stdio lines --- tests/security/codex-flowkit-probe.mjs | 32 ++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 1ea0a6c..09c2d3b 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -145,9 +145,12 @@ function send(message) { record('app-server.requests.ndjson',JSON.stringify(message)+'\n'); child.stdin.write(JSON.stringify(message)+'\n'); } -function request(method,params) { +function request(method,params,beforeResolve) { const id=++seq; - return new Promise((resolve,reject)=>{pending.set(id,{resolve,reject});send({id,method,params});}); + return new Promise((resolve,reject)=>{ + pending.set(id,{resolve,reject,beforeResolve}); + send({id,method,params}); + }); } async function prepareFlowKit() { @@ -399,7 +402,7 @@ function denyEntry(code) { function captureEntryStart(p) { if (osEntry) { const turn=osTurns.get(p.threadId); - if (turn && (turn.id===undefined || turn.id===p.turnId)) turn.observations.record(p); + if (turn?.id && turn.id===p.turnId) turn.observations.record(p); return; } const observation={threadId:p.threadId,turnId:p.turnId,tool:p.item.tool,arguments:p.item.arguments}; @@ -636,8 +639,12 @@ lines?.on('line',line=>{ 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); + if (!waiter) return; + try { + if (message.error) throw new Error(JSON.stringify(message.error)); + waiter.beforeResolve?.(message.result); + waiter.resolve(message.result); + } catch (error) {waiter.reject(error);} return; } const p=message.params; @@ -688,7 +695,11 @@ lines?.on('line',line=>{ } else if(message.id!==undefined && message.method) { send({id:message.id,error:{code:-32601,message:'Unexpected server request'}}); } else if(message.method==='turn/completed') { - if (osEntry) {osTurns.get(p.threadId)?.resolve(p.turn);return;} + if (osEntry) { + const turn=osTurns.get(p.threadId); + if (turn?.id && turn.id===p.turn.id) turn.resolve(p.turn); + return; + } turnStatus=p.turn.status; if (p.turn.error) failure=p.turn.error.message; if (branch||entry) branchTurnDone(); @@ -790,9 +801,12 @@ async function osTurn(session,target,label,prompt,denial=false) { const done=new Promise(resolve=>osTurns.set(session.threadId, {label,resolve,target,denial,calls,observations:createOsToolObservations()})); const current=osTurns.get(session.threadId); - const {turn}=await request('turn/start',{threadId:session.threadId,input:[{type:'text',text:prompt}]}); - current.id=turn.id; - journal('os-turn',{label,threadId:session.threadId,turnId:turn.id,subject:session.subject,namespace:session.namespace}); + await request('turn/start',{threadId:session.threadId,input:[{type:'text',text:prompt}]},({turn})=>{ + assert.ok(typeof turn?.id==='string' && turn.id.length>0); + // Bind the response before processing another line in the same stdio chunk. + current.id=turn.id; + journal('os-turn',{label,threadId:session.threadId,turnId:turn.id,subject:session.subject,namespace:session.namespace}); + }); assert.equal((await done).status,'completed'); osTurns.delete(session.threadId); assert.deepEqual(calls,osFile&&!denial?['flowkit_file_task','flowkit_submit_file']:[osFile?'flowkit_submit_file':'flowkit_submit']); From e4d6128a9fe9618313dbc966638840e6e54eb03f Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 21:27:42 +0800 Subject: [PATCH 65/72] Record selected stdio observation regression and Linux sandbox limits --- .../p0a-g07-g12-20261113/g12-review.md | 4 ++ .../p0a-g07-g12-20261113/release-matrix.md | 5 ++- .../stdio-observation-recovery.md | 41 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/p0a-g07-g12-20261113/stdio-observation-recovery.md diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index 3886a15..13496aa 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -36,3 +36,7 @@ ## 首发受控 stdio P0 复审 **G12 维持 BLOCKED。** 已按用户选定的 Windows/Linux 受控 stdio 配置审查。独立角色核对 `90e20554ef5ae443fd56a662cea616723c956170` 的双平台原生回调、签名、两租户并发及拒绝记录,SEC-15 通过。复核 `f391fd07ff777a72360726ba7fd091f9aad623f5` 的 T29 测试增量,确认接纳前后按同一请求核对持久状态。实际 Model 完整权限、同链入口授权和披露、原生异常时序及供应商故障仍待取证。 [实测、原始归档与剩余 P0](./selected-stdio-p0.md) + +## 受控 stdio 观察回归复审 + +**G12 维持 BLOCKED。** 独立角色确认 `d6bd8d839fe33d344748ee5d889f4bd901cb958f` 已同步绑定匹配响应的回合 ID,后续完成事件按线程和回合匹配。缺失、失配和迟到观察的拒绝条件有定向单测。现有探针在异常拒绝后仍可能让当次验收失败,同一 Pending 在同一 App Server 中恢复尚无证据。Linux Model 进程隔离和实际命令沙箱分开裁决。 [本批复审、实测与剩余缺口](./stdio-observation-recovery.md) diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 74b1c94..522ba49 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -28,6 +28,7 @@ | E18 | `cfe47768194fb8bbaf7e817ee0efddb0e493116b` | 恢复后按 Run ID 自动发现已签回执,真实模型回合等待工具时传输骤断,以及首发等价配置预演。[实测、命令和首发输入](../p0a-codex-isolated-20261113/g12-recovery-transport-loss.md) | | E19 | `c338cd46261757e598ae63b2738c9f9d03f08be8` | 核对现有双平台证据、R5 提案字段与阶段校验、Genesis 前后强停、G17 本地包预检。[同批裁决](./release-candidate-review.md) | | E20 | `90e20554ef5ae443fd56a662cea616723c956170`、`f391fd07ff777a72360726ba7fd091f9aad623f5` | Windows/Linux 首发受控 stdio 的原生租户绑定与回执复核,以及 12 个恢复切点的逐点持久化断言。[首发 P0 验收](./selected-stdio-p0.md) | +| E21 | `d6bd8d839fe33d344748ee5d889f4bd901cb958f` | 选定 stdio 的观察拒绝与回合绑定回归,以及常规原生回合复验。[分平台结果、复审与归档](./stdio-observation-recovery.md) | ## SEC · 生产配置与候选入口 @@ -49,7 +50,7 @@ | SEC-14 | BLOCKED,生产轮换与撤销 | E4/E5/E8/E9 覆盖正常轮换、原 Ack 和分别撤销。E15/E16 补三个测试 Store 的部分撤销、测试入口阻断及逐库确认后的恢复。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | | SEC-15 | PASS,已选 Windows/Linux 受控 stdio | E20 两个经 OS 核验的主体与租户使用同 startKey、同 RunId 并发接纳,独立回执和 Journal,错租户拒绝且状态不变。完整同链授权与审计交付归 SEC-05/11。 | | SEC-16 | N/A,当前 Windows 配置无 Named Pipe 候选入口 | 当前使用标准流。若发行选择 Named Pipe,按原矩阵重新裁决。 | -| SEC-17 | BLOCKED,Linux 实际 CLI 拒绝 | E6 记录受限进程的 47 项文件和进程权限拒绝,E1 记录真实 Codex 同 PID 启动、不同 UID/GID 和零能力。补真实 CLI 的相应拒绝日志。Windows 不适用。 | +| SEC-17 | BLOCKED,Linux 实际 CLI 拒绝 | E6 有 47 项受限进程拒绝。E21 复核实际 Model 身份、零能力和 `no_new_privs=1`。普通命令沙箱因无法创建用户命名空间退出 1,实际 Model 描述符类型观察受限。Windows 不适用。 | | SEC-18 | PASS,双平台 Outbox 样本 | E2/E3/E4 覆盖签名前、签后 CAS 前及 CAS 后 `ACKED` 前的真实强杀、原字节验签与对账、未接纳过期拒绝和仍受信的历史 Ack。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | | SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E16 补 128 次接纳、八轮满额与清理、独立回环进程持久化告警。E17/E18 补三库联合恢复、按 Run 自动发现及二次扫描幂等。生产保留期限、持续容量、值班端送达及实际备份系统和启动编排仍待取证。 | @@ -72,7 +73,7 @@ | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | -| NAT-14 | BLOCKED,选定入口的异常时序 | E20 证实选定 OS 绑定 stdio 的两份原生签前事件匹配。E11 在独立回调路径验证迟到、缺失、失配和有界等待拒绝,零签名、零 CAS。仍需验证选定入口同类异常的有界退出和恢复。 | +| NAT-14 | BLOCKED,选定入口的异常时序 | E20 证实选定 OS 绑定 stdio 的原生签前事件匹配。E11 在独立回调路径验证迟到、缺失、失配和有界等待拒绝,零签名、零 CAS。E21 补选定 stdio 探针的即时拒绝回归及同步回合绑定。选定路径发生异常后有界结束、同一 Pending 后续接纳仍缺实测。 | | NAT-15 | PASS,双平台 Codex 实测样本 | E2 用原签名、回执和同一 Run 恢复签后 CAS 前切点。E3 增补文件引用与 CAS 后 Ack 修复,E4 增补真实过期拒绝。完整 N 级随 C12/S13 的组合证据复审。 | ## C01–C24 与阶段收口 diff --git a/docs/evidence/p0a-g07-g12-20261113/stdio-observation-recovery.md b/docs/evidence/p0a-g07-g12-20261113/stdio-observation-recovery.md new file mode 100644 index 0000000..a337606 --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/stdio-observation-recovery.md @@ -0,0 +1,41 @@ +# 受控 stdio 工具观察回归 + +起始提交为 `88fd1324716f819a5f02feb2926f45c1bc3995e1`,代码候选为 `d6bd8d839fe33d344748ee5d889f4bd901cb958f`。`code-increment.diff` SHA-256 为 `464dda730193a38e1bb4700c3365db92f5afa6192201d31e7d17c6261af9e45f`。Requirements V0.3、Technical Design V0.6-R5、P0-A V0.1 和三份附属材料的字节数与 SHA-256 均与 [G01 冻结登记](../p0a-g01-20261112/verified-inputs.txt) 一致。本批修改限于验收探针和回归测试。 + +## 探针行为与复审 + +Host 按已绑定的线程和回合记录首个可信工具开始事件。回调缺对应观察、字段或规范化参数失配,或同一回调已处理时,探针先返回统一的 `REQUEST_DENIED`。回调先到时,该回调 ID 不会因迟到事件重新取得资格。拒绝在资源查找、签名和 Store 写入之前返回。定向单测检查缺观察、迟到、失配、重复和下一回合的有效观察。 + +独立复审发现,同一批 stdio 数据中的回合启动响应和后续事件可能在 Promise 继续执行前一起被处理。代码候选已在处理匹配的 `turn/start` 响应时同步绑定回合 ID,并要求完成事件匹配同一线程及回合。复审确认现有请求响应路径保持行为。当前按已绑定的回合接收观察,若 App Server 在启动响应前发出工具开始事件,探针会拒绝相应回调。该顺序是否实际出现仍待验证。 + +## Windows + +真实固定启动链上的 `os-stdio isolated` 退出码 0。独立离线校验确认四个完成回合,两个合法回执的签名和 Journal 链通过,错主体和错租户各拒绝一次且零新增签名、零 CAS,回环监听清理后为 0。已部署的三个探针文件与代码候选 SHA-256 一致。证据为 P/M/T,覆盖常规入口回归。Node 22.13.0 和 24.22.2 的定向测试各通过 7/7,证据为 P。 + +```powershell +volta run --node 22.13.0 node --test tests/interactive/codex-os-tool-observations.test.mjs tests/interactive/codex-os-access.test.mjs tests/interactive/crash.test.mjs +node --test tests/interactive/codex-os-tool-observations.test.mjs tests/interactive/codex-os-access.test.mjs tests/interactive/crash.test.mjs +# windows-run-os.ps1 以普通 Host 身份启动 +node tests/security/codex-flowkit-probe.mjs <新Host私有目录> os-stdio isolated +node <归档目录>/verify-windows.mjs <归档目录> +``` + +`FlowKit-Windows-G12-Stdio-Observations.zip` SHA-256 为 `2759dfa75753e0f1bccbd1516275e9481c18e6533131c8429a69964fbc556011`。已核对 19 个归档文件,包含原始 stdio 协议、真实启动脚本、部署文件哈希、退出状态、复核日志和增量 Diff。 + +## Linux + +快进到同一代码候选后,既有隔离容器的 `os-stdio isolated` 首次退出 0,耗时 18.10 秒。真实 App Server 完成三个线程、四个回合,核对两份签名、两项拒绝、Journal 和审计,Host/Model 回收后无遗留进程。定向回归 4/4 通过。原生常规回归为 P/M/T,定向回归为 P。`20a621ba2ecff3ba574804933571027ac13c0321` 的首次正向运行保留为中间记录。 + +```sh +git pull --ff-only origin feat/p0a-interactive-20261112 +docker --host=unix:///var/run/docker.sock exec --user 41001:41001 --workdir /opt/flowkit flowkit-linux-20261113 /usr/local/bin/node --test tests/interactive/codex-os-tool-observations.test.mjs tests/interactive/codex-os-access.test.mjs +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-nat14-final-controller.mjs attempt-1 +``` + +实际 Model 进程 UID/GID 为 41002,capabilities 为零,`no_new_privs=1`。普通 Model 账户的 `codex sandbox linux -- /usr/bin/true` 退出 1,bubblewrap 报无法创建用户命名空间。对实际 Model 进程的只读描述符类型观察未能取得类型,记录 `EACCES` 和进程退出期间的 `ENOENT`。命令沙箱和完整描述符范围维持 BLOCKED,容器权限配置保持原值。 + +`FlowKit-Linux-NAT14-Final-d6bd8d8-20261113.zip` SHA-256 为 `6948f3bf5314bc274cb1d04aa9a45e7ed030222a6c4146a3a90241e544f8817b`。Linux 证据归档包含命令、退出码、原始协议、SHA-256、权限观察及回收核验。 [Linux 原始记录和归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +## 裁决 + +**NAT-14 与 G12 维持 BLOCKED。** 本批验证探针的拒绝逻辑和正常原生回合。完整 NAT-14 仍缺所选 stdio 入口异常顺序下的有界结束和同一 Pending 后续接纳证据。当前回合在立即拒绝后仍会因缺少预期工具调用而无法通过用例断言。Linux 的实际 Codex 命令沙箱和描述符证据另行裁决。供应商故障以及其他 P0 缺口继续见 [发布矩阵](./release-matrix.md)。 From 2594511d058da47f8cad43e57de48c23db8abf0e Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 21:39:21 +0800 Subject: [PATCH 66/72] Verify same Pending survives stdio observation denial --- .../codex-os-tool-observations.test.mjs | 72 ++++++++++++++++++- tests/security/codex-flowkit-probe.mjs | 20 +++--- tests/security/codex-os-tool-observations.mjs | 7 ++ 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/tests/interactive/codex-os-tool-observations.test.mjs b/tests/interactive/codex-os-tool-observations.test.mjs index eff5b36..e8314d8 100644 --- a/tests/interactive/codex-os-tool-observations.test.mjs +++ b/tests/interactive/codex-os-tool-observations.test.mjs @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; +import {generateKeyPairSync,sign} from 'node:crypto'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import {createOsToolObservations} from '../security/codex-os-tool-observations.mjs'; +import {createAgentReceiptStatement,createSqliteCheckpointStore,createTrustedInteractiveHost, + encodeAgentReceiptStatement} from '../../src/interactive/index.mjs'; +import {createOsToolObservations,dispatchObservedOsTool} from '../security/codex-os-tool-observations.mjs'; +import {compileOpsWorkflow,opsRuntime,opsStart} from '../security/p0a-ops-fixture.mjs'; const turn={threadId:'thread',turnId:'turn'}; const args={requestId:'request',answer:'ready'}; @@ -35,3 +42,66 @@ test('observation state belongs to one turn and preserves the first start',()=>{ next.record(start('call')); assert.ok(next.consume(callback('call'))); }); + +test('observation denial returns immediately and the same Pending remains available to the next turn',async t=>{ + const root=await mkdtemp(path.join(tmpdir(),'flowkit-os-observation-')); + let store; + t.after(async()=>{ + store?.close(); + await rm(root,{recursive:true,force:true}); + }); + const program=await compileOpsWorkflow(root); + store=createSqliteCheckpointStore(path.join(root,'run.sqlite'),{namespace:'tenant-observation'}); + const {privateKey,publicKey}=generateKeyPairSync('ed25519'); + let signatures=0,cas=0; + const host=createTrustedInteractiveHost({ + store:{...store,compareAndSwap(input){cas++;return store.compareAndSwap(input);}}, + runtime:opsRuntime,trustedPublicKeys:{proof:publicKey}, + trustedArtifacts:[program.programIdentity.artifactHash], + allowedInputKeys:['question'],disclosurePolicyId:'observation-fixture',authorizeDisclosure:()=>true, + }); + const {runId}=await host.start(opsStart(program,'observation-recovery')); + const task=await host.getPublicAgentTask({runId}); + const {requestId,nodeId,attempt}=task; + const input={requestId,answer:'ready'}; + const snapshot=()=>({run:store.load(runId),journal:store.listJournal(runId), + receipt:store.getReceipt(runId,requestId),cas,signatures}); + const before=snapshot(); + assert.equal(before.run.status,'waiting'); + assert.equal(before.receipt,null); + const accept=async observation=>{ + assert.deepEqual(observation.arguments,input); + const receipt={runId,requestId,nodeId,attempt,kind:'agent-result',result:{answer:input.answer}}; + const issuedAt=Date.now(); + const statement=createAgentReceiptStatement({run:store.load(runId),receipt,keyId:'proof',proofId:'next-call', + issuedAt,expiresAt:issuedAt+30000}); + const signature=sign(null,encodeAgentReceiptStatement(statement),privateKey).toString('base64url'); + signatures++; + return host.accept({receipt,attestation:{statement,signature}}); + }; + + const observations=createOsToolObservations(); + const denied={accepted:false,code:'REQUEST_DENIED'}; + let denials=0; + const onDenied=()=>{denials++;}; + assert.deepEqual(dispatchObservedOsTool(null,callback('unbound-call',input),accept,onDenied),denied); + assert.deepEqual(dispatchObservedOsTool(observations,callback('prior-call',input),accept,onDenied),denied); + observations.record(start('prior-call',input)); + assert.deepEqual(dispatchObservedOsTool(observations,callback('prior-call',input),accept,onDenied),denied); + observations.record(start('different',{...input,answer:'other'})); + assert.deepEqual(dispatchObservedOsTool(observations,callback('different',input),accept,onDenied),denied); + assert.equal(denials,4); + assert.deepEqual(snapshot(),before); + assert.deepEqual(await host.getPublicAgentTask({runId}),task); + + const next=createOsToolObservations(); + next.record({...start('next-call',input),turnId:'next-turn'}); + const accepted=await dispatchObservedOsTool(next,{...callback('next-call',input),turnId:'next-turn'},accept,onDenied); + assert.equal(accepted.status,'succeeded'); + assert.equal(denials,4); + assert.equal(signatures,1); + assert.ok(cas>before.cas); + assert.equal(store.getReceipt(runId,requestId).acceptedRevision,accepted.acceptedRevision); + assert.equal(store.listJournal(runId).length,accepted.revision+1); + assert.equal(await host.getPublicAgentTask({runId}),null); +}); diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 09c2d3b..4cd7822 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -17,7 +17,7 @@ import {createFileHandoff} from '../../examples/interactive/codex-file-handoff.m import {openCodexOutbox} from '../../examples/interactive/codex-outbox.mjs'; import {createCodexOsAccess} from '../../examples/interactive/codex-os-access.mjs'; import {serveCodexOsSession} from '../../examples/interactive/codex-os-stdio.mjs'; -import {createOsToolObservations} from './codex-os-tool-observations.mjs'; +import {createOsToolObservations,dispatchObservedOsTool} from './codex-os-tool-observations.mjs'; import {PassThrough} from 'node:stream'; const [exe,cwd,out,requested='loop',profile='direct',scenarioDir,answer,cut,scenarioBytes]=process.argv.slice(2); @@ -475,14 +475,14 @@ async function osEntryCallback(message) { const p=message.params; const turn=osTurns.get(p.threadId); const context=entrySessions.get(p.threadId); - const sameTurn=turn && isolatedIdentity && context?.modelPid===isolatedIdentity.pid && turn.id===p.turnId; - const started=sameTurn && !completed.has(p.callId) ? turn.observations.consume(p) : null; - if (!started) { - journal('os-observation-denied',{callId:p.callId,threadId:p.threadId,turnId:p.turnId}); - send({id:message.id,result:{success:false,contentItems:[{type:'inputText', - text:JSON.stringify({accepted:false,code:'REQUEST_DENIED'})}]}}); - return; - } + const sameTurn=turn?.id && isolatedIdentity && context?.modelPid===isolatedIdentity.pid && turn.id===p.turnId; + const observations=sameTurn && !completed.has(p.callId) ? turn.observations : null; + const response=await dispatchObservedOsTool(observations,p,started=>submitObservedOsTool(p,turn,context,started), + ()=>journal('os-observation-denied',{callId:p.callId,threadId:p.threadId,turnId:p.turnId})); + send({id:message.id,result:{success:response.accepted,contentItems:[{type:'inputText',text:JSON.stringify(response)}]}}); +} + +async function submitObservedOsTool(p,turn,context,started) { turn.calls.push(p.tool); const target=entryResources.get(p.arguments.requestId); let result; @@ -528,7 +528,7 @@ async function osEntryCallback(message) { journal('os-entry-rejected',{case:turn.label,callId:p.callId,subject:context.subject,namespace:context.namespace, targetSubject:target?.subject,targetNamespace:target?.namespace,tool:p.tool}); } - send({id:message.id,result:{success:result.accepted,contentItems:[{type:'inputText',text:JSON.stringify(result)}]}}); + return result; } async function handleEntryCallback(message) { diff --git a/tests/security/codex-os-tool-observations.mjs b/tests/security/codex-os-tool-observations.mjs index de09722..ce2885d 100644 --- a/tests/security/codex-os-tool-observations.mjs +++ b/tests/security/codex-os-tool-observations.mjs @@ -22,3 +22,10 @@ export function createOsToolObservations() { }, }; } + +export function dispatchObservedOsTool(observations, callback, accept, onDenied) { + const started = observations?.consume(callback); + if (started) return accept(started); + onDenied?.(); + return {accepted:false,code:'REQUEST_DENIED'}; +} From 122c6f51386ed1fd703ac2d10f5763880c2ad4cc Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 21:46:58 +0800 Subject: [PATCH 67/72] Record Pending recovery and Linux command sandbox evidence --- .../p0a-g07-g12-20261113/g12-review.md | 4 ++ .../p0a-g07-g12-20261113/release-matrix.md | 5 ++- .../stdio-pending-recovery.md | 43 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/p0a-g07-g12-20261113/stdio-pending-recovery.md diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index 13496aa..9569897 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -40,3 +40,7 @@ ## 受控 stdio 观察回归复审 **G12 维持 BLOCKED。** 独立角色确认 `d6bd8d839fe33d344748ee5d889f4bd901cb958f` 已同步绑定匹配响应的回合 ID,后续完成事件按线程和回合匹配。缺失、失配和迟到观察的拒绝条件有定向单测。现有探针在异常拒绝后仍可能让当次验收失败,同一 Pending 在同一 App Server 中恢复尚无证据。Linux Model 进程隔离和实际命令沙箱分开裁决。 [本批复审、实测与剩余缺口](./stdio-observation-recovery.md) + +## 受控 stdio Pending 恢复回归复审 + +**G12 维持 BLOCKED。** 独立角色复核共享观察派发及 SQLite 回归,确认异常观察在签名和 CAS 前被拒绝,原 Pending 保留并可由下一模拟回合接纳。该结果计为 P。实际 App Server 的异常后续跑与 Linux Model 命令沙箱仍单列。 [本批范围、命令和证据](./stdio-pending-recovery.md) diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 522ba49..bdbd1d5 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -29,6 +29,7 @@ | E19 | `c338cd46261757e598ae63b2738c9f9d03f08be8` | 核对现有双平台证据、R5 提案字段与阶段校验、Genesis 前后强停、G17 本地包预检。[同批裁决](./release-candidate-review.md) | | E20 | `90e20554ef5ae443fd56a662cea616723c956170`、`f391fd07ff777a72360726ba7fd091f9aad623f5` | Windows/Linux 首发受控 stdio 的原生租户绑定与回执复核,以及 12 个恢复切点的逐点持久化断言。[首发 P0 验收](./selected-stdio-p0.md) | | E21 | `d6bd8d839fe33d344748ee5d889f4bd901cb958f` | 选定 stdio 的观察拒绝与回合绑定回归,以及常规原生回合复验。[分平台结果、复审与归档](./stdio-observation-recovery.md) | +| E22 | `2594511d058da47f8cad43e57de48c23db8abf0e` | 共享观察派发与真实 SQLite 的 P 级恢复回归,拒绝后零签名、零 CAS,原 Pending 可由下一模拟回合接纳。[实测与剩余缺口](./stdio-pending-recovery.md) | ## SEC · 生产配置与候选入口 @@ -50,7 +51,7 @@ | SEC-14 | BLOCKED,生产轮换与撤销 | E4/E5/E8/E9 覆盖正常轮换、原 Ack 和分别撤销。E15/E16 补三个测试 Store 的部分撤销、测试入口阻断及逐库确认后的恢复。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | | SEC-15 | PASS,已选 Windows/Linux 受控 stdio | E20 两个经 OS 核验的主体与租户使用同 startKey、同 RunId 并发接纳,独立回执和 Journal,错租户拒绝且状态不变。完整同链授权与审计交付归 SEC-05/11。 | | SEC-16 | N/A,当前 Windows 配置无 Named Pipe 候选入口 | 当前使用标准流。若发行选择 Named Pipe,按原矩阵重新裁决。 | -| SEC-17 | BLOCKED,Linux 实际 CLI 拒绝 | E6 有 47 项受限进程拒绝。E21 复核实际 Model 身份、零能力和 `no_new_privs=1`。普通命令沙箱因无法创建用户命名空间退出 1,实际 Model 描述符类型观察受限。Windows 不适用。 | +| SEC-17 | BLOCKED,Linux 实际 CLI 拒绝 | E6 有 47 项受限进程拒绝。E21 复核实际 Model 身份、零能力和 `no_new_privs=1`。既有双账户容器的命令沙箱无法创建用户命名空间,描述符类型观察受限。E22 在另一托管执行器核对普通账户能启动本地沙箱,仍缺同一双账户目标证据。Windows 不适用。 | | SEC-18 | PASS,双平台 Outbox 样本 | E2/E3/E4 覆盖签名前、签后 CAS 前及 CAS 后 `ACKED` 前的真实强杀、原字节验签与对账、未接纳过期拒绝和仍受信的历史 Ack。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | | SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E16 补 128 次接纳、八轮满额与清理、独立回环进程持久化告警。E17/E18 补三库联合恢复、按 Run 自动发现及二次扫描幂等。生产保留期限、持续容量、值班端送达及实际备份系统和启动编排仍待取证。 | @@ -73,7 +74,7 @@ | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | -| NAT-14 | BLOCKED,选定入口的异常时序 | E20 证实选定 OS 绑定 stdio 的原生签前事件匹配。E11 在独立回调路径验证迟到、缺失、失配和有界等待拒绝,零签名、零 CAS。E21 补选定 stdio 探针的即时拒绝回归及同步回合绑定。选定路径发生异常后有界结束、同一 Pending 后续接纳仍缺实测。 | +| NAT-14 | BLOCKED,选定入口的原生异常时序 | E20 验证选定入口正常原生签前事件匹配。E11 验证独立回调路径的异常拒绝。E21 补选定探针的即时拒绝与回合绑定。E22 验证共享派发拒绝后零签名、零 CAS 及原 Pending 后续接纳,证据为 P。所选 App Server 实际异常后的有界结束和同一 Pending 续跑仍缺原生证据。 | | NAT-15 | PASS,双平台 Codex 实测样本 | E2 用原签名、回执和同一 Run 恢复签后 CAS 前切点。E3 增补文件引用与 CAS 后 Ack 修复,E4 增补真实过期拒绝。完整 N 级随 C12/S13 的组合证据复审。 | ## C01–C24 与阶段收口 diff --git a/docs/evidence/p0a-g07-g12-20261113/stdio-pending-recovery.md b/docs/evidence/p0a-g07-g12-20261113/stdio-pending-recovery.md new file mode 100644 index 0000000..4b7a0e0 --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/stdio-pending-recovery.md @@ -0,0 +1,43 @@ +# 受控 stdio 拒绝后保留 Pending + +基线 `e4d6128a9fe9618313dbc966638840e6e54eb03f` 的 [CI](https://github.com/devcodex-labs/flowkit/actions/runs/43434017732) 为 8/8。代码候选为 `2594511d058da47f8cad43e57de48c23db8abf0e`。增量 Diff 的 SHA-256 为 `365984224f2aab5f6556d6ecfc3c7b46d04705a16f97c77e7764fc96e67632a4`。Requirements V0.3、Technical Design V0.6-R5、P0-A V0.1 和三份附属材料的字节数及 SHA-256 均与 [G01 冻结登记](../p0a-g01-20261112/verified-inputs.txt) 一致。 + +## 复验范围 + +受控 stdio 探针和程序回归使用同一个观察派发函数。程序回归使用合成的回合记录、真实 SQLite Store、测试签名密钥和 `TrustedInteractiveHost`。未绑定回合、缺失观察、迟到观察和参数失配各自同步返回 `REQUEST_DENIED`。四次拒绝后,签名数和 CAS 调用数无新增,Checkpoint、Journal 和回执索引相同,公开任务仍为原 Pending。下一模拟回合的匹配观察只签名一次,接纳同一个 `requestId`,写入回执索引并到达终态。证据为 P。 + +独立角色复核了共享派发与用例,确认签名前仍执行身份、回合和观察检查,程序回归覆盖同一 Pending 的保留及接纳。真实 App Server 的异常后续跑仍缺证据,选定探针的回合断言与并发等待也未由该用例覆盖。NAT-14 维持 BLOCKED。 + +## Windows + +Node 22.13.0 与 24.22.2 各通过 8/8,退出码均为 0。双账户固定启动链的常规 `os-stdio isolated` 退出 0,核对四个真实回合、两项拒绝、两份签名、两个 Journal 链和受权审计。三份已部署探针文件与候选字节一致,回环监听清理后为 0。常规样本证据为 P/M/T。 + +```powershell +$tests='tests/interactive/codex-os-tool-observations.test.mjs','tests/interactive/codex-os-access.test.mjs','tests/interactive/crash.test.mjs' +volta run --node 22.13.0 node --test @tests +node --test @tests +node tests/security/codex-flowkit-probe.mjs <新Host私有目录> os-stdio isolated +node <归档目录>/verify-windows.mjs <归档目录> +``` + +`FlowKit-Windows-G12-Pending-Recovery.zip` SHA-256 为 `d8a1edd64c5bdeb04399e65937eacb9a4ecebad8245711e8ca5a0dfcbc8589d0`。逐项核验 20 个归档文件,包含启动脚本、协议、退出码、部署哈希、定向日志和 Diff。 + +本机已登记 WSL 2 的 Ubuntu 24.04。对该发行版执行 `wsl.exe -d Ubuntu-24.04 -- /bin/sh -c "id -u; uname -sr; command -v codex; command -v bwrap; command -v node"`,20 秒内未获得标准输出、错误输出或正常退出,结束了该次客户端进程。诊断 JSON SHA-256 为 `78383d3c9137552485958660fa87adb13ddf2f3e8316d5f9d4ff3fbdd16a73f9`。WSL 的测试身份和沙箱可用性仍为 BLOCKED。 + +## Linux + +快进到同一代码候选并核对 75 个依赖文件。普通 Host 的定向回归 5/5,退出 0。既有容器的双账户 `os-stdio isolated` 常规复跑退出 0,耗时 22.30 秒。四个真实回合,两份签名、两项拒绝、Journal 和审计核验通过,无遗留进程。定向回归为 P,真实常规样本为 P/M/T。 + +```sh +git pull --ff-only origin feat/p0a-interactive-20261112 +docker --host=unix:///var/run/docker.sock exec --user 41001:41001 --env TMPDIR=/profile-shared-observations-unit-r5 --workdir /opt/flowkit flowkit-linux-20261113 /usr/local/bin/node --test tests/interactive/codex-os-tool-observations.test.mjs tests/interactive/codex-os-access.test.mjs +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-shared-observations-controller.mjs attempt-1 +``` + +`FlowKit-Linux-Shared-Observations-2594511-20261113.zip` SHA-256 为 `f0d1d7f547216080bf3dccaf51ca0ac4b035265b7f43cd970ab2d4265651f029`。Linux 端核对 173 个载荷文件、ZIP 完整性和逐项哈希。 [Linux 命令、协议与归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +### 命令沙箱环境 + +既有双账户容器沿用 E21 的原始记录。以普通 Model 账户启动 `codex sandbox linux -- /usr/bin/true` 退出 1,bubblewrap 无法创建用户命名空间。容器配置未改变。另检查了托管执行器和已登记环境,尚未找到可供双账户验收的备用 Linux 机器。`FlowKit-Linux-Command-Sandbox-Inventory-20261113.zip` SHA-256 为 `704863665460e881543ee4ec488e66841aef5a306877c7ca387aeeae46e94c03`,逐项核验 43 个载荷文件。 + +托管执行器的 Codex CLI 0.167.0-alpha.4 使用 `/opt/codex/bin/codex sandbox /usr/bin/true`。普通 UID 1000 执行后退出 0,证据为 P,仅覆盖本地命令启动。盘点归档中的退出 101 来自该版本把旧语法的 `linux` 当作可执行文件。更正记录和原始日志见 `FlowKit-Linux-Sandbox-CLI-Help-20261113.zip`,SHA-256 为 `07ec2550c97ab3390054ad2c169a7ccd8df587146a8eafb473c60483aa819e39`。托管执行器尚未确认为首发隔离双账户目标,SEC-17 维持 BLOCKED。 [Linux 原始记录与更正](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) From 8667704a79bd8f572ace969d48c5ef49d1add3ac Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 21:56:09 +0800 Subject: [PATCH 68/72] Exercise a single OS-authenticated Run through Codex and auditor delivery --- tests/security/codex-flowkit-probe.mjs | 76 ++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 4cd7822..0be8d2b 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -25,7 +25,8 @@ const scenario=['outbox-worker','outbox-recover','outbox-setup','outbox-next','o 'outbox-check-revocation','release-check','release-revoke','release-clean','ops-clean'].includes(requested); const worker=requested==='outbox-worker'; const offline=scenario&&!worker; -const osEntry=['os-stdio','os-file'].includes(requested); +const osChain=requested==='os-chain'; +const osEntry=['os-stdio','os-file','os-chain'].includes(requested); const entry=requested==='entry-r5'||osEntry; const branch=requested.startsWith('branch-')?requested.slice(7):null; assert.ok(branch===null || ['approved','refused','closed','missing'].includes(branch)); @@ -509,7 +510,7 @@ async function submitObservedOsTool(p,turn,context,started) { osAccess.authorize(context.grant,'run:complete',target.namespace); assert.equal(target,turn.target); journal('os-ready-to-accept',{namespace:target.namespace,subject:context.subject,callId:p.callId}); - await osOverlap(); + await osOverlap?.(); osAccess.authorize(context.grant,'run:complete',target.namespace); if (osFile) { assert.equal(p.tool,'flowkit_submit_file'); @@ -763,7 +764,7 @@ async function openEntrySession(binding,model) { return session; } -async function bindEntrySubject(binding,model) { +async function bindEntrySubject(binding,model,{runId='entry-shared-run',startKey='entry-shared-start'}={}) { if (osEntry) osAccess.authorize(binding.grant,'run:create',binding.namespace); const store=createSqliteCheckpointStore(entryDatabase,{namespace:binding.namespace}); let writes=0; @@ -782,7 +783,7 @@ async function bindEntrySubject(binding,model) { finally {journal('os-result-audit',{identity:osAuditSession.identity,action,namespace,runId,revision,allowed});} }}:{}), }); - const frame=await host.start({runId:'entry-shared-run',startKey:'entry-shared-start', + const frame=await host.start({runId,startKey, programBytes:Buffer.from(program.code),programIdentity:program.programIdentity, state:{question:'Return ready.',tenantMarker:binding.namespace}}); assert.equal(frame.status,'waiting'); @@ -793,7 +794,7 @@ async function bindEntrySubject(binding,model) { assert.ok(!entryResources.has(task.requestId)); entryResources.set(task.requestId,target); journal('entry-binding',{...session,runId:task.runId,requestId:task.requestId,revision:frame.revision}); - return {session,target}; + return {session,target,revision:frame.revision}; } async function osTurn(session,target,label,prompt,denial=false) { @@ -843,6 +844,7 @@ async function runOsEntry(model) { handoff=createFileHandoff({database:osFileDb,inbox, validateArtifact:value=>Object.keys(value).length===1 && typeof value.answer==='string'}); } + if (osChain) return runOsChain(model); const a=await bindEntrySubject({subject:operators[0].identity,namespace:operators[0].namespace,grant:operators[0]},model); const b=await bindEntrySubject({subject:operators[1].identity,namespace:operators[1].namespace,grant:operators[1]},model); const auditor=await openEntrySession({subject:auditors[0].identity,namespace:auditors[0].namespace,grant:auditors[0]},model); @@ -910,6 +912,70 @@ async function runOsEntry(model) { return 'entry-completed'; } +async function runOsChain(model) { + const stdin=createInterface({input:process.stdin}); + const connections=new Map(); + const reply=value=>console.log(JSON.stringify(value)); + let created,terminal; + const dispatch=async(grant,command)=>{ + if (command.action==='run:create') { + assert.equal(created,undefined); + assert.match(command.runId,/^chain-[a-f0-9]{32}$/); + created=await bindEntrySubject({subject:grant.identity,namespace:grant.namespace,grant},model, + {runId:command.runId,startKey:command.runId}); + const {runId}=created.target.task; + journal('chain-created',{runId,revision:created.revision,identity:grant.identity,namespace:grant.namespace}); + return {runId,revision:created.revision,status:'waiting'}; + } + assert.equal(command.action,'result:read'); + assert.ok(terminal); + osAuditSession=grant; + const result=await created.target.host.readTrustedTerminalResult({resultRef:command.resultRef}); + journal('chain-audit-replied',{identity:grant.identity,resultRef:command.resultRef,namespace:result.namespace}); + return result; + }; + reply({hostSid,pid:process.pid,ready:true}); + try { + for await (const line of stdin) { + const {op,id,identity,command}=JSON.parse(line); + if (op==='open') { + assert.ok(!connections.has(id)); + const input=new PassThrough(),output=new PassThrough(); + const responses=createInterface({input:output})[Symbol.asyncIterator](); + const running=serveCodexOsSession({access:osAccess,identity,input,output,dispatch}); + connections.set(id,{input,output,responses,running}); + reply({opened:id}); + } else if (op==='data') { + const {input,responses}=connections.get(id); + input.write(JSON.stringify(command)+'\n'); + reply({id,response:JSON.parse((await responses.next()).value)}); + } else if (op==='complete') { + assert.equal(terminal,undefined); + const {session,target}=created; + const {runId,requestId}=target.task; + await osTurn(session,target,'chain-valid',`Call flowkit_submit once with requestId "${requestId}" and answer "ready". Then finish.`); + terminal=target.store.load(runId); + assert.equal(terminal.status,'succeeded'); + assert.equal(entrySignatures,1); + const receipt=target.store.getReceipt(runId,requestId); + journal('chain-completed',{runId,requestId,receipt,journal:target.store.listJournal(runId)}); + reply({resultRef:{runId,revision:terminal.revision,projectionVersion:1}}); + } else { + assert.equal(op,'finish'); + assert.ok(terminal); + reply({finished:true}); + return 'entry-completed'; + } + } + throw new Error('Supervisor closed before completing the chain'); + } finally { + stdin.close(); + for (const {input} of connections.values()) input.end(); + for (const {running,output} of connections.values()) {await running;output.end();} + process.stdin.pause(); + } +} + async function verifyOsSessionRevocation(bindings,target,identity,resultRef) { const input=new PassThrough(),output=new PassThrough(); const lines=createInterface({input:output}); From 97d01bbca46b2b605f7977e38d2b52756cef02c5 Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 22:01:11 +0800 Subject: [PATCH 69/72] Record Windows same-Run acceptance and Linux readiness gate --- .../p0a-g07-g12-20261113/g12-review.md | 4 +++ .../operator-model-audit-chain.md | 34 +++++++++++++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 7 ++-- 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 docs/evidence/p0a-g07-g12-20261113/operator-model-audit-chain.md diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index 9569897..3bd9429 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -44,3 +44,7 @@ ## 受控 stdio Pending 恢复回归复审 **G12 维持 BLOCKED。** 独立角色复核共享观察派发及 SQLite 回归,确认异常观察在签名和 CAS 前被拒绝,原 Pending 保留并可由下一模拟回合接纳。该结果计为 P。实际 App Server 的异常后续跑与 Linux Model 命令沙箱仍单列。 [本批范围、命令和证据](./stdio-pending-recovery.md) + +## Windows 同一 Run 联测复审 + +**G12 维持 BLOCKED。** 独立角色核对 `8667704a79bd8f572ace969d48c5ef49d1add3ac` 的探针和 Windows 首次启动问题。补齐启动参数后,同一代码候选的真实操作员创建、Codex 签收及审计员收件经离线证据核验。Windows 正向样本为 P/M/T。NAT-14 的原生异常恢复、Linux 隔离双账户目标和完整授权披露证据仍待验。 [同链实测与环境裁决](./operator-model-audit-chain.md) diff --git a/docs/evidence/p0a-g07-g12-20261113/operator-model-audit-chain.md b/docs/evidence/p0a-g07-g12-20261113/operator-model-audit-chain.md new file mode 100644 index 0000000..a4bb6a3 --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/operator-model-audit-chain.md @@ -0,0 +1,34 @@ +# 同一 Run 的操作员、Codex 与审计员联测 + +基线 `122c6f51386ed1fd703ac2d10f5763880c2ad4cc` 的 [CI](https://github.com/devcodex-labs/flowkit/actions/runs/43436840381) 为 8/8。代码候选为 `8667704a79bd8f572ace969d48c5ef49d1add3ac`,增量 Diff SHA-256 为 `7a9c6f85aa08acec44df2b12e97f3c105b71e784faff2b6918710e0166ecb1d3`。Requirements V0.3、Technical Design V0.6-R5、P0-A V0.1 和三份附属材料的字节数及 SHA-256 均与 [G01 冻结登记](../p0a-g01-20261112/verified-inputs.txt) 一致。代码变更限于验收探针。 + +## Windows 同一 Run + +普通 Host、Model、操作员及审计员使用不同 Windows SID。受信监督器核对实际对端 PID 和 SID,在 Host 私有标准流中绑定操作员与审计员的 OS 身份。测试对端进程从各自标准流发送测试命令并确认收件。操作员的 `run:create` 创建 `chain-22fb3e5457cc4425b054fe71a65fa80e`,收件为 `waiting`,revision 3。真实 Codex App Server 提交这个 Run 的当前 `requestId`,独立校验工具开始事件与回调匹配、签名有效,回执在 revision 4 接纳。同一 Run 达到 revision 9,十行 Journal 的哈希链复算一致。 + +操作员读取终态被拒绝,审计员进程收到同一 `runId` 的授权终态,包含 `answer: "ready"`。真实 Model 协议未出现私有 `tenantMarker`。三个受监督器启动的普通进程退出 0,实验室回环监听清理后为 0。该 Windows 正向样本为 P/M/T。监督器提供测试命令并转发字节,首发完整拒绝范围和披露矩阵仍按 SEC-05/11 裁决。 + +```powershell +windows-deploy-and-run.ps1 -Output <归档目录> +windows-os-chain.ps1 -RunName <本次Run目录> -Output <归档目录> +node tests/security/codex-flowkit-probe.mjs <新Host私有目录> os-chain isolated +node <归档目录>/verify-windows-chain.mjs <归档目录> +``` + +第一次启动遗漏 `RunName` 参数声明,Host 创建证据目录时返回 `EINVAL`。保留第一次退出 1 的原始日志,补齐并校验参数后复跑,同一代码候选退出 0。复跑的标准错误记录一次模型列表刷新超时,正式回合、签收及审计链完成。Windows Node 22.13.0 与 24.22.2 的定向回归各通过 8/8。`FlowKit-Windows-G12-Operator-Audit.zip` SHA-256 为 `f331c89026084c2bfd50915a42f918ca43f4c026c6e665f56891f16f1c5a0f19`,逐项核对 26 个归档文件。 + +## Linux 隔离双账户条件 + +托管执行器的普通 UID 1000 执行 `/opt/codex/bin/codex sandbox /usr/bin/true` 退出 0,仍缺专用 Model 账户、受管启动器和独立登录。现有容器的 Host 41001 与 Model 41002 独立,Model 登录有效,命令沙箱仍因无法创建用户命名空间退出 1。两项能力尚未在同一候选环境齐备,条件性拉取未触发。Linux checkout 仍为 `2594511d058da47f8cad43e57de48c23db8abf0e`。隔离双账户沙箱及本轮 Linux 同链验收为 BLOCKED。 + +```sh +/opt/codex/bin/codex sandbox /usr/bin/true +docker --host=unix:///var/run/docker.sock exec --user 41002:41002 flowkit-linux-20261113 /usr/local/bin/flowkit-model-account status +docker --host=unix:///var/run/docker.sock exec --user 41002:41002 --env HOME=/home/fkmodel --env CODEX_HOME=/home/fkmodel/.codex --workdir /model-work flowkit-linux-20261113 /usr/local/bin/codex sandbox /usr/bin/true +``` + +`FlowKit-Linux-Managed-Dual-Account-Readiness-20261113.zip` SHA-256 为 `24a339fe78ce28457fcf53eec8808d66fc2c83891fbfbd21f2e884b78b978e88`。 [Linux 命令、退出码与原始证据](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +## 裁决 + +独立角色审查了 `os-chain` 的测试范围、受信监督器和首次启动问题。Windows 同一 Run 正向链通过。**NAT-14 原生异常恢复和 G12 仍为 BLOCKED**。E22 保留原 Pending 并在下一模拟回合接纳的 P 级证据有效,本批真实回合仅覆盖正常观察顺序。待补原生异常后有界结束及原 Pending 后续接纳、Linux 隔离双账户的实际命令沙箱、完整授权及披露范围和供应商故障证据。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index bdbd1d5..4684359 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -30,6 +30,7 @@ | E20 | `90e20554ef5ae443fd56a662cea616723c956170`、`f391fd07ff777a72360726ba7fd091f9aad623f5` | Windows/Linux 首发受控 stdio 的原生租户绑定与回执复核,以及 12 个恢复切点的逐点持久化断言。[首发 P0 验收](./selected-stdio-p0.md) | | E21 | `d6bd8d839fe33d344748ee5d889f4bd901cb958f` | 选定 stdio 的观察拒绝与回合绑定回归,以及常规原生回合复验。[分平台结果、复审与归档](./stdio-observation-recovery.md) | | E22 | `2594511d058da47f8cad43e57de48c23db8abf0e` | 共享观察派发与真实 SQLite 的 P 级恢复回归,拒绝后零签名、零 CAS,原 Pending 可由下一模拟回合接纳。[实测与剩余缺口](./stdio-pending-recovery.md) | +| E23 | `8667704a79bd8f572ace969d48c5ef49d1add3ac` | Windows 同一 Run 的实际操作员进程创建、真实 Codex 签收、审计员进程收件。Linux 双账户沙箱目标仍缺条件。[同链实测、原始归档与环境裁决](./operator-model-audit-chain.md) | ## SEC · 生产配置与候选入口 @@ -39,19 +40,19 @@ | 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,启用入口完整授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E14/E16 补普通 OS 客户端与审计收件,E20 在选定 stdio 验证实际模型身份及回调授权。操作员创建、同一 Run 的真实 Model 回执及审计客户端交付仍需联证。 | +| SEC-05 | BLOCKED,启用入口完整授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E20 验证实际模型身份及回调授权。E23 的 Windows 同一 Run 正向联测通过,操作员创建、真实 Model 签收和审计员收件已联证。Linux 同链及完整入口拒绝范围待验。 | | 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 后强杀恢复通过,E8 覆盖真实接纳后校验恢复。E19/E20 补 Genesis 前后与 12 个 SQLite 切点逐点验证。选定宿主上的组合证据随 T29 裁决。 | -| SEC-11 | BLOCKED,完整披露矩阵 | E1/E10 覆盖任务、工具回复、终态投影,E20 复核 E10 原始 Model 协议无 Host 私有标记。完整日志可见性与授权审计的同链覆盖仍缺。 | +| SEC-11 | BLOCKED,完整披露矩阵 | E1/E10 覆盖任务、工具回复、终态投影,E20 复核 E10 原始 Model 协议。E23 验证 Windows 同一 Run 的审计员收件、操作员读取拒绝以及 Model 协议无私有租户标记。Linux 同链和完整日志可见性仍缺。 | | SEC-12 | BLOCKED,真实资源边界 | E1 文件入口两个大小通过。E7 内联 64 KiB 通过,两档更大内联无工具调用。补实际入口异常正文、限流/超时及内联回执上限分类。 | | SEC-13 | BLOCKED,完整替换负例 | E7 的文件替换、硬链接、符号链接或目录联接点有 P 级证据。E5 的旧策略拒绝有双平台原生结果。补实际启动链下 Program 和所有已启用配置入口的替换负例。 | | SEC-14 | BLOCKED,生产轮换与撤销 | E4/E5/E8/E9 覆盖正常轮换、原 Ack 和分别撤销。E15/E16 补三个测试 Store 的部分撤销、测试入口阻断及逐库确认后的恢复。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 | | SEC-15 | PASS,已选 Windows/Linux 受控 stdio | E20 两个经 OS 核验的主体与租户使用同 startKey、同 RunId 并发接纳,独立回执和 Journal,错租户拒绝且状态不变。完整同链授权与审计交付归 SEC-05/11。 | | SEC-16 | N/A,当前 Windows 配置无 Named Pipe 候选入口 | 当前使用标准流。若发行选择 Named Pipe,按原矩阵重新裁决。 | -| SEC-17 | BLOCKED,Linux 实际 CLI 拒绝 | E6 有 47 项受限进程拒绝。E21 复核实际 Model 身份、零能力和 `no_new_privs=1`。既有双账户容器的命令沙箱无法创建用户命名空间,描述符类型观察受限。E22 在另一托管执行器核对普通账户能启动本地沙箱,仍缺同一双账户目标证据。Windows 不适用。 | +| SEC-17 | BLOCKED,Linux 实际 CLI 拒绝 | E6 有 47 项受限进程拒绝。E21 复核实际 Model 身份、零能力和 `no_new_privs=1`。E23 确认既有双账户容器的命令沙箱仍无法创建用户命名空间,托管执行器能启动本地沙箱但未配独立 Model 身份、受管启动器及登录。Windows 不适用。 | | SEC-18 | PASS,双平台 Outbox 样本 | E2/E3/E4 覆盖签名前、签后 CAS 前及 CAS 后 `ACKED` 前的真实强杀、原字节验签与对账、未接纳过期拒绝和仍受信的历史 Ack。E8 增补 R5 已接纳回执在 validate 阶段强杀后的恢复。 | | SEC-19 | BLOCKED,生产有界保留 | E8/E9 覆盖真实工具满额、清理回滚、快照清理强杀和分配块采样。E16 补 128 次接纳、八轮满额与清理、独立回环进程持久化告警。E17/E18 补三库联合恢复、按 Run 自动发现及二次扫描幂等。生产保留期限、持续容量、值班端送达及实际备份系统和启动编排仍待取证。 | From 1d2d216e919a9d5aa5874123542682e4bca5105a Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 22:15:13 +0800 Subject: [PATCH 70/72] Verify native Codex interruption resumes the same Pending --- tests/security/codex-flowkit-probe.mjs | 50 +++++++++++++++++++++----- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/tests/security/codex-flowkit-probe.mjs b/tests/security/codex-flowkit-probe.mjs index 0be8d2b..1046d69 100644 --- a/tests/security/codex-flowkit-probe.mjs +++ b/tests/security/codex-flowkit-probe.mjs @@ -25,8 +25,9 @@ const scenario=['outbox-worker','outbox-recover','outbox-setup','outbox-next','o 'outbox-check-revocation','release-check','release-revoke','release-clean','ops-clean'].includes(requested); const worker=requested==='outbox-worker'; const offline=scenario&&!worker; -const osChain=requested==='os-chain'; -const osEntry=['os-stdio','os-file','os-chain'].includes(requested); +const osChainRecovery=requested==='os-chain-recovery'; +const osChain=requested==='os-chain'||osChainRecovery; +const osEntry=requested==='os-stdio'||requested==='os-file'||osChain; const entry=requested==='entry-r5'||osEntry; const branch=requested.startsWith('branch-')?requested.slice(7):null; assert.ok(branch===null || ['approved','refused','closed','missing'].includes(branch)); @@ -71,6 +72,7 @@ const osTools=osFile?[ tool('flowkit_file_task','Get the current Host-provisioned file task',objectSchema({})), tool('flowkit_submit_file','Submit the exact file reference returned by the Host',objectSchema({requestId:str,id:str})), ]:specs.filter(spec=>spec.name==='flowkit_submit'); +if (osChainRecovery) osTools.push(tool('probe_hold','Wait at a read-only integration test barrier',objectSchema({}))); 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) @@ -480,11 +482,22 @@ async function osEntryCallback(message) { const observations=sameTurn && !completed.has(p.callId) ? turn.observations : null; const response=await dispatchObservedOsTool(observations,p,started=>submitObservedOsTool(p,turn,context,started), ()=>journal('os-observation-denied',{callId:p.callId,threadId:p.threadId,turnId:p.turnId})); + if (response===null) return; send({id:message.id,result:{success:response.accepted,contentItems:[{type:'inputText',text:JSON.stringify(response)}]}}); } async function submitObservedOsTool(p,turn,context,started) { turn.calls.push(p.tool); + if (p.tool==='probe_hold') { + assert.ok(turn.interrupt); + assert.deepEqual(turn.calls,['probe_hold']); + assert.deepEqual(p.arguments,{}); + journal('chain-recovery-held',{threadId:p.threadId,turnId:p.turnId,callId:p.callId}); + turn.interruptRequest=request('turn/interrupt',{threadId:p.threadId,turnId:p.turnId}); + await turn.interruptRequest; + return null; + } + assert.equal(turn.interrupt,false); const target=entryResources.get(p.arguments.requestId); let result; try { @@ -797,10 +810,10 @@ async function bindEntrySubject(binding,model,{runId='entry-shared-run',startKey return {session,target,revision:frame.revision}; } -async function osTurn(session,target,label,prompt,denial=false) { +async function osTurn(session,target,label,prompt,{denial=false,interrupt=false}={}) { const calls=[]; const done=new Promise(resolve=>osTurns.set(session.threadId, - {label,resolve,target,denial,calls,observations:createOsToolObservations()})); + {label,resolve,target,denial,interrupt,calls,observations:createOsToolObservations()})); const current=osTurns.get(session.threadId); await request('turn/start',{threadId:session.threadId,input:[{type:'text',text:prompt}]},({turn})=>{ assert.ok(typeof turn?.id==='string' && turn.id.length>0); @@ -808,8 +821,13 @@ async function osTurn(session,target,label,prompt,denial=false) { current.id=turn.id; journal('os-turn',{label,threadId:session.threadId,turnId:turn.id,subject:session.subject,namespace:session.namespace}); }); - assert.equal((await done).status,'completed'); + assert.equal((await done).status,interrupt?'interrupted':'completed'); osTurns.delete(session.threadId); + if (interrupt) { + assert.deepEqual(calls,['probe_hold']); + await current.interruptRequest; + return current.id; + } assert.deepEqual(calls,osFile&&!denial?['flowkit_file_task','flowkit_submit_file']:[osFile?'flowkit_submit_file':'flowkit_submit']); return current.ref; } @@ -855,8 +873,8 @@ async function runOsEntry(model) { return `Synthetic negative test. Call ${osFile?'flowkit_submit_file':'flowkit_submit'} once with ${JSON.stringify(args)}. Finish after the response without retrying.`; }; const probeRef={requestId:a.target.task.requestId,id:randomUUID()}; - await osTurn(b.session,a.target,'wrong-tenant',negative(a.target,probeRef),true); - await osTurn(auditor,a.target,'wrong-subject',negative(a.target,probeRef),true); + await osTurn(b.session,a.target,'wrong-tenant',negative(a.target,probeRef),{denial:true}); + await osTurn(auditor,a.target,'wrong-subject',negative(a.target,probeRef),{denial:true}); assert.equal(entrySignatures,0); assert.deepEqual(pendingWrites,[a.target.writes(),b.target.writes()]); assert.deepEqual(pendingBefore,osSnapshot()); @@ -873,7 +891,7 @@ async function runOsEntry(model) { if (turn.target!==a.target) return; const stagedState=osSnapshot(); assert.equal(stagedState.files.find(row=>row.id===ref.id)?.state,'staged'); - await osTurn(b.session,a.target,'wrong-tenant-staged-reference',negative(a.target,ref),true); + await osTurn(b.session,a.target,'wrong-tenant-staged-reference',negative(a.target,ref),{denial:true}); assert.equal(entrySignatures,0); assert.deepEqual([a.target.writes(),b.target.writes()],pendingWrites); assert.deepEqual(osSnapshot(),stagedState); @@ -890,7 +908,7 @@ async function runOsEntry(model) { assert.equal(entrySignatures,2); const before=osSnapshot(); const counts=[a.target.writes(),b.target.writes()]; - if (osFile) await osTurn(b.session,a.target,'wrong-tenant-reference',negative(a.target,refs[0]),true); + if (osFile) await osTurn(b.session,a.target,'wrong-tenant-reference',negative(a.target,refs[0]),{denial:true}); const resultRef={runId:a.target.task.runId,revision:9,projectionVersion:1}; osAuditSession=operators[0]; await assert.rejects(a.target.host.readTrustedTerminalResult({resultRef}),{code:'FK_I_HOST_DENIED'}); @@ -953,6 +971,20 @@ async function runOsChain(model) { assert.equal(terminal,undefined); const {session,target}=created; const {runId,requestId}=target.task; + if (osChainRecovery) { + const before=entrySnapshot(); + const writes=target.writes(); + assert.equal(entrySignatures,0); + assert.equal(target.store.getReceipt(runId,requestId),null); + const turnId=await osTurn(session,target,'chain-interrupt', + 'Call the read-only probe_hold tool once with an empty object and wait for the result.',{interrupt:true}); + assert.deepEqual(entrySnapshot(),before); + assert.deepEqual(await target.host.getPublicAgentTask({runId}),target.task); + assert.equal(entrySignatures,0); + assert.equal(target.writes(),writes); + journal('chain-recovery-pending',{runId,requestId,threadId:session.threadId,turnId, + unchanged:true,signatures:0,cas:0,status:'interrupted'}); + } await osTurn(session,target,'chain-valid',`Call flowkit_submit once with requestId "${requestId}" and answer "ready". Then finish.`); terminal=target.store.load(runId); assert.equal(terminal.status,'succeeded'); From 5842586a049364fcecc1f8b60df3ba40d5aa90df Mon Sep 17 00:00:00 2001 From: rocky Date: Fri, 13 Nov 2026 22:18:06 +0800 Subject: [PATCH 71/72] Record native Windows recovery and managed Linux provisioning gap --- .../p0a-g07-g12-20261113/g12-review.md | 4 +++ .../native-interrupt-recovery.md | 26 +++++++++++++++++++ .../p0a-g07-g12-20261113/release-matrix.md | 3 ++- 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 docs/evidence/p0a-g07-g12-20261113/native-interrupt-recovery.md diff --git a/docs/evidence/p0a-g07-g12-20261113/g12-review.md b/docs/evidence/p0a-g07-g12-20261113/g12-review.md index 3bd9429..ee73b1d 100644 --- a/docs/evidence/p0a-g07-g12-20261113/g12-review.md +++ b/docs/evidence/p0a-g07-g12-20261113/g12-review.md @@ -48,3 +48,7 @@ ## Windows 同一 Run 联测复审 **G12 维持 BLOCKED。** 独立角色核对 `8667704a79bd8f572ace969d48c5ef49d1add3ac` 的探针和 Windows 首次启动问题。补齐启动参数后,同一代码候选的真实操作员创建、Codex 签收及审计员收件经离线证据核验。Windows 正向样本为 P/M/T。NAT-14 的原生异常恢复、Linux 隔离双账户目标和完整授权披露证据仍待验。 [同链实测与环境裁决](./operator-model-audit-chain.md) + +## Windows 原生取消后续跑复审 + +**G12 维持 BLOCKED。** 独立角色核对 `1d2d216e919a9d5aa5874123542682e4bca5105a` 的原始日志。实际 App Server 响应标准取消指令,原 Pending 零签名、零 CAS 保留。同一线程下一回合接纳该请求,审计员收件,证据为 P/M/T。缺异常观察后的原生恢复、Linux 双账户目标、完整授权及披露证据和供应商故障证据。 [本批实测及供给裁决](./native-interrupt-recovery.md) diff --git a/docs/evidence/p0a-g07-g12-20261113/native-interrupt-recovery.md b/docs/evidence/p0a-g07-g12-20261113/native-interrupt-recovery.md new file mode 100644 index 0000000..58b85b1 --- /dev/null +++ b/docs/evidence/p0a-g07-g12-20261113/native-interrupt-recovery.md @@ -0,0 +1,26 @@ +# Windows 原生回合取消后续跑 + +基线 `97d01bbca46b2b605f7977e38d2b52756cef02c5` 的 [CI](https://github.com/devcodex-labs/flowkit/actions/runs/43438819472) 为 8/8。实测代码候选 `1d2d216e919a9d5aa5874123542682e4bca5105a`,增量 Diff SHA-256 为 `37a659c5bf4ae5747976202f1cc76f7c61ae18781424a6211ae1d394b44c5000`。Requirements V0.3、Technical Design V0.6-R5、P0-A V0.1 及三份附属原件字节数和 SHA-256 均与 [G01 冻结登记](../p0a-g01-20261112/verified-inputs.txt) 一致。代码变更限于验收探针。 + +## Windows 受控取消与续跑 + +普通操作员创建 `chain-055bfff334f84ce5abfac52b4029978e` 并收到 revision 3 的 Pending。第一个真实 App Server 回合进入只读测试工具等待点。Host 从原生 stdio 收到匹配的工具开始事件与回调,调用标准 `turn/interrupt` 并收到匹配响应,回合状态为 `interrupted`。Host 对比 SQLite Checkpoint、Journal、回执索引、签名次数和 CAS 次数,确认状态不变、零签名、零 CAS,公开任务仍是原 Pending。本次从观察到工具等待到核对 Pending 保留用时 4 ms。 + +同一个 App Server 和线程启动第二回合,回调提交原 `requestId`,唯一有效签名在 revision 4 接纳。Run 到达 revision 9,审计员进程收到同一终态,操作员读取被拒。独立复核关联两回合、原始工具事件与取消响应、签名与回执索引、10 条 Journal 哈希链及实际审计员收件。Model 双向协议均未出现私有租户标记。三普通进程退出 0,探针退出 0,回环监听清理后为 0。证据为 P/M/T,覆盖受控取消后的续跑。 + +```powershell +windows-deploy-and-run.ps1 -Output <归档目录> +windows-os-chain.ps1 -RunName <本次Run目录> -Output <归档目录> +node tests/security/codex-flowkit-probe.mjs <新Host私有目录> os-chain-recovery isolated +node <归档目录>/verify-windows-recovery.mjs <归档目录> +``` + +Windows Node 22.13.0 与 24.22.2 定向回归各通过 8/8。`FlowKit-Windows-G12-Native-Recovery.zip` SHA-256 为 `e24acdda9a67aec0e447b6ef60c3c4db35e2f5dd1257abdab8301290d5d80b8c`,逐项核对 21 个文件,包含原始双向协议、OS 会话收件、命令、退出、SHA-256 和离线复核输出。 + +## Linux 供给与裁决 + +FlowKit 核对了托管环境及管理入口。共享执行器为 UID 1000、零有效 capabilities、无 `sudo`,可见的 Cloud 配置未提供专用 Linux VM 或创建普通账户的管理员入口。既有容器的沙箱限制沿用 E23。待提供已启动、支持正常 Codex 命令沙箱的专用 Linux 验收环境和平台认可的管理员执行入口。环境可用后可按现有授权配置普通 Host/Model 和受信启动器,再核对 Model 登录。当前未配置账户、未触发条件性 pull,Linux 同链复跑为 BLOCKED。 + +`FlowKit-Linux-Provisioning-Entry-Gate-20261113-final.zip` SHA-256 为 `3e53bc7c6e95f2b7e694f3609aa7fc7920d4a0d1f9d9982c3576386696d42b09`,Linux 端核对 8 个载荷文件。 [Linux 供给盘点、原始命令与归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +**NAT-14 完整验收和 G12 维持 BLOCKED。** E22 提供观察异常的 P 级证据。本批原生样本在已收到有效观察后主动取消。缺失、迟到或失配观察后有界结束并在实际 App Server 接纳原 Pending 的证据仍缺。供应商故障、Linux 双账户命令沙箱及 Linux 同链验收仍待补。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index 4684359..c6212bf 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -31,6 +31,7 @@ | E21 | `d6bd8d839fe33d344748ee5d889f4bd901cb958f` | 选定 stdio 的观察拒绝与回合绑定回归,以及常规原生回合复验。[分平台结果、复审与归档](./stdio-observation-recovery.md) | | E22 | `2594511d058da47f8cad43e57de48c23db8abf0e` | 共享观察派发与真实 SQLite 的 P 级恢复回归,拒绝后零签名、零 CAS,原 Pending 可由下一模拟回合接纳。[实测与剩余缺口](./stdio-pending-recovery.md) | | E23 | `8667704a79bd8f572ace969d48c5ef49d1add3ac` | Windows 同一 Run 的实际操作员进程创建、真实 Codex 签收、审计员进程收件。Linux 双账户沙箱目标仍缺条件。[同链实测、原始归档与环境裁决](./operator-model-audit-chain.md) | +| E24 | `1d2d216e919a9d5aa5874123542682e4bca5105a` | Windows 真实 App Server 回合在只读工具等待点被受控取消,原 Pending 保留,同一线程下一回合接纳,审计员收件。Linux 缺专用目标与管理员供给入口。[实测、复审及环境证据](./native-interrupt-recovery.md) | ## SEC · 生产配置与候选入口 @@ -75,7 +76,7 @@ | NAT-11 | BLOCKED,真实模型权限负例 | E6 有跨账户 P 级拒绝。补真实模型会话的密钥、数据库、全量 State 及伪审批负例,再走合法提交。 | | NAT-12 | BLOCKED,真实会话和版本换绑 | E5 证明改变公钥集合后的拒绝。补切换会话、模型版本或工作区后的旧调用拒绝及显式重新授权。 | | NAT-13 | BLOCKED,V 级 | 已有 Host Ed25519 验签证据。缺供应商可验证的规范、可信公钥和实际签名。按原定义单独裁决。 | -| NAT-14 | BLOCKED,选定入口的原生异常时序 | E20 验证选定入口正常原生签前事件匹配。E11 验证独立回调路径的异常拒绝。E21 补选定探针的即时拒绝与回合绑定。E22 验证共享派发拒绝后零签名、零 CAS 及原 Pending 后续接纳,证据为 P。所选 App Server 实际异常后的有界结束和同一 Pending 续跑仍缺原生证据。 | +| NAT-14 | BLOCKED,选定入口的原生异常时序 | E20/E21 验证正常观察和回合绑定。E11 验证独立回调路径的异常拒绝。E22 验证共享派发拒绝后零签名、零 CAS 及原 Pending 后续接纳,证据为 P。E24 验证 Windows 原生受控取消后同一 App Server 接纳原 Pending。观察缺失、迟到或失配时的原生有界结束与续跑仍缺。 | | NAT-15 | PASS,双平台 Codex 实测样本 | E2 用原签名、回执和同一 Run 恢复签后 CAS 前切点。E3 增补文件引用与 CAS 后 Ack 修复,E4 增补真实过期拒绝。完整 N 级随 C12/S13 的组合证据复审。 | ## C01–C24 与阶段收口 From e145d949658eeb32be53994eb27017174c6f7bec Mon Sep 17 00:00:00 2001 From: rocky Date: Sat, 14 Nov 2026 00:27:48 +0800 Subject: [PATCH 72/72] Record Linux same-Run Codex and auditor acceptance --- .../operator-model-audit-chain.md | 18 +++++++++++------- .../p0a-g07-g12-20261113/release-matrix.md | 5 +++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/evidence/p0a-g07-g12-20261113/operator-model-audit-chain.md b/docs/evidence/p0a-g07-g12-20261113/operator-model-audit-chain.md index a4bb6a3..c533296 100644 --- a/docs/evidence/p0a-g07-g12-20261113/operator-model-audit-chain.md +++ b/docs/evidence/p0a-g07-g12-20261113/operator-model-audit-chain.md @@ -17,18 +17,22 @@ node <归档目录>/verify-windows-chain.mjs <归档目录> 第一次启动遗漏 `RunName` 参数声明,Host 创建证据目录时返回 `EINVAL`。保留第一次退出 1 的原始日志,补齐并校验参数后复跑,同一代码候选退出 0。复跑的标准错误记录一次模型列表刷新超时,正式回合、签收及审计链完成。Windows Node 22.13.0 与 24.22.2 的定向回归各通过 8/8。`FlowKit-Windows-G12-Operator-Audit.zip` SHA-256 为 `f331c89026084c2bfd50915a42f918ca43f4c026c6e665f56891f16f1c5a0f19`,逐项核对 26 个归档文件。 -## Linux 隔离双账户条件 +## Linux 同一 Run -托管执行器的普通 UID 1000 执行 `/opt/codex/bin/codex sandbox /usr/bin/true` 退出 0,仍缺专用 Model 账户、受管启动器和独立登录。现有容器的 Host 41001 与 Model 41002 独立,Model 登录有效,命令沙箱仍因无法创建用户命名空间退出 1。两项能力尚未在同一候选环境齐备,条件性拉取未触发。Linux checkout 仍为 `2594511d058da47f8cad43e57de48c23db8abf0e`。隔离双账户沙箱及本轮 Linux 同链验收为 BLOCKED。 +FlowKit 先快进拉取并核对 `5842586a049364fcecc1f8b60df3ba40d5aa90df`,同步验证 75 个测试源文件,复用专用容器内普通 Host UID 41001、已登录 Model UID 41002 和固定 stdio 启动链。受信监督器核对实际普通操作员及审计员进程,将各自的命令转发至 Host 私有 stdio,并记录对端收件。 + +`chain-aaaf1a243271417d9408b1e600a31d6e` 的操作员收到 revision 3 的等待态。真实 Codex App Server 产生匹配的工具开始与回调,Host 的唯一签名回执在 revision 4 接纳。审计员进程收到同一 Run 的 revision 9 终态,操作员读取被拒。两次读取前后的 SQLite 快照一致。离线复核通过同一 requestId、Ed25519 签名、回执索引、十条 Journal 哈希链和实际收件。监督器首次运行退出 0,耗时 7.94 秒。离线核验退出 0,Host、Model 和测试对端均已回收。样本为 P/M/T。 ```sh -/opt/codex/bin/codex sandbox /usr/bin/true -docker --host=unix:///var/run/docker.sock exec --user 41002:41002 flowkit-linux-20261113 /usr/local/bin/flowkit-model-account status -docker --host=unix:///var/run/docker.sock exec --user 41002:41002 --env HOME=/home/fkmodel --env CODEX_HOME=/home/fkmodel/.codex --workdir /model-work flowkit-linux-20261113 /usr/local/bin/codex sandbox /usr/bin/true +git -C /workspace/flowkit-linux-test pull --ff-only origin feat/p0a-interactive-20261112 +docker --host=unix:///var/run/docker.sock exec --user 0:0 flowkit-linux-20261113 /usr/local/bin/node /run/flowkit/run-os-chain-controller.mjs +node deliverables/scripts/verify-os-chain-public.mjs "$PWD" ``` -`FlowKit-Linux-Managed-Dual-Account-Readiness-20261113.zip` SHA-256 为 `24a339fe78ce28457fcf53eec8808d66fc2c83891fbfbd21f2e884b78b978e88`。 [Linux 命令、退出码与原始证据](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) +`FlowKit-Linux-OS-Chain-5842586-20261114.zip` SHA-256 为 `6cde468e651cf36879051b34588a124b8b717f020cc3aa121044125aee96510e`。Linux 端核对 156 个载荷文件与 ZIP 完整性。归档包含真实启动命令、退出码、两向原始协议、普通进程收件、只读快照与逐项 SHA-256。 [Linux 原始记录及归档](thread://6af5c6d6-410c-8191-be10-a7ad14d2702a?hostId=durable) + +Linux 命令沙箱 SEC-17 维持 BLOCKED,沿用既有容器的用户命名空间诊断。本轮按已确认范围执行同链实测。早先的环境核对归档 `FlowKit-Linux-Managed-Dual-Account-Readiness-20261113.zip` SHA-256 为 `24a339fe78ce28457fcf53eec8808d66fc2c83891fbfbd21f2e884b78b978e88`。 ## 裁决 -独立角色审查了 `os-chain` 的测试范围、受信监督器和首次启动问题。Windows 同一 Run 正向链通过。**NAT-14 原生异常恢复和 G12 仍为 BLOCKED**。E22 保留原 Pending 并在下一模拟回合接纳的 P 级证据有效,本批真实回合仅覆盖正常观察顺序。待补原生异常后有界结束及原 Pending 后续接纳、Linux 隔离双账户的实际命令沙箱、完整授权及披露范围和供应商故障证据。 +**Windows/Linux 同一 Run 正向链通过。** Windows 由独立角色复审,Linux 通过离线验签、哈希链及收件复核。NAT-14 原生异常恢复和 G12 仍为 BLOCKED,剩余门禁见 [发布矩阵](./release-matrix.md)。 diff --git a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md index c6212bf..b04649e 100644 --- a/docs/evidence/p0a-g07-g12-20261113/release-matrix.md +++ b/docs/evidence/p0a-g07-g12-20261113/release-matrix.md @@ -32,6 +32,7 @@ | E22 | `2594511d058da47f8cad43e57de48c23db8abf0e` | 共享观察派发与真实 SQLite 的 P 级恢复回归,拒绝后零签名、零 CAS,原 Pending 可由下一模拟回合接纳。[实测与剩余缺口](./stdio-pending-recovery.md) | | E23 | `8667704a79bd8f572ace969d48c5ef49d1add3ac` | Windows 同一 Run 的实际操作员进程创建、真实 Codex 签收、审计员进程收件。Linux 双账户沙箱目标仍缺条件。[同链实测、原始归档与环境裁决](./operator-model-audit-chain.md) | | E24 | `1d2d216e919a9d5aa5874123542682e4bca5105a` | Windows 真实 App Server 回合在只读工具等待点被受控取消,原 Pending 保留,同一线程下一回合接纳,审计员收件。Linux 缺专用目标与管理员供给入口。[实测、复审及环境证据](./native-interrupt-recovery.md) | +| E25 | `5842586a049364fcecc1f8b60df3ba40d5aa90df` | Linux 既有双账户容器内,同一 Run 的操作员创建、真实 Codex 签收及审计员收件通过。[Linux 同链实测](./operator-model-audit-chain.md#linux-同一-run) | ## SEC · 生产配置与候选入口 @@ -41,13 +42,13 @@ | 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,启用入口完整授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E20 验证实际模型身份及回调授权。E23 的 Windows 同一 Run 正向联测通过,操作员创建、真实 Model 签收和审计员收件已联证。Linux 同链及完整入口拒绝范围待验。 | +| SEC-05 | BLOCKED,启用入口完整授权 | E10–E13 覆盖真实错请求、缺观察、主体与文件引用错配。E20 验证实际模型身份及回调授权。E23/E25 的 Windows/Linux 同一 Run 正向联测通过,操作员创建、真实 Model 签收和审计员收件已联证。完整入口拒绝范围待验。 | | 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 后强杀恢复通过,E8 覆盖真实接纳后校验恢复。E19/E20 补 Genesis 前后与 12 个 SQLite 切点逐点验证。选定宿主上的组合证据随 T29 裁决。 | -| SEC-11 | BLOCKED,完整披露矩阵 | E1/E10 覆盖任务、工具回复、终态投影,E20 复核 E10 原始 Model 协议。E23 验证 Windows 同一 Run 的审计员收件、操作员读取拒绝以及 Model 协议无私有租户标记。Linux 同链和完整日志可见性仍缺。 | +| SEC-11 | BLOCKED,完整披露矩阵 | E1/E10 覆盖任务、工具回复、终态投影,E20 复核 E10 原始 Model 协议。E23/E25 验证双平台同一 Run 的审计员收件及操作员读取拒绝。完整日志可见性仍待验证。 | | SEC-12 | BLOCKED,真实资源边界 | E1 文件入口两个大小通过。E7 内联 64 KiB 通过,两档更大内联无工具调用。补实际入口异常正文、限流/超时及内联回执上限分类。 | | SEC-13 | BLOCKED,完整替换负例 | E7 的文件替换、硬链接、符号链接或目录联接点有 P 级证据。E5 的旧策略拒绝有双平台原生结果。补实际启动链下 Program 和所有已启用配置入口的替换负例。 | | SEC-14 | BLOCKED,生产轮换与撤销 | E4/E5/E8/E9 覆盖正常轮换、原 Ack 和分别撤销。E15/E16 补三个测试 Store 的部分撤销、测试入口阻断及逐库确认后的恢复。生产全量清单、入口暂停、实际权限变更及适用的证书/KMS 操作待测。 |