fix(sandbox): 去掉无飞书通道会话的强制文件读隔离,改为跟随本地 sandbox 配置 - #899
Conversation
## 改了什么
forkWorker 装配 worker init 时,readIsolation 表达式此前把「会话没有飞书
transport 通道」当作强制隔离条件:
readIsolation: botCfg.readIsolation === true
|| !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly })
即只要是 apiOnly bot 或 HTTP virtual(http_async_/http_wait_)会话,无论
owner 有没有配 sandbox,都被强制文件读隔离,owner 无法关闭。现改为:
readIsolation: botCfg.readIsolation === true
readIsolation 变为 opt-in only,只由显式 per-bot readIsolation 驱动,与普通
聊天会话对称(unset/false → 不隔离)。紧邻注释同步重写为如实描述新语义。
## 为什么
原设计把这条强制当作凭证 fail-closed 边界(让这类会话读不到含所有兄弟 bot
secret 的 bots.json)。但磁盘可读范围应由 owner 自己的 sandbox/readIsolation
配置决定,不该在 no-transport 逻辑里写死强制;单 bot 部署或载荷可信时这条强制
纯属束缚。多 bot 同机的横向读取风险改由 owner 显式配 sandbox 来防。
## 被接受的取舍(安全边界变化)
no-transport 会话默认不再隔离:没配 sandbox 时其 CLI 能以同一 OS 用户身份直接
读宿主 bots.json(含各兄弟 bot 的 app secret)。兄弟凭证的横向读取防护改为依赖
owner 显式开 sandbox / readIsolation / BOTMUX_SANDBOX=1。
两条正交边界原样保留,是放宽后剩下的安全网:
1. 本 bot 自身 transport secret 的 env 扣留(LARK_APP_SECRET/larkAppSecret 仍
gated on larkTransportEnabled)—— no-transport 会话即使能读磁盘 bots.json,
也拿不到注入进程 env 的本 bot secret,Botmux 自身发送链路仍关闭。
2. device-credential 强制隔离(worker.ts credentialIsolationRequired)——
enrolled 设备上独立强制,与文件沙盒 toggle 无关。旧代码把 no-transport 强制
全沙盒会让 fullIsolationCoversCredentials=true 从而跳过 credential-only
gate;放宽后无 sandbox 的 no-transport 会话让该 gate 在 enrolled 机器上真正
engage,是正确的 fail-closed 方向。
## 安全不变量审查(全仓消费点)
grep larkTransportEnabled / readIsolation / apiOnly 全部消费点,确认没有别处把
「no-transport ⟹ 已隔离 ⟹ 读不到兄弟凭证」当隐含前提而放松其它检查:
- fs-policy 的 !larkTransport 凭证 deny 只在 sandboxRequested 分支内跑(无
sandbox 的 no-transport 会话干脆不建沙盒),语义正确。
- currentBotIsApiOnly 按运行时真实 underReadIsolation() 分派,不隔离时读
bots.json、隔离时读 env/send-cred,两路都给对的 apiOnly 结论。
- adoptSandboxBlocked 仍对 apiOnly/HTTP-virtual 一律拒 adopt(fail-safe 的过度
限制、非放松),本次不动 adopt 范围。
## 影响面
forkWorker readIsolation 是全 CLI × pty/tmux 共用装配点,本改动是对 no-transport
这一支的严格放宽:
- 普通聊天:不受影响(本就只看 botCfg.readIsolation)。
- apiOnly / HTTP virtual / A2A / core-only:从「强制隔离」变「跟随本地 sandbox」
(无配置→不隔离,与普通聊天对称)。
- adopt/restore:forkAdoptWorker 不自设 readIsolation,不受这行影响;
device-credential 对 adopt 的拒绝(worker.ts)不变。
mac(Seatbelt)/Linux(bwrap) × pty/tmux × sandbox on/off 逐组合核对:沙盒实际建
与否只由 worker 侧 sandboxRequested 决定,本改动仅改变 readIsolation 是否进入该
或值,未新增平台/后端分叉。
## 测试
- test/api-only-mode-wiring.test.ts:source-lock 同步到新表达式,并加
not.toContain 断言旧强制析取项已消失;env 扣留 source-lock 未动、保持绿。
- test/session-lifecycle-start.test.ts:新增 5 条行为测试(直接跑 forkWorker 读
init.readIsolation):apiOnly / http_wait_ / http_async_ 无 sandbox → false;
no-transport + 显式 readIsolation:true → 仍 true;普通 chat → false。
- 定向 10 文件全绿(api-only-mode-wiring / api-only-transport-boundary /
session-lifecycle-start / read-isolation / claude-read-isolation /
codex-read-isolation / fs-policy / api-only-card-patch-suppression /
dashboard-bot-payload / dashboard-ipc)= 440 pass / 1 skip。
- 反向变异自检:临时还原旧强制表达式 → 3 条 no-transport 行为测试 + source-lock
全变红,2 条控制用例(显式 opt-in / 普通 chat)保持绿,证明新测试精确锁住
no-transport 这一支;已还原。
- pnpm build 绿。
本 PR supersede 未合并的 PR #857(apiTaskFullAccess 例外口):本改动直接去掉强制,
例外口不再需要,建议关闭 #857。基线从 master 出,未基于 #857。
deepcoldy
left a comment
There was a problem hiding this comment.
结论:REQUEST_CHANGES。
当前认证身份与 PR 作者相同,GitHub 不允许对自己的 PR 提交正式
REQUEST_CHANGES,因此用 review comment 记录阻断结论。
有 1 个阻断项:
持久后端从旧“强制隔离”迁移到新 policy-off 时会错误复用旧 pane
本 PR 把无 sandbox、且设备未 enrollment 的 no-transport 会话改为 readIsolation=false。worker 侧此时算出的 appliedIsolationCapabilities 是空数组。但 src/worker.ts 的持久 pane 策略校验外层目前是:
if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {因此新策略为 OFF 时整段校验直接跳过。段内为 policy-off 准备的
marker === null && !hostEntryExistsNoFollow(markerPath)分支实际上不可达。
这会命中本 PR 的常见升级路径:旧版本创建的 apiOnly / HTTP virtual tmux、zellij、zmx 或 herdr 会话已经在持久 pane 内以全文件沙盒运行,并留下 credential/read/write marker;升级本 PR、daemon 重启后,新 worker 虽然收到 readIsolation=false,backend selection 仍会把存活 pane 标成 isReattach=true。由于上述 gate 被跳过,worker 会直接 reattach 到旧的 bwrap/Seatbelt 进程,而不是 kill + cold-spawn。结果是该会话在 resume/restart 后仍摸不到宿主 IPC/文件,和 PR 声明的“fresh/resume/restart 跟随本地配置”不一致;旧的 CLI-data redirect / 环境也可能继续留在存活进程中。
请让持久 pane 的 marker/policy 比较在“期望能力为空”时也执行:只要目标 pane 存活,就验证 policy-off 必须是“无 marker”,发现旧 marker 时 kill 并重新选择后端、冷启动。建议补一个 worker orchestration 回归,覆盖“旧全隔离 marker + 存活持久 pane → 新 policy-off”,断言不会 reattach,而不是只断言 forkWorker 发出的 init 字段。
另外建议同步收紧安全说明:不建沙盒后得到的是同一 OS 用户的宿主读写能力,不只是横向读;agent 不仅能读取 bots.json 的本 bot/兄弟 bot secret,也可直接调用 Lark API 或改写宿主配置。因此 env 扣留只能表述为“Botmux 内建 transport 调用链关闭”,不能作为恶意 agent 场景下的凭证安全网。
独立验证:pnpm build 通过;定向 11 文件(PR 列出的 10 文件 + backend-gate)共 461 passed / 1 skipped;git diff --check 通过。现有测试未覆盖上述持久 pane 的 policy-on → policy-off 迁移。
## 背景(复审阻断点) 去掉 no-transport 强制隔离后,发现一处升级迁移回归:worker.ts 的持久 pane reattach 校验外层门是 `if (appliedIsolationCapabilities.length > 0 && ...)`, 新策略为 OFF(无 sandbox、设备未 enrollment)时 capabilities 为空,整段校验被 跳过,段内为 policy-off 准备的 `marker === null && !hostEntryExistsNoFollow(...)` 分支永不可达。 命中场景:旧版本在 forced no-transport 隔离下创建的 apiOnly / HTTP virtual 持久 pane(tmux/zellij/zmx/herdr)已带 credential/read/write marker 并以全文件沙盒 运行。升级本改动 + daemon 重启后,新 worker 虽收到 readIsolation=false,backend selection 仍把存活 pane 标为 isReattach=true;因上述门被跳过,worker 直接 reattach 回旧 bwrap/Seatbelt 进程,而非 kill + 冷启动。结果该会话 resume/restart 后仍摸不到 宿主 IPC/文件,与"读范围跟随本地配置"矛盾,旧 CLI-data redirect/env 也继续留存。 ## 改了什么 抽出纯函数 `persistentPaneReattachGuardEngaged(capabilities, markerPresentOnDisk)` (adapters/cli/read-isolation.ts)作为 reattach guard 的入口判定: - policy ON(capabilities 非空)→ 恒 engage(覆盖 suspend→resume 与 legacy 两种) - policy OFF 但磁盘存在 boot marker → engage(让 OFF 臂 kill + 冷启动 unconfined) - policy OFF 且无 marker → 不 engage(普通从未隔离会话原样 warm reattach,零误杀) worker.ts reattach gate 改用该 helper;marker 存在性用 no-follow 存在探测(planted/ tampered 叶子读不出也算存在,不能用来强制静默 reattach)。**kill 分支在 kill 前 unlink stale marker**:policy-off 冷启动不写新 marker(stamp 仍 gated on capabilities>0),不清会导致每次 restart 重新 engage 误杀刚冷启动的 pane(kill 循环)。 stamp gate(仅 policy-on 写 marker)保持不变。 ## 正交边界不变 env 扣留(本 bot secret 不进 CLI env)与 device-credential 强制隔离均未触碰。 ## 测试 - read-isolation.test.ts 新增 `persistentPaneReattachGuardEngaged` 真值表 3 条 (policy ON×marker有无、policy OFF+marker→engage、policy OFF 无 marker→不 engage)。 - api-only-mode-wiring.test.ts 新增 worker 装配 source-lock:gate 由 helper 驱动 + kill 前 unlink marker 在 kill 之前(kill 循环防护)。 - backend-gate.test.ts reattach gate source anchor 同步到新表达式。 - 反向变异自检:①helper 丢掉 markerPresentOnDisk 项 → policy-off+marker 行为测试 变红;②worker gate 换回裸 length>0 → 装配 source-lock 变红;均已还原。 - 定向 11 文件 465 pass / 1 skip;pnpm build 绿。 ## 文档 file-sandbox.md + api-only 设计文档:安全口径从"同 OS 用户可读"收紧为"可读写" (能改写宿主配置/直接调 Lark API);env 扣留表述为"只关闭 Botmux 内建 transport 调用链,非恶意代码下凭证隔离";设计文档补升级迁移(持久后端)说明。
deepcoldy
left a comment
There was a problem hiding this comment.
结论仍为:REQUEST_CHANGES。新 helper 修到了“旧 marker + 存活 pane + kill 成功”的 happy path,但 marker 生命周期还有两个阻断分支;当前测试没有穿过它们。
1. kill 成功前先删 marker,会在 teardown 失败后丢失唯一迁移证据
src/worker.ts 现在于调用 killPersistentBackendTarget / ZmxBackend.killManagedSession 之前执行:
try { unlinkSync(stalePaneMarkerPath); } catch { /* ... */ }若 kill 抛错,或 post-kill probe 仍返回 exists(ZMX 还包括 unknown),本次启动会按预期抛错,但旧 pane 可能仍存活,marker 却已经消失。下一次启动在 policy-off 下得到 caps=[] + markerPresent=false,persistentPaneReattachGuardEngaged 返回 false,worker 便跳过整段 guard,直接 reattach 到刚才未能确认终止的旧隔离 pane——原回归在重试时重新出现。
marker 应至少保留到 kill 成功且 post-kill 状态通过现有确认逻辑之后,再在重新选择/冷启动前删除;删除失败也不能静默继续,否则会进入后述误杀循环。
2. “有旧 marker、pane 已不存在”不会清 marker,会在下一次重启误杀新 pane
当 stalePaneMarkerPresent=true 但 paneProbe='missing' 时,guard 会进入,但 if (paneLive) 整块跳过,旧 marker 不会删除。当前启动随后会正常创建一个 policy-off、无沙盒的新 pane,且按现有 stamp gate 不写新 marker。下一次 daemon 重启时旧 marker 仍在、此时 pane 已 live,于是 guard 把这个刚创建的正常无沙盒 pane 当成旧隔离 pane杀掉。
因此已确认 pane 不存在时,也需要安全清理 policy-off 的 stale marker 后再 fresh spawn。若 marker 是目录/其它不可 unlink 的异常 leaf,当前吞掉 unlinkSync 错误同样会造成每次重启误杀;应验证 marker 确实消失,否则拒绝继续。
还有一个同源的迁移盲区:旧的隔离 marker 写入本来是 best-effort(stamp 处 catch 后仍允许 spawn),所以“policy-off + 无 marker”并不能严格证明存活 pane 从未隔离。若要完整保证升级语义,需要显式的 policy-off generation/tombstone 或其它持久迁移状态;否则旧隔离 pane 恰遇 marker 写失败/丢失时仍会被直接 reattach。
测试缺口
新增的是 helper 真值表 + worker source-lock,并非穿过 worker teardown/reselect 编排的行为测试;它无法观察 kill 失败、post-kill 失败、pane missing、marker 删除失败这些状态,所以本轮 465 个定向测试全绿仍未锁住上述问题。建议把 marker/pane/kill/probe/reselect 抽成可注入依赖的状态机 seam,至少覆盖:
- live + marker + kill 失败:marker 保留,下一次仍 engage;
- live + marker + post-kill 拒绝:marker 保留;
- missing + marker:清 marker后 fresh spawn,下一次不误杀;
- marker 清理失败:不发布新 policy-off pane;
- policy-off 的正常 live generation:允许 warm reattach。
文档已准确补充“同 OS 用户可读写”及“env 扣留只关闭 Botmux 内建 transport 链”,这部分复核通过。
独立验证(head 03cf22ac8):pnpm build 通过;定向 11 文件 465 passed / 1 skipped;git diff --check 通过。
## 背景(复审第二轮阻断点) 上一版 marker 生命周期还有两个阻断分支 + 一个同源根因: 1. **先 unlink 后 kill 丢迁移证据**:kill 抛错/post-kill probe 拒绝时旧 pane 可能 仍活、marker 却已删→下次 policy-off 启动 caps=[]+无 marker→helper 返 false→ 跳过 guard→reattach 回没杀成的旧隔离 pane,原回归重现。 2. **marker 在但 pane 已 missing 不清 marker**:本次冷启正常无沙盒 pane(不写新 marker),旧 marker 残留→下次重启它变 live 被误判杀掉。 3. **根因:隔离 marker 写入是 best-effort**(stamp 处 catch 后仍 spawn),故 "policy-off + 无 marker" 不严格等价 "从未隔离";据"无 marker"直接 warm reattach 无法保证迁移语义。 ## 改了什么 **抽纯状态机 `evaluatePersistentPaneMigration`**(read-isolation.ts,可注入、 纯函数)作为持久 pane 迁移决策唯一真源,返回 reattach / kill-then-cold-spawn (clearAfterKill) / clear-stale-then-cold-spawn / skip。worker.ts 的 guard 只做 IO(probe/kill/clear)并 dispatch 该决策。 **tombstone 正向证明**(根治 issue 3):新增 `<sid>.policy-off` tombstone,由 policy-off 冷启动写入,正向证明"此 generation 由新无沙盒策略创建"。policy-off 下**只有拿到 tombstone 且无隔离 marker 才允许 warm reattach**;带旧隔离 marker、 或两文件都无(fail-closed——"无 marker"不再被当作安全)的存活 pane 一律 kill。 tombstone 写失败即拒绝启动(不发布无法证明的 generation)。 **关键时序(fail-closed)**:provenance 文件只在 **kill 成功 + post-kill probe 确认终止之后**才删,删除本身再验证消失(`removeProvenanceOrThrow`:unlink→ no-follow 复查→仍在则抛错),删不掉拒绝继续。绝不在 kill 前删。pane 已 missing 但残留 stale 文件→先清理(验证)再冷启动。 **作用域**:迁移臂只对 no-transport(apiOnly/HTTP virtual)+ isolation-capable (tmux——沙盒只作用 pty/tmux,pty 不持久)会话生效;普通 transport-enabled 聊天 从未被强制隔离,不受 tombstone 要求约束→无误杀。四类后端仍只操作各自精确 target。 ## 崩溃可恢复 kill 后清 marker 前崩溃→下次 pane missing + stale 文件→clear-stale 恢复;清 marker 后 spawn 前崩溃→下次无文件→skip→冷启动写 tombstone;都不留虚假证明。 ## 测试 - read-isolation.test.ts:`evaluatePersistentPaneMigration` 真值表 12 条,覆盖 codex 5 分支(kill/clear/missing/正常 live/transport-enabled 不误杀)+ issue-3 的"NEITHER file→kill"。 - api-only-mode-wiring.test.ts:worker 装配 source-lock 重写——guard 由状态机驱动、 provenance 清理在 post-kill 确认之后(顺序断言)、removeProvenanceOrThrow fail-closed、tombstone 写入 + 写失败拒绝启动;`not.toContain` 旧 helper 名。 - backend-gate.test.ts:gate/kill log anchor 同步到新表达式。 - 反向变异自检:①去掉 tombstone 要求(provenPolicyOffGeneration 放松)→"NEITHER file→kill"变红;②removeProvenanceOrThrow 改吞错→装配 source-lock 变红;均已还原。 - 定向 11 文件 474 pass / 1 skip;pnpm build 绿;git diff --check 干净。 ## 文档 api-only 设计文档升级迁移段重写为 tombstone/provenance 语义 + kill 确认后清 + fail-closed 时序。
deepcoldy
left a comment
There was a problem hiding this comment.
结论仍为:REQUEST_CHANGES。纯状态机的多数分支合理,但 worker 接线与 provenance 不变量仍有 3 个阻断项。
1. “policy-off + live pane + NEITHER file → kill”在真实 worker 中仍不可达
状态机已正确规定 NEITHER file 的存活 pane 必须 kill,但外层预过滤是:
const persistentPaneMigrationEvidence = appliedIsolationCapabilities.length > 0
|| (noTransportSession && isolationCapableBackend
&& (stalePaneMarkerPresent || policyOffTombstonePresent));NEITHER file 时最后一项必为 false,整段不会 probe pane,也不会调用 evaluatePersistentPaneMigration。因此旧隔离 pane 恰逢 best-effort marker 写失败/丢失时,升级后仍直接 warm reattach;issue 3 只在纯函数测试里修了,产品路径没修。
policy-off 的 no-transport tmux 迁移必须在不依赖 provenance 已存在的前提下进入状态机,例如外层条件覆盖 noTransportSession && isolationCapableBackend,由状态机结合真实 paneLive 决定 NEITHER 是 kill 还是 fresh/skip。
2. isolationCapableBackend === tmux 的早退破坏了 device-credential 持久 pane 校验
evaluatePersistentPaneMigration 在 policy-on 判断前先执行:
if (!isolationCapableBackend) return { action: 'skip' };但 appliedIsolationCapabilities 不只代表文件沙盒,也包含独立的 credential 能力。enrolled 主机、未开 full sandbox 时,credential-only Seatbelt/bwrap 会包装 fresh launch;Herdr/Zellij/ZMX 会接收该 wrapper 命令,而且现有 stamp gate 会给所有 persistent backend 写 ['credential'] marker。旧 guard 也会对这些非 PTY persistent panes 校验 exact capabilities。
当前改动中,它们虽然因 caps.length > 0 进入 worker gate,却在状态机被 isolationCapableBackend=false 直接 skip,可能 warm reattach 旧/缺失/策略不匹配的 credential-only pane。这是对 device-credential 正交边界的回归。文件沙盒迁移确实只需 tmux,但 policy-on 的 capability 校验必须先于/独立于这个 no-transport tmux scope。请补 credential-only + Herdr/Zellij/ZMX 的 mismatch/reattach 测试。
设计文档“实际只有 tmux pane 可能带 marker”也因此不准确:full file sandbox 只有 tmux/pty,但 credential-only persistent generation 可在其它后端带 marker。
3. tombstone 还不是可信、generation-bound 的正向证明
worker 用 hostEntryExistsNoFollow(policyOffTombstoneFilePath) 得到 policyOffTombstonePresent,状态机随后把“存在且无 isolation marker”直接当成 reattach 证明。但该 probe 只做 lstat:空文件、目录、symlink、错误权限/内容都算 present。注释声称 tombstone 是“真实 0600 file”,实现却没有通过 readManagedOriginAuthorityFile 校验 regular file、owner、mode、size,也没有解析 version / policyOff:true / bootId。对 isolation marker,异常 leaf 会促使 kill;对 tombstone,它反而可授权 warm reattach,方向相反。
此外,policy ON + pane missing 时状态机直接 skip,不会清遗留 tombstone。随后 fresh isolated spawn 的 isolation marker 写入仍是 best-effort:若它失败,存活的隔离 pane 会只剩旧 policy-off tombstone;以后切回 policy-off 就被误认成无沙盒 generation 而 warm reattach。无 live pane 时,任何 stale provenance 都应在 fresh spawn 前清理,不应只在 policy-off arm 清。
最后,tombstone 实际在 backend.spawn 之前写入,与“spawn 失败不留假证明”的描述不符。若保留 pre-spawn intent 设计,需要明确区分 intent/active proof并证明所有失败/崩溃点可收敛;否则应在成功 fresh spawn 后发布,发布失败时精确 teardown 新 pane。
测试仍未穿过 IO 编排
新增 12 条是 evaluatePersistentPaneMigration 的纯输入真值表;该函数没有可注入的 kill/probe/clear/reselect 依赖,因此并未行为验证“kill 失败保 marker、post-kill 拒绝保 marker、删除失败拒绝、reselect”等故障。worker 部分仍是 source-lock。正因如此,测试同时漏掉了:
- 外层 prefilter 让 NEITHER 分支不可达;
- 非 tmux credential-only 被早退;
- 无效 tombstone 被当 proof;
- policy-on/missing 未清 tombstone。
建议把“决策 + 有序副作用”做成真正可注入 seam,或至少为 worker 子进程/后端依赖做故障注入,而不是只锁源码字符串。
独立验证(head 17ab844bc):pnpm build 通过;定向 11 文件 474 passed / 1 skipped;git diff --check 通过。绿测不覆盖上述连接与 provenance 故障。
## 背景(复审第三轮 3 阻断) 上一版状态机语义对,但 worker 接线与 provenance 不变量仍有 3 个真 bug: 1. **NEITHER-file 修复产品路径不可达**:外层 gate 仍要求 marker/tombstone 至少 一个在,两文件都无时根本不 probe/不进状态机→best-effort marker 写失败的旧隔离 pane 升级后仍直接 warm reattach。issue3 只在纯函数测了、产品没修。 2. **`isolationCapableBackend=tmux` 早退破坏 device-credential 正交边界**: `credential` cap 对 enrolled 设备独立注入,credential-only wrapper 用于 zellij/herdr/zmx 且 stamp gate 给它们写 marker;状态机开头 `!isolationCapableBackend → skip` 让这些非 tmux 的 credential-only pane 不再校验旧/缺失/mismatch marker →可能 warm reattach 策略不匹配的 credential pane。 3. **tombstone 非可信 generation-bound 证明**:worker 仅 `lstat` 存在即授权 reattach(空/目录/symlink/坏内容都算);且 policy-on+pane missing 不清旧 tombstone→若 isolated marker best-effort 写失败,未来 policy-off 会误认。 ## 改了什么 **issue1(worker gate)**:入口条件改为 `appliedIsolationCapabilities.length > 0 || (noTransportSession && isolationCapableBackend)`——**不再要求 provenance 已存在**, NEITHER-file 的 no-transport tmux pane 也进状态机,由真实 paneLive 决策 kill。 **issue2(状态机 scope 拆分)**:policy-ON capability 校验对**所有** persistent backend 生效(去掉开头的 `!isolationCapableBackend→skip` 早退);`isolationCapableBackend` (tmux)限制只 scope policy-OFF 文件沙盒迁移臂。credential-only 非 tmux pane 的 mismatch marker 现在正确 kill。 **issue3(tombstone 可信 + 清理 + 时序)**: - 新增 `policyOffTombstoneValid()`——secure-read(worker 用 `readManagedOriginAuthorityFile` 校验真实 0600)后再 schema/version 校验;present(触发清理/保守 kill)与 valid (授权 reattach)分开。bootId 仅诊断、不与当前 boot id 比对(合法 policy-off pane 须跨 restart reattach)。 - policy-on + pane missing + 残留 tombstone → clear-stale(不再 skip)。 - stamp block 互斥:policy-on 写 marker 前清 stale tombstone(best-effort); policy-off 写 tombstone 前清 stale marker(verified,marker dominate 必须清净)。 **可注入 IO seam**:抽 `executePersistentPaneMigration(decision, effects)`——把 kill→confirm→clear→reselect 的有序副作用 + stop-on-failure 语义从 worker 提出来, effects(killStalePane/confirmPaneGone/clearProvenanceVerified/reselectBackend) 可注入 mock。worker 提供真实实现。 ## 测试(行为,非纯 source-lock) - read-isolation.test.ts:状态机真值表扩到 ~18 条(含 credential-only 非 tmux mismatch/match、tombstone present-but-invalid→kill、NEITHER→kill、marker dominate、非 scope 死 pane 仍清);**executor seam 行为测试**(注入 mock 观察 kill→confirm→clear→reselect 顺序 + kill失败/post-kill拒绝/clear失败各自"不调用" 后续);`policyOffTombstoneValid` 校验表。 - api-only-mode-wiring.test.ts:worker 接线 source-lock 重写(gate 无 provenance 前置/executor 驱动/tombstone secure-read 校验/effects 五闭包/写失败拒绝)。 - backend-gate.test.ts:region anchor 同步到 effects 结构。 - 反向变异:①恢复 issue2 早退→credential-only 非 tmux 测试红 ②executor clear 前移到 confirm 前→3 顺序测试红;均已还原。 - 定向 11 文件 487 pass/1 skip;build 绿;git diff --check 干净。 ## 文档 设计文档升级迁移段重写:policy-on 全后端校验、policy-off tombstone secure-read+ bootId 不比对、执行器 fail-closed 时序、无 live pane 必清 provenance。
deepcoldy
left a comment
There was a problem hiding this comment.
结论:REQUEST_CHANGES。(当前认证身份与 PR 作者相同,GitHub 不允许提交正式 REQUEST_CHANGES,因此以 COMMENT 记录。)
新 head 已修复上一轮的三个问题:policy-ON 对全部 persistent backend 做 exact capability 校验;policy-OFF no-transport+tmux 的 NEITHER-file 分支已真实进入状态机;tombstone 的 present/valid 分离、secure read、kill→confirm→clear→reselect 的故障顺序也成立。独立验证:pnpm build 通过;定向 11 文件 487 passed / 1 skipped;git diff --check 通过。
仍有 1 个阻断:worker 的外层 gate 仍让状态机新增的“policy-OFF + dead pane + 任意 stale provenance → clear-stale”分支在 migration scope 外不可达。具体反例:
- 普通 transport tmux 会话在 policy-ON 下产生一个当前版本/策略完全匹配的 isolation marker;
- pane 消失后把策略关掉,worker 冷启动一个 policy-OFF、未隔离的新 pane;
- 此时
caps=[]且noTransport=false,外层 gate 跳过状态机;旧 marker 没被清,policy-OFF 普通会话的 stamp 分支也不会覆盖它; - 之后重新启用同一 policy,旧 marker 会对新的未隔离 pane 通过
isolatedPaneReattachSafe,导致把未隔离进程 warm-reattach 成“已隔离”。
这与状态机注释/新增测试以及设计文档“无 live pane 时无论 policy on/off 都清 stale provenance”的承诺相反。请让 worker 在发现任一 provenance 时也进入状态机(或等价地保证 dead-pane cleanup 在所有 persistent backend 上真实执行),并补一条穿过 worker gate 的回归,覆盖“transport-enabled + policy-OFF + dead pane + stale marker”。
另外,PR body 仍停留在初始提交(10 文件、440 pass/1 skip),尚未描述三个迁移 follow-up commit、tombstone/执行器及当前 11 文件 487/1 的验证结果;合并前请同步到当前实现与风险口径。
| // Enter for: any policy-ON spawn (capability check runs on every persistent | ||
| // backend, incl. credential-only zellij/herdr/zmx — codex R3 #2), OR a | ||
| // policy-OFF no-transport tmux session (the file-sandbox migration scope). | ||
| const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0 |
There was a problem hiding this comment.
这里仍把状态机挡在 policyOn || (noTransport && tmux) 之后,因此 evaluatePersistentPaneMigration 中“policy-OFF + dead pane + stale provenance → clear-stale”在普通 transport(以及 policy-OFF 非 tmux)上不可达。一个旧 policy-ON marker 可以跨过 policy-OFF 冷启动留在磁盘上,之后重新启用相同 policy 时误认证新的未隔离 pane。请把 provenance-present 纳入进入条件(或用等价接线),并加 worker gate 级回归。
## 背景(复审第四轮阻断)
上一版把 dead-pane「clear-stale」分支写进了纯状态机,但 worker 外层入口
`persistentPaneGuardApplies = caps>0 || (noTransport && tmux)` 仍不覆盖它,导致
该分支在 migration scope 外不可达。真实回归(codex R4):
1. 普通 transport tmux 在 policy-ON 下留下一个当前策略有效的 isolation marker;
2. pane 消失后关闭 sandbox,冷启动一个 policy-OFF 未隔离新 pane——此时 caps=[]、
noTransport=false,外门直接跳过状态机,旧 marker 没被清;
3. 之后重新启用同一 policy,旧 marker 对新的未隔离 pane 通过
isolatedPaneReattachSafe→未隔离进程被 warm-reattach 成"已隔离"。
这与状态机新增的「policy-OFF + dead pane + 任意 provenance → clear-stale」用例、
以及设计文档「无 live pane 时无论 policy on/off 都清 provenance」的承诺相矛盾。
根因仍是纯函数修对、产品入口挡住分支(与 R2/R3 同类)。
## 改了什么
worker gate 增加第三个析取项:
const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0
|| (noTransportSession && isolationCapableBackend)
|| stalePaneMarkerPresent || policyOffTombstonePresent;
任何会话(含 transport-enabled、任意 backend)只要磁盘上有 stale marker/tombstone
就进状态机,dead pane 的残留证明在 cold-spawn 前于**所有** backend 被清理。
不引入误杀:transport-enabled 的 LIVE pane + stale marker 经状态机仍返回 skip
(policyOn=false && !inMigrationScope && paneLive → skip),只有 DEAD pane 才
clear-stale。两种情况纯状态机均已有用例(read-isolation.test.ts)。
## 测试
- api-only-mode-wiring source-lock 更新到新 gate(含 provenance 析取项)。
- 反向变异:gate 去掉 provenance 析取项 → source-lock 变红,已还原。
- 定向 11 文件 487 pass / 1 skip;build 绿。
deepcoldy
left a comment
There was a problem hiding this comment.
结论:APPROVE(代码层面)。(当前认证身份与 PR 作者相同,GitHub 不允许提交正式 APPROVE,因此以 COMMENT 记录,并以本条取代上一轮 REQUEST_CHANGES。)
上一轮阻断已正确关闭:
- worker gate 现在是
policyOn || (noTransport && tmux) || markerPresent || tombstonePresent,因此 stale provenance 不再受 migration scope 的入口限制; - transport-enabled / 非 tmux 的 live pane 进入状态机后仍走
skip,不会扩大 kill 范围; - dead pane 则在所有 persistent backend 上走
clear-stale-then-cold-spawn,旧 marker 不会跨 generation 误认证新的 policy-OFF pane; - policy-ON 全后端 capability 校验、no-transport tmux 的 NEITHER-file fail-closed、tombstone secure-read/schema、marker dominate、bootId 仅诊断、kill→confirm→clear→reselect 的失败顺序均保持成立。
独立验证(head 57d7e817f):
pnpm build通过;- 定向 11 文件:487 passed / 1 skipped;
git diff --check origin/master...HEAD通过;- 分支相对当前 master:5 ahead / 0 behind,PR mergeable;
- PR body 已同步到全部 follow-up、当前风险口径与 487/1 结果。
剩余仅流程门:GitHub Actions 的 build check 当前仍在运行,合并需等它转绿。
另有一项非逻辑清理:本 PR 新增的源码/测试注释里仍带内部 review 轮次和协作身份(例如 R3/R4、谁提出的测试要求)。按仓库公开历史规范,建议合并前改成客观缺陷标签/不变量描述;不影响上述代码结论。
进公开 git 历史的 source/test 注释不带内部 review 编排:把 no-transport 持久 pane 迁移相关注释里的「R3/R4」轮次标记与「exactly what … asked」等协作措辞,改写成 客观的缺陷标签 + 不变量描述(所述边界/时序不变)。纯注释改动,无逻辑变更: `pnpm build` 绿,定向 read-isolation/backend-gate/api-only-mode-wiring 3 文件 125 pass,source-lock 断言不受影响。
|
结论:REQUEST_CHANGES(自动评审的初步意见,最终以维护者审阅为准)。 当前 head 反例:探活不确定时会清证明,随后可能把旧 pane 认证成新 generationworker 只有 ZMX 会在初始 const paneLive = paneProbe === 'exists';所以 这不是纯可用性问题:marker / policy-off tombstone 都在 真实代码的另外两个事实放大了这个路径:
因此 executor 测试里“kill/confirm 失败会 throw”的 mock 契约没有覆盖真实 backend。当前 建议:
独立验证: |
## 缺陷
持久 pane 迁移状态机 evaluatePersistentPaneMigration 把 pane 存活性建模为
boolean(paneLive = paneProbe === 'exists'),但真实后端探活是三态
SessionProbe = 'exists' | 'missing' | 'unknown'。worker 把 unknown 压成
paneLive=false,导致两条不安全路径:
1. 初始探活 unknown:no-transport tmux 会话升级后带 legacy 隔离 marker,tmux
控制面抖动返回 unknown → 走 dead-pane 臂 clear-stale,**在没杀掉 pane 的
情况下删掉隔离 marker**——而那个 pane 可能仍然活着且仍被旧沙盒关着。
2. kill 后确认 unknown:迁移 teardown 复用共享 helper
shouldRejectPersistentPostKillProbe,它只对 ZMX 拒 unknown;tmux/herdr/
zellij 的 post-kill unknown 被放行 → 继续清证明+重选后端。叠加 tmux
killSession catch{} 吞掉包括 timeout 在内的错误、zellij spawnSync 不校验
退出码,kill 未确认就把新 generation 证明落盘。
## 修复
- 状态机入参 paneLive:boolean → paneProbe:SessionProbe(三态)。unknown 不再
当 dead:只要有隔离风险(policy-ON / 在 policy-off 迁移 scope / 磁盘有任何
provenance)就返回新的 refuse-inconclusive-probe 决策;仅当完全无隔离风险
(policy-OFF、非 scope、无 provenance)才 skip,避免普通会话因探活抖动无谓
起不来。只有权威 missing 才清 stale / cold-spawn。
- 迁移 teardown 的 post-kill 确认改为对所有后端要求权威 missing(exists 与
unknown 都 fail-closed),不再借用 ZMX-only 的共享 helper;共享 helper 与
mcp-gateway 那条既有 gate 保持原样、未改语义。
- 移除 worker 里 ZMX 专有的初始 unknown 早退 throw,unknown 统一经状态机
(单一决策点),ZMX 的 fail-closed 结果不变。
- 执行器 executePersistentPaneMigration 新增 refuseInconclusiveProbe effect
(必抛,不碰 provenance、不 reselect)。
pre-spawn 发布证明的顺序刻意不动:修复后 spawn 失败遗留的孤儿证明,下次启动
在 missing→clear-stale、unknown→refuse、exists→仅当真有匹配 pane 才合法
reattach 三种探活结果下都安全;反而把发布挪到 spawn 后会新开一个「pane 活着
但证明未落盘、下次重启被误杀」的窗口。
## 测试
- read-isolation.test.ts 新增 7 条三态用例(policy-ON/OFF × unknown ×
有/无 provenance × 是否在 scope),executor seam 新增 refuse-only 顺序断言。
- api-only-mode-wiring / backend-gate source-lock 同步为新语义(迁移臂用
postKillProbe !== 'missing' + refuseInconclusiveProbe;mcp-gateway gate 仍
ZMX-scoped 共享 helper,验证两条 gate 语义已分开)。
- 反向变异自检三处(unknown 压回 dead、去掉 policy-ON unknown 拒、post-kill
改回共享 helper)均确认对应测试变红后还原。
- 定向 11 文件 494 pass / 1 skip;pnpm build 绿;git diff --check 干净。
Co-Authored-By: Claude <noreply@anthropic.com>
|
补充一个仍需修复的代际证明竞态:当前 provenance 在实际 persistent pane generation 建立之前发布,因此证明绑定的是“worker 计划 fresh”,不是“实际创建成功的 pane”。 可达路径(以 zellij 为最直接例子):
TmuxPipe / Herdr 的具体表现略不同:late collision 会使 fresh spawn 失败,但 pre-spawn provenance 会留在磁盘;下次启动若探到 这会在 policy-ON 方向把晚到的旧未隔离 pane 认证为当前隔离 generation;在本 PR 新增的 policy-OFF 方向,也会把晚到的旧隔离 pane 认证为当前无沙盒 generation,继续违反本次迁移目标。 建议把 provenance 改为两阶段:spawn 前只能发布永不授权 reattach 的 pending/uncommitted 状态;后端确认实际 fresh generation 建立后再 finalize。任何 spawn 失败、late reattach 或无法确认的路径都不能把 pending 转成有效证明。崩溃窗口导致下次把未证明 pane kill 掉属于 fail-closed 的可用性代价,不能用来证明 pre-spawn 有效证明是安全的。需要补 probe→publish→late winner→spawn 的行为回归,至少覆盖 zellij 动态 reattach 与 tmux fresh collision 后重启。 另有两项非安全清理:
当前 head 的三态修复本身已验证通过:定向 11 文件 494 pass / 1 skip、 |
225c677 to
ed1bc7d
Compare
|
基于 1.
|
ed1bc7d to
97529ab
Compare
|
基于 阻断:Herdr 会把 predicted-reattach 降级成 actual-fresh,绕过 wrapper 与 PENDING当前只防了 predicted-fresh → actual-reattach。Herdr 还允许反方向:
结果:enrolled 主机上的 credential-isolated Herdr pane 可在这个窗口启动一个未包 credential boundary的新 CLI,并继续继承旧 committed proof;后续还会被当成隔离 generation warm-reattach。现有 修复应像 ZMX 一样冻结 reattach 决策: 本机验证:
绿测不反驳该结论;其中 Herdr 用例直接证明了可达性。 另外,若最终不是 squash merge,当前早期 commit body 仍含“复审第 N 轮”/内部协作标记,不符合仓库公开历史规范;合并前需 reword/squash 为中性客观描述。 |
97529ab to
3f8332f
Compare
自动化终审(head
|
## 缺陷(独立于三态修复的第 2 个) read-isolation 持久 pane 证明(isolation marker / policy-off tombstone)此前在 backend.spawn() 之前无条件写入。但 spawn() 可能把本次 launch 绑定到一个晚到的 同名 pane:zellij / TmuxBackend 在 spawn 内动态把 fresh 翻成 reattach, TmuxPipe/herdr/zmx 则在重名时抛错。于是"给还不存在的 pane 预写的有效证明"会被 一个外来/未知隔离态的 pane 洗白——下次重启 probe=exists+证明 valid 即被接受, 证明是循环的。三态修复挡不住它(这次结果是 exists 且"匹配证明"正是本 worker 自写)。 ## 修复(per-backend,非统一 bootstrap) 关键事实:四个持久后端里三个在 spawn() 同步返回时就"可归属 fresh"(TmuxPipe new-session 同步抛错 / herdr agent-start 同步返回创建的 pane、重名抛错 / zmx createFreshSession 内部已用 bootstrapPath+launchPid+release-token 握手自证), 只有 zellij 因 pty.spawn 异步创建 session 而没有同步可归属信号;且 policy-off tombstone 是 tmux-only。所以按后端分别处理,不引入统一 bootstrap。 - **证明拆 PENDING→COMMITTED 两态**:pre-spawn 写 PENDING(带随机 nonce 的记录, 两个 validator 都拒、presence 仍进保守 guard);spawn 同步返回后仅在确认 fresh 非 reattach 时 compare-before-replace 换成 committed(带四元 generation fence)。 - **PENDING 是状态机支配输入**:pendingProvenancePresent 先于一切判定(所有后端、 两个 policy 方向、无视 tmux scope)——exists→kill、unknown→refuse、missing→clear。 堵住原先 policy-OFF+live+!inMigrationScope 直接 skip 的洞(enrolled 非 tmux pane 留 pending、credential policy 翻 OFF 后会 warm-reattach 未决 generation)。 - **late-flip teardown**:预测 fresh 但 spawn 返回 actual isReattach===true → 立刻 teardown exact target→确认权威 missing→refuse。 - **PENDING 是 spawn 前 fail-closed 准入前置**:policy-ON 与 policy-OFF 两臂的 pending 写失败都抛错拒启动(policy-ON 之前误设 best-effort catch 吞错——写失败后若 late-flip 反接旧 pane,pendingProvenanceCommit 为空会跳过整个 teardown 块、让未归属 pane 继续跑)。 - **exact-target teardown(不 name-only)**:teardown 走纯策略 persistentTeardownKillKind ——herdr 隔离/MCP agent 落在共享 host `botmux`,name-only kill 会杀光所有 bot 的 agent; 改用 captured persistentBackendTarget(herdr agent scope)/ ZMX frozen-PID 身份路径; 所有后端 post-kill 只接受权威 missing,否则保留 pending。 - **bump ISOLATION_PANE_MARKER_VERSION 10→11 + 严格 state==='committed'**:v10 marker 正是 "漏洞态 pre-spawn 直接发布、无 state",若容忍无-state 则升级后**存量**被洗白的旧 marker 仍被当有效。版本 bump + 严格 committed 强制每个 pre-v11 无-state marker 冷启一次,闭合 存量风险。tombstone 从未正式发布,直接严格 committed 不给旧无-state 兼容。 - **zellij(选项 B)**:无同步可归属信号,isolation-capable zellij 永不 commit → 证明停在 pending → 每次 restart/suspend-resume 都 cold-spawn(暂不 warm-reattach)。 ## 影响面 credential-isolated zellij persistent pane 暂不 warm-reattach(availability 降级,非安全 降级——cold-spawn 恒 fail-closed)。恢复它需一个 fresh-launch attributable ack 协议,留独立 follow-up。tmux/herdr/zmx warm-reattach 不变。普通聊天、非持久后端不受影响。 ## 测试 - read-isolation.test.ts:PENDING 支配真值表(exists/unknown/missing × scope)+ 两条 policy 翻转回归 + pending/committed validator 严格表 + v10 无-state marker 拒(存量迁移)+ persistentTeardownKillKind 行为测试(herdr→target 不 name / zmx→frozen-PID / tmux 有无 target)。 - api-only-mode-wiring source-lock:PENDING 写入两臂 fail-closed(policy-ON 无 best-effort catch) + 支配入参 + 提交块(late-flip teardown / exact-target 分发 / fence + compare-before-replace / commit-fail teardown / postKill!=='missing' 保留 pending)。worker-pipe source-lock 计数 CliSpawnSupersededError 5→6(commit-fail 分支正确 re-throw superseded 非吞)。 - 反向变异 5 处(去三态 unknown 保护 / 去 PENDING 支配 / 去 late-flip teardown / 验证器容忍无-state / teardown name-only / policy-ON 吞错)均确认对应测试变红后还原。 - 定向 11 文件 504 pass/1 skip + 全部读 worker.ts 源码的 source-lock 测试通过;config-dir 与 worker-dsh 两红为既有环境基线(与 master 一致),零新增回归。pnpm build 绿;git diff --check 干净。 - 顺带清理:docs/design 内部复审轮次字样 scrub 成中性(公开史规范)。 Co-Authored-By: Claude <noreply@anthropic.com> ## 对称竞态补修(predicted-reattach → actual-fresh) 上一版只处理了 predicted-fresh→actual-reattach,漏了对称方向:herdr backend 在 isReattach=true 但 botmux agent 消失时会静默 agent start(fresh),而 worker 已按 willReattachPersistent=true 跳过 PENDING + credential wrapper(13316/13453/13499) → enrolled 主机上起个未包 credential boundary 的 CLI 并继承旧 committed marker。 跨后端核实:仅 herdr 生产路径有此转换(TmuxPipe 冻结 _isReattach / Zellij reattaching 只 false→true / ZMX 已显式抛 / TmuxBackend 仅测试面),故只在 herdr 修: - **herdr backend 冻结**:isReattach=true 但 getAgent 空 → 抛错(镜像 ZMX),绝不内部 fresh;worker 下一轮 cold path 才写 PENDING + 组 wrapper。 - **selector owned-session 分支改 agent 级三态预测**(不用 hasSession&&hasAgent——两个 boolean 会把 unknown 压 false 重新 fail-open):host unknown→refuse;host missing→迁 shared cold;host exists+agent unknown→refuse;host exists+agent exists→同 host warm reattach;host exists+agent missing→**同 host 内 cold start(isReattach:false,不 teardown 仍活的 host)**。避免 backend 冻结在 session 级预测下 throw-loop。 - 回归:herdr backend true→missing 冻结(TOCTOU 防线)+ selector owned-session 5 态表 (owned-session 5 态表)。反向变异(selector 退回 session 级预测)确认变红后还原。 Co-Authored-By: Claude <noreply@anthropic.com>
3f8332f to
474a952
Compare
背景与决定
forkWorker装配 worker init 时,readIsolation表达式此前把「会话没有飞书 transport 通道」当强制隔离条件:即 apiOnly bot / HTTP virtual(
http_async_*/http_wait_*)会话无论 owner 有没有配 sandbox 都被强制文件读隔离,owner 无法关闭。owner 拍板:磁盘可读范围应由 owner 自己的sandbox/readIsolation配置决定,不该在 no-transport 逻辑里写死;多 bot 同机的横向读取风险改由 owner 显式配 sandbox 来防。改了什么(核心一行 + 迁移安全)
核心(
src/core/worker-pool.ts):readIsolation: botCfg.readIsolation === true(删去|| !larkTransportEnabled(...))。readIsolation 变为 opt-in only,与普通聊天对称。完整文件沙盒仍由 worker 侧sandboxRequested独立决定。去掉这条强制后暴露出一个持久后端升级迁移问题,本 PR 一并根治(下述若干 follow-up commit):旧版本在 forced-isolation 下创建的持久 pane 带 isolation marker,升级 + 重启后若被 warm-reattach 会仍关在旧沙盒里,与「跟随本地配置」矛盾。修法:
evaluatePersistentPaneMigration+ 可注入执行器executePersistentPaneMigration(src/adapters/cli/read-isolation.ts):worker guard 只做 IO + dispatch。<sid>.policy-off)做正向 generation 证明:policy-OFF 下只有 secure-read + schema 校验通过的 tombstone(且无 isolation marker)才允许 warm reattach;带旧 marker、tombstone 缺失/无效、或两文件都无(isolation 写入 best-effort,"无 marker"≠"从未隔离")一律 kill。bootId 仅诊断、不与当前 daemon boot id 比对。迁移安全的两个后续修复(同 PR)
复审在上面的迁移机制里又发现两个真回归,本 PR 一并修掉:
① 探活三态 unknown 被压成 dead(fix:paneProbe 三态 + refuse-inconclusive-probe)
状态机原先把 pane 存活性建模为 boolean(
paneLive = paneProbe === 'exists'),但真实后端探活是三态SessionProbe = exists | missing | unknown,unknown被压成「dead」。后果:① 初始 unknown(tmux 控制面抖动)走 dead-pane 臂clear-stale,在没杀掉可能仍活着且仍被旧沙盒关着的 pane 的情况下删掉隔离 marker;② kill 后确认 unknown 借用只对 ZMX 拒 unknown 的共享 helper,tmux/herdr/zellij 的 post-kill unknown 被放行(叠加 tmux killSession 吞 timeout、zellij spawnSync 不校验退出码),kill 未确认就发布新 generation。修法:状态机入参改三态,unknown只要有隔离风险(policy-ON / 在 policy-off 迁移 scope / 磁盘有任何 provenance)就返回新决策refuse-inconclusive-probe(fail-closed),仅完全无风险时才 skip;只有权威missing才清 stale / cold-spawn。迁移 teardown 的 post-kill 确认对所有后端要求权威missing(exists与unknown都 fail-closed),不再借 ZMX-only 共享 helper(共享 helper + mcp-gateway 那条既有 gate 语义不变)。② 证明先于 spawn 发布 → late-winner 洗白代际竞态(fix:PENDING→COMMIT 两阶段)
证明此前在
backend.spawn()之前无条件写入,但 spawn() 可能把本次 launch 绑定到晚到的同名 pane(zellij/TmuxBackend 在 spawn 内动态把 fresh 翻 reattach;TmuxPipe/herdr/zmx 重名抛错)。「给还不存在的 pane 预写的有效证明」会被外来/未知隔离态的 pane 洗白——下次重启 probe=exists+证明 valid 即被接受,证明是循环的。修法(per-backend,非统一 bootstrap):证明拆 PENDING→COMMITTED 两态——pre-spawn 写 PENDING(带 nonce、两个 validator 都拒、presence 仍进保守 guard),spawn 同步返回后仅在确认 fresh 非 reattach 时 compare-before-replace 换 committed(带四元 generation fence)。pendingProvenancePresent是状态机支配输入(先于一切、无视 tmux scope):exists→kill、unknown→refuse、missing→clear——堵住原先 policy-OFF+live+!inMigrationScope直接 skip 的洞。预测 fresh 但 spawn 返回actual isReattach===true→ 立刻 teardown(exact target→确认权威 missing)→ refuse;commit 写失败同样 teardown→确认 missing,否则保留 pending 并报错。关键事实:tmux(-pipe)/herdr/zmx 在 spawn() 同步返回时即可归属 fresh(同步抛错 / 内部握手自证),故同步 commit;只有 zellij(pty.spawn 异步创建 session)无同步可归属信号,采用选项 B:isolation-capable zellij 永不 commit → 证明停在 pending → 每次 restart/suspend-resume 都 cold-spawn。no-transport 会话默认不再隔离:没配 sandbox 时其 CLI 以同一 OS 用户身份对宿主文件有完整读写权——能读
bots.json(各兄弟 bot secret)、也能改写宿主配置 / 直接调 Lark API。兄弟凭证的横向读写防护改为依赖 owner 显式开sandbox/readIsolation/BOTMUX_SANDBOX=1。两条正交边界原样保留:① 本 bot 自身 transport secret 的 env 扣留(gated on
larkTransportEnabled)——这只关闭 Botmux 内建 transport 调用链,不构成恶意代码下的凭证隔离;② enrolled 设备的 device-credential 强制隔离独立生效(放宽后无 sandbox 的 no-transport 会话反而让 credential-only gate 在 enrolled 机器上真正 engage,是正确的 fail-closed 方向)。影响面
forkWorkerreadIsolation 是全 CLI × pty/tmux 共用装配点。本改动对 no-transport 一支严格放宽:普通聊天不受影响;apiOnly / HTTP virtual / A2A / core-only 从「强制隔离」变「跟随本地 sandbox」;adopt/restore 的 forkAdoptWorker 不自设 readIsolation 不受影响;device-credential 对 adopt 的拒绝不变。mac(Seatbelt)/Linux(bwrap) × pty/tmux × sandbox on/off 逐组合核对:沙盒实际建与否只由 worker 侧sandboxRequested决定,未新增平台/后端分叉。测试验证(当前 head)
pnpm build绿;git diff --check干净。api-only-mode-wiring、api-only-transport-boundary、session-lifecycle-start、read-isolation、backend-gate、claude-read-isolation、codex-read-isolation、fs-policy、api-only-card-patch-suppression、dashboard-bot-payload、dashboard-ipc。session-lifecycle-start:行为测试直接跑 forkWorker 读init.readIsolation(apiOnly/http_wait_/http_async_ 无 sandbox → false;no-transport + 显式 readIsolation:true → 仍 true;普通 chat → false)。read-isolation:evaluatePersistentPaneMigration真值表(含三态 unknown fail-closed、PENDING 支配 exists/unknown/missing + 两条 policy 翻转回归、credential-only 非 tmux mismatch/match、tombstone present-but-invalid → kill、marker dominate、NEITHER → kill、transport-enabled live 不误杀 / dead 清理);executor seam 行为测试(注入 mock 观察 kill→confirm→clear→reselect 顺序 + refuse-only + 各失败路径「不调用」后续);PENDING/COMMITTED validator 表(两 validator 拒 pending、接受 committed、legacy 无 state 仍 valid、nonce round-trip)。api-only-mode-wiring:worker 装配 source-lock(三态 paneProbe 入参 + PENDING 写入 + pendingProvenancePresent + 提交块 late-flip teardown / zellij 不 warm-reattach / fence + compare-before-replace + commit-fail teardown)。npx vitest run失败集合落在本机既有环境基线(浏览器 e2e / coco / plugin-mcp-sandbox / shutdown-supervisor / skill-doctor / config-dir / bwrap / 时区 / multi-bot-session 等,均在干净 master 上同样失败),零新增回归。supersede #857
本 PR 取代未合并的 PR #857(
feat(sandbox): 支持 API 任务显式 Full Access)——#857 是给这条强制加 owner 例外口(apiTaskFullAccess),本改动直接去掉强制本身,例外口不再需要。建议关闭 #857。 基线从 master 出,未基于 #857。