Skip to content

fix(sandbox): 收口 Linux lark-cli keystore 跨 bot 泄漏 - #777

Open
deepcoldy wants to merge 3 commits into
masterfrom
wt/botmux-claude-14
Open

fix(sandbox): 收口 Linux lark-cli keystore 跨 bot 泄漏#777
deepcoldy wants to merge 3 commits into
masterfrom
wt/botmux-claude-14

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

背景 / 问题

沙盒的白名单模型设计上按「每个 bot 只放行自己那份 lark-cli 密钥」,macOS 早已在 keystore 上闭合,但 Linux 漏写了这半边

lark-cli 在 Linux 把密钥落在共享 store ${XDG_DATA_HOME:-$HOME/.local/share}/lark-cli

appsecret_<appId>.enc   每个飞书 app 一份密文
cli_<appId>_ou_<openId>.enc   用户登录 token
master.key              一把共享 master key(解上面所有 .enc)

而 Linux fs-policy 只有一条 baseline ro(~/.local/share)没有更深的 deny,于是任何开了 sandbox/readIsolation 的 bot 在沙盒内都能只读到所有兄弟 bot 的 appsecret_*.enc + 共享 master.key,可改写自身 config 指向兄弟 appId/keyId 从而冒用兄弟 app 身份LARKSUITE_CLI_CONFIG_DIR 只重定向 config、不搬 data,所以没有任何推荐配置能把 Linux 密钥搬成 per-bot。

macOS 对照:~/Library/Application Support/lark-cli 整目录 deny + 深一层只放行自身 master.key.file + 自身 appsecret_<self>.enc——本 PR 就是把这段镜像到 Linux。

额外发现:no-transport(apiOnly / HTTP 虚拟会话,本该冻结全部飞书凭证)下,该 store 之前仍是只读——底层 policy 在最严格模式下都存在 Linux 跨 bot 密钥泄漏。

改动

  1. fs-policy.ts — Linux transport 会话镜像 macOS carve-out:deny(store 根)(deeper than baseline ro(~/.local/share) → longest-prefix 赢)+ 深一层 ro(master.key)(Linux 文件名是 master.key,非 macOS 的 master.key.file)+ ro(appsecret_<self>.enc)。兄弟密文、用户 token、未来新文件全 deny-by-default。
  2. computeNoTransportAuthorityRoots — 把该 store 纳入 no-transport authority roots:apiOnly / HTTP 虚拟会话彻底冻结整个 keystore、不发任何 carve-out(连自身 key 也不给),并靠已有的 dropAuthority 压住敌意 user RW/RO 与宽 workingDir 的重开洞。
  3. store 路径解析 — 按 XDG Base Directory 规范由 worker 从冻结宿主环境解析(绝对 XDG_DATA_HOME 才认、相对值按 spec 忽略回退 $HOME/.local/share)+ canonical() 后作为显式 FsPolicyContext.larkCliLinuxStore 传入;policy 保持纯函数、绝不读 env。新增纯函数 resolveLarkCliLinuxStoreDir / larkCliLinuxStorePath 便于单测。因沙盒子进程原样继承宿主 HOME/XDG_DATA_HOME(worker 从不改写),解析出的正是沙盒内 lark-cli 会读的同一目录。

影响面评估

  • 跨平台:仅 Linux 且开 sandbox/readIsolation 的会话行为变化(收紧)。darwin 路径完全不变——新逻辑在 platform === 'linux' 分支内,darwin 分支与断言一字未动。
  • 跨会话类型:transport-enabled(普通话题/群会话)拿到自身 key carve-out;no-transport(apiOnly / HTTP 虚拟)整个 store 冻结、零 carve-out。两条路径都覆盖并测试。
  • 跨后端:改的是 policy 规则装配层,PtyBackend/TmuxBackend 共用同一编译产物,两引擎都按 longest-prefix 语义,无差异。
  • 跨 CLI:动的是共用 fs-policy.ts,不涉及任何单个 CLI 适配器;所有 20+ CLI 只要开沙盒都受同一(收紧后的)keystore 规则保护。

