Skip to content

fix(isolation): 补齐 credential-only 会话的发送通道 - #1321

Open
deepcoldy wants to merge 1 commit into
masterfrom
wt/botmux-claude-bug-pr
Open

fix(isolation): 补齐 credential-only 会话的发送通道#1321
deepcoldy wants to merge 1 commit into
masterfrom
wt/botmux-claude-bug-pr

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

问题

设备已注册但 Bot 未开完整文件沙盒时(Linux credential-only bwrap),被约束的 botmux send 在解析 argv 之前就被拒,连 botmux send --help 也一起挂 —— agent 完全无法回复用户:

botmux send refused: read-isolated owning data-root locator is missing or ambiguous

根因:不是漏写一个文件,而是三道闸结构性都过不去

全部在本机 bwrap 0.8.0 上实测(非阅读推断):

  1. --unshare-pid 让 marker 走查永远不可能成功。子进程 pid=2/ppid=1/proc 只剩 2 个 pid ⟹ findAncestorSessionContext 解析不出宿主 session。实测确认 marker 目录本身可读、文件就在那儿,纯粹是宿主 pid 不在该命名空间:

    marker dir readable from child: ["1","2"]
    visible /proc pids: 2      ← 宿主有几百个
    liveMarkerCtx: null        ← 于是走 isolation marker 那条臂
    
  2. locator 从没写过 —— 只在 if (sandboxRequested) → if (darwin)worker.ts)里写。

  3. 补写也无效,只是把失败推到下一道闸(这条是关键,也是我实测推翻的「显而易见的修法」):

    • locator 文件名是 .dashboard-secret.origin-root-<hash>.json正好被本形态自己的 .dashboard-secret.* mask 枚举命中,被 --ro-bind /dev/null 掉。子进程里读到的是 0 字节字符设备:
      enumeration masked the locator? true
      locator stat: isFile=false isCharDevice=true size=0   → EACCES → 同一句报错
      
    • 即便绕过,下一道闸要求 data-root probe 读出 EPERM,而 --tmpfs 盖住 read-isolation/ 后读出 ENOENTmissing_or_unsafe
      botmux send refused: locator-selected data root is not protected by the active sandbox
      

为什么修法是「宿主侧 relay」而不是「pane 内补文件证明」

pane 内的文件类证明整体不成立。子进程带 --unshare-user,实测可以 mount --bind 把一个可写目录盖到宿主 ro-bind 之上

baseline write into host ro-bind attest dir: errno=EROFS
mount --bind shadow over attest dir:         status=0     ← 成功
write after shadow attempt:                  WRITABLE     ← 已被接管

所以「这个路径只读」/「这个文件存在」在 pane 内都不是信任根(我一度考虑用 EROFS 当判据,实测证明可伪造后放弃)。唯一可靠的是宿主侧通道 —— 也就是完整沙盒已经在用的 relay:outbox + daemon 侧 watcher 做权威判定。

本 PR 把这条通道接给 credential-only:provision per-session outbox(读写 bind)、注入 BOTMUX_SEND_RELAY、启动宿主 outbox watcher(session-id 强制绑定)。凭证依旧不进 pane,安全边界只增不减。

影响面评估

维度 结论
Linux credential-only ✅ 本 PR 修复目标(设备已注册 + Bot 未开完整 sandbox)
完整文件沙盒(两平台) 逐字未改 —— gate mode covered不进本分支
macOS credential-only 已核实不在范围内:其 deny 集合只有 device-auth/platform.json/device.json/device-enroll-pending/.device-credential-isolation不含 .dashboard-secret*不含 .botmux-cli-pids;且 Seatbelt 不做 pid namespace ⟹ marker 走查仍成立。没有 mac 机器实测,故不擅自纳入修复范围
remote backend(riff/mojo 云端) 不受影响,走 remote-bypass
跨 CLI 改动在 worker 的隔离层,与具体 CLI 适配器无关;不涉及 adapters/cli/ 共用基类
隔离 pane marker 13 → 14:本改动新增 spawn 期 mount + 启动 env,旧 pane warm reattach 拿不到 ⟹ 必须冷启一次

