Skip to content

refactor(sandbox): 统一 Linux/macOS 文件沙盒为 deny-by-default 三档白名单(FsPolicy)#482

Open
xu4wang wants to merge 12 commits into
deepcoldy:masterfrom
xu4wang:feat/sandbox-fs-policy
Open

refactor(sandbox): 统一 Linux/macOS 文件沙盒为 deny-by-default 三档白名单(FsPolicy)#482
xu4wang wants to merge 12 commits into
deepcoldy:masterfrom
xu4wang:feat/sandbox-fs-policy

Conversation

@xu4wang

@xu4wang xu4wang commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

背景

现有文件沙盒是「双引擎、双语义」:Linux 走 bwrap + overlayfs(读全盘 + 黑名单 mask、写隔离到 upper 层 + landing),macOS 走 Seatbelt(allow-default + deny 黑名单)。两平台语义不一致,sandboxHidePaths/sandboxReadonlyPaths 只在 Linux 生效;黑名单模型「默认可读」,新敏感路径要人工追加 deny,历史上漏过多处(lark-cli 密钥库、bots.json sidecar 等)。

本 PR 把两平台统一为单一策略源 + deny-by-default 三档白名单

改动

核心:src/adapters/cli/fs-policy.ts(新增,唯一事实源,纯函数)

  • FsPolicy = 三档路径规则(readWrite / readOnly / deny),默认不可访问;最深路径胜出(longest-prefix),支持任意黑白名单嵌套。
  • buildFsPolicy(ctx) 合成:平台基线 preset + CLI adapter 声明 + botmux 内部注入 + 用户 sandboxPaths
  • compileToSeatbelt()(macOS)/ compileToBwrap()(Linux)两个薄编译器,按路径深度序发射规则 → 两引擎「后者胜」同构,跨平台语义一致由构造保证。

删除 overlay + landingsandbox.ts 重写为 direct 模式(写直达项目,无 upper 层/无 landing);删 services/sandbox-land.ts/land 命令、落盘卡 UI、挂载 GC/bridge 重定向。read-isolation.ts 精简(旧五套规则构建器移除,保留 BOT_HOME/send-cred 原语)。

配置:新增 bots.jsonsandboxPaths: { readWrite, readOnly, deny };daemon 启动时自动迁移旧字段(readIsolation/sandboxHidePaths/sandboxReadonlyPaths/readDenyExtraPaths → 新字段,写新留旧供降级,bots.json.bak-sandbox-v1 备份)。

安全要点(经 codex 两轮定向复审)

  • cross-bot 隔离:deny-by-default 下兄弟 bot 的凭证/会话/内容天然不可见,无需枚举。macOS lark-cli 密钥库 carve-out(只放行自己的 appsecret + master.key)。
  • agent 工具链 allow-list:botmux 安装目录 + ~/.botmux精确 allow-list(install 根、.dashboard-port、bin、claude-plugin、data/dashboard-daemons、bots-info.json、自己的 sessions/bot-openids/attachments、turn-sends、schedules.json)——绝不整体暴露 ~/.botmux(含 voice 凭证的 config.json、.env、webhook 主密钥、别的 bot 的 schedules 全部默认拒,且对未来新增文件不 fail-open)。
  • 写隔离:项目 + scratch 可写,其余 deny;crown jewels(~/.ssh/.aws/keychains 等)恒 deny。

测试

  • 单元:fs-policy.test.ts(策略语义/嵌套/编译顺序/迁移)、sandbox.test.ts(relay 边界)、sandbox-migration.test.tsbot-registry.test.ts(brandLabel env)等。
  • 真机 e2efs-policy-seatbelt.e2e.test.ts 真调 sandbox-exec 拉起 cat/touch/node 验证三档;scripts/sandbox-probe.mjs 本机一键验证(含 codex 泄漏点回归守卫 + 沙盒内跑 botmux CLI/hook 端到端)。
  • 全量套件对照 upstream/master 基线零沙盒回归(既有 flaky:dashboard-monitoring-ui / plugin-mcp-gateway / skill-agentbuddy / workflow-cli,隔离复跑均过)。
  • 飞书真机验证(macOS,真实 bot 会话):读/写/跨-bot 隔离达标;claude 对话 / botmux send / hook / python / lark-cli / footer 角色名 / botmux history 全部正常。

影响面

  • macOS + Linux 统一。sandbox: true(或 readIsolation: true 自动迁移)的 bot 下个新会话生效;沙盒仍冷启动套用,升级后需 botmux suspend all
  • 旧字段兼容映射,降级无需反向脚本(旧 daemon 读保留的旧字段)。

🤖 Generated with Claude Code

