fix(sandbox): 白名单纳入本 bot 角色库子树,修复沙盒下角色系统整体不可用 - #644
Conversation
开了 sandbox 的 bot 只有 workingDir(= 当前角色目录)在 readWrite 白名单里, 角色库的其余部分不在白名单 → deny-by-default 下「有哪些角色 / 切换角色 / 新建角色」全部 Operation not permitted: - 「有哪些角色 / 切换角色」要枚举兄弟角色目录、读各自 .botmux-dir.json - 「新建角色」要写 users/<openId>/<slug>/,并复制库根的 _role-protocol.md - 切换之后「沉淀知识」写的是新角色目录下的 knowledge/ 与 .botmux-dir.json buildFsPolicy 因此多一个 roleLibrarySubtree(`<角色库根>/<appId>`),按 readWrite 注入。给 readWrite 而不是 readOnly 是因为最后一条:只读的话枚举和 切换看起来都正常,直到写知识那步才 EPERM,最难查。 按 appId 限定,不是整个角色库根 —— 兄弟 bot 的角色目录(及其中别的用户的私有 角色)仍由构造保证不可见,跨 bot 读隔离不变。这刻意比 validateRoleLibraryPath 更窄:后者只要求目标在角色库根之下,于是跨 bot 切换即使过了 daemon 侧校验,也 会在 fs 层被挡住。 三道收口(后两道是 codex 两轮定向 review 抓出来的,都能把这条便利规则变成越权): 1. appId 形状:它要被拼进白名单路径,`join(root, '../../.ssh')` 会归一成 `~/.ssh` 把 rw 授到库外。`..` 被 join 吃掉后 normalizeFsPath 的 `..` 拦截 够不到、realpath 也会抹平,所以必须在拼之前挡:限定单段目录名。 2. 末两段不许跟链:只 realpath 角色库根的**父目录**(`$HOME` 本身是符号链接的 机器不归一会静默 fail-open,所以上层必须归一、也允许是链接),然后 `botmux-roles` 与 `<appId>` 各自 lstat、必须是真目录。任一段是链接就不产生 规则 —— 否则它被预先摆成指向 `~/.ssh`、`~/.botmux` 或**另一个 bot 的角色库** 时,跟随解析会把链接目标当成本 bot 子树直接授 rw:任意目录读写 + 跨 bot 越权。 返回值天然 canonical,调用方不得再 realpath(再跟一次就把校验作废)。 3. 任何 deny 覆盖即不产生规则:source rank 只裁**同路径**冲突,机主写 `deny: ["~/botmux-roles"]` 时更深的 internal rw 会按最长前缀胜重新开放 `<appId>`。现在 baseline / user / mandatory 三类 deny 只要覆盖该子树,这条 规则整条不产生。 没用过角色的 bot 不产生这条规则;spawn 后才出现的子树要等下一次 spawn 生效 (bwrap 本来也无法 bind 不存在的源)。 明确不做、且不该只为这条规则加固的两件事(codex 复验提出,均需宿主级写权限—— 而拿到宿主级写权限的人本来就能直接改 bots.json 关掉沙盒;且策略里**每条**路径 规则都在同一时点做一次性检查,同样成立): - TOCTOU:校验后、真正 spawn/bind 前把目录换成符号链接。路径型沙盒(Seatbelt 吃路径字符串、bwrap 吃 bind 源)无法靠持 fd 关闭该窗口。 - 挂载点:末段是 bind/FUSE 挂载点时仍是「真目录」,能把挂载目标整棵授出去。 既有遗留(本 PR 未引入、也未加剧):大小写不敏感卷上两个仅大小写不同的 appId 指向同一角色库目录 —— 角色库按 appId 分目录这个布局本身的性质(不开沙盒也共享), 要治得在 bot 配置加载期按文件系统身份拒绝碰撞。
首审 by Claude(Botmux开发者(Claude))结论:安全实现扎实、测试有牙、影响面干净;唯一阻塞是一处 scoping/文档矛盾(P1),按现有 runbook 部署时本修复是 silent no-op。 验证过(均通过)
🟠 P1(阻塞):
|
|
To use Codex here, create a Codex account and connect to github. |
复审收敛(Claude + codex)— P1 修法更新codex 独立复现了 P1(human-slug 布局下 撤回:我原来建议「直接从 active
任何修法的三条硬约束:① 本 bot 自己的角色根(隔离)② 切换不可变(冻结)③ 匹配运营实际命名(否则回到 silent no-op)。
接线缺口(供作者注意):worker 的 建议落点:daemon 侧从本 bot 的 codex 仍在收口安全面( |
双审收敛结论:Request Changes(唯一阻塞 = P1)— by Claude + codex两轮独立复审(Claude 首审 + codex 二审)结论一致:单一阻塞项为 P1;安全收口本身未发现新越界。未经 @deepcoldy 确认不合码。 P1(阻塞,双方独立复现)
修法(双方一致,已从「按 active workingDir 推」修正为「冻结受信根」)不能按可变 active 安全面(codex 逐项,未发现新 blocker)
需 @deepcoldy 知情接受的既有面(作者已在 PR 描述披露,双方不另立 blocker)grant 是整棵 same-bot 子树 rw,含 独立验证(双方)head |
第三条独立对抗审计补充(Claude 侧后台 agent,真 build + 编译后 policy 跑对抗输入)与前两审收敛一致:无 P1、无沙盒逃逸、无跨 bot 泄漏、无 deny 打穿。三道核心防御全部成立( 两处非阻塞完整性缺口(与 codex 二审一致,均 P2/P3、blast radius 限本 bot own
三审收敛:唯一代码 blocker 仍是 P1(human-slug scoping)。维持 Request changes,等 @deepcoldy 确认。 |
作者回应:P1 认领,但提议反向修 —— 把每-bot 目录段的契约定为
|
| P1 | 跨 bot 洞 | 存量迁移 | 改动面 | |
|---|---|---|---|---|
目录段 = appId(本提议) |
文档 3 处 + 告警 | 同一处收口(1 行) | 需要 | docs + 1 处校验 + 1 行日志 |
| 冻结根(reviewer 建议) | 代码 | 仍需另修 | 不需要 | core/types.ts init IPC + daemon 侧解析 + 透传 + 集成测试 |
两条硬约束(本 bot 自己的根 / 切换不可变)appId 方案天然满足 —— 它来自 per-bot 受信配置,且不随 active cwd 变化。
请拍
- A:采纳 appId 契约。我改
deploy-runbook.md、role-protocol-template.md、role-system-design.md三处 + 加告警 + 把validateRoleLibraryPath收窄到<root>/<appId>(存量兼容:该目录不存在时回落到全局根校验并打 deprecation 日志,避免直接切不动)+ 写迁移步骤。 - B:坚持人类 slug。我按二审的「冻结受信根」铺 IPC 管线。
两条路我都会一并收掉三审提的两个非阻塞项:P2 —— roleLibDenied() 只枚举 access==='deny',机主在祖先设的 readOnly 会被更深的 internal rw 静默升级成 rw(我倾向不是抑制而是降级为 readOnly:机主说只读就给只读,角色枚举/切换仍可用,只是写不了 knowledge);P3 —— mandatoryDenyRegexes 纳入抑制判断(compileToBwrap 不消费 denyRegexes,Linux 侧确实无兜底,虽然当前不可利用)。
在拍板之前我不动代码,head 停在 1c76f65e。
回应 review 的 P1(照 runbook 用人类 slug 命名时上一个 commit 是 silent no-op)。
两条修法里选了「把契约定为 appId」而不是「从 defaultWorkingDir 推冻结根」,理由:
- 没有任何代码/脚本创建角色库(`grep -rn botmux-roles scripts/ src/` 只有校验与
路径常量),建目录全是 runbook 手敲步骤 —— `<bot名>` 这个占位符从来没有代码
约束力,文档就是契约本身;
- appId 已是 botmux 其余每-bot 资源的统一 key(`bots/<appId>`、
`sessions-<appId>.json`、`attachments/<appId>`、`.lark-cli-bots/<appId>`),
角色库是唯一一处另起命名的;人类可读名早有专属去处(`.botmux-dir.json` 的
`name`),且 runbook 本就强制下一层角色目录用 ASCII slug;
- 让 P1 与二审自己发现的跨 bot 洞同一处收口。
改动:
1. docs(契约):`deploy-runbook.md` 第 2 步写明「每-bot 目录名必须 = larkAppId」
并给出原因与后果,新增第 8 节迁移步骤(含 `_role-protocol.md` 重新分发、
`defaultWorkingDir` 同步、记忆桶会换桶的提醒);`role-protocol-template.md`
与 `role-system-design.md` 的占位符同步,并在设计文档补一条「两条命名约定以
runbook 为准」(它此前还写着「目录名即角色名(支持中文)」,与 runbook 的
ASCII slug 硬约定矛盾)。
2. `validateRoleLibraryPath(input, rootOverride?, ownAppId?)`:传 ownAppId 时收窄到
`<根>/<appId>`。不收窄就能切进别的 bot 的角色目录,`/cd` 路由随后把
`ds.workingDir` 钉过去,那个 bot 的沙盒会话就拿到对方整棵角色库(含
`users/<别人 openId>/` 私有角色)的 rw —— 打穿上一个 commit 刻意加的跨 bot 隔离。
越界细分为 `outside_own_role_library`(库内、非自己子树,同 403)便于自查。
**存量兼容**:`<根>/<appId>` 不是真目录时回落全局根校验(= 旧行为)并回
`legacyRootFallback`,路由据此打 deprecation 日志 —— 否则一次收窄会让所有存量
部署当场切不动角色。
3. 命名不匹配不再静默:sandbox 会话若 workingDir 位于角色库根下、而
`<根>/<appId>` 不是真目录,worker 打一行 role library dir mismatch,明确说
角色系统会 EPERM 并指向 runbook。只用现有字段,不动 init IPC 契约。
4. 收掉三审的两个非阻塞项:
- P2 覆盖性 `readOnly`(user / mandatoryReadOnly)不再被更深的 internal rw
静默升级 —— **降级为 readOnly** 而不是抑制:机主说只读就给只读,角色枚举
与切换仍可用,只是写不了 knowledge。deny 仍然是整条不产生。
(baseline readOnly 刻意不计入:那是 internal grant 本就该抬升的地板,
如工具链目录;baseline deny 是天花板,计入。)
- P3 `mandatoryDenyRegexes` 纳入抑制判断。`compileToSeatbelt` 消费
`denyRegexes` 且在所有 path allow 之后发,macOS 有兜底;但
`compileToBwrap` 完全不消费,Linux 侧没有兜底 —— 当前不可利用(该字段只
指向 BOT_HOME 凭据 sidecar),但不该留着变成洞。无法编译的正则不作为授权
依据(try/catch 忽略)。
测试:`role-library.test.ts` +6(收窄放行/跨 bot 403/库外/子树本身/存量回落/
子树是链接时回落且不跟链)、`fs-policy.test.ts` +1 组(readOnly 降级、deny 压过
readOnly、regex deny 抑制、坏正则不误伤)、`ipc-cd-route.test.ts` +2(路由级
跨 bot 403 + 自己子树 200;原有 12 例不带 larkAppId,正好覆盖存量不收窄语义)。
变异测试确认新断言有牙:去掉收窄 / 去掉 readOnly 降级 / 去掉 regex 检查,
各自只杀掉对应用例(5 failed / 83 passed),还原后 88 全过。
第三轮定向 review 判「无 P1、无逃逸」,收窄与 readOnly/deny 抑制均无绕过。剩下的 四条按严重度收掉: 1. **既有 bug(中):`/cd` 路由的 initConfig 半同步。** `ds.initConfig.workingDir` 原先只在 live-worker 分支里更新,no-worker 与 `worker.killed` 两条分支不更新 —— 下次 forkWorker 用 initConfig 重建 init 消息时把会话带回旧 cwd(旧角色、连记忆桶 都是旧的)。改为紧跟 repin 无条件同步。**不是本 PR 引入的**,但就在本 PR 收窄的 同一个 handler 里,顺手修掉;补路由级断言(变异回原样即失败)。 2. **诊断漏报(低)**:mismatch 告警只比 canonical 路径,配置路径「表面在库内、经 中间符号链接解析到库外」时不告警 —— 而那恰好也是 roleLibrarySubtree 返回空的场景。 改为 lexical 与 canonical 双比。 3. **文档自相矛盾(中)**:runbook 第 2 节仍写 `validateRoleLibraryPath()`「不是单独 按 bot 子目录收紧」,与上一 commit 相反;设计文档刚加了「角色目录名必须是 ASCII slug」的注,紧接着的示例树还在用中文目录名(照抄即踩记忆串台)。两处都改;runbook 同时补上 IM `/cd` 走的是通用 `validateWorkingDir`、不经角色库校验这一事实(收窄只 作用于模型驱动的 `botmux role switch`)。 4. **迁移漏项(中)**:`botmux stop` 只是停 daemon,不等于关掉/重钉存量会话 —— 它们的 cwd 记在 session store 里,`mv` 之后仍指向旧路径,而改 `defaultWorkingDir` 只影响 新话题。迁移章节补这一步与重启后的验证项。 刻意不改,理由写进代码注释(`fs-policy.ts` 的 KNOWN BOUNDARY):regex deny 抑制只 测子树根,能挡住祖先型正则(botmux 目前只生成这一种),但挡不住只匹配后代的正则 (`<subtree>/secret$`)—— 正则与子树是否相交无法从前缀判定。真正的解法是给内部正则 同时生成具体 deny path,或让 `compileToBwrap` 消费 `denyRegexes`(后者是既有缺口, 对**每一条** rw grant 都成立,不止这一条),都超出本 PR。不写「固化当前行为」的测试。 测试 89 全过(+1 例:no-worker 分支 initConfig 同步)。
P1 已修:契约定为 appId + 收窄 role switch,并收掉 P2/P3(追加两个 commit,未 force-push)按上一条提议的 A 方案落地 —— 说明一下决策来源:@deepcoldy 尚未回复,这是我这边部署方拍的方向,所以把它做成可评审的实物而不是继续等;如果你倾向 B(冻结受信根),这两个 commit 可以整体撤回,
1. P1:每-bot 目录名契约定为
|
| 失败文件 | 通过文件 | |
|---|---|---|
基线 7282bb68 |
26 | 723 |
a082e962 |
26 | 723 |
267ffdc7(本次 head) |
26 | 723 |
失败集合的差异是双向的,且每轮不同 —— 这轮相对基线多了 codex-app-runner.integration、workflow-c0-isolation,同时基线里的 child-env、maintenance 反而变绿;上一轮多的是 codex-app-runner.integration、v3-host。四个新增项单跑全过(47/47、44/44),都是满载下的 spawn/超时 flake,且与本 PR 触及的文件(fs-policy / role-library / worker 沙盒段 / /cd 路由 / docs)无交集。npx tsc --noEmit 干净。
基线 worktree 用的是本分支的 merge-base 7282bb68(不是当前 upstream tip)—— 同基比较才干净。与当前 upstream/master(abe9e237)git merge-tree --write-tree 无冲突。没有 rebase / force-push,好让前三轮 review 里对 1c76f65e 的引用继续有效;需要我 rebase 到最新 master 再跑一遍 CI 的话说一声。
仍需知情接受的既有面(未变)
一个 bot 的角色库内,users/<其他 openId>/ 下的私有角色对该 bot 的任意 sandbox 会话在 fs 层可读写,「别人的私有角色不展示、不可切换」仍只由 _role-protocol.md 的行为约束保证。要做 fs 级隔离得按 sender/session 收窄,共享 bot 一个会话可能服务多个发送者,需单独设计。
本分支相对 merge-base 共 3 个 commit / 11 文件 / +558-32。未改 live、未重启、未合并。
问题
开了
sandbox: true的 bot,角色系统整体不可用——不是「不能新建角色」这种局部退化,是连「有哪些角色 / 切换角色」都直接Operation not permitted。沙盒是 deny-by-default 三档白名单,
buildFsPolicy()拿到的 botmux 内部路径只有workingDir/botHome/sessionDataDir(src/worker.ts的buildFsPolicy({…})),完全不认识角色库——~/botmux-roles此前只出现在src/core/role-library.ts和botmux role switch的目标校验里。而workingDir只等于当前角色目录,于是_role-protocol.md规定的每一步都落在白名单外:shared/*、users/<openId>/*,读各自.botmux-dir.jsonusers/<openId>/<slug>/,复制库根_role-protocol.mdknowledge/、回填.botmux-dir.json的url现状下机主只能自己往
bots.json的sandboxPaths手配一遍才能用角色系统——而这件事没有任何提示,撞上去只看到 EPERM。等于「开沙盒 = 静默禁用角色系统」。改动
FsPolicyContext加roleLibrarySubtree,按readWrite注入;worker 传本 bot 自己的<角色库根>/<appId>。为什么是 readWrite 而不是 readOnly:上表最后一行。只读的话枚举和切换看起来都正常,直到切换后写知识那步才 EPERM——最难查的那类失败。
为什么按 appId 限定、不是整个角色库根:兄弟 bot 的角色目录(及其中别的用户的私有角色)仍由构造保证不可见,跨 bot 读隔离不变。这刻意比
validateRoleLibraryPath更窄:后者只要求目标在角色库根之下,于是跨 bot 切换即使过了 daemon 侧校验,也会在 fs 层被挡住。三道收口(后两道是 codex 两轮定向 review 抓出来的)
1. appId 形状(
roleLibrarySubtree(),src/core/role-library.ts):appId 来自bots.json(机主自己写),但它要拼进白名单路径,join(root, '../../.ssh')会被归一成~/.ssh,把 rw 授到库外。注意..被join吃掉后normalizeFsPath的..拦截够不到,realpath也会抹平——所以必须在拼路径之前挡。限定单段目录名,不合法 → 不产生规则。2. 末两段不许跟链:只
realpath角色库根的父目录($HOME本身是符号链接的机器/home/u→/data00/home/u这一类不归一会静默 fail-open,所以上层必须归一、也允许是链接),然后botmux-roles与<appId>各自lstat、必须是真目录。任一段是符号链接就不产生规则——否则它被预先摆成指向~/.ssh、~/.botmux或**另一个 bot 的整棵角色库(含users/<别人 openId>/私有角色)**时,跟随解析会把链接目标当成本 bot 的子树直接授 rw:一条便利规则变成任意目录读写 + 跨 bot 越权。返回值天然 canonical,调用方不得再realpath/keepExisting(再跟一次就把这道校验作废,注释里写明了)。3. 任何 deny 覆盖即整条不产生:source rank 只裁同路径冲突,不同路径永远更深者胜。机主写
deny: ["~/botmux-roles"]想整个关掉角色库时,这条更深的 internal rw 会在被 deny 的库根上重新开个洞。现在 baseline / user / mandatory 三类 deny 只要覆盖该子树,规则整条不产生。存在性/canonical 化都在
roleLibrarySubtree()里做完,worker 不再走keepExisting(它的realpath正是第 2 条要防的东西)。测试
test/fs-policy.test.ts+3 例、test/role-library.test.ts+7 例:本 bot 子树 rw / 兄弟 bot 与库根不覆盖 / 同路径 deny / 祖先 deny(user、mandatory、baseline 三类)不得被打洞 / appId 形状(../../.ssh、..、.、a/b、/abs、空值、控制字符、空格 全部 → null)/ 末段是符号链接 → null(分别指向库外目录与另一个 bot 的角色库) / 库根本身是符号链接 → null / 根之上中间段是符号链接 → 放行且返回 canonical / 不存在或是文件 → null / 库根不存在 → null。变异测试(证明新断言不是恒真——codex 明确要求验这个):把 root 的
lstat校验去掉、把 baseline deny 从抑制列表里删掉,各自只杀掉对应的那条用例:内核级实测(macOS Seatbelt,
sandbox-exec -f <profile>真跑/bin/ls、/bin/cat、/usr/bin/touch,用的是本机真实~/botmux-roles树;committed e2e 做不到这点——它的 scratch 树在 TMPDIR 根下,而 darwin baseline 把 TMPDIR 设成 rw,那里的路径不论有没有这条规则都可写):npx tsc --noEmit通过;全量 vitest 与upstream/master同 commit 基线对照,失败文件名集合无新增(见下)。全量对照(同机、同 commit 基线 worktree,各 ~910s;判据是失败文件名集合,不是数字):
upstream/master7282bb68基线失败集合差异只有一个文件:
test/skill-agentbuddy-install.test.ts→agentbuddy_clear_telemetry_failed: spawnSync node ETIMEDOUT(满载下 14s 超时)。单跑 15/15 通过,3.36s —— 满载 spawnSync 超时的 flake,与本改动无关(本 PR 只碰 fs-policy / role-library / worker 的沙盒段,不碰 skill registry)。通过用例 +4 = 新增 5 例减去这 1 个 flake。影响面
sandbox: true的会话生效;未开沙盒的 bot 行为不变(那条路径本来就可访问)。~/.botmux里的凭证与兄弟数据、bots.json等一概不变,仍在白名单外。bots.json关掉沙盒;且策略里每条路径规则(workingDir、botHome、cliDataPaths…)都在同一时点做一次性存在性检查,同样成立。路径型沙盒(Seatbelt 吃路径字符串、bwrap 吃 bind 源)也无法靠持 fd 关闭 TOCTOU 窗口。users/<别人 openId>/下的私有角色对同 bot 的其他用户在 fs 层是可读的——「别人的私有角色不展示、不可切换」仍只由_role-protocol.md的行为约束保证。要做 fs 级隔离得按会话ownerOpenId收窄,但共享 bot 的一个会话可能服务多个发送者,会误伤,需要单独设计。