测试

新增 test/credential-only-send-authority.test.ts

为什么此前 102/102 全绿:既有 read-isolation / managed-origin 单测只覆盖 locator/capability 的 helper 函数,全仓没有任何测试真的 spawn 一个 credential-only 子进程再跑 botmux sendbuildCredentialOnlySandboxArgs 只在 sandbox.test.ts 出现,不接 send 路径)。这正是缺陷能全绿上线的原因,所以新测试真的起 bwrap 子进程跑真实构建产物

$ npx vitest run --project unit test/credential-only-send-authority.test.ts
 Test Files  1 passed (1)
      Tests  7 passed (7)

$ npx vitest run --project unit test/credential-only-send-authority.test.ts \
    test/sandbox.test.ts test/read-isolation.test.ts \
    test/managed-origin-capability.test.ts test/managed-origin-attestation.test.ts
 Test Files  5 passed (5)
      Tests  143 passed (143)

$ npx vitest run --project unit  (全部 isolation/device 套件,18 文件)
      Tests  272 passed (272)

$ npx tsc --noEmit   → rc=0
$ bun run build      → rc=0

反向验证:11 枪变异全部转红

不只跑绿,逐条确认测试真的承重(每枪改完复原、无残留):

# 变异 结果
1 去掉 outbox 的 --bind 🔴
2 outbox 已 provision 但不注入 BOTMUX_SEND_RELAY 🔴 报回原始那句 locator 错误
3 outbox 权限 0700 → 0755 🔴
4 cleanup 改成 no-op 🔴
5 把「pre-fix 复现」用例翻转成带 relay 🔴
6–11 worker 侧逐项删除:relay env / startOutboxWatcher / fail-closed 分支 / writableOutbox 入参 / cleanup 归属 / 第二次 publishSandboxRelayCapability 🔴 ×6

⚠️ 过程中的一处自查:第 6–11 枪最初全绿 —— 因为 spawn 类用例直接驱动 CLI,根本观察不到 worker.ts。补了 worker 侧 source-text 断言(沿用 read-isolation.test.ts 既有写法)后 6/6 转红。另外我也如实标注:生产形态下没有任何 mask 是 outbox 的祖先,所以那个显式 --bind 是防御性的、并非承重(承重的是 relay env + watcher),已在代码注释中写明真实作用。

全量单测:与基线逐文件相同

全量跑有 25 文件 / 56 用例红。做了 load-matched 对照(保留新测试文件、只把 src/ 回退到本分支基线 0ad77ffcc,同一子集同样并发):

基线 (clean src):  5 failed | 20 passed (25 files) — 28 failed | 340 passed
本分支:            5 failed | 20 passed (25 files) — 28 failed | 340 passed
失败文件名 diff:   完全一致

失败集合逐文件相同plugin-mcp-sandbox / plugin-registry-sandbox-read / mojo-launcher-env-quarantine / session-store-sqlite-*,均为本机环境相关:bwrap/root DAC/Bun-vs-Node sidecar),且都不是本 PR 触碰的文件 ⟹ 本改动新增 0 失败。全量跑里另外 20 个文件只在满载并发时红、单跑即绿。

待确认

需要在真机飞书里手动验证一次(本机 daemon 未启用设备凭证隔离,~/.botmux/device-auth 不存在,无法在 live daemon 上自然触发这条形态)。要我 switch:here && daemon:restart 部署本 checkout 实测的话说一声 —— 那会让所有 bot 都跑本 build,所以先不擅自动。

🤖 Generated with Claude Code

设备已注册但 Bot 未开完整文件沙盒时(Linux credential-only bwrap),
被约束的 `botmux send` 在解析 argv 之前就被拒,连 `botmux send --help`
也一起挂,agent 完全无法回复用户:

    botmux send refused: read-isolated owning data-root locator is missing or ambiguous

根因不是漏写一个文件,而是这条形态下 `cmdSend` 的三道闸**结构性**都过不去
(均在 bwrap 0.8.0 上实测):