ksher and others added 12 commits July 16, 2026 03:38
- 新增 adapters/cli/fs-policy.ts:FsPolicy(readWrite/readOnly/deny,默认不可访问)、
  最深路径胜出语义(accessForPath)、平台基线 preset、compileToSeatbelt/compileToBwrap
  (深度序发射,两引擎 last-wins 同构 → parity 由构造保证)
- bot-registry:新增 sandboxPaths 字段解析;旧字段标注 LEGACY
- 新增 services/sandbox-migration.ts:daemon 启动时自动迁移(写新留旧 + bak 备份 + 幂等)
- 单测 33 个:语义、嵌套黑白名单、编译顺序、迁移含未知字段保留

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- worker.ts:四个平台耦合布尔(willFileSandbox/wantsFileSandbox/willWriteSandbox/sandboxOn)
  收敛为 sandboxRequested + willRedirectCliData;两平台同一 buildFsPolicy → 各自编译器;
  删除 bridge 重定向(CLI 数据落真实路径,resume 探测不再有 ephemeral upper 坑)
- sandbox.ts 重写为 direct 模式(985→~530 行):删 overlayfs/fuse-overlayfs 双路径、
  /var/tmp upper、meta.json、挂载生命周期;GC 改纯目录清扫(保留 /proc 活进程守卫防
  删活 outbox);outbox relay 安全边界原样保留;新增 prepareDirectSandbox
- 删除 sandbox-land.ts(356 行)+ /land 命令 + 落盘卡 + dashboard 两条路由/代理/LandPanel
  + i18n 全部 land 键
- read-isolation.ts 603→156 行:删五套规则构建器(buildV2DenyPaths/Regexes/CarveOuts、
  buildWriteSandboxRules、buildLinuxReadIsolationMasks、buildSeatbeltProfile);保留
  BOT_HOME/send-cred/gate/reattach 原语
- localSandboxApplies 改单参(riff 旁路;平台不再是因素——darwin 也走本地沙盒)
- 测试:重写 sandbox.test.ts/read-isolation.test.ts 对齐新 API;全量 8239 测试
  对照干净 upstream/master 基线零回归(唯一失败 dashboard-monitoring-ui 为既有基线)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 空白 (deny file-read* (subpath "/")) 会让所有进程在 dyld bootstrap 阶段 SIGABRT
  (连 /usr/bin/true 都起不来)——即便已放行 /System /usr。根因是进程启动读取
  dyld shared cache/系统路径的方式需要 Apple 基座 profile 铺垫
- 改为 (deny default) + import /System/Library/Sandbox/Profiles/bsd.sb + 重新放行
  CLI 所需非文件操作类(process/mach/ipc/sysctl/signal,network 按 policy.net 门控)
- 实测 bsd.sb 不放开全盘读(/etc/hosts、$HOME 仍 deny),三档语义完整保持
- 新增 fs-policy-seatbelt.e2e.test.ts:真实 sandbox-exec 拉起 /bin/cat、touch、node
  验证 rw 可读写 / deny 凿洞不可达 / readOnly 只读 / 未覆盖不可达 / node bootstrap+联网

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
codex 定向 review 确认的真 bug(Linux+macOS):一个 deny 目标在 spawn 时尚不存在时
被存在性过滤丢弃,而其父目录 rw 暴露 → agent 可自行创建该路径再读写,deny 意图被绕过。

修复:deny 规则在两平台都不做存在性过滤(只过滤 allow——bwrap 不能 bind 不存在的
source)。Seatbelt 保留字面 deny 规则;bwrap 用 tmpfs 遮罩(挂载点由 mount 创建,
写入落到 ephemeral tmpfs,真实路径永不被创建)。

- worker.ts 两处过滤点修正:resolveDeny 不过滤存在性;assembled-rules 过滤放行所有 deny
- 单测:nonexistent deny under exposed parent → --tmpfs 遮罩(非跳过)
- e2e 实测:spawn 时 secrets 不存在,沙盒内 mkdir/写入均 DENIED,真实路径未被创建

其余 5 项 codex 复核为 not-a-bug(路径规范化/最深胜出/relay 边界/worker fail-safe 均通过)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…),沙盒内实测 claude+lark-cli 均正常

沙盒内真机实测发现并修复:baseline 整体 deny `~/Library/Application Support/lark-cli`
会让本 bot 自己的 lark-cli 也无法认证(实测 `lark-cli auth` 报
'keychain Get failed ... operation not permitted')——它需要读自己的
appsecret_<self>.enc + master.key.file 来解密。

- buildFsPolicy 内部注入层(darwin)新增两条 readOnly carve-out(比 deny 更深,
  最深路径胜出):own appsecret + master.key.file。兄弟 bot 的 appsecret/token 仍 deny
  → 有 master key 也读不到兄弟密文(实测兄弟 appsecret DENIED)
