Skip to content

fix(sandbox): 去掉无飞书通道会话的强制文件读隔离,改为跟随本地 sandbox 配置 - #899

Open
deepcoldy wants to merge 8 commits into
masterfrom
sandbox-no-transport-follow-config
Open

fix(sandbox): 去掉无飞书通道会话的强制文件读隔离,改为跟随本地 sandbox 配置#899
deepcoldy wants to merge 8 commits into
masterfrom
sandbox-no-transport-follow-config

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Aug 16, 2026

Copy link
Copy Markdown
Owner

背景与决定

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 无法关闭。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 + 可注入执行器 executePersistentPaneMigrationsrc/adapters/cli/read-isolation.ts):worker guard 只做 IO + dispatch。
  • policy-off tombstone<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 比对。
  • policy-ON capability 校验对所有 persistent backend 生效(credential-only wrapper 在 zellij/herdr/zmx 上也带 marker);tmux 限制只 scope policy-OFF 文件沙盒迁移臂。
  • fail-closed 时序:kill → post-kill probe 确认 → 清 provenance(验证消失、删不掉拒绝)→ 重选后端;任一步失败在清证明/重选前中止。
  • dead-pane cleanup 覆盖全后端:任意会话只要有 stale provenance 就进状态机,dead pane 的残留证明在 cold-spawn 前清理(避免 transport 会话关 sandbox 后旧 marker 误配新未隔离 pane)。

迁移安全的两个后续修复(同 PR)

复审在上面的迁移机制里又发现两个真回归,本 PR 一并修掉:

① 探活三态 unknown 被压成 dead(fix:paneProbe 三态 + refuse-inconclusive-probe)
状态机原先把 pane 存活性建模为 boolean(paneLive = paneProbe === 'exists'),但真实后端探活是三态 SessionProbe = exists | missing | unknownunknown 被压成「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 确认对所有后端要求权威 missingexistsunknown 都 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。

⚠️ 由此产生的 availability 变化(非安全降级)credential-isolated zellij persistent pane 暂不 warm-reattach(恒 cold-spawn,strictly fail-closed)。恢复它需要一个「fresh-launch attributable ack」协议,留作独立 follow-up PR。tmux/herdr/zmx 的 warm-reattach 不受影响。

⚠️ 被接受的取舍(安全边界变化)

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 方向)。

影响面

forkWorker readIsolation 是全 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 干净。
  • 定向 11 文件 504 pass / 1 skipapi-only-mode-wiringapi-only-transport-boundarysession-lifecycle-startread-isolationbackend-gateclaude-read-isolationcodex-read-isolationfs-policyapi-only-card-patch-suppressiondashboard-bot-payloaddashboard-ipc
  • session-lifecycle-start:行为测试直接跑 forkWorker 读 init.readIsolation(apiOnly/http_wait_/http_async_ 无 sandbox → false;no-transport + 显式 readIsolation:true → 仍 true;普通 chat → false)。
  • read-isolationevaluatePersistentPaneMigration 真值表(含三态 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)。
  • 反向变异自检覆盖各关键不变量(去掉强制项、三态 unknown 压回 dead、去掉 PENDING 支配分支、去掉 late-flip teardown、post-kill 改回共享 helper 等),均确认对应测试变红后还原。
  • 全量 npx vitest run 失败集合落在本机既有环境基线(浏览器 e2e / coco / plugin-mcp-sandbox / shutdown-supervisor / skill-doctor / config-dir / bwrap / 时区 / multi-bot-session 等,均在干净 master 上同样失败),零新增回归。

supersede #857

本 PR 取代未合并的 PR #857feat(sandbox): 支持 API 任务显式 Full Access)——#857 是给这条强制加 owner 例外口(apiTaskFullAccess),本改动直接去掉强制本身,例外口不再需要。建议关闭 #857 基线从 master 出,未基于 #857

## 改了什么

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 deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论: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 deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论仍为: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=falsepersistentPaneReattachGuardEngaged 返回 false,worker 便跳过整段 guard,直接 reattach 到刚才未能确认终止的旧隔离 pane——原回归在重试时重新出现。

marker 应至少保留到 kill 成功且 post-kill 状态通过现有确认逻辑之后,再在重新选择/冷启动前删除;删除失败也不能静默继续,否则会进入后述误杀循环。

2. “有旧 marker、pane 已不存在”不会清 marker,会在下一次重启误杀新 pane

stalePaneMarkerPresent=truepaneProbe='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 deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论仍为: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 deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论: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 skippedgit diff --check 通过。

