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-g01-20261112/README.md b/docs/evidence/p0a-g01-20261112/README.md new file mode 100644 index 0000000..ae34f2a --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/README.md @@ -0,0 +1,49 @@ +# G01 — P0-A 正式契约审查 + +**审查已执行;符合性未通过,待独立复审。** 已完整读取三份确认原件,逐项核对 [追踪表](./traceability.md) 中的 34 T / 22 R / 14 S / 31 计划任务及下文 §§1–18。结论是发现明确缺口、契约差异和未证明的场景。此阶段只交证据,没有修改 Core、ABI、Store、SDK 或 Standard,也没有进入 G04。 + +审查基线:`21b02e785cacf703c359d7d4f853b8196aa17b19`,树 `3beb2ef618d1061b184ddb774512c4d827783db7`,与合并 [G02 PR #21](https://github.com/devcodex-labs/flowkit/pull/21) 后的 main `5a82a88160e01b0e9e2e457dea6af1e3db2c54e0` 同树。[Draft PR #22](https://github.com/devcodex-labs/flowkit/pull/22) 是唯一交付 PR。[基线 Actions 43233955173](https://github.com/devcodex-labs/flowkit/actions/runs/43233955173) 七项成功;本机 Windows / Node 24.22.2,`npm ci` 与 `npm run verify` 均退出 0,395/395、0 跳过。CF-08 另行复现,结果见下。独立复审包有原始 stdout/stderr、真实命令、SHA-256 和精确增量 diff;新证据提交的 CI 状态在独立清单中记录。 + +| 冻结原件(实际读完) | 字节 | SHA-256 | 取证来源 | +|---|---:|---|---| +| Requirements V0.3 | 56883 | `3a0adc3edb41869a6309bae29ec5496df00970c815f8dc16330a90cbf26e703e` | 本地 Grok 交接 ZIP 原始条目,与冻结登记一致 | +| Technical Design V0.6-R5 | 114621 | `09f3a5698e238d10b80efb8e26d1c41e51b663ca620efa70815dd7b52f2106ba` | 本地 R5 确认交接 ZIP 原始条目,与冻结登记一致 | +| P0-A Plan V0.1 | 42988 | `0ac8719f204aae274e64abc30d02caa637fc55a5f1751a6884341f05117e02c0` | ChatGPT Library 原始下载,与冻结登记一致 | + +校验采用原始文件字节(未重编码)。范围 V0.3 全 710 行、R5 全 1128 行、计划全 319 行。R5 §18 将技术冻结与实施授权区分;计划的确认页和已确认交接决定给出 V0.1 实施范围。计划 §14 尾句留有“待用户确认”的旧措辞,保留原样;此处依据确认页、后续交接与用户本轮指示执行 G01/G03。R1/R2 仅作历史问题索引,审查结论直接取自原件和固定代码。下表的 D 是 `types/interactive.d.ts`。 + +| 发现 | 严重度 / 裁决 | 原件 → 当前行为 → 最小验证/修复候选(未执行) | +|---|---|---| +| CF-01 | P1,确认 P0-A 缺口 | R5 §§1.1/3.6/4–5/16、计划 A-M5-02/04/S12-A 要求独立受权 `adminTerminate`、无可用 Program 时仍能管理、原子墓碑和审计。公开入口、Coordinator/Trusted Host、类型中均无此能力;`src/interactive/index.mjs:2`、`coordinator.mjs:220/659`、`types/interactive.d.ts:183/254`。候选:独立管理凭据与单事务 CAS;验无 Program、普通模型输入拒绝、业务终态竞态及旧回执不能恢复运行。P0-B 的业务 cancel 不替代它。 | +| CF-02 | P1,DESIGN-QUESTION(确认差异,待裁决) | V0.3 §7.1 是示意;R5 §4 指向 §5 唯一规范签名、计划 A-M4-05 要求顶层 `start/complete/resume`。现为 `createInteractiveCoordinator`/`createTrustedInteractiveHost` 实例方法,签名也不同(入口第 2 行、D:161/183/254)。实际能力存在,未查得已批准用 factory 替代冻结顶层接口。先确认获批记录或向后兼容路径,再以发布包 ESM/TS 消费者验证;不直接加别名或撤旧接口。 | +| CF-05 | P1,确认 P0-A 投影缺口 | R5 §§4–5/7/11/16 与 A-M5-01 要求公开 Frame 用 `resultRef` 等有限信息,完整 State 走单独 `result:read` 可信读取。当前终态 `asPublicFrame` 包含 State(`coordinator.mjs:144`、D:83),受信 Host start/resume/load 转发(`trusted-host.mjs:147/159/163`);`getPublicAgentTask` 有授权和字段限制(:175、测试 :99/117)。证实 SDK 角色未分离,未证真实模型泄漏。候选:拆分公开投影和受权读取;分别验证私密数据不会随模型/stdout 发送、跨租户和错误角色拒绝、合法读取成功。 | +| CF-06 | P1,DESIGN-QUESTION(确认差异,待裁决) | R5 §§5/8.1 冻结五个 Promise 操作 `createIfAbsent/load/compareAndSwap/loadTransition/loadReceipt` 与独立 Registry;§13.2 允许 SQLite 内部 DatabaseSync。当前 D:106、`coordinator.mjs:220/247/332`、`sqlite-store.mjs:290/340` 是同步 Store+Registry,冻结五操作单独提供时不能接入。现有 SQLite 原子测试有效。先找授权的差异或评审适配方案;适配必须保住原子 CAS、Receipt/Journal、历史查询和丢 ACK 后线性化判断,另验非线性化读不会导致派发。 | +| CF-08 | P2,基线实测复现 | R5 R04、计划 A-M1-02/A-M2-02 的稳定快照/阶段门禁。 `compile.mjs:37–44` 首次 await 后重新读取原 `options.featureStage`。`probe-cf08.mjs`:稳定 P0-A 编译 7,634 B;调用后改原选项到 P0-B,产物 0 B,`FK_I_UNSUPPORTED_GRAPH`。探针退出 0 表示成功复现问题;没有绕过阶段门禁。候选:统一使用已校验快照,再用同一负例保证产物与对照一致。 | +| ABI-01 | DESIGN-QUESTION,静态差异 | R5 §§3/5/8 的 transitionId 使用规范化事件/Program/Proposal;`coordinator.mjs:29` 实际以 namespace/run/revision/block/kind 构造带前缀 ID。:54/92 检查当前 Block 与部分 Proposal 字段,并不能据测试证明冻结判别联合所有未知键/非法字段都拒绝。D:83/93 的 Frame/Pending 形态也不同。先独立确认已持久历史、支持版本及升级风险;不改计算、字段或 schema。 | + +CF-07(与 CF-08 分开)由 `606d055d5f02451fea0b30294889d740ef370d49` 修复,基线 `coordinator.test.mjs:407/421` 两项通过。CF-03 仍缺原生 Grok 可验证来源与真实 Host/Model 隔离;G03 仅能力 PoC。CF-04 维持已批准范围:Action/Human/Loop/业务 cancel 属 P0-B、Parallel all_succeeded 属 P1、any 留待 P2 专项裁决。 + +## V0.6-R5 §§1–18 正文审查 + +| § / 原件起始行 | 对照和裁决 | +|---|---| +| 1 / 27 | 产品链路仍为用户意图→可执行 JS→程序控制 Agent。G02 已合并有实际 JS/Pending,根入口 `src/index.mjs` 及包契约不扩张;本次不重跑 G02。Standard 与 Interactive 分层,无必需 FlowKit 服务。 | +| 2 / 60 | `compile.mjs:37`、`src/compiler/interactive/{lower,validate-ir,emit}.mjs` 与 `load.mjs:54` 落地 Standard 前端复用、真实 JS、同字节加载;程序决策和宿主持久化分离。ABI-01 留审。 | +| 3 / 142 | `coordinator.mjs:332/451/567/659`、crash 测试证明主要阶段/接纳屏障和无空 waiting;全故障矩阵 T29 部分,独立管理终止 CF-01。 | +| 4 / 226 | 实际子路径与公开 SDK/权限/可信读取对应 CF-01/02/05;R5 §5 是唯一规范声明,不能以 V0.3 示意代替。 | +| 5 / 264 | `types/interactive.d.ts:83/93/106/161/183/254` 和 `coordinator.mjs:29/92`:可执行 A 子集、显式形态差异 CF-02/05/06、ABI-01;未批准改 ABI。 | +| 6 / 509 | `sqlite-store.mjs:139/150/163/178` 的不可变字节、引用 FK、`coordinator.mjs:247` 固定旧 Program;m6:340 和 cache 测试成立,跨 SDK/CP 升级未实测。 | +| 7 / 547 | `trusted-host.mjs:175/221` 最小任务、可信回执可测;CF-05,Human 通道延期;实际模型和 Host 权限隔离未证。 | +| 8 / 592 | `sqlite-store.mjs:290/340` Genesis/CAS 和历史回执;R03/R11 仍缺、CF-06 的外部五操作未符合。 | +| 9 / 628 | 当前 SQL `sqlite-store.mjs:75/92–130/196/212/290/340` 对比 §9:WAL/FULL/外键、本地事务和 Journal/Receipt 索引有实测;实际四张 `fk_i_*` 表以 run→artifact FK 保留 Program,与参考 retain 结构不同;admin/outbox/墓碑与更广保留策略未证明。物理名字差异不单独判失败;不做迁移。 | +| 10 / 710 | 已落地 Condition/Terminal/基础 Agent;`feature-gate.mjs` 阻止提前扩张,P0-B/P1/P2 依原件。 | +| 11 / 787 | `canonical.mjs:34/111/115`、`trusted-host.mjs:175/221` 和 SQLite namespace,P 级测试通过;跨语言 JCS/完整文本重复键边界、目标 Grok 账户/ACL 及原生来源未证明。 | +| 12 / 820 | 基础错误已实测(FK_R002/FK_R004/容量/权限),运行期 `FK_I_INVALID_CHECKPOINT` 等实际分类与冻结词表须逐项核销;不扩大固定 Standard 61 码。 | +| 13 / 866 | 容量、预算、历史回执有界缓存已测,R12 长期样本缺;JS 不可用时真实拒绝记录缺(T11),G03 文件入口不能等同 FlowKit 2 MiB 安全验收。 | +| 14 / 901 | m6:171/250/293/365 证明 A 子集、状态与预算的对照。JSON 边界/缺失与 null 区别是已登记差异;B/P1/P2 不推断。 | +| 15 / 924 | 追踪表覆盖全部 T/R/S;原件的「未执行」是设计时状态,本轮证据逐项分级。 | +| 16 / 1030 | P0-A 与后续阶段区分;管理终止、可信读取、宿主能力是缺口或阻断,不能提前宣称 M5/M6 合格。 | +| 17 / 1084 | R5-01–10 是设计裁决,测试仅覆盖具体断言;远端 Store/升级/运维/宿主来源按剩余门禁处理。 | +| 18 / 1118 | 冻结授权与实施授权独立;依据本轮授权只做 G01/G03、同一个 Draft PR,停于独立复审。 | + +**退出状态:提交审查记录,冻结契约符合性未通过。** G04 依赖独立复审与对争议契约的裁决;T11、T27/S13 原生可信宿主、R03/R11/R12、跨版本、部分故障与完整 ABI/诊断仍待对应证据。证据等级:P 是现有流程运行,M 是真实模型实际消费 FlowKit Pending,T 是受保护观察链,N 是原生端到端可信验收,V 是可独立验证的 provider 证明;本次 G01 不产生新的 M/T/N/V。 diff --git a/docs/evidence/p0a-g01-20261112/commands.md b/docs/evidence/p0a-g01-20261112/commands.md new file mode 100644 index 0000000..1f0e9b1 --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/commands.md @@ -0,0 +1,21 @@ +# G01 执行与复现 + +PowerShell;命令从本次工作区运行,日志随本文件分发。审查用 detached worktree 对象为 `21b02e785cacf703c359d7d4f853b8196aa17b19`。 + +```powershell +git ls-remote https://github.com/devcodex-labs/flowkit.git refs/heads/main refs/heads/feat/p0a-interactive-20261112 +git -C D:/Worker/flowkit -c core.longpaths=true worktree add --detach 21b02e785cacf703c359d7d4f853b8196aa17b19 +Get-ChildItem work/frozen-originals -File | Get-FileHash -Algorithm SHA256 +Get-Content -LiteralPath -Encoding UTF8 +``` + +进入审查 worktree,实际依次运行 `npm ci` 与 `npm run verify`,各自重定向 stdout/stderr 并记录 `$LASTEXITCODE`。两条命令输出分别见 `npm-ci.*` 和 `npm-verify.*`;探针调用如下: + +```powershell +$audit = Resolve-Path outputs/flowkit-p0a-review/G01 +$repo = Resolve-Path work/flowkit-pr22 +node (Join-Path $audit 'probe-cf08.mjs') $repo 1> (Join-Path $audit 'cf08.stdout') 2> (Join-Path $audit 'cf08.stderr') +$LASTEXITCODE | Set-Content (Join-Path $audit 'cf08.exit') +``` + +正式原件包身份:`FlowKit-Interactive-Development-Handoff-For-Grok-2026-11-10.zip` = `8a2f679a796234383b5108d58db16c1016a35c6110301bc4eb7e091ee2ed5826`;`FlowKit-Interactive-V0.6-R5-Confirmed-Handoff.zip` = `2deb5b3fa60ed3b7c2d29f028e33835c47aaa0a976e947de717128caf7326549`。计划见 [Library 原始条目](https://chatgpt.com/space/file/libfile_de7a4fcb51c881919e942f9348ccd935)。读取各自原始文件的 SHA-256 和长度见 `verified-inputs.txt`。ZIP 原始条目解压后校验的是条目字节,未保存为重编码版本。 diff --git a/docs/evidence/p0a-g01-20261112/probe-cf08.mjs b/docs/evidence/p0a-g01-20261112/probe-cf08.mjs new file mode 100644 index 0000000..279ad89 --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/probe-cf08.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import {mkdtemp, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {pathToFileURL} from 'node:url'; + +const repo = resolve(process.argv[2]); +const {compileInteractive} = await import(pathToFileURL(join(repo,'src/interactive/compile.mjs'))); +const projectRoot = await mkdtemp(join(tmpdir(), 'flowkit-g01-cf08-')); +const file = join(projectRoot, 'cf08.workflow.js'); +await writeFile(file, "import {defineWorkflow,terminal} from '@devcodex-labs/flowkit/dsl';\n" + + "export default defineWorkflow({id:'cf08',version:'1.0.0',name:'CF08',nodes:[terminal('done',{name:'Done',status:'succeeded'})]});\n"); +const opts = () => ({output:'memory',featureStage:'P0-A',projectRoot}); +const control = await compileInteractive(file, opts()); +const options = opts(); +const pending = compileInteractive(file, options); +options.featureStage = 'P0-B'; +const raced = await pending; +console.log(JSON.stringify({node:process.version, file, controlBytes:Buffer.byteLength(control.code), racedBytes:Buffer.byteLength(raced.code), diagnostics:raced.diagnostics}, null, 2)); +assert.ok(control.code.length); +assert.equal(raced.code, ''); +assert.ok(raced.diagnostics.some(d=>d.message.includes('P0-B'))); diff --git a/docs/evidence/p0a-g01-20261112/sha256.txt b/docs/evidence/p0a-g01-20261112/sha256.txt new file mode 100644 index 0000000..82b1c74 --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/sha256.txt @@ -0,0 +1,14 @@ +13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354 cf08.exit +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cf08.stderr +29b364604f36f6d29d8ff7685867f86e389ab943c9afc00151c316c8c0700f02 cf08.stdout +0ed9115fdd627b14aa67b3e87706aa47f87d4aeee32e7abf5aecb3117d3afa51 commands.md +13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354 npm-ci.exit +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 npm-ci.stderr +ebbc3b8201ebd2d3248fb714b906556f5447e3c046672586c902d0dba9599dfa npm-ci.stdout +13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354 npm-verify.exit +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 npm-verify.stderr +328458367d798b68574509c7e928405475ca2eda13b8235bcb1ed65427e6dafb npm-verify.stdout +383d49b1b3d5da71276cee80b779d394029168c262d9a84137fb69f7802b2561 probe-cf08.mjs +059985a7d17927148ffd85069a671f202f215d5718ddb7b7e1290c47d4bff986 README.md +f17fd767459098b431d8d79a308b88b6a25ea2b9df44fa040703d93d8256d9f3 traceability.md +e016df07605e9b03444794754851ebabe3619b2e09d429f3607641b240c5c033 verified-inputs.txt diff --git a/docs/evidence/p0a-g01-20261112/traceability.md b/docs/evidence/p0a-g01-20261112/traceability.md new file mode 100644 index 0000000..fa04881 --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/traceability.md @@ -0,0 +1,134 @@ +# G01 — 冻结原件追踪表 + +审查对象 `21b02e785cacf703c359d7d4f853b8196aa17b19`。原件位置见下表;测试位置以审查对象为准。下面的路径缩写:**I/** = `src/interactive/`,**CI/** = `src/compiler/interactive/`,**t/** = `tests/interactive/`(测试名默认后缀 `.test.mjs`),**m6** = `tests/integration/m6-e2e.test.mjs`,**D** = `types/interactive.d.ts`。冒号后是起始行。 + +**P** = 基线实际运行的程序/Store/脚本 Host 覆盖所述子例;**P 部分** = 有运行证据但缺完整断言、切点、阶段或适配器;**静态** = 源码/设计证据;**缺口** = P0-A 应交付但当前不存在或不符合;**待裁决** = 存在接口或语义冲突,未找到获批替代契约;**延期** = 冻结范围明确排除本阶段;**BLOCKED** = 缺真实运行、前置权限或专项决策。所有测试引用结合 `npm-verify.stdout` 使用;相邻的设计断言未因此变成 PASS。 + +## T01–T34 + +需求 V0.3 §16 第 598–631 行,技术 V0.6-R5 §15.1 第 932–965 行;两列不完全等价的,以各原件的断言为准。 + +| ID | V0.3 / R5 行 | 结果与证据 | +|---|---|---| +| T01 | 598 / 932 | P:生成 JS 路由、各阶段持久化、无 Agent 终点及缺布尔路径,t/emitter:51、t/coordinator:75/90/115/122、m6:250/293/365。 | +| T02 | 599 / 933 | P:Pending 持久和 Agent 指令、Schema、缺失及 attempt 子例,I/coordinator.mjs:153/391、t/coordinator:90、t/trusted-host:314、m6:201。 | +| T03 | 600 / 934 | P 部分:发布后重启并接纳、接纳后不重新派发,t/crash:89/118、m6:201/234;全切点见 T29。 | +| T04 | 601 / 935 | P:非法输出/异常 validator/缺绑定不提交候选,t/coordinator:170/197/283/353/365;非纯外部验证延期。 | +| T05 | 602 / 936 | P:错误 node/attempt/requestId、跨租户 runId 拒绝;startKey 冲突和命名空间隔离,t/coordinator:151/231/313、t/store:107、t/trusted-host:357。 | +| T06 | 603 / 937 | P:旧同回执保留首次接受 revision、最新 Frame、不同内容拒绝,t/coordinator:137/151/297/376、t/crash:152。 | +| T07 | 604 / 938 | P 部分:身份/字节/策略/非法边子例,I/load.mjs:54、t/emitter:103、t/ir:62、t/coordinator:244、m6:279/306/340;跨 SDK 版本未实测。 | +| T08 | 605 / 939 | P:非法值/循环/accessor/Proxy/上限拒绝、own-key 数据与输入快照,t/store:40/50、t/coordinator:324/407/421、t/s07-capacity:89/419。 | +| T09 | 606 / 940 | P 部分:真实 SQLite 双进程和丢 ACK,t/store:196、t/crash:152、t/coordinator:376;未覆盖不合规 Store 的陈旧读(R03/R11)。 | +| T10 | 607 / 941 | P:Standard 合法而 A 禁用的节点和绑定拒绝;纯流程可运行,t/compiler-front:38/57/67/79/89、t/coordinator:283;另有 CF-08。 | +| T11 | 608 / 942 | BLOCKED:没有无 JS 平台上的实际 Host 拒绝记录;文档要求不冒充可执行。 | +| T12 | 609 / 943 | P:`npm run verify` 的 Standard/AOT、类型、打包和固定契约;package.json、tests/contracts.test.mjs、基线 Actions 43233955173。 | +| T13 | 610 / 944 | P:Agent attempt、reasons、State 不变,t/coordinator:170;高级验证 P0-B。 | +| T14 | 611 / 945 | P 部分:合法失败和耗尽 FK_R004,t/coordinator:170/213;控制信号与失败并发仍是 R17。 | +| T15 | 612 / 946 | 延期 P0-B:高级 Agent 验证顺序与候选 State;t/compiler-front:57、t/coordinator:283 证明 A 不会静默忽略。 | +| T16 | 613 / 947 | 延期 P0-B:Action 写集合、提交和外部效果;t/compiler-front:38。 | +| T17 | 614 / 948 | 延期 P0-B:Action 对账;R5 §10.3。 | +| T18 | 615 / 949 | 延期 P0-B:Action 错误与跳过 Verification;R5 §10.3/14。 | +| T19 | 616 / 950 | 延期 P0-B:独立/Loop Verification;t/compiler-front:38。 | +| T20 | 617 / 951 | 延期 P0-B:可信 Human/修订;R5 §10.5。 | +| T21 | 618 / 952 | 延期 P0-B:Loop 边界;R5 §10.6。 | +| T22 | 619 / 953 | 完整 Skill 失败/重试矩阵延期 P0-B;P0-A 旧 Pending 与 Skill/Program 身份子例,m6:201/340。 | +| T23 | 620 / 954 | 业务 cancel、补偿和控制信号延期 P0-B;计划 T23-A 管理终止是独立 P0-A 缺口(CF-01)。 | +| T24 | 621 / 955 | 延期 P1:Parallel all_succeeded;R5 §10.7。 | +| T25 | 622 / 956 | BLOCKED(P2 专项决策):any 与迟到结果未冻结;V0.3 §15、R5 §10.7/16。 | +| T26 | 623 / 957 | 延期 P1:多 Pending/写集合并发冲突;R5 §10.7。 | +| T27 | 624 / 958 | P:m6:171/328;M:已合并 G02 的 ChatGPT Pending 证据。原生 Grok T/N/V BLOCKED;同 PR G03 只证实合成 MCP 能力。 | +| T28 | 625 / 959 | P 部分:五导出和包/类型/文档回归、此矩阵;CF-02/05/06 说明版本/接口并未全面对齐。 | +| T29 | 626 / 960 | P 部分:t/crash:89/118/152、m6:201/234,覆盖已列的 Agent 前后切点;未证 SQLite 内核/掉电、任意切点或 P0-B。 | +| T30 | 627 / 961 | P 部分:程序/Checkpoint/签名/非法边负例 t/emitter:103、t/ir:62、t/store:226、t/trusted-host:147/357;完整运行时 Proposal 判别及 OS 隔离未证。 | +| T31 | 628 / 962 | P 部分:纯 validator 失败、双进程仅接纳一次 t/coordinator:197/365、t/crash:152;不会宣称可检测任意副作用或 exactly-once。 | +| T32 | 629 / 963 | 延期 P0-B:不支持幂等的外部 Action 的未知状态;R5 §10.3。 | +| T33 | 630 / 964 | P 部分:Standard/Interactive A 子集对照 m6:171/293/365、t/emitter:51;Human、Loop、Action 路线延期。 | +| T34 | 631 / 965 | P 部分:容量、暂停恢复、PublicTask 最小字段 t/s07-capacity:89/165/407/419、m6:250/328、t/trusted-host:99;长期压力、正式结果投影和原生隔离未证。 | + +## R01–R22 + +技术 V0.6-R5 §15.2 第 975–986、992–1001 行。 + +| ID | R5 行 | 结果与证据 | +|---|---:|---| +| R01 | 975 | P:接纳后、校验前 kill 并恢复候选,t/crash:118。 | +| R02 | 976 | P 部分:晚到重复保留原 acceptedRevision、返回最新 Frame,t/coordinator:137、t/s07-capacity:165/193;独立核查精确 revision+5 场景。 | +| R03 | 977 | P 部分:丢 ACK 恢复 t/coordinator:376;未证非线性化适配器返回陈旧读会被安全拦截(CF-06)。 | +| R04 | 978 | P:State/startIntent 在 await 前快照,t/coordinator:407/421;编译 options 的 CF-08 是另一个入口。 | +| R05 | 979 | P 部分:throw/undefined、稀疏非法数组,t/coordinator:197/365;数字返回同类型路径,但这两项不等于数字子例有独立记录。 | +| R06 | 980 | 静态:纯声明契约(R5 §10.1),不得推断可拦截副作用;现有纯校验拒绝 t/coordinator:197,外部验证器延期。 | +| R07 | 981 | 延期 P0-B:Action/Verification/contextError;R5 §10.3/14。 | +| R08 | 982 | P:received 恢复不重新发同一请求或空 waiting,t/crash:118、t/coordinator:258。 | +| R09 | 983 | P:同命名空间冲突、不同租户分隔,t/store:107、t/coordinator:231/313。 | +| R10 | 984 | P:相同经验证字节才执行、篡改拒绝,I/load.mjs:54、t/emitter:103、m6:306/340;不等于任意可信 Registry 管理策略已验证。 | +| R11 | 985 | P 部分:晚到回执和丢 ACK 分别实测,t/coordinator:137/376;未证与并发多个中间提交同时发生的完整组合。 | +| R12 | 986 | P 部分:21 条 receipt 后的有界 Checkpoint 和旧 Ack,t/s07-capacity:165/193;≥2000 条/WAL 写放大没有测量。 | +| R13 | 992 | P 部分:稳定 IR/缺阶段拒绝 t/ir:23/62;`await` 与 `awaiting` 精确混合负例未单列。 | +| R14 | 993 | P 部分:编译 IR 错边 t/ir:62、产物不符 t/emitter:103;I/coordinator.mjs:54/92 校验当前 Block/部分字段,完整运行时 Proposal 非法并集未证。 | +| R15 | 994 | 延期 P0-B:Verification 执行错误 vs false;R5 §10.4。 | +| R16 | 995 | 延期 P0-B:断线不是 Human 拒绝;R5 §10.5。 | +| R17 | 996 | P 部分:普通调用失败重试 t/coordinator:213;控制信号与失败同时出现的完整场景未执行,业务取消为 B。 | +| R18 | 997 | P:无 Agent 无需校验器或解析器,t/compiler-front:89、t/coordinator:75。 | +| R19 | 998 | 延期 P0-B:checkStep/verify 视图;R5 §10.1/14。 | +| R20 | 999 | 延期 P0-B:contextError 后继语义;R5 §10.3/14。 | +| R21 | 1000 | P 部分:业务漂移/同 key、预算独立最终状态 t/coordinator:231/258、m6:250/279;变日志选项的完整组合未证明。 | +| R22 | 1001 | 延期 P0-B:同 operationId 的未知/确认对账;R5 §10.3。 | + +## S01–S14 + +技术 V0.6-R5 §15.3 第 1007–1020 行。这里的 S 场景编号与 P/M/T/N/V 证据等级无关。 + +| ID | R5 行 | 结果与证据 | +|---|---:|---| +| S01 | 1007 | P:t/coordinator:75/122、m6:365,纯终点/缺输入和终态稳定。 | +| S02 | 1008 | P 部分:t/coordinator:90/170、t/crash:89/118、m6:201;未涵盖底层 SQLite 掉电。 | +| S03 | 1009 | P 部分:t/coordinator:197/283/365;暂缺绑定后再挂载的恢复协议没有完整证明。 | +| S04 | 1010 | P 部分:t/crash:152、t/coordinator:137/151/376,适用于 SQLite/现有 API;外部 Store 接缝 CF-06。 | +| S05 | 1011 | P 部分:t/store:107/226、t/ir:62、t/coordinator:313、t/trusted-host:357;完整伪造 PC/合法边错组合及真实隔离未证。 | +| S06 | 1012 | P 部分:t/store:82/241、t/stage2-program-cache:76、m6:340;跨 SDK/CP Schema 矩阵未执行。 | +| S07 | 1013 | P:t/store:40/50、t/s07-capacity:89/109/165/419、m6:250 与 tests/integration/m6-ledger-boundary.test.mjs:28;限额和预算结论限定于现有 P 测试,不推及真实模型安全边界。 | +| S08 | 1014 | 延期 P0-B:高级验证读视图/进程恢复;R5 §10.1/14。 | +| S09 | 1015 | 延期 P0-B:Action Patch 与 reconcile;R5 §10.3。 | +| S10 | 1016 | 延期 P0-B:Action 错误路线;P0-A 的可信结果读取另见 CF-05。 | +| S11 | 1017 | 延期 P0-B:Human 真审批与 Loop 最终修复边界;R5 §10.5/10.6。 | +| S12 | 1018 | P0-A 管理终止缺口(CF-01、计划 S12-A);P0-B 业务补偿延期。 | +| S13 | 1019 | P/M 已有,原生 Grok T/N/V BLOCKED;m6:171、G02 #21、同 PR G03。 | +| S14 | 1020 | P 部分:基线本地和 CI 四矩阵、包和类型;T33 完整分期及新提交 CI 分开记录。 | + +## A-M1-01–A-M6-06(31 项) + +计划 V0.1 §§4–9(原件行号如下)。计划中建议的文件名不是唯一判定标准;有同等功能时定位实际实现,缺失功能另列。 + +| 任务 | V0.1 行 | 当前文件 / 可观察证据与残余 | +|---|---:|---| +| A-M1-01 | 114 | P:I/compile.mjs:37/41 调用现有 src/compiler/analyze.mjs,t/compiler-front:17/79,真实 JS/TS 与 Standard 诊断。 | +| A-M1-02 | 115 | P 部分:CI/feature-gate.mjs、t/compiler-front:38/57/67/89;CF-08 是 options 快照差异。 | +| A-M1-03 | 116 | P:CI/lower.mjs、t/ir:23/54/91、t/coordinator:122,稳定图/源身份和缺布尔。 | +| A-M1-04 | 117 | P 部分:CI/validate-ir.mjs、t/ir:62;运行时 Proposal 全量判别另审。 | +| A-M1-05 | 118 | P:t/compiler-front:17/79、t/ir:23/62/100 及本地 Standard 回归。 | +| A-M2-01 | 130 | P:CI/emit.mjs、t/emitter:35/51/75/119,零 import、字节稳定、真实分支。 | +| A-M2-02 | 131 | P 部分:I/compile.mjs、CI/feature-gate.mjs、t/emitter:119、t/compiler-front:79;CF-08,诊断全部字段未逐项核销。 | +| A-M2-03 | 132 | P:实际是 I/load.mjs:54;t/emitter:103、t/stage2-program-cache:76、m6:306。 | +| A-M2-04 | 133 | 待裁决:I/coordinator.mjs:29/47/54/92/117、t/ir:62;完整运行时 unknown keys、transitionId 和 ABI 形态待确认。 | +| A-M2-05 | 134 | P 部分:t/emitter:35/51/75/103、t/coordinator:122;完整字面转义向量未逐项核销。 | +| A-M3-01 | 146 | P 部分:I/canonical.mjs:34/111/115、I/sqlite-store.mjs:139、t/store:40/50/82;重复 JSON 文本键/JCS 互操作和五操作外部 Store 尚未证明。 | +| A-M3-02 | 147 | P 部分:I/sqlite-store.mjs:75/92/196/290/340、t/store:130/167/196/241;R5 DDL、冻结接口 CF-06 待裁决。 | +| A-M3-03 | 148 | P 部分:I/sqlite-store.mjs:92/150/163/178/290、t/store:82、t/coordinator:244;同库 FK retain 实际有效,V1/V2 共存/迁移未证。 | +| A-M3-04 | 149 | P 部分:t/store:40/50/82/107/130/167、t/coordinator:137/376;R03/R11 的组合场景仍缺。 | +| A-M3-05 | 150 | P 部分:t/crash:89/118/152;应用进程故障和事务异常有测试,SQLite 内部掉电未测。 | +| A-M4-01 | 164 | P 部分:I/coordinator.mjs:451/534/567/659、t/coordinator:75/90/122/258;§5 运行时 shape 仍有差异。 | +| A-M4-02 | 165 | P 部分:I/coordinator.mjs:174/247/534、t/coordinator:231/283、t/stage2-integration:141/173;日志参数完整组合 R21 未执行。 | +| A-M4-03 | 166 | P:I/coordinator.mjs:153/391/419、t/coordinator:90/170/197/324、t/trusted-host:314;高级验证为 B。 | +| A-M4-04 | 167 | P 部分:I/coordinator.mjs:567、t/coordinator:137/151/376、t/crash:118;外部 Store 语义 CF-06。 | +| A-M4-05 | 168 | 待裁决(CF-02):I/index.mjs:2、D:161、package.json;根入口保持,子路径可消费,冻结顶层签名缺失。 | +| A-M4-06 | 169 | P 部分:t/coordinator:170/197/231/258/297、t/crash:89/118/152;不是所有主机/故障切点。 | +| A-M5-01 | 190 | 缺口(CF-05):I/trusted-host.mjs:175 和 t/trusted-host:99/117 证明 PublicTask;I/coordinator.mjs:144、D:83 仍把完整终态 State 放进 SDK Frame。 | +| A-M5-02 | 191 | 缺口(CF-01):I/index.mjs:2、I/coordinator.mjs:220、D:183 无独立 Admin Controller/凭据/墓碑事务。 | +| A-M5-03 | 192 | P 部分:m6:171/328、tests/integration/m6-host.mjs 短进程实验;未证生产主机完整授权或无 JS 主机。 | +| A-M5-04 | 193 | P 部分:t/trusted-host:99/117/147/291/357、t/coordinator:313;管理端和目标 Grok OS 证据仍缺。 | +| A-M6-01 | 207 | P:package.json、scripts/package-files.mjs、scripts/check-package.mjs、tests/contracts.test.mjs;verify 有契约和包证据。 | +| A-M6-02 | 208 | P 部分:tests/types/interactive.ts 在仓库内直接检查声明,tests/package/consumer.mjs 实测已安装 tarball 的 Interactive ESM;现有 TS tarball 消费者没有直接 import Interactive,仍缺该子路径的已安装 TS 最低版本正负例。CF-02/05/06 仍影响冻结声明。 | +| A-M6-03 | 209 | P:package.json、.github/workflows/ci.yml、基线 Actions 43233955173,四矩阵加安全/文档检查与本地 verify。 | +| A-M6-04 | 210 | P 部分:t/crash:89/118/152、t/coordinator:244/376、m6:201/234/306/340;完整存储故障矩阵未关闭。 | +| A-M6-05 | 211 | P 部分:docs/interactive.md、docs/interactive-host-security.md、m6:171/328;NAT-14、安全宿主及完整差异仍开放。 | +| A-M6-06 | 212 | P 部分:m6:250、t/s07-capacity:165/193 与计划容量断言;无 ≥2000 阶段/WAL 写放大、延迟分位或跨平台性能原始样本。 | diff --git a/docs/evidence/p0a-g01-20261112/verified-inputs.txt b/docs/evidence/p0a-g01-20261112/verified-inputs.txt new file mode 100644 index 0000000..76178ef --- /dev/null +++ b/docs/evidence/p0a-g01-20261112/verified-inputs.txt @@ -0,0 +1,6 @@ +0ac8719f204aae274e64abc30d02caa637fc55a5f1751a6884341f05117e02c0 42988 01-P0A-IMPLEMENTATION-PLAN-V0.1-CONFIRMED.md +ccb61c7f2cf48aaae02551b7d4e9c89dbaca068728a124c8f7d2c40889701703 6107 02-ONE-PR-DELIVERY-PLAN.md +4cf572072f51d0cb8142af88f8c2030e0dbd15a93d8415aa771b4c551feb8496 7635 03-EXECUTION-ROADMAP.md +4ab6c2872eb1289b8a5215e90b1b8daedcf6007f5269a9144f894f5630ff67b8 4301 06-FROZEN-SOURCES-AND-LINKS.md +3a0adc3edb41869a6309bae29ec5496df00970c815f8dc16330a90cbf26e703e 56883 FlowKit-Host-Driven-Interactive-Execution-Requirements-V0.3-Confirmed.md +09f3a5698e238d10b80efb8e26d1c41e51b663ca620efa70815dd7b52f2106ba 114621 TECHNICAL-DESIGN-V0.6-R5-CONFIRMED.md diff --git a/docs/evidence/p0a-g03-20261112/README.md b/docs/evidence/p0a-g03-20261112/README.md new file mode 100644 index 0000000..21588e4 --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/README.md @@ -0,0 +1,34 @@ +# G03 — Grok Build 原生工具能力 PoC + +**已执行,结果 PARTIAL;G07 的安全前置条件 BLOCKED。** 真正的 Grok Build 能注册、发现并调用本地 MCP,且小型内联入参有独立可观察的开始事件。文件入参可到 MCP,但开始事件只含文件路径;PoC 未取得在要求范围内可独立验证的完整开始入参,也没有可信签名、持久收据或受保护的观察链。不能据此通过 NAT-14、S13、T27 或宣称原生安全宿主可用。 + +基线 `21b02e785cacf703c359d7d4f853b8196aa17b19`,无 FlowKit 产品代码改动。Windows,Node `v24.22.2`,真实 `grok 1.0.83 (adcd31156f45)`,Grok EXE SHA-256 `b589678bb2597796068b7d2add337a7182f93ef76522267fa2db28a894f50557`,成功回合的模型为 `grok-4.7-build`。初查 `grok models` 报未认证,随后真实模型回合成功;未提供或读取凭据,也未为其他目录扩大权限。只对临时项目 MCP 目录使用 `--trust`,以 `dontAsk` 和 `MCPTool(flowkitg03__probe)` 运行,未启用无条件审批、shell、web search 或子 agent。 + +## 真实观察 + +`run.mjs` 启动真实 CLI,逐字节保存 stdout/stderr;另外解析流里的 `tool_call`/`tool_call_update`。临时 stdio `probe.mjs` 是合成工具,不依赖 FlowKit 或任何签名/Store。观察器只在拿到同一小型内联参数的 SHA-256 和 `toolCallId` 后写临时 gate;MCP 端独立由收到的参数计算摘要、核对 gate,最多等 4 秒。注册命令、MCP Doctor 实际握手和发现、CLI 实际事件在每轮原始日志中保存(详见 `commands.md` 和哈希清单)。 + +| 样本 / 日志目录时间(UTC) | CLI 开始事件 | MCP 实收 / 响应 | 裁决 | +|---|---|---|---| +| 小内联,14:32:48 与 14:41:49 | `use_tool`,`pending`,`rawInput.tool_input` 为完整 82 B 规范样本;同 ID 后续 `completed` | 两次独立摘要相符,gate 放行 | 真实能力成立;只能证明观察早于工具成功返回 | +| 64 KiB,14:34:56 | 有 ID,仅 `rawInput.tool_input_file` | 65,536 B,与文件 SHA 相符;门保持关闭,调用 failed | 传输到 MCP 成立,开始事件未给字节 | +| 256 KiB,14:35:18 | 同上 | 262,144 B,与文件 SHA 相符;门保持关闭,调用 failed | 同上 | +| 2 MiB − 4 KiB,14:35:44 | 同上 | 2,093,056 B,与文件 SHA 相符;门保持关闭,调用 failed | 同上;仅为选定的近上限样本,非声明 CLI 或 FlowKit 精确上限 | +| 不放行,14:36:14 | 有真实开始、ID/小入参均相符 | 人为扣住 gate;MCP 4,022 ms 后返回失败,随后同调用 ID failed | 该 PoC 有界失败,不会因缺 gate 永久等待 | +| 错 nonce,本地负例 | 无真实 CLI / 无模型 | 独立调用合成 endpoint,`isError:true`、无 gate,未释放 | 只证 fixture 拒绝无匹配;不能证明抵抗伪造 stdout、写 gate、文件替换或跨用户攻击 | + +文件传输的三组 fixture 和 MCP 摘要分别是: + +- `e582bd96184cfa8782f824471e756f0e1303e114eb0f6d358559f2cb62837a37` +- `30f1255d6ed2eedec663b7cd6ecf4133dcc9f932e9f7be11cfb8049d9ebe9393` +- `b077081ef9635e26d2a1049d3644770e14d822ecbafe1a256c7636c454eaee71` + +小内联复测 `run-2026-11-12T14-41-49-005Z`,调用 ID `call-a577f2f9-8591-481e-bc3d-176a4414176c-1`,摘要 `01560b2722fa7cad8483fbd839b54b3f22b6ae2a860f49a4bf6444b2052a17a9`。同机 epoch 毫秒:MCP ingress `1794494524116` → 观察开始 `1794494524122` → gate `1794494524123` → MCP response `1794494524155` → CLI completed `1794494524157`。首次正例也为 MCP ingress 先发生(领先 4 ms);没有观察到可保证先于工具进程接收请求的 hook。两进程的 wall clock 用于对齐,观察器另记单调时间;这些时间不证明 API 一般性顺序保证。这里没有签名步骤;所谓“签名前”最多能在合成门中模拟,不是签名前安全验收。 + +两次失败的安装/授权探索也保留:14:30:41 项目工具因目录未信任而拒绝,14:32:29 使用的旧 permission 语法无效。某些 CLI 回合退出码为 0 但工具事件是 failed,因此以工具事件和 MCP 实收为准。文件路径只作定位,不能把读同一路径当作“同一字节”的来源证明。本轮未测大入参直接内联的模型生成/流截断,不能推出 CLI 所有模式均无法观察大参数。 + +## 证据等级与复审门槛 + +G03 是真实 CLI PoC。**无新增 FlowKit P/M/T/N/V**:未执行 FlowKit JS 或 Pending,M 要求真实模型处理真实 FlowKit Pending;观察器与模型端使用同一 OS 用户,不能作为受保护 T;没有生产 Host、签名/CAS、端到端 N 或可独立验证的 V。原始流中的不透明 usage 字段不作为 provider 签名证据。 + +继续 BLOCKED:完整大入参的独立绑定;真实 Host/Pending/tenant 的预签绑定;受保护 OS 通道和伪造负例;NAT-14。供复审的备选:验证是否有能提供同一份规范字节的原生流,或单独裁决一种由受保护 Host 生成并固定不可变内容摘要的传递机制;后者须验证路径替换、独立身份、顺序与失配拒绝,不能信任模型自报摘要。没有实施备选,也未启动 G05/G07/H3-02。文档参照:[Grok Build CLI](https://docs.x.ai/build/cli/reference);本报告运行事实来自原始记录。 diff --git a/docs/evidence/p0a-g03-20261112/commands.md b/docs/evidence/p0a-g03-20261112/commands.md new file mode 100644 index 0000000..a7acc4a --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/commands.md @@ -0,0 +1,25 @@ +# G03 执行与复现 + +每次真实执行的绝对命令、参数、工作目录、PID、退出码、时间以各 `run-*/observer.ndjson` 的 spawn/exit 行为准。CLI 原始 stdout/stderr 文件按 01 版本、02 项目注册、03 发现、04 doctor、05 实际模型回合编号。MCP 自身的参数摘要和响应记录另见 `mcp-rpc.ndjson`。最初两次错误和首次成功使用迭代中的脚本;它们的实际命令与原始输出均保留。下列调用使用包中当前脚本,覆盖四组边界和 14:41:49 的小参数复测。 + +```powershell +$g = Resolve-Path outputs/flowkit-p0a-review/G03 +$p = Resolve-Path work/g03-cli +$grok = Join-Path $env:USERPROFILE '.grok/bin/grok.exe' +$node = (Get-Command node).Source +Get-FileHash -LiteralPath $grok -Algorithm SHA256 +node (Join-Path $g 'run.mjs') $grok $p $node trusted 65536 +node (Join-Path $g 'run.mjs') $grok $p $node trusted 262144 +node (Join-Path $g 'run.mjs') $grok $p $node trusted 2093056 +node (Join-Path $g 'run.mjs') $grok $p $node withhold 0 +node (Join-Path $g 'run.mjs') $grok $p $node trusted 0 +``` + +复跑时创建自己控制的空临时项目目录(`$p`),并使用已可用的 Grok Build。脚本对该项目目录使用 CLI 的项目 MCP 注册和信任;不要指向真实项目。生成的三个固定大小样本另存于 `fixtures/`。小内联样本 82 B 是 `JSON.stringify({nonce,payload})` 的 UTF-8;观察器和 MCP 各自计算,不作为 JCS 互操作证明。 + +错 nonce 的非原生负例(只用本地合成 MCP): + +```powershell +$n = Join-Path $g 'negative-wrong-nonce' +Get-Content (Join-Path $n 'input.ndjson') | node (Join-Path $g 'probe.mjs') (Join-Path $n 'mcp-rpc.ndjson') (Join-Path $n 'absent.gate') expected-fixture +``` diff --git a/docs/evidence/p0a-g03-20261112/probe.mjs b/docs/evidence/p0a-g03-20261112/probe.mjs new file mode 100644 index 0000000..33a50c4 --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/probe.mjs @@ -0,0 +1,33 @@ +// Synthetic stdio MCP endpoint. No FlowKit, credentials, receipt signer or network. +import { createHash } from 'node:crypto'; +import { appendFileSync, existsSync, readFileSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { setTimeout } from 'node:timers/promises'; +const [, , log, gate, nonce] = process.argv; +const record = (kind, fields = {}) => appendFileSync(log, JSON.stringify({ kind, at: Date.now(), pid: process.pid, ...fields }) + '\n'); +const send = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n'); +const lines = createInterface({ input: process.stdin }); +for await (const line of lines) { + const request = JSON.parse(line); + if (request.id === undefined) continue; + if (request.method === 'initialize') { + record('initialize', { protocolVersion: request.params?.protocolVersion }); + send(request.id, { protocolVersion: request.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'flowkitg03', version: '0.1.0' } }); + } else if (request.method === 'tools/list') { + record('tools/list'); + send(request.id, { tools: [{ name: 'probe', description: 'Submit synthetic test data; waits for a separate observer to acknowledge seeing the CLI start event.', inputSchema: { type: 'object', properties: { nonce: { type: 'string' }, payload: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }] } }, required: ['nonce','payload'], additionalProperties: false } }] }); + } else if (request.method === 'tools/call' && request.params?.name === 'probe') { + const args = request.params.arguments; + const good = args?.nonce === nonce && Object.keys(args).length === 2 && (typeof args.payload === 'string' || (Array.isArray(args.payload) && args.payload.every(x=>typeof x==='string'))); + const canonical = good ? JSON.stringify({ nonce, payload: args.payload }) : ''; + const digest = good ? createHash('sha256').update(canonical).digest('hex') : null; + record('tools/call', { rpcId: request.id, bytes: Buffer.byteLength(canonical), digest }); + const until = Date.now() + 4000; + while (good && !existsSync(gate) && Date.now() < until) await setTimeout(20); + const released = good && existsSync(gate) && readFileSync(gate,'utf8').trim() === digest; + record('respond', { rpcId: request.id, released, digest }); + send(request.id, { isError: !released, content: [{ type: 'text', text: released ? `observed ${digest}` : 'probe gate closed; no native start match' }] }); + } else { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Method not found' } }) + '\n'); + } +} diff --git a/docs/evidence/p0a-g03-20261112/run.mjs b/docs/evidence/p0a-g03-20261112/run.mjs new file mode 100644 index 0000000..b058552 --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/run.mjs @@ -0,0 +1,81 @@ +// Capture raw streams from the actual Grok Build executable, with a bounded timeout. +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const [exe, cwd, node = process.execPath, mode = 'untrusted', targetBytesText = '0'] = process.argv.slice(2); +const here = fileURLToPath(new URL('.', import.meta.url)); +const dir = join(here, `run-${new Date().toISOString().replace(/[:.]/g, '-')}`); +mkdirSync(dir); +mkdirSync(cwd, { recursive: true }); +const nonce = randomUUID(), rpc = join(dir,'mcp-rpc.ndjson'), gate = join(dir,'observed.gate'); +const timeline = join(dir, 'observer.ndjson'); +const event = (kind, fields = {}) => appendFileSync(timeline, JSON.stringify({ kind, at: Date.now(), monoNs: String(process.hrtime.bigint()), ...fields }) + '\n'); +function invoke(label, args, observe) { + return new Promise((resolveRun, reject) => { + const stdout = join(dir,`${label}.stdout`), stderr = join(dir,`${label}.stderr`); + writeFileSync(stdout,''); writeFileSync(stderr,''); + const cp = spawn(exe,args,{ cwd, windowsHide: true, stdio:['ignore','pipe','pipe'] }); + event('spawn',{label, executable: exe, args, cwd, pid: cp.pid }); + let tail = ''; + // Pipe chunks are Buffers. Save the original bytes and decode a separate + // copy incrementally for observation. + const decoder = new TextDecoder(); + cp.stdout.on('data',chunk=>{ + appendFileSync(stdout, chunk); + if (!observe) return; + tail += decoder.decode(chunk, { stream: true }); + let cut; + while ((cut=tail.indexOf('\n'))>=0) { + const line=tail.slice(0,cut); tail=tail.slice(cut+1); + try { observe(JSON.parse(line)); } catch (e) { event('observer-exception',{message:e.message}); } + } + }); + cp.stderr.on('data',chunk=>appendFileSync(stderr,chunk)); + const timer = setTimeout(()=>{event('timeout',{label,pid:cp.pid}); cp.kill();},35000); + cp.on('error',e=>{clearTimeout(timer); reject(e);}); + cp.on('close',(code,signal)=>{clearTimeout(timer); event('exit',{label,code,signal}); resolveRun(code);}); + }); +} +await invoke('01-version',['--version']); +await invoke('02-register',['mcp','add','--scope','project','flowkitg03','--',node,resolve(here,'probe.mjs'),rpc,gate,nonce]); +await invoke('03-discover',['mcp','list','--json']); +const trust = mode === 'untrusted' ? [] : ['--trust']; +await invoke('04-doctor',[...trust,'mcp','doctor','flowkitg03']); +const expected = { nonce, payload: 'flowkit-g03-synthetic' }; +const targetBytes = Number(targetBytesText); +let file; +if (targetBytes) { + const payload = []; + for (let left=targetBytes-JSON.stringify({nonce,payload:[]}).length;left>3;) { + const length = Math.min(65536,left-(payload.length ? 3 : 2)); + left -= length+(payload.length ? 3 : 2); + payload.push(String.fromCharCode(65+payload.length%26).repeat(length)); + } + expected.payload=payload; + file = join(cwd,`arguments-${nonce}.json`); + writeFileSync(file,JSON.stringify(expected)); +} +const digest = createHash('sha256').update(JSON.stringify(expected)).digest('hex'); +event('fixture',{mode, targetBytes, bytes:Buffer.byteLength(JSON.stringify(expected)), digest, file}); +let startId = null; +const target = mode === 'untrusted' ? 'flowkitg03_probe' : 'flowkitg03__probe'; +const allow = mode === 'untrusted' ? ['--allow','mcp__flowkitg03__probe'] : ['--allow',`MCPTool(${target})`]; +const invocation = file ? `Set tool_input_file to this exact absolute path: ${JSON.stringify(file)}. Do not use tool_input, read or retype the file.` : `tool_input must be ${JSON.stringify(expected)}.`; +await invoke('05-native', [...trust,'--no-subagents','--no-plan','--disable-web-search','--tools','list_dir','--permission-mode','dontAsk',...allow,'--max-turns','4','--output-format','streaming-json','-p',`This is a synthetic local tool capability test. Do not inspect files or use shell or other servers. Call ${target} with the native use_tool exactly once. ${invocation} Report the tool result.`], e=>{ + if (e.type === 'tool_call') { + const wrapped = e.toolName === 'use_tool' && e.rawInput?.tool_name === target; + const raw = wrapped ? e.rawInput.tool_input : undefined; + const input = raw?.nonce===nonce && Object.keys(raw).length===2 ? JSON.stringify({nonce,payload:raw.payload}) : null; + const observedDigest = input ? createHash('sha256').update(input).digest('hex') : null; + const match = !!e.toolCallId && observedDigest===digest; + event('tool-start',{ toolCallId:e.toolCallId, toolName:e.toolName, status:e.status, match, bytes:input?Buffer.byteLength(input):null, fileReference:!!e.rawInput?.tool_input_file, digest:observedDigest }); + if (match && !startId && mode!=='withhold') { + startId=e.toolCallId; + writeFileSync(gate,digest+'\n',{flag:'wx'}); + event('released',{ toolCallId:startId, digest }); + } + } else if (e.type === 'tool_call_update') event('tool-update',{ toolCallId:e.toolCallId, status:e.status }); +}); +console.log(dir); diff --git a/docs/evidence/p0a-g03-20261112/sha256.txt b/docs/evidence/p0a-g03-20261112/sha256.txt new file mode 100644 index 0000000..9b632e7 --- /dev/null +++ b/docs/evidence/p0a-g03-20261112/sha256.txt @@ -0,0 +1,109 @@ +60b1754c0713af182bd75811ceec1e60425a3379ecea72fb1f70b32b3f9a4a97 commands.md +e582bd96184cfa8782f824471e756f0e1303e114eb0f6d358559f2cb62837a37 fixtures/arguments-5bc106dc-6730-4177-86a2-fed0138e27bf.json +30f1255d6ed2eedec663b7cd6ecf4133dcc9f932e9f7be11cfb8049d9ebe9393 fixtures/arguments-65fc8dcb-8e70-4251-9ee1-0d85e8ca55f7.json +b077081ef9635e26d2a1049d3644770e14d822ecbafe1a256c7636c454eaee71 fixtures/arguments-c4b2aabf-3814-4533-a9ab-d26273fea372.json +13bf7b3039c63bf5a50491fa3cfd8eb4e699d1ba1436315aef9cbe5711530354 negative-wrong-nonce/exit +cdc930025d3859d0c802b1723043a98dcdf3b2dc46bb58c5564d6912c8f8cf8a negative-wrong-nonce/input.ndjson +61d11e10c4b195c2e550d083a9cdb1d6198f1cef7fe2ec4b57a20a86eaf7f916 negative-wrong-nonce/mcp-rpc.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 negative-wrong-nonce/stderr +f0b6ffd24ab212110f229cd33dbd43b2516bf3d02362a52376122a537ff88fa6 negative-wrong-nonce/stdout +a0b56e6c6a2b7e61c4ff2da94e6588abcde8463fb3e5343647641516c85c432f probe.mjs +1335883b8266b6d90d583cb321674ab6f98714ba9b2a7fadad1f583f3f9bf2d1 README.md +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-30-41-950Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-30-41-950Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-30-41-950Z/02-register.stderr +e625e1357f7251685c75d0ea392d375b1bde5c4a5bd6aa686e12bc8960d6b07e run-2026-11-12T14-30-41-950Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-30-41-950Z/03-discover.stderr +e5b3e7325b3c222727904f9f01c08ef1fa140a4bbb4e217b347004bcf1ae10a9 run-2026-11-12T14-30-41-950Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-30-41-950Z/04-doctor.stderr +00bf33b92cffc5d3289479d3da9d3347a2cdd9d3ea6486d35ed007bc6dcc6e8e run-2026-11-12T14-30-41-950Z/04-doctor.stdout +1127ae1c7dd1b79d4b96a29a396796d3519fb3231e5d93db2413cfcc4cf698e5 run-2026-11-12T14-30-41-950Z/05-native.stderr +1126dff43eeedc6068af01f21aaedfbe41e68cee82018ea5f09e902fea3630d7 run-2026-11-12T14-30-41-950Z/05-native.stdout +b49743a461b5b1fe7d940f32157b5a995e8e4bf6a57fa9dba69cda3fd71efe1a run-2026-11-12T14-30-41-950Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-32-29-735Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/02-register.stderr +c5c76a8f5f9f232693fe144983039694abb8e27a16840f8d9aecad1215d49945 run-2026-11-12T14-32-29-735Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/03-discover.stderr +3dc6b64efaba316a8aec7590865e4df833c8f9dfc5c62a74947df169e5b8fd78 run-2026-11-12T14-32-29-735Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/04-doctor.stderr +4962fe66324490b128e63d3246fd5ced8d3126e4e116910dcdae0c2ae93ce036 run-2026-11-12T14-32-29-735Z/04-doctor.stdout +ba3918adebe8e486ae225969dd01b8b084f728a224a7fce4864805413611d293 run-2026-11-12T14-32-29-735Z/05-native.stderr +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-29-735Z/05-native.stdout +b518e46c994bbac60c53e64e35bd12b89e12c39c86f764744761d458aecde539 run-2026-11-12T14-32-29-735Z/mcp-rpc.ndjson +1877b39ca8834171963877d720793df814667c1e5069358a68773c7874ebc4e5 run-2026-11-12T14-32-29-735Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-48-678Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-32-48-678Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-48-678Z/02-register.stderr +1715d03974643532c1d41fe9abec301b1c4577c897a4fe367b5e632462518bfa run-2026-11-12T14-32-48-678Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-48-678Z/03-discover.stderr +1e18bf89f07c5c7de6a9b55ea1e7387a7a972d67844239d38b5eb0df76c13960 run-2026-11-12T14-32-48-678Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-32-48-678Z/04-doctor.stderr +961d3dbc6ba3303da182df629a153f9f513bf55ead281040dbb106173d37c738 run-2026-11-12T14-32-48-678Z/04-doctor.stdout +ecb67ffded5d12de90f02d26952912145bd84ba1509db17a2663e752db3ea421 run-2026-11-12T14-32-48-678Z/05-native.stderr +da30ca80a5b02659c08c0586db64564f31d1793cf0e09119c9b26634eb03b805 run-2026-11-12T14-32-48-678Z/05-native.stdout +fdd3336c01a0d5b893cc85f945f7351cdff5e56e4e5376508e74550251eab82d run-2026-11-12T14-32-48-678Z/mcp-rpc.ndjson +b02677709a305724dd4ed8dadf93e5f14deef6d2f3792705da5e1ff997341862 run-2026-11-12T14-32-48-678Z/observed.gate +0031e02132946d7b98a8158ff59aa071f68caf27615eec8b1c7b663ce4835842 run-2026-11-12T14-32-48-678Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-34-56-346Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-34-56-346Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-34-56-346Z/02-register.stderr +32c40483f7039216b6eff3faf1221327415af28191e4ef43cdc6e34e65028dcd run-2026-11-12T14-34-56-346Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-34-56-346Z/03-discover.stderr +3829d35cc07abd53010b2bbfbaee9ef66d86fbdac7f49920b1a013cecfe3f682 run-2026-11-12T14-34-56-346Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-34-56-346Z/04-doctor.stderr +e5b450e3c7f25a9dceef60da38ae5412789199d6db6f422c7c6fd423b6442741 run-2026-11-12T14-34-56-346Z/04-doctor.stdout +40090e738e89cd5cb58cb9ec4abdb984b72d36f460f719c9fc1ef185bd393492 run-2026-11-12T14-34-56-346Z/05-native.stderr +b601c75a6a24442c551aaa3122135796297a80cfe1b183c51fc732c29ad9382b run-2026-11-12T14-34-56-346Z/05-native.stdout +7edfa99f7b47fe7173a5e1404c9471073d2748a9e50a01883002450f9bda8888 run-2026-11-12T14-34-56-346Z/mcp-rpc.ndjson +3ffee1c35fe4a0d53bd162e108289516d6b197ace131f4ada03419611bc9280b run-2026-11-12T14-34-56-346Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-18-036Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-35-18-036Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-18-036Z/02-register.stderr +e81fad5e9ba96e999c555cfe309a142af4cf8d7ad73c4c9d956db471cb5f0fc0 run-2026-11-12T14-35-18-036Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-18-036Z/03-discover.stderr +206a15bf044257384d0bc05e482245a7c28aa804fcf158ebb4628a1182ccec1f run-2026-11-12T14-35-18-036Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-18-036Z/04-doctor.stderr +7274d4e87afe14bce2a0aa0e756ee3d1bd740b6a71b7c74cee079c83ec8af4cd run-2026-11-12T14-35-18-036Z/04-doctor.stdout +1b8f80ea0f6fee2412ef0af9418596462fb3e4a3cd2263fdad3e87de78d713d5 run-2026-11-12T14-35-18-036Z/05-native.stderr +a54ba7fe7bd39641bcab5967c97b65f8f3918260c8220d29c194ac6e2900f2ec run-2026-11-12T14-35-18-036Z/05-native.stdout +1ffae65208188beb30963898e3cc92366c59e76ff9d66bfda6c88da4b4923496 run-2026-11-12T14-35-18-036Z/mcp-rpc.ndjson +59dd683a8b4d54b1dd6bef43afd33e54053b3c8ae2439227d49152c7677ce08c run-2026-11-12T14-35-18-036Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-44-974Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-35-44-974Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-44-974Z/02-register.stderr +7a31f91074d2d24e1a413ee625d7949050f65367416ca5e1f703fbee11f30669 run-2026-11-12T14-35-44-974Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-44-974Z/03-discover.stderr +f8c25635106be2e8472aa82c4157ec4fcff69aa3e86d34c83184b2fae978a2f7 run-2026-11-12T14-35-44-974Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-35-44-974Z/04-doctor.stderr +d01e7edb31bcef0b49aa15956eb1efb103d6fff97eedb2e53389c588e0f77e7a run-2026-11-12T14-35-44-974Z/04-doctor.stdout +81e4cd23d2e3b4032c44d3cce9f3c10995c780873a138bf7af1f44a7304f4916 run-2026-11-12T14-35-44-974Z/05-native.stderr +1fc254d81db9d1276e100fe76c1a677f82d378c9c49054406907a2675f6a4812 run-2026-11-12T14-35-44-974Z/05-native.stdout +ef7832214b8457734ab1f82060aa108dacb1d9e5c2a6541e9108f8c397094345 run-2026-11-12T14-35-44-974Z/mcp-rpc.ndjson +6d0f893f7e8f38b2fdea3472b7a6205b38ec0c454a6c47eef819450daa47969b run-2026-11-12T14-35-44-974Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-36-14-288Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-36-14-288Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-36-14-288Z/02-register.stderr +ecf1f12aa2270ab7a0c9f1577fa396e373ec0aebade49112dc95f6d515d04043 run-2026-11-12T14-36-14-288Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-36-14-288Z/03-discover.stderr +fb698b28accd22586eac6f6125b728033989a539d5464f1b78135ca4329b2585 run-2026-11-12T14-36-14-288Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-36-14-288Z/04-doctor.stderr +1d2c5f350b0d2335514778eaac95d96ab9580c1cc511d16f808968e712329f7f run-2026-11-12T14-36-14-288Z/04-doctor.stdout +0af56ca73ef828d67a575376928a0060241ac1731d7519e497b30a5f279b71f4 run-2026-11-12T14-36-14-288Z/05-native.stderr +fe34c55831ad06ebf7001f42ea99ae8c1020dce0042bed1c5d97d553d25f73fb run-2026-11-12T14-36-14-288Z/05-native.stdout +3971df5994707bf6381b2c3881e48bb9e395826ea8a6c03f8975e03d7ab4c06a run-2026-11-12T14-36-14-288Z/mcp-rpc.ndjson +b2e9529f0a489bc7c9b4d961a5a7761ae047ecb361c58621acfe2f6f976dac79 run-2026-11-12T14-36-14-288Z/observer.ndjson +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/01-version.stderr +a94142ace2d5d57785c044f8788f68cc8502f9a24cc2cdfd8b8899dc5a52ff39 run-2026-11-12T14-41-49-005Z/01-version.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/02-register.stderr +ec78834cb31ef91bfd5aa83db7aaaeb4f6d6bd6077b0c697c18bac76c84ae8f4 run-2026-11-12T14-41-49-005Z/02-register.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/03-discover.stderr +1e1d6594688c15125bc214086b9e38fca5d0ae08b4497ae25e530bb0a573fe21 run-2026-11-12T14-41-49-005Z/03-discover.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/04-doctor.stderr +c81c7ac28f0de10fdfb2c6e9cd0c05bb298ea9189a6471ed39c07d18843c290d run-2026-11-12T14-41-49-005Z/04-doctor.stdout +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 run-2026-11-12T14-41-49-005Z/05-native.stderr +9a2cf31ed61d01cdaa83d338e21ee7d0eb456d7499db521f67eb7172590807fe run-2026-11-12T14-41-49-005Z/05-native.stdout +014192b3c089000dc6e1b7fa401c2269b5e0244c9c9012756a86290ffb8fad75 run-2026-11-12T14-41-49-005Z/mcp-rpc.ndjson +b3a4ebf0137095b0c465b5e21573d54759347ce4954e5d947a9bbc4af40b8bd3 run-2026-11-12T14-41-49-005Z/observed.gate +4b1f0b7d5f94db7dc9f80124978e62b68eed165b950bcb9f415c14e1f9a7a399 run-2026-11-12T14-41-49-005Z/observer.ndjson +3be09a32fc2708bd16962f2ac954f14f7b70051d2e7374684b38189fc169c4d1 run.mjs diff --git a/docs/evidence/p0a-g04-20261112/README.md b/docs/evidence/p0a-g04-20261112/README.md new file mode 100644 index 0000000..3f78839 --- /dev/null +++ b/docs/evidence/p0a-g04-20261112/README.md @@ -0,0 +1,23 @@ +# G04 批次交付与契约裁决 + +Draft PR #22 的 G04 基线是 `d17a7da7a0efbd6b0ab4e468586d132f648b2e23`。已完成当前可做的 CF-08 和 CF-05 终态读取部分。三份确认原件、版本和哈希在 [G01 审查](../p0a-g01-20261112/README.md)。 + +| 条目 | 改动与证据 | +|---|---| +| CF-08 | 修复异步编译在 lowering 阶段读取原始可变选项的问题。定向回归修复前失败、修复后通过。提交 `6989190412817d3ee8740531cf48c3297b619934`。见 [CF-08 记录](../p0a-g04-cf08-20261112/README.md)。 | +| CF-05 | R5 §§4–5 和计划 A-M5-01。可信 Host 增加 `getPublicTerminalFrame` 和 `readTrustedTerminalResult`。`run:read` 返回状态、Checkpoint 引用和版本化结果引用,`result:read` 独立控制完整 State。两条路径从受信 Store 读取同 namespace 的终态,再核对 revision。缺少授权回调时两条路径均拒绝。 | +| 兼容范围 | 现有 factory 的执行帧保留供可信 Host 调用方使用。新公开终态帧与完整结果是分开的类型。通用 PublicFrame 及旧入口迁移由下表 CF-02/05 决定。现有 Agent 任务继续走已授权的 `getPublicAgentTask`。 | +| 证据边界 | 验证为 Windows / Node 24.22.2 的真实 JS、SQLite 和打包消费者测试,属于 P 级证据。原生宿主 G07 及 NAT-14 保持 BLOCKED。 | + +CF-05 定向命令为 `node --test --test-name-pattern=CF-05 tests/interactive/trusted-host.test.mjs`。最初加入权限回归时因缺失方法而失败,最终两个用例通过。覆盖双向权限分离、跨租户、旧 revision、无授权回调、活跃 Run 拒绝和错误终态脱敏。测试比较 SQLite Checkpoint 和 Journal 前后字节。打包 ESM 消费者验证两条路径,声明测试验证公开终态没有 State。全量 `npm run verify` 通过 398 项,0 失败、0 跳过。最终的类型兼容调整另跑 `npm run build`、`npm run test:types` 和 `npm run check:package`,均退出 0。 + +## 需要用户决定的冻结契约 + +| 项目 | 决策与影响 | +|---|---| +| CF-01 管理终止 | R5 §§3.6/4/5/9 的 `cancelled` 与独立管理审计超出现有 Store 的可读写集合。选择按 R5 做兼容迁移,或批准现有 Journal 内保存等价管理审计。两种方案都需先处理旧版读取 `cancelled` 的行为,并验证旧 Program 缺失、业务终态竞争和迟到回执。 | +| CF-02 / CF-05 SDK 与公开帧 | 选择新增 R5 顶层 `start/complete/resume` 与通用安全帧并保留现有 factory 为可信 Host 兼容入口,或安排旧调用方迁移后移除原执行帧的 State。第一种允许已安装消费者逐步迁移。当前已交付终态的分权读取。 | +| CF-06 Store | 选择并行提供 R5 五操作异步 Store 与独立 Registry 接缝并兼容现有同步 factory,或对公共 Store 入口做统一的版本迁移。两种都需要证明原子 CAS、历史查询和丢 ACK 后的线性化判断。 | +| ABI-01 转移身份 | 旧 Journal 使用现行 transition ID 公式,R5 公式还绑定事件、Program 与提案摘要。选择为新 Run 启用版本化公式并保留旧 Run 的公式,或先批准历史迁移及历史 Ack 对账策略。 | + +G04 的本批可执行改动已交付。冻结裁决仍开放。第二批 G05–G06 的 OS 隔离准备和验证可以独立进行。原始命令、输出、增量 diff、确切提交 SHA 与文件 SHA-256 随本地证据保存。 diff --git a/docs/evidence/p0a-g04-cf08-20261112/README.md b/docs/evidence/p0a-g04-cf08-20261112/README.md new file mode 100644 index 0000000..1ecf799 --- /dev/null +++ b/docs/evidence/p0a-g04-cf08-20261112/README.md @@ -0,0 +1,26 @@ +# G04 · CF-08 编译选项快照 + +基线为 [`d17a7da7a0efbd6b0ab4e468586d132f648b2e23`](https://github.com/devcodex-labs/flowkit/commit/d17a7da7a0efbd6b0ab4e468586d132f648b2e23),交付仍在 [Draft PR #22](https://github.com/devcodex-labs/flowkit/pull/22)。本次由 Codex 直接实施。三份冻结原件的字节身份已记录在 [G01](../p0a-g01-20261112/README.md)。 + +R5 §4 要求编译器按经校验的静态阶段编译,§15.2 的 R04 记录同类异步输入突变。G01 的 CF-08 定位到 `compileInteractive()` 在校验后又从调用方对象读取 `featureStage`。现在 lowering 使用编译入口已经校验的 `snapshotOptions.featureStage`。实际修复是 `src/interactive/compile.mjs:44` 的一次读取替换。 + +回归用同一份合法 DSL 比较稳定调用和立即修改原选项的调用。修复前,第二次调用错误返回 `FK_I_UNSUPPORTED_GRAPH`。修复后,两个调用的诊断、Program 标识和 JS 完全一致。原有 P0-B 拒绝用例随全量验证一起运行。 + +| Windows / Node 24.22.2 | 命令 | 结果 | +|---|---|---| +| 只加入回归测试 | `node --test --test-name-pattern=CF-08 tests/interactive/stage2-integration.test.mjs` | 退出 1,1 项失败,见 `before.stdout` | +| 加入修复 | 同上 | 退出 0,1 项通过,见 `after.stdout` | +| 加入修复 | `npm run verify` | 退出 0,396 项通过,0 失败、0 跳过,见 `verify.stdout` | + +上述是本地 P 级证据。日志原始字节、测试单独 diff、增量 diff、基线与修复提交身份和新提交的 CI 结果在本地证据包。 + +## G04 其余裁决 + +| 项目 | 已核对的约束与下一步 | +|---|---| +| CF-01 管理终止 | R5 §§3.6、4、5、9 要求独立管理权限、无需加载 Program、原子墓碑和审计。当前 Store `sqlite-store.mjs:8/44/340` 拒绝 `cancelled` Checkpoint,现有 SQL 也没有管理事件。需要裁决部署中的 Checkpoint 兼容方式、管理审计的存储位置,以及旧读取方如何处理新终态。验收须覆盖旧 Program 缺失、迟到回执和与业务终态的 CAS 竞争。 | +| CF-05 结果投影 | R5 §§4–5 要求公开 Frame 只给 `resultRef`,完整结果由 Host 的 `result:read` 独立授权。现有 `InteractiveRunFrame` 与打包消费者明确读取终态 `state`(`types/interactive.d.ts:83/258`、`tests/package/consumer.mjs:55/98`)。需裁决旧 factory 是否作为仅限 Host 的兼容入口保留,以及版本化 PublicFrame 的接入方式。现有 `getPublicAgentTask` 的字段限制继续是当前模型通道的证据。 | +| CF-02 / CF-06 | 顶层 SDK 和五操作异步 Store 差异见 G01。需要先裁决对当前消费者及自定义 Store 的兼容承诺,再评审接口或适配器。 | +| G07 / NAT-14 | G03 的完整入参独立绑定条件仍未满足。此次 G04 的验证对象为编译器,证据等级为 P。 | + +CF-08 是本提交的已验证修复。G04 其余发现及依赖步骤按各自裁决和复审条件保留。 diff --git a/docs/evidence/p0a-g05-g06-20261112/README.md b/docs/evidence/p0a-g05-g06-20261112/README.md new file mode 100644 index 0000000..b75b516 --- /dev/null +++ b/docs/evidence/p0a-g05-g06-20261112/README.md @@ -0,0 +1,36 @@ +# G05–G06 双账户 Host 隔离 + +本批验证 Windows 和 Linux 上的两个独立非管理员主体。测试使用一次性目录、测试签名密钥、真实 FlowKit SQLite Store 和可公开的任务样本。启动器把 Host 和受限测试进程作为不同身份启动。每个进程报告实际 SID 或 UID、PID 和父 PID。Windows 同时记录文件与目录的实际 SDDL,Linux 记录权限位和有效能力。启动器以白名单构造环境,测试进程验证 Host 的环境标记未传入。 + +这是一套可复跑的隔离测试配置。已有的 Linux M5/M6 隔离测试仍在 CI 中运行。新增测试不需要服务、监听端点、模型账户或外部凭据。真实模型接入的启动及句柄继承验收仍属于后续部署测试。 + +## 运行方法 + +从仓库根目录运行,使用 Node 24.22.1。 + +```sh +sudo -n "$(command -v node)" tests/security/secure-host-linux.mjs +``` + +Linux 启动器要求一次性环境,且 UID/GID 41001 和 41002 未占用。只有引导进程使用 root。两个实际工作进程通过 `setpriv` 清空附加组及能力,并启用 `no_new_privs`。Host 私有目录为 0700,密钥为 0600。 + +```powershell +.\tests\security\secure-host-windows.ps1 -Node (Get-Command node.exe).Source -DisposableRunner +``` + +Windows 使用一次性 Windows Server 2025 运行机、提升权限的 64 位 Windows PowerShell 和两个随机命名的本地普通用户。引导进程创建显式文件 DACL,只有 Host 能修改私有目录,SYSTEM 和 Administrators 保留恢复权限。测试 Host 在生成密钥前收紧自己的进程和 Token DACL,之后引导进程才启动受限进程。测试用临时账户和目录在结束时清理。 + +## 验收映射 + +| 范围 | Linux | Windows | +|---|---|---| +| SEC-01 进程身份 | 核对 Host 和受限进程的实际 UID/GID、进程身份、能力和附加组。 | 核对两个进程的实际 SID、Administrators 组及调试等高权限。 | +| SEC-02 密钥、配置和产物清单 | 测试各文件的读取、复制、打开写权限、删除、移走和替换。验证 Host 签名仍可用。 | 同样的文件测试及实际文件/目录 DACL。 | +| SEC-03 SQLite、WAL、SHM 和备份 | 数据库打开期间验证六类访问被拒。Host 关闭后重新打开主库和备份做完整性检查,并核对受保护文件的字节摘要。 | 同样的文件和完整性测试。 | +| SEC-04 进程访问 | 验证 Host 进程 fd、环境和内存入口的读取被拒,并检查无副作用的信号权限探测。 | 验证内存、线程创建和句柄复制权限请求被拒,验证不可获取可复制或可分配的 Host Token。探针只请求并关闭测试句柄。最终部署还需验证模型先于 Host 运行时的启动保护。 | +| 句柄范围 | 实际枚举受限进程的 `/proc/self/fd`,校验没有 Host 私有文件,原生启动链仍待验证。 | 当前启动器分别启动两个账户,不向受限进程提供 Host 私有句柄。系统级子进程句柄表仍待在最终启动方式下验证。 | +| SEC-17 Linux 实际受限进程 | 验证跨 UID 的脚本进程和 OS 拒绝。原生模型进程与可信来源仍待 G07 以后验证。 | 不适用 Linux UID 条款。 | + +Linux 的 47 次、Windows 的 43 次文件和 Linux 进程拒绝由测试计数校验。Windows 另有 6 次进程或 Token 权限请求。两边都有公开任务可读的正例。Windows 测试没有部署 Windows Service,服务控制权限要在采用该部署方式时单独验证。 + +以当前 GitHub CI 的 `Verify the disposable Linux Host profile` 和 `Verify the disposable Windows Host profile` 实测输出为准。只做语法检查时,不能把对应平台记为通过。G05–G06 证据用于 OS 隔离,原生 N 级、NAT-14 和 G07 保留现有阻断。 diff --git a/docs/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/compile.mjs b/src/interactive/compile.mjs index 43f6dfa..06f25f7 100644 --- a/src/interactive/compile.mjs +++ b/src/interactive/compile.mjs @@ -41,7 +41,7 @@ export async function compileInteractive(file, options) { const analysis = await analyzeSource(snapshot,{ ...analyzerOptions,filename,projectRoot, }); - const lowered = lowerInteractive(analysis,{featureStage:options.featureStage}); + const lowered = lowerInteractive(analysis,{featureStage:snapshotOptions.featureStage}); if (!lowered.ir) return {code:'',diagnostics:[...lowered.diagnostics]}; const emitted = emitInteractive(lowered.ir); if (emitted.diagnostics.some(d => d.severity === 'error')) { diff --git a/src/interactive/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/stage2-integration.test.mjs b/tests/interactive/stage2-integration.test.mjs index cacd726..4219ab8 100644 --- a/tests/interactive/stage2-integration.test.mjs +++ b/tests/interactive/stage2-integration.test.mjs @@ -138,6 +138,22 @@ test('S2 compiler refuses unrelated Standard options and accessors before any wr assert.equal(await readFile(disk.artifactPath,'utf8'),out.code); }); +test('CF-08: Interactive lowering uses the feature stage validated at compile entry',async()=>{ + const {workflows}=await project(); + const file=path.join(workflows,'snapshot.workflow.js'); + await writeFile(file,pure); + const options={output:'memory',featureStage:'P0-A'}; + const expected=await compileInteractive(file,options); + assert.ok(expected.code.length>0); + + const compilation=compileInteractive(file,options); + options.featureStage='P0-B'; + const actual=await compilation; + assert.deepEqual(actual.diagnostics,expected.diagnostics); + assert.deepEqual(actual.programIdentity,expected.programIdentity); + assert.equal(actual.code,expected.code); +}); + test('S2 harmless shared Host checkStep does not block pure Condition/Terminal, but Agent remains fail-closed',async t=>{ const {root,workflows}=await project(); const pureFile=path.join(workflows,'pure.workflow.js'); diff --git a/tests/interactive/trusted-host.test.mjs b/tests/interactive/trusted-host.test.mjs index af0d98a..e686702 100644 --- a/tests/interactive/trusted-host.test.mjs +++ b/tests/interactive/trusted-host.test.mjs @@ -114,6 +114,68 @@ test('M5 authorized public task contains only allowlisted declared input, not St assert.equal(f.store.load(f.frame.runId).revision,f.frame.revision); }); +test('CF-05: terminal projection and trusted result require separate permissions',async t=>{ + let allowedAction='run:read'; + const calls=[]; + const f=await waiting(t,{ + authorizeTerminalAccess:async context=>{ + calls.push(context); + return context.action===allowedAction; + }, + }); + const runId=f.frame.runId; + await assert.rejects(f.host.getPublicTerminalFrame({runId}),{code:'FK_I_HOST_DENIED'}); + const receipt=agentResult(f.frame); + const completed=await f.host.accept({receipt,attestation:proof(f.store.load(runId),receipt,f.keys.privateKey)}); + const resultRef={runId,revision:completed.revision,projectionVersion:1}; + const before=canonicalBytes(f.store.load(runId)); + const journal=f.store.listJournal(runId); + + const frame=await f.host.getPublicTerminalFrame({runId}); + assert.deepEqual(frame,{ + protocolVersion:1,runId,revision:completed.revision,status:'succeeded',pending:[], + checkpointRef:{runId,revision:completed.revision},terminal:{nodeId:'done',status:'succeeded'},resultRef, + }); + await assert.rejects(f.host.readTrustedTerminalResult({resultRef}),{code:'FK_I_HOST_DENIED'}); + allowedAction='result:read'; + await assert.rejects(f.host.getPublicTerminalFrame({runId}),{code:'FK_I_HOST_DENIED'}); + const result=await f.host.readTrustedTerminalResult({resultRef}); + assert.deepEqual(result,{ + projectionVersion:1,namespace:'tenant-m5',runId,status:'succeeded', + terminal:{nodeId:'done',status:'succeeded'},state:completed.state,contextError:null, + }); + assert.ok(calls.every(context=>Object.isFrozen(context) && + context.namespace==='tenant-m5' && context.runId===runId && context.revision===completed.revision)); + await assert.rejects(f.host.readTrustedTerminalResult({ + resultRef:{...resultRef,revision:resultRef.revision-1}, + }),{code:'FK_I_HOST_DENIED'}); + + const unconfigured=createTrustedInteractiveHost({...f.hostOptions,authorizeTerminalAccess:undefined}); + await assert.rejects(unconfigured.getPublicTerminalFrame({runId}),{code:'FK_I_HOST_DENIED'}); + const otherStore=createSqliteCheckpointStore(path.join(f.dir,'run.db'),{namespace:'another-tenant'}); + t.after(()=>otherStore.close()); + const other=createTrustedInteractiveHost({...f.hostOptions,store:otherStore,authorizeTerminalAccess:()=>true}); + await assert.rejects(other.readTrustedTerminalResult({resultRef}),{code:'FK_I_HOST_DENIED'}); + assert.deepEqual(canonicalBytes(f.store.load(runId)),before); + assert.deepEqual(f.store.listJournal(runId),journal); +}); + +test('CF-05: public error terminal omits the private cause and State',async t=>{ + const f=await fixture(t,{authorizeTerminalAccess:()=>true}); + const frame=await f.host.start({ + ...f.program,startKey:'terminal-with-error',state:{secret:'Host private data'}, + }); + assert.equal(frame.status,'errored'); + const projection=await f.host.getPublicTerminalFrame({runId:frame.runId}); + assert.equal(projection.status,'errored'); + assert.equal(Object.hasOwn(projection,'terminal'),false); + assert.equal(Object.hasOwn(projection,'causeCode'),false); + assert.equal(Object.hasOwn(projection,'state'),false); + const result=await f.host.readTrustedTerminalResult({resultRef:projection.resultRef}); + assert.equal(result.causeCode,'FK_R002'); + assert.deepEqual(result.state,{secret:'Host private data'}); +}); + test('M5 strict disclosure allowlist and authorizer both fail closed with zero CAS writes',async t=>{ for(const options of [ {allowedInputKeys:[]}, diff --git a/tests/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/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.cs b/tests/security/secure-host-win-process.cs new file mode 100644 index 0000000..602c1cc --- /dev/null +++ b/tests/security/secure-host-win-process.cs @@ -0,0 +1,107 @@ +using System; +using System.ComponentModel; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; + +public static class FlowKitProcessAccess { + private const uint QueryLimitedInformation = 0x1000; + private const uint WriteDacl = 0x00040000; + private const uint TokenQuery = 0x0008; + private const uint DaclSecurityInformation = 0x00000004; + [DllImport("kernel32.dll", SetLastError=true)] + private static extern IntPtr OpenProcess(uint rights, bool inherit, int pid); + [DllImport("advapi32.dll", SetLastError=true)] + private static extern bool OpenProcessToken(IntPtr process, uint rights, out IntPtr token); + [DllImport("kernel32.dll")] + private static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll")] + private static extern int GetCurrentProcessId(); + [DllImport("advapi32.dll", SetLastError=true)] + private static extern bool SetKernelObjectSecurity(IntPtr handle, uint information, byte[] descriptor); + + // Ask for access and immediately close any returned handle. + private static int CheckProcess(int pid, uint rights) { + IntPtr handle = OpenProcess(rights, false, pid); + if (handle == IntPtr.Zero) return Marshal.GetLastWin32Error(); + CloseHandle(handle); + return 0; + } + + private static int CheckToken(int pid, uint rights) { + IntPtr process = OpenProcess(QueryLimitedInformation, false, pid); + if (process == IntPtr.Zero) return Marshal.GetLastWin32Error(); + try { + IntPtr token; + if (!OpenProcessToken(process, rights, out token)) return Marshal.GetLastWin32Error(); + CloseHandle(token); + return 0; + } finally { + CloseHandle(process); + } + } + + private static string Check(string name, int positiveControl, int result) { + if (positiveControl != 0) throw new InvalidOperationException("Positive control failed: " + name); + return "{\"operation\":\"" + name + "\",\"denied\":" + (result == 5 ? "true" : "false") + + ",\"win32Error\":" + result.ToString(CultureInfo.InvariantCulture) + "}"; + } + + // The Host invokes this in the disposable profile before creating private data. + // The helper must run with the same account as the target Host process. + private static void Restrict(int pid) { + IntPtr process = OpenProcess(WriteDacl | QueryLimitedInformation, false, pid); + if (process == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "Open Host process"); + try { + IntPtr token; + if (!OpenProcessToken(process, WriteDacl | TokenQuery, out token)) { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Open Host token"); + } + try { + string sid; + using (WindowsIdentity current = WindowsIdentity.GetCurrent()) + using (WindowsIdentity target = new WindowsIdentity(token)) { + sid = current.User.Value; + if (sid != target.User.Value) throw new InvalidOperationException("Host identity mismatch"); + } + RawSecurityDescriptor security = new RawSecurityDescriptor("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;" + sid + ")"); + byte[] descriptor = new byte[security.BinaryLength]; + security.GetBinaryForm(descriptor, 0); + if (!SetKernelObjectSecurity(token, DaclSecurityInformation, descriptor)) { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Restrict Host token DACL"); + } + if (!SetKernelObjectSecurity(process, DaclSecurityInformation, descriptor)) { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Restrict Host process DACL"); + } + Console.WriteLine("{\"sid\":\"" + sid + "\",\"processDaclApplied\":true,\"tokenDaclApplied\":true}"); + } finally { + CloseHandle(token); + } + } finally { + CloseHandle(process); + } + } + + public static int Main(string[] args) { + int host; + int self = GetCurrentProcessId(); + bool restrict = args.Length == 2 && args[0] == "restrict"; + if ((!restrict && args.Length != 1) || !Int32.TryParse(args[restrict ? 1 : 0], out host) + || host <= 1 || host == self) return 2; + if (restrict) { + Restrict(host); + return 0; + } + string[] results = { + Check("process-vm-read", CheckProcess(self, 0x0010), CheckProcess(host, 0x0010)), + Check("process-vm-write", CheckProcess(self, 0x0020), CheckProcess(host, 0x0020)), + Check("process-duplicate-handle", CheckProcess(self, 0x0040), CheckProcess(host, 0x0040)), + Check("process-create-thread", CheckProcess(self, 0x0002), CheckProcess(host, 0x0002)), + Check("token-duplicate", CheckToken(self, 0x0002), CheckToken(host, 0x0002)), + Check("token-assign-primary", CheckToken(self, 0x0001), CheckToken(host, 0x0001)) + }; + Console.WriteLine("[" + String.Join(",", results) + "]"); + return 0; + } +} diff --git a/tests/security/secure-host-windows.ps1 b/tests/security/secure-host-windows.ps1 new file mode 100644 index 0000000..f6f8714 --- /dev/null +++ b/tests/security/secure-host-windows.ps1 @@ -0,0 +1,173 @@ +[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', + 'src\interactive\sqlite-store.mjs','src\interactive\canonical.mjs' + )) { + $destination = Join-Path $publicDir $file + [void][IO.Directory]::CreateDirectory((Split-Path $destination -Parent)) + Copy-Item -LiteralPath (Join-Path $repo $file) -Destination $destination + } + Copy-Item -LiteralPath $Node -Destination (Join-Path $publicDir 'node.exe') + # Compile before launching either ordinary user. The model runs only the built probe. + Add-Type -Path (Join-Path $PSScriptRoot 'secure-host-win-process.cs') -OutputType ConsoleApplication ` + -OutputAssembly (Join-Path $publicDir 'process-access.exe') + [IO.File]::WriteAllText((Join-Path $publicDir 'task.json'),"flowkit-public-task`n") + + $hostProcess = Start-ProfileWorker $hostUser $hostPassword 'host' $hostDir + $ready = Wait-Record $hostProcess (Join-Path $hostDir 'host-ready.json') + if ($ready.pid -ne $hostProcess.Id -or $ready.identity.sid -ne $hostUser.SID.Value) { throw 'Host SID/PID mismatch' } + Write-Host ($ready | ConvertTo-Json -Depth 8 -Compress) + foreach ($resource in @($root,$publicDir,$hostDir,$modelDir)+ + @($ready.files.PSObject.Properties.Name | ForEach-Object { Join-Path $hostDir $_ })) { + $acl = Get-Acl -LiteralPath $resource + Write-Host (@{event='acl';path=$resource;owner=$acl.Owner;sddl=$acl.Sddl} | ConvertTo-Json -Compress) + } + + $modelProcess = Start-ProfileWorker $modelUser $modelPassword 'model' $modelDir ([string]$ready.pid) + Wait-Success $modelProcess 45000 + $result = Read-Record (Join-Path $modelDir 'model-result.json') + if ($result.pid -ne $modelProcess.Id -or $result.identity.sid -ne $modelUser.SID.Value -or + !$result.publicTaskReadable -or $result.checks.Count -ne 43 -or $result.processEvidence.Count -ne 6) { + throw 'Model identity or negative test count mismatch' + } + Write-Host ($result | ConvertTo-Json -Depth 8 -Compress) + [IO.File]::WriteAllText((Join-Path $hostDir 'stop'),'stop') + Wait-Success $hostProcess 15000 + $closed = Read-Record (Join-Path $hostDir 'host-result.json') + if (!$closed.keyUsable -or !$closed.protectedBytesUnchanged) { throw 'Host postcondition failed' } + Write-Host ($closed | ConvertTo-Json -Depth 8 -Compress) +} catch { + foreach ($errorPath in @( + (Join-Path $hostDir 'host-error.json'),(Join-Path $modelDir 'model-error.json'), + (Join-Path $modelDir 'model-file-result.json') + )) { + if ([IO.File]::Exists($errorPath)) { Write-Host (Get-Content -LiteralPath $errorPath -Encoding UTF8 -Raw) } + } + throw +} finally { + try { + foreach ($child in @($modelProcess,$hostProcess)) { + if ($null -ne $child) { + if (!$child.HasExited) { $child.Kill(); [void]$child.WaitForExit(5000) } + $child.Dispose() + } + } + if ([IO.Directory]::Exists($root)) { + $resolved = Get-Item -LiteralPath $root + if ($resolved.FullName -ne $root -or $resolved.Parent.FullName -ne $parent -or + $resolved.Name -ne "FlowKit-Profile-$suffix" -or + ($resolved.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Refusing to remove a path other than the disposable profile root' + } + Remove-Item -LiteralPath $resolved.FullName -Recurse -Force + } + } finally { + foreach ($account in @($modelUser,$hostUser)) { + if ($null -ne $account) { Remove-LocalUser -SID $account.SID } + } + } +} diff --git a/tests/security/secure-host-worker.mjs b/tests/security/secure-host-worker.mjs new file mode 100644 index 0000000..309ea73 --- /dev/null +++ b/tests/security/secure-host-worker.mjs @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {createHash,createPrivateKey,generateKeyPairSync,sign,verify} from 'node:crypto'; +import {constants} from 'node:fs'; +import {copyFile,lstat,open,readFile,readdir,readlink,rename,unlink,writeFile} from 'node:fs/promises'; +import path from 'node:path'; +import {DatabaseSync} from 'node:sqlite'; +import {setTimeout as delay} from 'node:timers/promises'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; + +const [mode,root,hostPidArg]=process.argv.slice(2); +assert.ok(['host','model'].includes(mode) && path.isAbsolute(root)); +const hostDir=path.join(root,'host-private'); +const modelDir=path.join(root,'model-scratch'); +const publicTask=path.join(root,'public','task.json'); +const key=path.join(hostDir,'signing-key.pem'); +const database=path.join(hostDir,'checkpoint.db'); +const backup=path.join(hostDir,'backup.db'); +const config=path.join(hostDir,'host-config.json'); +const allowlist=path.join(hostDir,'program-allowlist.json'); +const files=[key,config,allowlist,database,database+'-wal',database+'-shm',backup]; +const canary='flowkit-disposable-host-fixture'; + +async function report(folder,name,value) { + const destination=path.join(folder,name); + await writeFile(destination+'.tmp',JSON.stringify(value,null,2)+'\n',{mode:0o600}); + await rename(destination+'.tmp',destination); +} + +async function identity() { + if (process.platform==='linux') { + const status=await readFile('/proc/self/status','utf8'); + const readField=(name)=>status.match(new RegExp('^'+name+':\\s*(.+)$','m'))?.[1].trim(); + const uid=process.getuid(); + const gid=process.getgid(); + const groups=process.getgroups(); + assert.notEqual(uid,0); + assert.ok(groups.every(group=>group===gid), 'unexpected supplementary group'); + assert.equal(readField('NoNewPrivs'),'1'); + for (const field of ['CapInh','CapPrm','CapEff','CapBnd','CapAmb']) { + assert.match(readField(field),/^0+$/,field); + } + return {uid,gid,groups,status:status.split('\n').filter(line=>/^(Uid|Gid|Groups|Cap|NoNewPrivs):?/.test(line))}; + } + assert.equal(process.platform,'win32'); + const whoami=path.join(process.env.SystemRoot,'System32','whoami.exe'); + const query=(...args)=>execFileSync(whoami,args,{encoding:'utf8',windowsHide:true,timeout:10000}); + const user=query('/user','/fo','csv','/nh').trim(); + const sid=user.match(/S-1-[\d-]+/)?.[0]; + const groups=query('/groups','/fo','csv','/nh'); + const privileges=query('/priv','/fo','csv','/nh'); + assert.match(sid,/^S-1-5-21-/); + assert.doesNotMatch(groups,/S-1-5-32-544(?:["\s,]|$)/,'local administrator group present'); + assert.doesNotMatch(privileges,/Se(?:Debug|Tcb|Impersonate|AssignPrimaryToken|Backup|Restore|TakeOwnership)Privilege/i); + return {sid,user,groups,privileges}; +} + +async function hashes() { + return Object.fromEntries(await Promise.all(files.map(async file=>{ + const digest=createHash('sha256').update(await readFile(file)).digest('hex'); + return [path.basename(file),digest]; + }))); +} + +function verifyDatabase(filename) { + const handle=new DatabaseSync(filename,{readOnly:true}); + try { + assert.equal(handle.prepare('PRAGMA integrity_check').get().integrity_check,'ok'); + assert.equal(handle.prepare("SELECT count(*) AS n FROM sqlite_schema WHERE name='fk_i_runs'").get().n,1); + } finally {handle.close();} +} + +async function runHost() { + const actualIdentity=await identity(); + assert.equal(process.env.FLOWKIT_HOST_CANARY,canary); + let windowsAccessPolicy; + if (process.platform==='win32') { + const probe=path.join(root,'public','process-access.exe'); + windowsAccessPolicy=JSON.parse(execFileSync(probe,['restrict',String(process.pid)],{ + encoding:'utf8',windowsHide:true,timeout:10000, + })); + assert.deepEqual(windowsAccessPolicy,{ + sid:actualIdentity.sid,processDaclApplied:true,tokenDaclApplied:true, + }); + } + const pair=generateKeyPairSync('ed25519'); + await writeFile(key,pair.privateKey.export({format:'pem',type:'pkcs8'}),{mode:0o600}); + await writeFile(config,'{"fixture":true}\n',{mode:0o600}); + await writeFile(allowlist,'[]\n',{mode:0o600}); + const store=createSqliteCheckpointStore(database,{namespace:'disposable-profile'}); + try { + const connection=new DatabaseSync(database); + try {connection.prepare('VACUUM INTO ?').run(backup);} + finally {connection.close();} + const before=await hashes(); + const fileModes=Object.fromEntries(await Promise.all([hostDir,...files].map(async file=>{ + const s=await lstat(file); + return [path.basename(file),{uid:s.uid,mode:(s.mode&0o777).toString(8)}]; + }))); + if (process.platform==='linux') { + assert.equal(fileModes['host-private'].mode,'700'); + assert.equal(fileModes['signing-key.pem'].mode,'600'); + assert.ok(Object.values(fileModes).every(file=>file.uid===actualIdentity.uid)); + } + await report(hostDir,'host-ready.json',{ + pid:process.pid,ppid:process.ppid,identity:actualIdentity,files:before,fileModes,windowsAccessPolicy, + }); + const until=Date.now()+75000; + while (true) { + try {await lstat(path.join(hostDir,'stop')); break;} + catch (error) {if (error.code!=='ENOENT') throw error;} + assert.ok(Date.now()1 && hostPid!==process.pid); + const actualIdentity=await identity(); + assert.equal(process.env.FLOWKIT_HOST_CANARY,undefined); + assert.equal(await readFile(publicTask,'utf8'),'flowkit-public-task\n'); + const checks=[]; + async function denied(resource,operation,callback) { + try {await callback();} + catch (error) { + assert.ok(['EACCES','EPERM'].includes(error.code),resource+' '+operation+': '+error.code); + checks.push({resource,operation,code:error.code}); + return; + } + assert.fail(resource+' '+operation+' was allowed'); + } + await denied('host-private','list',()=>readdir(hostDir)); + const incoming=path.join(modelDir,'replacement'); + await writeFile(incoming,'disposable replacement',{mode:0o600}); + for (const file of files) { + const resource=path.basename(file); + await denied(resource,'read',()=>readFile(file)); + await denied(resource,'copy',()=>copyFile(file,path.join(modelDir,'copy'),constants.COPYFILE_EXCL)); + await denied(resource,'open-write',async()=>{const handle=await open(file,'r+'); await handle.close();}); + await denied(resource,'delete',()=>unlink(file)); + await denied(resource,'move',()=>rename(file,path.join(modelDir,'moved'))); + await denied(resource,'replace',()=>rename(incoming,file)); + } + + let processEvidence; + if (process.platform==='linux') { + const hostProc='/proc/'+hostPid; + await denied('host-fd','list',()=>readdir(hostProc+'/fd')); + await denied('host-env','read',()=>readFile(hostProc+'/environ')); + await denied('host-memory','open-read',async()=>{const handle=await open(hostProc+'/mem','r'); await handle.close();}); + await denied('host-process','signal-permission',()=>process.kill(hostPid,0)); + const fds=[]; + for (const fd of await readdir('/proc/self/fd')) { + try {fds.push({fd,target:await readlink('/proc/self/fd/'+fd)});} + catch (error) {if (error.code!=='ENOENT') throw error;} + } + assert.ok(fds.every(fd=>!fd.target.startsWith(hostDir+'/')),'a Host fixture file descriptor is visible'); + processEvidence={fds}; + } else { + await report(modelDir,'model-file-result.json',{pid:process.pid,identity:actualIdentity,publicTaskReadable:true,checks}); + const probe=path.join(root,'public','process-access.exe'); + processEvidence=JSON.parse(execFileSync(probe,[String(hostPid)],{ + encoding:'utf8',windowsHide:true,timeout:10000, + })); + assert.equal(processEvidence.length,6); + assert.ok(processEvidence.every(result=>result.denied===true),JSON.stringify(processEvidence)); + } + await report(modelDir,'model-result.json',{ + pid:process.pid,ppid:process.ppid,identity:actualIdentity,publicTaskReadable:true,checks,processEvidence, + }); +} + +try { + if (mode==='host') await runHost(); + else await runModel(); +} catch (error) { + await report(mode==='host'?hostDir:modelDir,mode+'-error.json',{ + pid:process.pid,code:error.code,message:error.message,stack:error.stack, + }).catch(()=>{}); + console.error(error); + process.exitCode=1; +} diff --git a/tests/types/interactive.ts b/tests/types/interactive.ts index 446452a..db7488c 100644 --- a/tests/types/interactive.ts +++ b/tests/types/interactive.ts @@ -46,7 +46,7 @@ import { normalizeHostAgentReceipt, } from '../../types/interactive.js'; import type { - HostAgentReceipt,HostAgentAttestation,PublicInteractiveAgentTask, + HostAgentReceipt,HostAgentAttestation,PublicInteractiveAgentTask,PublicInteractiveTerminalFrame, TrustedInteractiveHostOptions, } from '../../types/interactive.js'; @@ -58,10 +58,17 @@ const trustedOptions:TrustedInteractiveHostOptions={ disclosurePolicyId:'tenant-approved-fields-v1', allowedInputKeys:['message'], authorizeDisclosure:async evidence=>evidence.task.kind==='agent', + authorizeTerminalAccess:context=>context.action==='run:read', }; const trusted=createTrustedInteractiveHost(trustedOptions); const publicTask:Promise= trusted.getPublicAgentTask({runId:'r'}); +const publicTerminal:Promise=trusted.getPublicTerminalFrame({runId:'r'}); +void publicTerminal.then(frame=>{ + // @ts-expect-error public projections omit private State + void frame.state; + return trusted.readTrustedTerminalResult({resultRef:frame.resultRef}); +}); const receipt:HostAgentReceipt={ kind:'agent-result',runId:'r',requestId:'q',nodeId:'n',attempt:1,result:{answer:'ok'}, }; diff --git a/types/interactive.d.ts b/types/interactive.d.ts index a178b2a..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;