- Linux 不受影响(lark 密钥在 ~/.lark-cli-bots/<self>,已 readWrite)

实测结论(真实 sandbox-exec profile,本 bot cli_aacd7ceeb5789cc6):
- lark-cli auth scopes:解密 appsecret + 调飞书 API 成功(132 scopes),兄弟密钥/BOT_HOME 仍隔离
- claude --version + claude -p 一次性对话:读 BOT_HOME 配置 + 读项目文件 + 调 API + 返回结果全部正常
  (注:claude 启动读 cwd 项目信任配置,真实会话 cwd=workingDir 已 readWrite,无问题)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
王旭要求:放松沙盒读范围让 python/perl 等各种工具都能用(保持 deny-by-default,扩白名单)。

commonHomeBaseline 新增 readOnly 工具链/版本管理器/包缓存目录(跨平台):
- node: .fnm/.nvm/.volta/.nodenv/.yarn/.pnpm/.bun/.deno/.npm-global
- python: .pyenv/Library-Python/.local-lib/.pipx
- ruby: .rbenv/.rvm/.gem/perl5/.cpanm/.cpan
- rust: .cargo/.rustup | go: go/.gvm/.goenv
- jvm: .sdkman/.jenv/.m2/.gradle | 通用: .asdf/.mise/.plenv/.opam/.ghcup/.stack/.nimble/.pub-cache
工具链设 readOnly(agent 能跑不能篡改宿主工具链;构建缓存走已有 rw ~/.cache + ~/Library/Caches)。

工具链目录内的凭证文件在更深层 deny(最深胜出):
.cargo/credentials(.toml)、.gem/credentials、.m2/settings.xml(+security)、.gradle/gradle.properties。

实测(真实 profile):python3/perl/ruby/brew-python 全部正常;~/.cargo/registry 可读、
~/.cargo/credentials.toml DENIED。系统工具(/usr/bin+/bin,stdlib /System+/Library)本就已覆盖。
单测:工具链可读 + 凭证 deny + 只读不可写。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- sandbox-probe.mjs:用真实 fs-policy 为某 bot 编译真 Seatbelt profile,在 sandbox-exec
  内实跑进程,逐条断言三档权限 + 密钥/跨-bot 隔离 + 工具链(--tools)+ 未覆盖 deny。
  自动探测 bot appId,退出码反映是否全绿。cwd=项目(贴合真实会话)。
- 删除 3 个 overlay 模型旧 probe(import 已删除的 prepareSandbox,无法运行)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e hook EPERM

飞书实测暴露:deny-by-default 漏放行 botmux CLI 自身运行必需的路径 → 沙盒内
botmux send 和 claude 的 SessionStart/AskUserQuestion hook(都 exec
node ~/beta/botmux/dist/cli.js)EPERM 起不来。

- fs-policy 新增 ctx.botmuxInstallRoot(安装/checkout 根)readOnly;~/.botmux
  readOnly umbrella + 精确 deny 跨-bot 敏感项(bots.json/logs/bots/feishu-session/
  dashboard 密钥/data 下 sessions.json·frozen-cards·turn-sends·crash-diagnostics·
  attachments·queues·read-isolation/.lark-cli-bots),己方 BOT_HOME·sessions-self·
  attachments/self·lark-cfg 更深 re-allow(复刻旧 buildV2DenyPaths carve-out 集)
- ctx.extraDenyPaths:worker 枚举兄弟 sessions-id.json / identities-* / .send-cred-* /
  bots.json.* 精确 deny(同目录不能前缀 deny,沿用旧 sibling 枚举思路)
- worker:算 botmuxInstallRoot(dist/worker.js → ../../)+ 枚举 extraDenyPaths
- probe 扩端到端:真在沙盒内跑 node dist/cli.js --help + session-ready 形态
  (上轮漏的正是"沙盒内跑 botmux CLI"这条),+ 验 ~/.botmux 结构可读 / 敏感 deny
- 单测 +1(botmux 运行时面),全量 70 沙盒测试绿;probe --tools 全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
读隔离/沙盒 bot 的 footer 有时丢角色名:agent 显式 botmux send 在沙盒内渲染 footer,
resolveBrandLabel 无常驻 registry → 回落读 bots.json(deny)→ 拿不到 brandLabel 模板
→ 渲染成默认 [botmux]。daemon 侧渲染的卡片不受影响(未沙盒),故表现为"时好时坏"。