仍有 1 个阻断:worker 的外层 gate 仍让状态机新增的“policy-OFF + dead pane + 任意 stale provenance → clear-stale”分支在 migration scope 外不可达。具体反例:

  1. 普通 transport tmux 会话在 policy-ON 下产生一个当前版本/策略完全匹配的 isolation marker;
  2. pane 消失后把策略关掉,worker 冷启动一个 policy-OFF、未隔离的新 pane;
  3. 此时 caps=[]noTransport=false,外层 gate 跳过状态机;旧 marker 没被清,policy-OFF 普通会话的 stamp 分支也不会覆盖它;
  4. 之后重新启用同一 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 的验证结果;合并前请同步到当前实现与风险口径。

Comment thread src/worker.ts
// 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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里仍把状态机挡在 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 deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论: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 断言不受影响。
@deepcoldy

Copy link
Copy Markdown
Owner Author

结论:REQUEST_CHANGES(自动评审的初步意见,最终以维护者审阅为准)。

当前 head 229ae3793 仍有 1 个阻断:持久 pane 迁移把 SessionProbe='unknown' 当成了 missing,因此“kill → 确认真死 → 清 provenance”的 fail-closed 承诺在 tmux / herdr / zellij 上并不成立。

反例:探活不确定时会清证明,随后可能把旧 pane 认证成新 generation

worker 只有 ZMX 会在初始 paneProbe === 'unknown' 时拒绝启动;其它后端直接执行:

const paneLive = paneProbe === 'exists';

所以 unknown 被压成 false。例如 policy-OFF + 旧 isolation marker + 实际仍存活的 tmux pane,在一次 tmux 超时下会被状态机判成 clear-stale-then-cold-spawn不 kill 就删 marker。post-kill 路径也有同样问题:shouldRejectPersistentPostKillProbe('tmux'|'herdr'|'zellij', 'unknown') === false,仍会继续 clear + reselect。

这不是纯可用性问题:marker / policy-off tombstone 都在 backend.spawn() 之前发布。若旧 pane 仍活着,新建随后失败或后端在探针恢复后重新附着,磁盘上却已经留下当前 marker/tombstone;下一次启动就可能把旧的隔离策略(policy-OFF 迁移)或旧/缺失的 credential 边界(policy-ON)误认证成当前 generation。

真实代码的另外两个事实放大了这个路径:

  • TmuxBackend.killSession 吞掉包括超时在内的全部错误;
  • ZellijBackend.killSession 使用 spawnSync 但不检查返回状态。

因此 executor 测试里“kill/confirm 失败会 throw”的 mock 契约没有覆盖真实 backend。当前 backend-gate 测试还明确锁定了“只有 ZMX 拒绝 unknown”,所以 487/1 全绿并不能证明这里 fail-closed。

建议:

  1. 状态机输入保留 SessionProbe 三态;在任何会删除/替换 provenance 的迁移路径上,初始 unknown 与 post-kill unknown 都必须拒绝继续,只有权威 missing 才能 clear。
  2. 补 worker/backend 行为测试:初始 unknown 不清 marker;post-kill unknown 不清 marker、不 reselect;tmux kill 超时 / zellij 非零退出均保留证明。
  3. 最好把 marker/tombstone 改为 fresh spawn 成功后发布(发布失败则精确 teardown 新 pane),或至少证明 pre-spawn intent 不会被当成 active proof。

独立验证:pnpm build 通过;定向 11 文件 487 passed / 1 skippedgit diff --check origin/master...HEAD 通过。复现当前映射:初始 unknown + policy-OFF + marker → clear-stale-then-cold-spawn;post-kill unknown 在 tmux/herdr/zellij 上均“不拒绝”。

## 缺陷
持久 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>
@deepcoldy

Copy link
Copy Markdown
Owner Author

补充一个仍需修复的代际证明竞态:当前 provenance 在实际 persistent pane generation 建立之前发布,因此证明绑定的是“worker 计划 fresh”,不是“实际创建成功的 pane”。

可达路径(以 zellij 为最直接例子):

  1. read-isolation guard 探到 paneProbe='missing',状态机允许 fresh;
  2. worker 在 backend.spawn() 前写入当前有效的 isolation marker / policy-off tombstone(worker.ts 约 13271–13328);
  3. 同名旧 pane 在 probe 与 spawn 之间晚到;
  4. ZellijBackend.spawn() 会执行 this.reattaching = this.reattaching || ZellijBackend.hasSession(...),把原先的 fresh 决策动态翻成 reattach;
  5. worker 虽然在 spawn 后得到 actuallyReattachedPersistent=true,但 read-isolation 路径没有据此拒绝,刚写的新 provenance 因而错误认证了旧 pane。

TmuxPipe / Herdr 的具体表现略不同:late collision 会使 fresh spawn 失败,但 pre-spawn provenance 会留在磁盘;下次启动若探到 exists,状态机会把该有效证明当成 live pane 的 generation 证明并允许 reattach。三态修复不能覆盖这一点,因为后续 probe 是权威 exists,而“匹配证明”正是 pane 创建成功前由本 worker 写出的,存在循环认证。