1. `--unshare-pid` 让子进程 pid=2/ppid=1、`/proc` 只剩 2 个 pid,
   `findAncestorSessionContext` 的进程树 marker 走查**永远**解析不出来
   (marker 目录本身可读,只是宿主 pid 不在该命名空间)。于是 cmdSend 走
   isolation marker 那条臂,转而要求 data-root locator。
2. 该 locator 只在 `sandboxRequested && darwin` 分支写,这条路从没写过。
3. 补写也无效:它的文件名是 `.dashboard-secret.origin-root-<hash>.json`,
   正好被本形态自己的 `.dashboard-secret.*` mask 枚举命中,被 ro-bind 到
   /dev/null,读回来是 0 字节字符设备(EACCES);即便绕过,下一道闸要求
   data-root probe 读出 EPERM,而 `--tmpfs` 盖住 `read-isolation/` 后是 ENOENT。

而「在 pane 内部放个文件当证明」整体不成立:带 `--unshare-user` 的子进程可以
`mount --bind` 把可写目录盖到宿主 ro-bind 之上(实测成功),所以「某路径只读」
或「某文件存在」在 pane 内都不是信任根。

因此改为给这条形态接上完整沙盒同款的**宿主侧 relay 通道**:provision per-session
outbox(读写 bind)、注入 `BOTMUX_SEND_RELAY`、启动宿主 outbox watcher 由 daemon
侧做权威 origin 判定。凭证依旧不进 pane,安全边界只增不减。

影响面:仅 Linux credential-only(设备已注册 + Bot 未开完整 sandbox)。完整沙盒
路径逐字未改(gate mode `covered`,不进本分支);remote backend 走 remote-bypass。
macOS credential-only 已核实不在范围内:其 deny 集合不含 `.dashboard-secret*` 与
`.botmux-cli-pids`,且 Seatbelt 不做 pid namespace,marker 走查仍成立。

隔离 pane marker 版本 13 → 14:本改动新增 spawn 期 mount + 启动 env,旧 pane
warm reattach 拿不到,必须冷启一次。

测试:新增 test/credential-only-send-authority.test.ts —— 全仓此前**没有任何**
测试真的 spawn 一个 credential-only 子进程再跑 `botmux send`(既有 read-isolation
/ managed-origin 单测只覆盖 helper 函数),这正是缺陷能全绿上线的原因。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner Author

CI 复核:13/13 全绿

首轮 bun-test 红过一次,已查明是 flake,非本 PR,判据三条:

  1. 同 SHA 重跑转绿f44b821c4 未变,bun-test 6m58s pass)——最强判据。
  2. 失败形状本身自证:那个文件的两个用例都 (pass),是文件级 720s 墙 SIGKILL 把它杀掉的,日志里 runner 自己也标注 OUTPUT TRUNCATED ... Any failure count from this file is a LOWER BOUND
    FAIL test/tmux-startup-storm-recovery.test.ts (killed: SIGKILL)
    (pass) self-heals a new-session that succeeded server-side ... [6072.88ms]
    (pass) subsequent launches on the recovered server take the fast path [72.24ms]
    
  3. 零可达性:该文件只 import TmuxPipeBackend,对本 PR 改动的符号(prepareCredentialOnlyRelayOutbox / writableOutbox / BOTMUX_SEND_RELAY / ISOLATION_PANE_MARKER_VERSIONgrep 0 命中;本机单跑该文件 6.4s / 2 passed(对比 CI 的 720s 墙,差两个数量级 ⟹ runner 争抢,非正确性)。

另附澄清:bun-test 不是必需 check(ruleset「Require CI green on master」只要求 build + test),且 workflow 里 continue-on-error: true。不过我没拿这点当理由放过它——上面三条是独立查证的。

最终状态:13 个 check 全 passbuild / test / test (1-3/3) / bun-test / 3×bun-binary* / CodeQL 3 项)· MERGEABLE · 仅剩 REVIEW_REQUIRED

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