- worker spawn 时把已解析的 brandLabel 经 childEnv.BOTMUX_BRAND_LABEL 注入沙盒 CLI
- resolveBrandLabel 优先读该 env(gated on 本 bot appId;present-empty=suppress),
  拿不到再走原 registry/bots.json 路径 → 沙盒内不必碰 bots.json
- BOTMUX_BRAND_LABEL 加入 tmux BOTMUX_INJECTED_ENV_KEYS 透传白名单
- brandLabel 是 markdown 模板非密钥,env 传递安全;密钥仍不进沙盒
- 单测 +3(env-first / present-empty suppress / 跨-bot 不串),probe +1 端到端
  (沙盒内 resolveBrandLabel 从 env 拿到、不碰 bots.json);105 测试绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/跨-bot 泄漏

codex 复验发现 ~/.botmux readOnly umbrella + 黑名单是"暴露后逐个挡、总会漏"的反模式,
漏了真凭证与跨-bot 内容(3 个确认 bug):
- Critical:config.json 的 voice accessKey/secretKey/apiKey、.env、data/webhook-master.key
  (AES 主密钥)+webhook-secrets.json 沙盒内可读
- High:data/schedules.json(别的 bot 的 prompt+路由)、observed-bots/bot-openids/connectors/
  federations 等跨-bot 内容
- High:spawn 时枚举兄弟文件 fail-open(后创建的兄弟文件冷重启前可读)

改法:放弃 umbrella,~/.botmux 改为精确 allow-list(deny-by-default)——只暴露 CLI/hook
真正读的非敏感文件,其余(含所有凭证 + 未来新增文件)默认拒,三个 bug 一次性根除:
- readOnly allow-list:install 根、.data-dir、.dashboard-port、bin、claude-plugin、
  lark-scopes.json、data/dashboard-daemons(hook IPC 发现)、data/bots-info.json、
  own bot-openids/sessions/attachments
- readWrite:own BOT_HOME、data/turn-sends(CLI append 去重标记,仅 message-id 无内容)、
  own lark-cli-bots、outbox
- 删除 extraDenyPaths 枚举(allow-list 下兄弟根本不暴露,deepcoldy#3 fail-open 自动消失)
- probe 加 codex 泄漏点回归守卫(config.json/.env/webhook-master.key/schedules.json → DENIED)
- 单测重写为 allow-list 语义 + 未来文件默认拒;124 沙盒测试全绿;probe 全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…json 放开(RMW)

codex 复审确认 3 个泄漏已闭,但发现 2 个新 correctness bug:
1. own 可写目录若 spawn 时不存在会被 worker 存在性过滤丢掉(turn-sends 去重标记、
   attachments/<self>)→ bwrap 无 mount / Seatbelt 无 write allow → CLI 写失败。
   修:worker spawn 前 mkdir turn-sends + attachments/<self>(+ schedules.json '{}'),
   让它们存在、过滤存活、被 bind rw。
2. attachments/<self> 我误设 readOnly,但 `botmux quoted`/downloadResources 要写
   attachments/<self>/<messageId>/ → 读带附件的引用消息会失败。修:改 readWrite。

schedules.json(王旭确认):`botmux schedule` 是全员共享的 RMW store,read-deny 会让
沙盒内 schedule 载入空 map 后覆盖、清空所有 bot 的任务。改 readWrite(暴露别的 bot
任务 prompt,owner 接受——与旧 read-isolation 一致的取舍)。

- fs-policy:attachments/<self> readOnly→readWrite;新增 schedules.json readWrite
- worker:pre-create turn-sends / attachments-<self> / schedules.json
- probe:schedules.json 改 ALLOWED(owner 接受);单测同步;124 沙盒测试 + probe 全绿
- codex 复审:3 泄漏 CLOSED,dashboard-daemons/bots-info/turn-sends 均 not-a-bug(非凭证/非会话内容)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g.json EPERM

飞书实测 botmux history 报两个问题:
1. "未找到 session":loadSessions 用 readdirSync(dataDir) 枚举 sessions-*.json,但
   allow-list 只放行具体文件、不放行 data/ 目录列举 → readdir EPERM → catch 吞掉 →
   own 会话也没加载。修:readdir 失败时按注入的 BOTMUX_LARK_APP_ID 直读
   sessions-<self>.json(沙盒内 agent 只操作自己的会话,无需枚举;也不暴露 data/
   列举,避免兄弟文件名/chat-id 泄漏)。
2. config.json EPERM 警告噪音:沙盒故意不暴露 config.json(含 voice TTS 凭证),
   EPERM 是预期而非解析失败 → readGlobalConfig 对 EPERM/EACCES 静默返回默认,
   只对真正的坏 JSON 告警。

实测:沙盒内 botmux history --limit 3 正常返回会话+消息,无 config.json 警告。
99+35 相关测试全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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