测试验证

  • pnpm build
  • fs-policy 单测 +7:默认 store 自身 RO / 兄弟+token+未来文件 deny / 无关工具链(pipx、bytedcli SSO)不误伤;自定义 XDG_DATA_HOME store 随之 rehome;no-transport 全 deny + 敌意 user RW 压不开;resolveLarkCliLinuxStoreDir 的「绝对认/相对忽略/未设回退」真值表。
  • bwrap 内核级 e2e +1:真 bubblewrap 挂载下——兄弟 appsecret + master.key 内核级读不到、ls 不列兄弟文件、自身 key 可读、store 不可写(白盒 deny→ro 父→ro 自身 key 的嵌套 carve-out 挂载真实性)。
  • 变异测试:去掉 store deny / 破坏 no-transport 门 / 把 carve-out 碰成 master.key.file,三处各让对应测试翻红(含内核级那条),确认新测有牙、非假绿。
  • 沙盒相关 12 套件 237 passed(darwin seatbelt e2e 在 Linux 正确跳过)。全量套件其余失败均为依赖外部 CLI 的既有 e2e(codex/CoCo 未安装/TUI 相关),与本改动无关。
  • live:真 sandbox 内 lark-cli auth scopes 仍可用(自身 app 解密出 158 个 scope),同时兄弟 appsecret 读取被内核级拦截("No such file",rc=1);no-transport 下自身 key 亦被冻结(BLOCKED)。

Refs #701(该文档 PR 保持 docs、阻塞到本代码修复合入后再 rebase 修正 Linux 密钥落点表述 + P2 首次 init 只能宿主沙盒外)。

@deepcoldy

Copy link
Copy Markdown
Owner Author

阻断性复审结论(HEAD dffa863a4

有两个会让凭证边界重新打开的 P1,需修复后再审;其余 master.key 文件名、默认 store carve-out、真实 bwrap 白黑白挂载形态均与预期一致。

P1:store 解析与 lark-cli 实现不一致,且不能只把 env 名替换掉

官方 lark-cli v1.0.56/v1.0.76 的 Linux StorageDir 读取的是 LARKSUITE_CLI_DATA_DIR,完全不读取 XDG_DATA_HOME:有效绝对路径经清理/最近存在祖先 symlink 解析后拼 /lark-cli;无效或未设才回退 $HOME/.local/share/lark-cli。见 keychain_other.goSafeEnvDirPath。本机 1.0.76 实跑也复现:仅设 XDG_DATA_HOME 时文件仍落在 $HOME/.local/share/lark-cli;设 LARKSUITE_CLI_DATA_DIRmaster.key/appsecret_*.enc 落在 <value>/lark-cli,XDG 目录为空。

当前 worker 用 process.env.XDG_DATA_HOME 计算 policy,因此合法自定义 data dir(例如位于已暴露的 ~/.local/share/custom 下)不会被 mask,原泄漏仍成立。

修复不能只是改成读取 process.env.LARKSUITE_CLI_DATA_DIR:该键当前可由 bots.json env 注入,且 backend 在 policy 计算后把 perBotInjectEnv 叠到进程环境;tmux pane rc/global env 也可能提供不同值,而 bwrap 没有 clear/unset 该键。应让 policy 与沙盒内 lark-cli 共用一个权威值,例如:按实际优先级解析并规范化 data root,然后通过 bwrap --setenv/--unsetenv LARKSUITE_CLI_DATA_DIR 固定子进程看到的值,再从同一值派生 canonical store。补 ambient/per-bot/相对值/../尾斜杠/最近存在祖先 symlink 的 wiring 测试。

P1:store 嵌在自身 BOT_HOME 时,no-transport 的 user grant 可重开 key

LARKSUITE_CLI_DATA_DIR 可以合法指向 <BOT_HOME>,此时 store 为 <BOT_HOME>/lark-cli。当前 insideAuthority 对整个 BOT_HOME 无条件例外:

authorityRoots.some(root => coversPath(root, p)) && !coversPath(ctx.botHome, p)

因此更深的 hostile userPaths.readWrite: [<store>/master.key] 不会被 dropAuthority 抑制,最终有效权限是 readWrite(已用当前 buildFsPolicy 直接复现)。BOT_HOME carve-out 不应盖过其内部更具体的 authority root。请补 nested-store 下 user RW/RO、readonlyRootsextraWritePaths 均无法重开的 no-transport 回归测试。

本轮验证:

  • pnpm build
  • pnpm exec vitest run test/fs-policy.test.ts test/fs-policy-bwrap.e2e.test.ts test/api-only-mode-wiring.test.ts --reporter=verbose:132 passed ✅
  • git diff --check origin/master...HEAD

@deepcoldy

Copy link
Copy Markdown
Owner Author

二轮复审:仍有 1 个安全 blocker(HEAD b3912a9bf

上一轮的 nested-BOT_HOME authority 逃逸已正确修复:per-root 例外能让 BOT_HOME 外部保持可写,同时更具体的内嵌 store 继续被 dropAuthority 冻结;新增纯函数与真实 bwrap 测试均有效。

P1:resolver 仍未镜像 SafeEnvDirPath,不存在目标下会冻结错 store

lark-cli 对合法绝对 LARKSUITE_CLI_DATA_DIR 的语义不是“仅判断 / 开头”,而是:拒绝控制字符 → filepath.Clean → 解析最近存在祖先的 symlink。当前实现只做 startsWith('/'),随后 worker 的 canonical() 仅在完整 store 已存在时才 realpath;目标尚不存在时原样保留 .. / lexical symlink。larkCliLinuxStorePath() 又会拒绝带 .. 的 override 并静默回退默认路径,于是 policy 与已被 bwrap pin 住的 lark-cli 再次分叉。

当前 SHA 的直接复现:

LARKSUITE_CLI_DATA_DIR=/srv/review/data/../actual
workerResolved=/srv/review/data/../actual/lark-cli
policyStore=/home/u/.local/share/lark-cli       # 错误回退
lark-cli actual=/srv/review/actual/lark-cli     # filepath.Clean 后
workingDir=/srv/review 时 actualAccess=readWrite

这也覆盖两个同根边界:

  • symlink HOME / 自定义 data root 且 store 尚不存在:child 的 HOME 已被 bwrap 设为 canonical home,但 resolver 用 lexical home;完整路径 realpath 失败时两边分叉。
  • 含控制字符或最近存在祖先解析失败的绝对 env:lark-cli 判无效并回退 HOME,当前 policy 仍按自定义路径冻结,默认 store 可落在 ro(~/.local/share) 下。

这不是“当前没有 secret 就无风险”:本仓库的 deny 设计明确要求保护 spawn 后新建路径/宿主并发创建(TOCTOU);冻结错路径会绕过该保证。

建议先把有效 data root按 lark-cli SafeEnvDirPath 等价规则解析并 canonicalize(含 missing leaf 的 nearest-existing-ancestor),用同一结果同时派生 policy store 与 bwrap child env;不要让 fs-policy 收到带 .. 的 override 后回退到另一目录。补 ..、重复/尾分隔符、控制字符、symlink home、symlink nearest-existing ancestor、store 不存在后宿主创建的测试。

P2:当前 child pin 会静默覆盖受支持的 per-bot env 值

sanitizePerBotEnv() 目前允许 LARKSUITE_CLI_DATA_DIR,backend 也把 perBotInjectEnv 最后叠到 bwrap 外层进程;但 sandbox.ts 又按 worker 的 process.env--setenv/--unsetenv,因此合法的 bots.json env.LARKSUITE_CLI_DATA_DIR 在 sandbox 内被静默忽略(已验证 sanitizer 保留该键,bwrap --unsetenv 会抹掉外层值)。这会让原本按 bot 重定向 store 的会话改读默认/宿主 store并导致 auth 回归。

请明确二选一:按实际优先级把 per-bot 值纳入上述权威解析并 pin;或将该键设为 reserved 并给出迁移/诊断,不能继续“配置接受但 sandbox 静默覆盖”。同时建议把解析结果作为参数传给 prepareDirectSandbox,避免 policy 与 sandbox 各自重读一次 process.env

本轮验证:

  • 定向 fs-policy / 真 bwrap / api-only wiring:135 passed ✅
  • pnpm build
  • git diff --check dffa863a4..b3912a9bf
  • CI 全绿 ✅

lark-cli 在 Linux 把密钥写到共享的 `${XDG_DATA_HOME:-$HOME/.local/share}/lark-cli`
(`appsecret_<appId>.enc` 每 app 一份 + 共享 `master.key`),而 Linux fs-policy 只有
一条 baseline `ro(~/.local/share)`、没有更深的 deny——于是任何开了 sandbox/readIsolation
的 bot 都能只读到**所有兄弟 bot 的 appsecret 密文 + master key**,可改写自身 config 指向
兄弟 appId 从而冒用其身份。macOS 早已在 `~/Library/Application Support/lark-cli` 上闭合
(deny 整目录 + 深一层只放行自身 master.key + 自身 appsecret),Linux 漏写了这半边。

改动:
- fs-policy.ts:Linux transport 会话镜像 macOS carve-out——`deny(store 根)` +
  深一层 `ro(master.key)`(Linux 文件名是 `master.key`,非 macOS 的 `master.key.file`)
  + `ro(appsecret_<self>.enc)`,靠 longest-prefix-wins 只放行自身两样、兄弟密文与用户
  token 全 deny。
- computeNoTransportAuthorityRoots:把该 store 纳入 no-transport authority roots——
  apiOnly / HTTP 虚拟会话彻底冻结整个 keystore、**不发任何 carve-out**(连自身 key 也不给),
  堵住底层 policy 在最严格模式下仍能只读共享 keystore 的缺口。
- store 路径按 XDG 规范由 worker 从**冻结宿主环境**解析(绝对 XDG_DATA_HOME 才认、相对值
  按 spec 忽略回退 $HOME/.local/share)+ canonical 后作为显式 FsPolicyContext 输入;
  policy 保持纯函数、不读 env。新增纯函数 resolveLarkCliLinuxStoreDir / larkCliLinuxStorePath
  便于单测。

影响面:仅 Linux 且开 sandbox/readIsolation 的会话行为变化(收紧);darwin 路径完全不变
(新分支 `platform==='linux'` 才进);no-transport(apiOnly) 收紧;PtyBackend/TmuxBackend
无关(policy 编译层共用,两引擎都按 longest-prefix 语义)。

验证:
- pnpm build 绿。
- fs-policy 单测 +7(默认 store 自身 RO / 兄弟+token+未来文件 deny / 无关工具链不误伤;
  自定义 XDG 生效;no-transport 全 deny + 敌意 user RW 压不开;XDG 解析真值表)。
- bwrap 内核级 e2e +1:真 bubblewrap 挂载下兄弟密文 + master.key 读不到、自身 key 可读、
  store 不可写、ls 不列兄弟文件。
- 变异测试:去 store deny / 破 no-transport 门 / 碰错 master.key 文件名,三处各让对应
  测试翻红(含内核级那条),确认新测有牙。
- 触及及沙盒相关 12 套件 237 passed(darwin seatbelt e2e 在 Linux 正确跳过)。
- live:真 sandbox 内 `lark-cli auth scopes` 仍可用(自身 app 解密出 158 scopes),
  同时兄弟 appsecret 读取被内核级拦截;no-transport 下自身 key 亦被冻结。
…HOME 嵌套 authority 逃逸

复审发现前一版两处安全边界未闭合,本 commit 收口:

P1-1 store 真值源错误。lark-cli 在 Linux 由 `LARKSUITE_CLI_DATA_DIR` 决定 keystore
落点(`<value>/lark-cli`,仅当绝对路径;相对/空/未设回退 `$HOME/.local/share/lark-cli`),
**完全不读 `XDG_DATA_HOME`**(strace 实证 v1.0.76 + 对齐官方 keychain_other.go::StorageDir)。
前一版 resolver 读的是 XDG,会冻结错目录 → 真 store 仍暴露、泄漏重开。
- resolveLarkCliLinuxStoreDir 改读 LARKSUITE_CLI_DATA_DIR;worker 传 process.env.LARKSUITE_CLI_DATA_DIR。
- bwrap 无 `--clearenv`,子进程会继承该变量,且 sandbox 的 `--setenv` 白名单不含它 →
  沙盒内 lark-cli 读的就是 worker 自身 env 值,冻结同一值即 policy==CLI 同一权威路径。
- sandbox.ts 追加:绝对值 `--setenv` 重钉、否则 `--unsetenv`,把子进程 keystore 解析
  锁死到 policy 冻结的同一目录,免疫 tmux pane rc / 继承漂移注入的分叉值。

P1-2 BOT_HOME 嵌套 authority 逃逸。若 `LARKSUITE_CLI_DATA_DIR=<BOT_HOME>` 使 store 落在
自身 BOT_HOME 内,旧 `insideAuthority = under(anyRoot) && !under(botHome)` 的 BOT_HOME
全量例外会让更深的 hostile user RW 逃过 dropAuthority → `master.key` 最终解析为 readWrite
(复审已复现)。改为**按 root 作用**:BOT_HOME 例外只对「作为 BOT_HOME 祖先的 root」生效;
嵌套在 BOT_HOME 内、比它更具体的 authority root(keystore)仍照常限制。

验证:
- pnpm build 绿;fs-policy 单测 79 passed(+resolver 真值表改 DATA_DIR、+transport/no-transport
  两条 keystore-nested-in-BOT_HOME 回归,覆盖 user RW/RO + readonlyRoots + extraWrite 四通道)。
- bwrap 内核级 e2e +1:DATA_DIR 落 BOT_HOME + no-transport + 敌意 user RW 直指 master.key,
  真 bubblewrap 下 master.key/兄弟密文仍内核级不可读不可写,BOT_HOME 其余 scratch 仍可写。
- 变异测试:resolver 把相对当绝对 → 真值表红;还原旧 insideAuthority → 嵌套逃逸单测 + 内核级
  e2e 均红(坐实两测各自有牙,且旧漏洞真能被复现)。
- 沙盒相关 12 套件 240 passed;sandbox.ts 相关 35 passed(新 --setenv/--unsetenv 未破坏断言)。
- live:真 sandbox + LARKSUITE_CLI_DATA_DIR 指向重定向 store,`lark-cli auth scopes` 出 158
  scope(自身 auth 从重定向 store 正常),植入的兄弟密文内核级被拦(rc=1)、ls 不列兄弟。

影响面同前:仅 Linux 且开 sandbox/readIsolation 收紧;darwin 分支不变;no-transport 收紧。
…R 纳入 per-bot reserved

三轮复审两处收口:

P1-3 canonical/TOCTOU 静默回退错 store。lark-cli 的 SafeEnvDirPath 对合法绝对
LARKSUITE_CLI_DATA_DIR 先 `filepath.Clean`(解析 `..`/`.`/`//`、控制字符判无效回退),
再 canonicalize。前一版 resolver 只 `startsWith('/')` 不 Clean,worker 的 full-path
realpath 在 store 叶子尚不存在时抛错、原样保留含 `..` 的值,随后 normalizeFsPath 因 `..`
拒绝并**静默回退默认 store** → policy 冻结/deny 的是默认目录,而 lark-cli(会 Clean)打开
的是真实目录 → 真 store 在 broad workingDir 下有效权限 readWrite(复审已复现)。同一根因
覆盖「symlink 祖先 / symlink HOME 且 store 尚不存在」的命名空间分叉。
- 新增纯函数 cleanPosixAbsPath:Go filepath.Clean 语义 + 控制字符拒绝,对齐 SafeEnvDirPath。
- resolveLarkCliLinuxStoreDir 用它 Clean 合法绝对值(strace 实证 parity:`/srv/a/../b`
  → policy 与 lark-cli 都落 `/srv/b/lark-cli`)。
- worker 新增 canonicalNearestAncestor:realpath 最深存在祖先(解析 symlink 前缀)+ 回接
  不存在尾段,替代会在缺失叶子上抛错的 full-path realpath。
- larkCliLinuxStorePath 对不可规范化 override 返回 null(发不出 carve-out、store 保持
  deny-by-default),**绝不静默回退默认 store**。

P2 per-bot env 静默覆盖。LARKSUITE_CLI_DATA_DIR 之前被 sanitizePerBotEnv 接受,但沙盒的
host `--setenv`/`--unsetenv` 会静默盖掉 per-bot 值(误导),且若在非沙盒路径生效会把 keystore
挪出冻结 policy。纳入 per-bot RESERVED_ENV_KEYS:配了即拒 + 可诊断,不再静默吞。
- sandbox.ts 的 child-pin 也改用 cleanPosixAbsPath:合法绝对 → `--setenv` 规范化值;否则
  `--unsetenv` 回退默认,保证沙盒内 lark-cli 与 policy 落同一 store。

前一轮结论:P1-1(变量 XDG→LARKSUITE_CLI_DATA_DIR) 与 P1-2(BOT_HOME 嵌套 authority per-root
判定) 复审已确认修正正确,本 commit 不改动其结论。

验证:
- pnpm build 绿。
- fs-policy 单测(+cleanPosixAbsPath 真值表、resolver 的 `..`/`//`/root-clamp/控制字符矩阵、
  larkCliLinuxStorePath 不可规范化→null);per-bot-env 单测(LARKSUITE_CLI_DATA_DIR 被 drop +
  isReserved=true)。
- 变异测试:去掉 resolver 的 Clean → SafeEnvDirPath 真值表翻红;从 reserved 去掉该键 →
  per-bot 测试翻红(两测各有牙)。
- 沙盒+env 相关 12 套件 239 passed。
- strace parity:`/srv/a/../b` 下 policy resolver 输出 == lark-cli 实际 open 的路径。
- live:raw `..`-bearing LARKSUITE_CLI_DATA_DIR 走完整 worker resolve+nearest-ancestor
  canonicalize → 真 bwrap 内 lark-cli auth scopes 出 158(自身从 Clean'd store 正常 auth)、
  植入兄弟密文内核级 rc=1 拦。

影响面:仅 Linux 且开 sandbox/readIsolation 收紧;darwin 不变;per-bot env 多 reserve 一个键。
@deepcoldy
deepcoldy force-pushed the wt/botmux-claude-14 branch from b3912a9 to 43cbae1 Compare August 7, 2026 05:10
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