这会在 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 后重启。

另有两项非安全清理:

  • docs/design/2026-07-30-api-only-core-only-bot-mode.md 新增标题仍含内部 review 花名,按仓库 PR 规范需改为客观描述;
  • PR 描述的“4 个 follow-up / 487 pass”已与当前 7 commits / 494 pass 不一致,应更新。

当前 head 的三态修复本身已验证通过:定向 11 文件 494 pass / 1 skip、pnpm build 通过、CI 全绿;这些测试尚未覆盖上述 generation TOCTOU。

@deepcoldy
deepcoldy force-pushed the sandbox-no-transport-follow-config branch from 225c677 to ed1bc7d Compare August 18, 2026 10:20
@deepcoldy

Copy link
Copy Markdown
Owner Author

基于 ed1bc7db2 复核:三态 probe 与 PENDING 支配状态机本身成立,但两阶段证明的真实接线仍有 3 个阻断,当前不建议合并。

1. tearDownAndRefuse 会把 agent-scoped Herdr 扩大成整宿主删除

worker.ts:13656-13666 声称 teardown “exact target”,实际调用的是:

killPersistentSession(effectiveBackendType, persistentSessionName, cfg.sessionId)
probePersistentSession(effectiveBackendType, persistentSessionName)

managed Herdr 的真实目标却是共享宿主上的单个 agent:{ backendType: 'herdr', sessionName: 'botmux', agentName }session-backend-selector.ts:377-397)。generic killPersistentSession('herdr', 'botmux') 最终执行 HerdrBackend.killSession('botmux'),会删除整个共享宿主及其中其它会话;post-kill probe 也探的是整宿主,不是刚启动的 agent。

这里应在 spawn 前捕获 selectedBackend.persistentBackendTarget,并和上方迁移路径一致使用 killPersistentBackendTarget(target, cfg.sessionId) / probePersistentBackendTarget(target)。建议补真实行为回归:共享宿主内两个 agent,commit failure/late-flip 只删除目标 agent,另一个仍存活;agent probe 为 unknown 时保留 PENDING 并拒绝。

2. 当前 v10 的无 state marker 不能作为可信 legacy proof 继续授权

master 现有的 v10 isolation marker 正是旧的 pre-spawn 直接发布格式,没有 state;而本次修复的根因恰好是这种发布方式可能把 late winner 错误认证为当前 generation。新 validator 在 read-isolation.ts:522-536, 577-584 仍把 state === undefined 当 committed,意味着升级后无法区分“真实旧 pane”和“已被旧竞态洗白的 pane”,后者会继续 warm-reattach。

需要把两阶段证明设为新的 schema 代际:例如将 ISOLATION_PANE_MARKER_VERSION 从 10 升到 11,并对新版本强制 state === 'committed',让所有旧 v10 marker 一次性 cold-spawn。.policy-off tombstone 尚未进入 master,不存在兼容既有无-state tombstone 的需求,也应只接受 committed。当前两条“legacy no state stays valid”测试反而锁住了不安全兼容。

3. policy-ON 的 PENDING 写失败仍被吞掉,能绕过本次 launch 的 late-flip 拒绝

worker.ts:13315-13344 对 policy-ON PENDING 写仍是 best-effort;写失败后 pendingProvenanceCommit 保持 null,而 late-flip teardown/option-B zellij 处理整块都位于 if (pendingProvenanceCommit)13639)内。因此 zellij/TmuxBackend 在 in-spawn probe 时动态反接同名旧 pane,或 zellij 在异步 create 窗口碰撞时,本次启动会直接继续;“没有 committed proof,下一次会冷启”无法挽回本次已经接入未归属 generation。

PENDING 是 predicted-fresh persistent launch 的准入前置条件:policy-ON 写失败也必须在 backend.spawn() 前拒绝,不能吞错。另应有故障注入回归:marker PENDING 写失败后断言 backend.spawn 未调用。

验证:

  • 定向 11 文件:504 passed / 1 skipped
  • pnpm build:通过
  • git diff --check origin/master...HEAD:通过
  • GitHub build / CodeQL / 3×Analyze:全绿

这些绿测不覆盖上述三条真实后端/升级状态,因此不改变阻断结论。

@deepcoldy

Copy link
Copy Markdown
Owner Author

基于 97529abac 复核:上一条评论中的 3 个阻断(exact-target teardown、v11/strict committed、policy-ON PENDING 写 fail-closed)均已落地;但真实后端还有一条对称方向的安全竞态,当前仍不建议合并。

阻断:Herdr 会把 predicted-reattach 降级成 actual-fresh,绕过 wrapper 与 PENDING

