Skip to content

fix(sandbox): 白名单纳入本 bot 角色库子树,修复沙盒下角色系统整体不可用 - #644

Open
xu4wang wants to merge 1 commit into
deepcoldy:masterfrom
xu4wang:fix/sandbox-role-library
Open

fix(sandbox): 白名单纳入本 bot 角色库子树,修复沙盒下角色系统整体不可用#644
xu4wang wants to merge 1 commit into
deepcoldy:masterfrom
xu4wang:fix/sandbox-role-library

Conversation

@xu4wang

@xu4wang xu4wang commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

问题

开了 sandbox: true 的 bot,角色系统整体不可用——不是「不能新建角色」这种局部退化,是连「有哪些角色 / 切换角色」都直接 Operation not permitted

沙盒是 deny-by-default 三档白名单,buildFsPolicy() 拿到的 botmux 内部路径只有 workingDir / botHome / sessionDataDirsrc/worker.tsbuildFsPolicy({…})),完全不认识角色库——~/botmux-roles 此前只出现在 src/core/role-library.tsbotmux role switch 的目标校验里。而 workingDir 只等于当前角色目录,于是 _role-protocol.md 规定的每一步都落在白名单外:

流程 要访问的路径 相对 workingDir
「有哪些角色 / 切换角色」 shared/*users/<openId>/*,读各自 .botmux-dir.json 兄弟目录,白名单外
「新建角色」 users/<openId>/<slug>/,复制库根 _role-protocol.md 库根 + 兄弟目录,白名单外
切换后「沉淀知识」 角色目录下的 knowledge/、回填 .botmux-dir.jsonurl 切换后才知道,白名单外

现状下机主只能自己往 bots.jsonsandboxPaths 手配一遍才能用角色系统——而这件事没有任何提示,撞上去只看到 EPERM。等于「开沙盒 = 静默禁用角色系统」。

改动

FsPolicyContextroleLibrarySubtree,按 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。

$ npx vitest run test/fs-policy.test.ts test/role-library.test.ts
 ✓ test/role-library.test.ts (19 tests)
 ✓ test/fs-policy.test.ts (48 tests)
 Test Files  2 passed (2)
      Tests  67 passed (67)

变异测试(证明新断言不是恒真——codex 明确要求验这个):把 root 的 lstat 校验去掉、把 baseline deny 从抑制列表里删掉,各自只杀掉对应的那条用例:

× 角色库根本身是符号链接 → null(末两段都不许跟链,否则整棵库被替换掉)
× an ANCESTOR deny (owner or mandatory) suppresses the role-library grant — …
Tests  2 failed | 65 passed (67)      ← 还原后 67 passed

内核级实测(macOS Seatbelt,sandbox-exec -f <profile> 真跑 /bin/ls/bin/cat/usr/bin/touch,用的是本机真实 ~/botmux-roles 树;committed e2e 做不到这点——它的 scratch 树在 TMPDIR 根下,而 darwin baseline 把 TMPDIR 设成 rw,那里的路径不论有没有这条规则都可写):

✓ WITHOUT the grant (current master): own role library is inaccessible
    ls ~/botmux-roles/<self>                    → EPERM   (复现本 issue)
    cat ~/botmux-roles/<self>/_role-protocol.md → EPERM
✓ WITH the grant: own library readable AND writable; sibling bot still denied
    ls  ~/botmux-roles/<self>                       → ok
    cat ~/botmux-roles/<self>/_role-protocol.md     → ok
    cat ~/botmux-roles/<self>/shared/default/CLAUDE.md → ok
    touch ~/botmux-roles/<self>/.fsp-write-probe    → ok(宿主真落盘,已核 existsSync)
    ls  ~/botmux-roles/<兄弟 appId>                  → EPERM  ← 跨 bot 隔离保持
    cat ~/botmux-roles/<兄弟 appId>/shared/default/CLAUDE.md → EPERM
    ls  ~/botmux-roles                              → EPERM  ← 库根本身不授权

npx tsc --noEmit 通过;全量 vitest 与 upstream/master 同 commit 基线对照,失败文件名集合无新增(见下)。

全量对照(同机、同 commit 基线 worktree,各 ~910s;判据是失败文件名集合,不是数字):

失败文件 失败用例 通过用例
upstream/master 7282bb68 基线 26 22 11232
本 PR 27 23 11236

失败集合差异只有一个文件:test/skill-agentbuddy-install.test.tsagentbuddy_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 行为不变(那条路径本来就可访问)。
  • 沙盒可写面新增且仅新增该 bot 自己的角色库子树。别的 bot 的角色库、~/.botmux 里的凭证与兄弟数据、bots.json 等一概不变,仍在白名单外。
  • 明确不做、且不该只为这条规则加固的两件(codex 复验提出):TOCTOU(校验后到真正 spawn/bind 前把目录换成符号链接)与挂载点(末段是 bind/FUSE 挂载点时仍算「真目录」,能把挂载目标整棵授出去)。两者都需要宿主级写权限,而拿到宿主级写权限的人本来就能直接改 bots.json 关掉沙盒;且策略里每条路径规则(workingDirbotHomecliDataPaths…)都在同一时点做一次性存在性检查,同样成立。路径型沙盒(Seatbelt 吃路径字符串、bwrap 吃 bind 源)也无法靠持 fd 关闭 TOCTOU 窗口。
  • 既有遗留(本 PR 未引入、未加剧):大小写不敏感卷上两个仅大小写不同的 appId 指向同一角色库目录——角色库按 appId 分目录这个布局本身的性质,不开沙盒也一样共享。要治得在 bot 配置加载期按文件系统身份拒绝碰撞。
  • 覆盖缺口(如实说明):worker 那一行接线没有单测守护(worker.ts 体量不适合直接 import),bwrap 侧只有 Linux 才跑的 e2e,本机是 macOS 所以 skip。
  • 遗留(不属本 PR):一个 bot 的角色库内,users/<别人 openId>/ 下的私有角色对同 bot 的其他用户在 fs 层是可读的——「别人的私有角色不展示、不可切换」仍只由 _role-protocol.md 的行为约束保证。要做 fs 级隔离得按会话 ownerOpenId 收窄,但共享 bot 的一个会话可能服务多个发送者,会误伤,需要单独设计。

开了 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 配置加载期按文件系统身份拒绝碰撞。
@xu4wang
xu4wang requested a review from deepcoldy as a code owner July 28, 2026 17:49
@deepcoldy

Copy link
Copy Markdown
Owner

首审 by Claude(Botmux开发者(Claude))

结论:安全实现扎实、测试有牙、影响面干净;唯一阻塞是一处 scoping/文档矛盾(P1),按现有 runbook 部署时本修复是 silent no-op。

验证过(均通过)

  • tsc --noEmit 干净;vitest run test/role-library.test.ts test/fs-policy.test.ts67/67 通过;与当前 master(37d0fc44merge-tree --write-tree 干净。
  • 变异测试证断言有牙
    • lstatSyncstatSync(跟随符号链接)→ 「子树是符号链接 → null」「库根本身是符号链接 → null」2 例立即失败。
    • roleLibDenied() 抑制列表删掉 baseline-deny 一行 → 「ANCESTOR deny suppresses the grant」用例立即失败。
  • 影响面:仅 sandbox: true 会话生效;该规则是 internal 源、不参与 CLI-data 重定向(authPathsSurvivingCliDataRedirect 不触及);未用过角色的 bot 得 null(无规则);linux/darwin 一致;buildFsPolicy 唯一调用点在 worker.tsFsPolicyContext 其它构造点(read-isolation.ts)不涉及本字段。

🟠 P1(阻塞):larkAppId 作 scoping key 与自家部署文档矛盾 → silent no-op

PR 把每-bot 目录段的 key 定为 larkAppIdcli_xxxx),但本仓库既有部署文档全用运营自选的人类 slug 命名该层:

  • docs/role-system-design.md §5 / §11:~/botmux-roles/<bot名>/
  • docs/roles/deploy-runbook.md 步骤2:mkdir -p ~/botmux-roles/<bot>/shared/default
  • docs/roles/role-protocol-template.md<ROLES_ROOT> = ~/botmux-roles/<bot名>

同一批文档里指 BOT_HOME 时用的是另一个占位符 <appId>~/.botmux/bots/<appId>/)——<bot><appId> 是被刻意区分的两个概念。且 daemon 侧 validateRoleLibraryPath() 只校验「在 ~/botmux-roles 根下」, pin 每-bot 段名 = appId。

实测复现(roleLibrarySubtree(appId, rootOverride)):

目录名 = 人类 slug(照 runbook)  →  roleLibrarySubtree(appId) = null   ← 不产生规则 = 修复未生效
目录名 = appId(cli_xxxx)        →  roleLibrarySubtree(appId) = <root>/cli_xxxx  ← 仅此才生效

即:除非运营恰好把角色库目录命名成 cli_xxxx,否则角色库仍在白名单外、沙盒下角色系统依旧整体不可用——正是本 PR 声称要修的 bug。全套单测绿,是因为测试用 rootOverride 直接建了 <root>/<appId>,绕过了「运营实际如何命名」这个前提。

判 P1(功能不达成 + 与文档矛盾)而非安全洞:方向 fail-safe(deny-by-default,key 错只 under-grant、不越权)。

建议修法(倾向 1)

  1. workingDir 推导每-bot 段workingDir 已在同一处传入 buildFsPolicy,它就是活跃角色目录 <root>/<bot>/shared/default;真正的每-bot 段 = 它在 ~/botmux-roles 下的第一段,可直接推出,无需猜 appId、不依赖运营命名。
  2. 若坚持用 appId:需把 design-doc / runbook / template 统一改成「目录名必须 = larkAppId」并让 validateRoleLibraryPath 收窄到该段——但把内部 id 泄漏进手动运营步骤,可用性差,不推荐。

次要(非阻塞)

  • role-library.ts realDir()isSymbolicLink() 判断在 lstatSync 下冗余(symlink-to-dir 的 isDirectory() 本为 false)。属防御性写法、非 bug,可保留(防将来 lstatstat 回归)。

下一步:@codex 独立复审确认;未经 @deepcoldy(申晗)确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner

复审收敛(Claude + codex)— P1 修法更新

codex 独立复现了 P1(human-slug 布局下 roleLibrarySubtree(larkAppId) 返回 null),并对我原建议的修法提出了正确的收紧,我已核实并采纳

撤回:我原来建议「直接从 active workingDir 推每-bot 段」——这有跨 bot 回归洞

  • dashboard-ipc-server.ts:817validateRoleLibraryPath() 只校验「在 ~/botmux-roles 根下」,无 per-bot 收窄;repinSessionWorkingDir(:822)随后把 ds.workingDir 钉到该目录。
  • 因此 active workingDir 可能落在另一个 bot 的子树里(跨 bot 切换)。从可变 active cwd 推第一段,会把对方整棵角色库授 rw,反而打穿本 PR 在 fs-policy.ts:389-392 刻意加的跨 bot 隔离。

任何修法的三条硬约束:① 本 bot 自己的角色根(隔离)② 切换不可变(冻结)③ 匹配运营实际命名(否则回到 silent no-op)。

  • larkAppId(现状):满足 ①②,缺 ③ → P1。
  • active workingDir:满足 ③,缺 ①② → 跨 bot 回归。
  • defaultWorkingDir(init 冻结):三条全中——per-bot 受信配置、daemon 侧解析(effectiveDefaultWorkingDir 已存在)、按 deploy-runbook.md 步骤3 就是 ~/botmux-roles/<bot>/shared/default,其在库根下的第一段即本 bot 自己的角色根。

接线缺口(供作者注意):worker 的 init 消息目前只带 larkAppId + 可变 workingDircore/types.ts),不带 defaultWorkingDir。「冻结根」需在 daemon 侧算好再透传进 init / 冻进 session,不能在 worker 里从现有字段现推。

建议落点:daemon 侧从本 bot 的 effectiveDefaultWorkingDir 求出「它在 ~/botmux-roles 下的第一段」作为冻结的角色根,透传给 buildFsPolicy(替代 roleLibrarySubtree(cfg.larkAppId));roleLibrarySubtree 的符号链接/deny 收口逻辑保留不变,只换 scoping 来源。

codex 仍在收口安全面(mandatoryDenyRegexes 是否该纳入 roleLibDenied 抑制、internal rw 是否会盖过 user readOnly),完成后一次性回报。未经 @deepcoldy 确认不合码。

@deepcoldy

Copy link
Copy Markdown
Owner

双审收敛结论:Request Changes(唯一阻塞 = P1)— by Claude + codex

两轮独立复审(Claude 首审 + codex 二审)结论一致:单一阻塞项为 P1;安全收口本身未发现新越界。未经 @deepcoldy 确认不合码。

P1(阻塞,双方独立复现)

worker.ts:7591roleLibrarySubtree(cfg.larkAppId),函数只查 <roleRoot>/<appId>role-library.ts:46-59)。但既有部署契约是人类目录名deploy-runbook.md:21,56role-protocol-template.md:4role-system-design.md:104 全用 <bot名>/<bot>,同文档在 BOT_HOME 语境另用 <appId>validateRoleLibraryPath() 也只 pin 全局根、不 pin appId。→ 按 runbook 部署时不产生 grant,沙盒角色系统仍 EPERM(即本 PR 要修的 bug)。fail-safe,非逃逸。

修法(双方一致,已从「按 active workingDir 推」修正为「冻结受信根」)

不能按可变 active workingDir 推——跨 bot 切换会把 ds.workingDir 钉到别的 bot 子树(validateRoleLibraryPath 不做 per-bot 收窄,dashboard-ipc-server.ts:817/822),从它推第一段会把对方整棵库授 rw,打穿本 PR 的跨 bot 隔离。
建议:① 从本 bot 受信配置定角色库子树(显式 roleLibraryDir 字段,或从 runbook 已要求的 defaultWorkingDir 推第一段);② 在 session init 时 canonicalize + no-follow 校验并冻结,不随切换重算;③ validateRoleLibraryPath/role switch 同步收窄到该冻结子树、禁跨 bot;④ worker 只收冻结子树,复用本 PR 的末两段 lstat + deny 抑制;⑤ 加集成回归:appId≠human slug 仍产 grant、切到/继承另一 bot cwd 不改写 own subtree。

安全面(codex 逐项,未发现新 blocker)

  • appId/symlink/canonical:无带出子树的组合(单段正则挡穿越;只 realpath 根父目录、末两段各自 lstat、后续不再 realpath → 顶层链接/跨 bot alias fail-closed)。
  • TOCTOU/挂载点:仍有宿主换链/mount alias 窗口,但需宿主级同 UID 写权(该主体也能改 bots.json 关沙盒),且所有路径规则同属一次性构建 → 按当前威胁模型可豁免(是被接受的边界,非「无竞态」)。
  • mandatoryDenyRegexes:不重开洞(Seatbelt regex deny 在所有 path allow 之后发、deny 终胜;Linux 不编译 regex,现有 regex 也不覆盖角色库)。未来若允许任意 Linux regex 是契约缺口,非当前可利用。
  • user readOnly:同路径 RO 覆盖 internal rw;祖先 RO 被更深 role rw 打穿——符合现有 longest-prefix 语义(workingDir internal rw 本就如此),非新增 blocker。若要「祖先 RO 锁整库」需统一处理 role grant 与 workingDir。
  • 接线:仅 sandboxRequested 分支执行;riff remote bypass;cfg.larkAppId 正确。

@deepcoldy 知情接受的既有面(作者已在 PR 描述披露,双方不另立 blocker)

grant 是整棵 same-bot 子树 rw,含 users/<其他 open_id>;该 bot 任意 sandbox 会话可读写,私有角色仍只是协议层隔离。若 oncall 威胁模型要求同 bot 跨用户机密隔离,需按 sender/session 另做设计。

独立验证(双方)

head 1c76f65emerge-tree --write-tree 37d0fc44 1c76f65e 干净;pnpm build 通过;vitest run test/role-library.test.ts test/fs-policy.test.ts 67/67;human-slug no-op、跨 bot switch、ancestor/same-path readOnly、regex deny emission-order 四组最小实验均如上。未改代码、未重启 live、未合并。

@deepcoldy

Copy link
Copy Markdown
Owner

第三条独立对抗审计补充(Claude 侧后台 agent,真 build + 编译后 policy 跑对抗输入)

与前两审收敛一致:无 P1、无沙盒逃逸、无跨 bot 泄漏、无 deny 打穿。三道核心防御全部成立(APP_ID_SEGMENT_RE 挡穿越/绝对路径/控制字符、末两段 lstat no-follow + 仅 realpath 根父目录、path-form deny 抑制、sandboxRequested 门控、caller 直传不走 keepASexisting 的 realpath、TOCTOU/挂载点豁免论成立)。

两处非阻塞完整性缺口(与 codex 二审一致,均 P2/P3、blast radius 限本 bot own <appId> 子树、当前不可利用):

  • P2 — 祖先 readOnly 被静默升级为 readWriteroleLibDenied()fs-policy.ts:400-410)只枚举 access==='deny' 源。机主/mandatoryReadOnlyPaths 在祖先(如 ~/botmux-roles)设的 readOnly,不被抑制,被 <appId> 更深的 internal rw 按 longest-prefix 覆盖。实测 userPaths.readOnly:["~/botmux-roles"] + 子树 → accessForPath(.../cli_self/x)=readWrite。PR 自己对 deny 说「机主锁库就该得到锁死的库」,同理适用于 readOnly。建议 roleLibDenied 也把覆盖性 readOnly(user + mandatoryReadOnly)计入抑制,或把 grant 降级为 readOnly。(注:workingDir 的 internal rw 本就穿透祖先 RO,属既有 longest-prefix 语义;若要「祖先 RO 锁整库」需连 workingDir 统一处理。)

  • P3 — mandatoryDenyRegexes 缺口,且 Linux 无 regex-deny 兜底:抑制列表(fs-policy.ts:403-407)不含 mandatoryDenyRegexes。已核实:compileToSeatbelt 消费 denyRegexes(path 规则之后发、deny 终胜,macOS 有兜底),但 compileToBwrap 完全不消费 denyRegexes(Linux 无兜底)。当前不可利用——mandatoryDenyRegexes 只被 BOT_HOME 凭据 sidecar + macOS MCP socket 填充,永不指向 ~/botmux-roles;且 bwrap 忽略 denyRegexes 是 pre-existing 行为(对所有 rw grant 成立,非本 PR 引入)。属 latent defense-in-depth,非本 PR blocker,供修 P1 时一并考虑收口。

三审收敛:唯一代码 blocker 仍是 P1(human-slug scoping)。维持 Request changes,等 @deepcoldy 确认。

@xu4wang

xu4wang commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

作者回应:P1 认领,但提议反向修 —— 把每-bot 目录段的契约定为 appId(求 @deepcoldy 拍)

P1 我认,复现过程也认:单测全绿正是因为用 rootOverride 直接建了 <root>/<appId>,把「运营实际如何命名」这个前提绕过去了。照 deploy-runbook.md 的人类 slug 部署,本 PR 是 silent no-op。

但在实现 reviewer 建议的「冻结根」之前,我想先请拍一个方向问题:该改的是代码的 scoping key,还是文档的命名契约? 我倾向后者,依据是三条查证到的事实。

1. 没有任何代码/脚本创建角色库 —— 文档就是契约本身

$ grep -rn 'botmux-roles' scripts/ src/ --include='*.sh' --include='*.ts'
src/cli.ts:4138,4145,4799,9927   # 注释 + 越界校验话术
src/core/role-library.ts:7       # roleLibraryRoot() 常量
src/adapters/cli/fs-policy.ts    # 本 PR 的注释

建目录、写 _role-protocol.md、分发副本全是 runbook 里的手敲步骤,没有任何程序读或生成这一层目录名。所以「改 runbook」不是让文档迁就代码,而是就地把一个从未被代码固定过的约定写清楚。反过来说,正因为没有程序依赖,<bot名> 这个占位符也从来没有真正的约束力。

2. 至少一个在产机队早就在用 appId 命名,文档与实践已脱节

我这台部署了 3 个 bot 的机器上,bots.json 的三个 defaultWorkingDir 与磁盘上的三个目录全部~/botmux-roles/cli_xxxxxxxx/shared/default 形态(appId),没有一个用人类 slug。这也是我最初按 appId 写实现、并且内核级实测能通过的原因——我把本机的实际约定当成了文档约定,没去核 runbook,这是我的疏漏。但它同时说明:<bot名> 已经不是唯一在用的布局。

3. appId 是 fs-policy 里所有 per-bot key 的统一形态

bots/<appId>(BOT_HOME)、sessions-<appId>.jsonattachments/<appId>bot-openids-<appId>.json.lark-cli-bots/<appId>、macOS appsecret_<appId>.enc。角色库是唯一一处按人类名分段的 per-bot 资源。

首审里「把内部 id 泄漏进手动运营步骤、可用性差」这条反对,我认为不成立:

  • 人类可读名早有专属去处 —— .botmux-dir.jsonname(卡片脚注与「切到XX」匹配都读它);
  • runbook 自己已经强制角色目录名必须是 ASCII slug(记忆桶按 cwd slug 分桶、非 ASCII 会串台)。既然下一层已经为了机器约束牺牲了可读性,上一层用 appId 完全同构,不是新增的认知负担。

顺带能收掉二审自己发现的跨 bot 洞

二审指出 validateRoleLibraryPath() 只 pin 全局根、不做 per-bot 收窄,于是 botmux role switch 能切进别的 bot 的角色目录dashboard-ipc-server.ts:817/822 随后把 ds.workingDir 钉过去)。

  • 目录段 = appId:收窄就是把校验从 <root> 改成 <root>/<appId>,一处改动。
  • 目录段 = 人类 slug:必须先把「冻结受信根」那套管线(daemon 侧求根 → 透传 init → 冻进 session)铺完,才有东西可 pin。

也就是说,appId 方案让 P1 与这个跨 bot 洞同一处收口;冻结根方案修完 P1 后,跨 bot 洞仍要另修。

两条代价,我不藏

  1. 存量人类 slug 部署要迁移mv 目录 + 改 bots.jsondefaultWorkingDir + 重新分发各角色目录里 _role-protocol.md<ROLES_ROOT>。我这台是零迁移,但 @deepcoldy 的机器我不知道 —— 这是拍板前最需要你确认的一点
  2. 光改文档仍是静默的:命名不匹配时依旧不产生规则。这是本方案唯一的实质弱点,所以它必须配一条告警,而不是只改文档:sandbox 开启、且 workingDir 位于角色库根下、但 <root>/<appId> 不是真目录时,打
    [sandbox] role library dir mismatch: <root>/<appId> is not a real dir — role system will be inaccessible (rename the per-bot dir to <appId>, see docs/roles/deploy-runbook.md)
    只用 worker 现有字段,不动 init IPC 契约,把 silent no-op 变成可诊断。

对比

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.mdrole-protocol-template.mdrole-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

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.

2 participants