当前只防了 predicted-fresh → actual-reattach。Herdr 还允许反方向:

  1. worker 初始 probe 得到 live + valid committed marker,令 willReattachPersistent=true
  2. worker 随即按 reattach 路径装配:credential-only Seatbelt/bwrap 均被 !willReattachPersistent gate 跳过(worker.ts:13453,13499),PENDING 也不写(13316)。
  3. 在真正 spawn 时,HerdrBackend.spawn 又调用一次 getAgent();若 agent 恰在两次 probe 之间消失,existing 为空便直接执行 agent startherdr-backend.ts:536-559),把 predicted reattach 变成 actual fresh。
  4. spawn 后虽然 backend.isReattach === false,但 pendingProvenanceCommit 为空,整段 commit/teardown 不会进入;旧 committed marker 也仍在。

结果:enrolled 主机上的 credential-isolated Herdr pane 可在这个窗口启动一个未包 credential boundary的新 CLI,并继续继承旧 committed proof;后续还会被当成隔离 generation warm-reattach。现有 herdr-backend.test.ts:583-594 的 “predicted reattach has no reusable agent → fresh start” 用例正好把该危险转换锁成绿色。

修复应像 ZMX 一样冻结 reattach 决策:opts.isReattach=true 但 agent 已不存在时,必须在 agent start 之前抛错,绝不能内部转 fresh。下一次 cold path 才能先写 PENDING、组装 credential wrapper,再创建 agent。不要只在 spawn 后 teardown,因为未隔离 CLI 已经获得执行窗口。回归测试应反转当前 583 用例:断言抛错且 agent start 未调用。

本机验证:

  • PR 定向 11 文件 + Herdr backend:558 passed / 1 skipped
  • pnpm build:通过
  • git diff --check origin/master...HEAD:通过
  • GitHub build / CodeQL / 3×Analyze:全绿

绿测不反驳该结论;其中 Herdr 用例直接证明了可达性。

另外,若最终不是 squash merge,当前早期 commit body 仍含“复审第 N 轮”/内部协作标记,不符合仓库公开历史规范;合并前需 reword/squash 为中性客观描述。

@deepcoldy
deepcoldy force-pushed the sandbox-no-transport-follow-config branch from 97529ab to 3f8332f Compare August 18, 2026 13:14
@deepcoldy

Copy link
Copy Markdown
Owner Author

自动化终审(head 3f8332fc4

结论:此前报告的 predicted-reattach → actual-fresh Herdr 对称竞态已闭合;本轮未发现新的代码阻断。没有提交 approving review,主功能的安全边界放宽仍需维护者明确接受。

复核证据:

  • backend 冻结成立isReattach:truegetAgent() 为空直接抛错,发生在任何 agent start 之前;预测 reattach 不再静默变 fresh。
  • owned selector 三态成立:host/agent 的 unknown 均拒绝,agent exists 才 warm reattach,agent missing 在同一 owned host 返回 isReattach:false,因此 worker 会先走 PENDING 与 credential wrapper;host 只有权威 missing 才迁到 shared fresh。
  • 同 host cold 的 late collision 不会转正 PENDING:Botmux 的 requiredJsonCommand 会传播 Herdr 启动错误,backend.spawn() 抛出后 post-spawn commit 块不可达,PENDING 保留。上游 Herdr 0.7.3 与 0.7.5 都在启动前做全局同名冲突检查并返回 duplicate-name;0.7.5 CLI 还把完成等待绑定到本次响应的 expected_terminal_id,不会把晚到的同名 agent 当成本次成功。参考 v0.7.3 start_agentv0.7.5 start_agentv0.7.5 terminal identity wait
  • 跨代副作用保持 fail-closed:pending nonce compare-before-replace、spawn generation fence、post-kill 仅接受权威 missing 的约束未被这次 selector/backend 修改破坏。

本地验证:

  • pnpm exec vitest run test/herdr-backend.test.ts test/tmux-reattach-backend.test.ts test/read-isolation.test.ts test/api-only-mode-wiring.test.ts test/backend-gate.test.ts test/worker-pipe-initial-screen-order.test.ts6 files / 265 tests 全绿
  • pnpm build → 通过。
  • git diff --check origin/master...HEAD → 干净。
  • GitHub:build 7m19s、CodeQL、3×Analyze 全绿。

合并卫生:建议继续使用 squash,并确保最终 squash commit body 不带当前 head body 中的内部协作措辞(例如“codex 点名的 5 条”);PR title/body 当前已是中性表述。

## 缺陷(独立于三态修复的第 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>
@deepcoldy
deepcoldy force-pushed the sandbox-no-transport-follow-config branch from 3f8332f to 474a952 Compare August 18, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant