Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/file-sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ worker spawnCli

**per-CLI authPaths**:每个适配器用 `authPaths` 声明自己的认证/登录状态目录,沙盒把它们真实 `--bind` 进来,token refresh / login 直接持久化到宿主。默认窄(仅 auth);CLI 若在 `$HOME` 下放 SQLite DB(codex 系),把整个状态目录加进 `authPaths`(否则该路径不在白名单 → 沙盒里不存在 → DB 打不开或拿不到 fcntl 锁)。macOS 用同一份 policy 经 Seatbelt(`compileToSeatbelt`)落地。

**角色库子树**:开沙盒的 bot 还会拿到 `<角色库根>/<自己 appId>/` 的 `readWrite`。`workingDir` 只覆盖**当前**角色目录,而角色系统要越过它:「有哪些角色 / 切换角色」枚举兄弟角色目录并读各自 `.botmux-dir.json`,「新建角色」写 `users/<openId>/<slug>/` 并复制库根的 `_role-protocol.md`,切换后「沉淀知识」写的是**新**角色目录下的 `knowledge/`。给 `readWrite` 而非 `readOnly` 正是因为最后一条——只读的话枚举和切换都正常,等到写知识才 EPERM。按 appId 限定(不是整个角色库根):别的 bot 的角色目录、以及其中别的用户的私有角色,仍在白名单外。两道收口:① `botmux-roles` 与 `<appId>` 这**最后两段各自必须是真目录**,任一段是符号链接就不产生规则——否则被预先摆成指向 `~/.ssh` 或别的 bot 角色库的链接时,跟随解析会把链接目标当成本 bot 的子树授 rw(更上层如 `$HOME` 允许是链接,且必须 realpath,否则 canonical 匹配会 fail-open);② 任何 deny(baseline / 机主 `sandboxPaths.deny` / mandatory)覆盖该子树时这条规则**整条不产生**——source rank 只裁同路径冲突,否则更深的 internal rw 会在被 deny 的库根上重新开个洞。

两件明确不在射程内、也不该只为这条规则加固的:**TOCTOU**(校验后到 spawn/bind 前把目录换成链接)与**挂载点**(末段是 bind/FUSE 挂载点仍算真目录)。两者都需要宿主级写权限,而拿到宿主级写权限的人本来就能改 `bots.json` 关掉沙盒;且策略里每条路径规则(`workingDir`、`botHome`、`cliDataPaths`…)都在同一时点做一次性检查,同样成立。

## botmux send 中转(关键)

`botmux send` 原本**直连飞书**(读 `bots.json` 拿密钥)。沙盒里没有 `bots.json`,所以:
Expand Down
46 changes: 45 additions & 1 deletion src/adapters/cli/fs-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ export interface FsPolicyContext {
sessionId?: string;
/** This bot's BOT_HOME (`<botmuxHome>/bots/<appId>`) — always readWrite. */
botHome: string;
/** This bot's OWN role-library subtree (`<roleLibraryRoot>/<appId>`) — readWrite.
* Optional: absent when the subtree does not exist (bot never used roles) or
* when building a non-session policy. See the emission site for why the whole
* subtree — not just the active role dir — has to be writable. */
roleLibrarySubtree?: string;
/** True when the CLI's data root is redirected into BOT_HOME
* (CLAUDE_CONFIG_DIR / CODEX_HOME). False → cliDataPaths are exposed rw. */
redirectedCliData: boolean;
Expand Down Expand Up @@ -360,7 +365,8 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {
for (const p of paths ?? []) candidates.push({ path: p, access, source });
};

candidates.push(...(ctx.platform === 'darwin' ? darwinBaseline(ctx.homeDir) : linuxBaseline(ctx.homeDir)));
const baseline = ctx.platform === 'darwin' ? darwinBaseline(ctx.homeDir) : linuxBaseline(ctx.homeDir);
candidates.push(...baseline);

// Adapter-declared surfaces.
push(ctx.execPaths, 'readOnly', 'adapter');
Expand All @@ -369,6 +375,44 @@ export function buildFsPolicy(ctx: FsPolicyContext): FsPolicy {

// botmux internals.
push([ctx.workingDir, ctx.botHome], 'readWrite', 'internal');
// Own role-library subtree (`~/botmux-roles/<self>`). workingDir alone covers
// only the ACTIVE role dir, which silently disables the whole role system
// under sandbox: 「切换角色/有哪些角色」enumerates the sibling role dirs and
// reads each `.botmux-dir.json`, 「新建角色」writes `users/<openId>/<slug>/`
// and copies the library-root `_role-protocol.md` into it, and after a switch
// 「沉淀知识」writes `knowledge/` + `.botmux-dir.json` in the NEW role dir —
// all of them outside the pre-switch workingDir. readWrite (not readOnly) for
// that last reason: a read-only grant looks fine through switch+enumerate and
// only EPERMs later, at the knowledge write.
// Scoped to `<appId>` on purpose — NOT the shared library root: a sibling
// bot's roles (and its users' private role dirs) stay denied by construction,
// so cross-bot read isolation is preserved. (Note this is deliberately
// NARROWER than `validateRoleLibraryPath`, which only requires the target to
// be somewhere under the library root; a cross-bot switch now fails at the fs
// layer even if the daemon-side check passes.)
// …and suppressed entirely when ANY deny — baseline, owner, or mandatory —
// already covers it: source rank only settles conflicts on the SAME path, so a
// shallower `deny: ["~/botmux-roles"]` would otherwise be silently re-opened
// at `<appId>` by this deeper internal rule (longest-prefix-wins). An owner who
// denies the library must get a denied library, not a hole in it. Baseline is
// in the list defensively: a canonical `<library>/<appId>` cannot normally land
// inside a crown-jewel deny, but "cannot normally" is not a check.
const roleLibDenied = (raw: string) => {
const p = normalizeFsPath(raw);
if (!p) return true; // unusable path → no rule (mergeFsRules would drop it anyway)
return [
...baseline.filter(r => r.access === 'deny').map(r => r.path),
...(ctx.userPaths?.deny ?? []),
...(ctx.mandatoryDenyPaths ?? []),
]
.map(normalizeFsPath)
.some((d) => !!d && coversPath(d, p));
};
push(
ctx.roleLibrarySubtree && !roleLibDenied(ctx.roleLibrarySubtree) ? [ctx.roleLibrarySubtree] : [],
'readWrite',
'internal',
);
push(ctx.outbox ? [ctx.outbox] : [], 'readWrite', 'internal');
push(ctx.extraWritePaths, 'readWrite', 'internal');
push(ctx.readonlyRoots, 'readOnly', 'internal');
Expand Down
56 changes: 54 additions & 2 deletions src/core/role-library.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,64 @@
import { realpathSync, statSync } from 'node:fs';
import { lstatSync, realpathSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { basename, dirname, join } from 'node:path';

/** 角色库根:v0 固定约定,不做配置。 */
export function roleLibraryRoot(): string {
return join(homedir(), 'botmux-roles');
}

/** appId 必须是「单段目录名」:它要被拼进沙盒 readWrite 白名单路径,含 `/` 或
* `.`/`..` 的值会被 join/realpath 归一到**角色库之外**(`join(root, '../../.ssh')`
* = `~/.ssh`),把 rw 授给任意目录。bots.json 是机主自己写的,但手改/拼错不该
* 升级成一次沙盒逃逸。 */
const APP_ID_SEGMENT_RE = /^(?!\.{1,2}$)[A-Za-z0-9_.-]+$/;

/**
* 该 bot 自己的角色库子树(`<角色库根 realpath>/<appId>`)——沙盒 readWrite 白名单用。
* 任何一步不满足就返回 null:宁可不产生规则(角色系统不可用,机主看得见查得到),
* 也不能把 readWrite 授到角色库之外。
*
* 三道校验,缺一不可:
* 1. appId 形状(单段目录名)——见 APP_ID_SEGMENT_RE。
* 2. 只 realpath 角色库根的**父目录**,然后 `botmux-roles` 与 `<appId>` 这**最后两段
* 各自 lstat、必须是真目录**。为什么这么切:沙盒两个引擎都按 canonical 路径匹配,
* `$HOME` 本身是符号链接的机器(`/home/u` → `/data00/home/u` 这一类)不归一会静默
* fail-open——所以上层中间段必须 realpath、也允许它们是链接;但最后两段一旦跟链,
* 「授权本 bot 的角色库」就变成「授权链接指向的任意目录」。
* 3. 因此末两段用 **lstat 而不是 stat/realpath**:若 `<root>` 或 `<root>/<appId>` 在
* spawn 前已被摆成指向 `~/.ssh`、`~/.botmux` 或**另一个 bot 的角色库**的符号链接,
* 跟随解析会把链接目标当成本 bot 的子树直接授 rw —— 任意目录读写 + 跨 bot 越权。
* 末两段确定不是链接、上层已 realpath,返回值天然是 canonical 路径,调用方不得再
* realpath(再跟一次就把这道校验作废了)。
*
* 明确不在本函数(也不在本文件)射程内的两件事,都需要宿主级写权限,而拿到宿主级写
* 权限的人本来就能直接改 bots.json 关掉沙盒 —— 且这两条对策略里**每一条**路径规则
* (workingDir、botHome、cliDataPaths…全都在同一时点做一次存在性检查)都同样成立,
* 只为这一条规则加固属于安全戏剧:
* - TOCTOU:lstat 之后、真正 spawn/bind 之前把目录换成符号链接。路径型沙盒(Seatbelt
* 吃路径字符串、bwrap 吃 bind 源)无法靠持 fd 关闭这个窗口。
* - mount point:末段是 bind/FUSE 挂载点时仍是「真目录」,能把挂载目标整棵授出去。
*
* 另一件既有遗留:大小写不敏感卷上两个仅大小写不同的 appId 指向同一目录。那是角色库
* 按 appId 分目录这个布局本身的性质(不开沙盒也一样共享),要治得在 bot 配置加载期按
* 文件系统身份拒绝碰撞。
*/
export function roleLibrarySubtree(appId: string, rootOverride?: string): string | null {
if (typeof appId !== 'string' || !APP_ID_SEGMENT_RE.test(appId)) return null;
const root = rootOverride ?? roleLibraryRoot();
const realDir = (p: string): string | null => {
try {
const st = lstatSync(p);
return st.isSymbolicLink() || !st.isDirectory() ? null : p;
} catch { return null; }
};
let parentReal: string;
try { parentReal = realpathSync(dirname(root)); } catch { return null; }
const rootPath = realDir(join(parentReal, basename(root)));
if (!rootPath) return null;
return realDir(join(rootPath, appId));
}

/** 文件系统身份包含判断(dev+ino):从 childReal 逐级向上,某祖先与 rootReal 同一目录即包含。
* 不依赖字符串大小写语义——大小写敏感/不敏感卷、darwin/linux 行为一致。
* childReal === rootReal 时首个检查从其父目录开始,天然拒绝根本身。 */
Expand Down
8 changes: 8 additions & 0 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths } from './adapters/cli/fs-policy.js';
import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, type PersistentBackendType } from './core/persistent-backend.js';
import { readProcessStartIdentity } from './core/session-marker.js';
import { roleLibrarySubtree } from './core/role-library.js';
import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText, type TranscriptEvent } from './services/claude-transcript.js';
import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
import { shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit, type BridgeSendMarker } from './services/bridge-fallback-gate.js';
Expand Down Expand Up @@ -7581,6 +7582,13 @@ async function spawnCli(
currentAppId: cfg.larkAppId,
sessionId: cfg.sessionId,
botHome: canonical(ownBotHome!),
// Own role-library subtree. roleLibrarySubtree() does the existence +
// canonicalization + "must be a real dir, not a symlink" checks itself
// (deliberately NOT via keepExisting: its realpath would follow a planted
// link and hand rw to the target). A bot that never used roles simply
// gets no rule, and a library created after spawn only takes effect for
// the next session — bwrap cannot bind a nonexistent source anyway.
roleLibrarySubtree: roleLibrarySubtree(cfg.larkAppId) ?? undefined,
redirectedCliData: willRedirectCliData,
cliDataPaths: willRedirectCliData ? undefined : keepExisting([
cliAdapter.claudeDataDir,
Expand Down
51 changes: 51 additions & 0 deletions test/fs-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,57 @@ describe('buildFsPolicy', () => {
expect(accessForPath(p.rules, '/Users/u/.botmux/data/attachments/cli_other/m1/f.pdf').access).toBe('none');
});

it('own role-library subtree is rw (enumerate/switch/create roles + post-switch knowledge writes); sibling bots’ libraries stay uncovered', () => {
const p = buildFsPolicy(ctx({ roleLibrarySubtree: '/Users/u/botmux-roles/cli_self' }));
// library-root protocol file the 新建角色 flow copies into each new role dir
expect(accessForPath(p.rules, '/Users/u/botmux-roles/cli_self/_role-protocol.md').access).toBe('readWrite');
// sibling role dirs 「切换角色」 enumerates, and their display-name metadata
expect(accessForPath(p.rules, '/Users/u/botmux-roles/cli_self/shared/pm/.botmux-dir.json').access).toBe('readWrite');
// 「新建角色」 writes under users/<openId>/<slug>/
expect(accessForPath(p.rules, '/Users/u/botmux-roles/cli_self/users/ou_x/xhs-ops/CLAUDE.md').access).toBe('readWrite');
// 「沉淀知识」 writes knowledge/ in whatever role is active AFTER a switch —
// this is why the grant is rw, not ro (a ro grant only EPERMs at this step).
expect(accessForPath(p.rules, '/Users/u/botmux-roles/cli_self/shared/pm/knowledge/INDEX.md').access).toBe('readWrite');
// scoped per-appId: another bot's library (incl. its users' private roles)
// stays denied by construction, and the shared root itself is not granted.
expect(accessForPath(p.rules, '/Users/u/botmux-roles/cli_other/shared/default/CLAUDE.md').access).toBe('none');
expect(accessForPath(p.rules, '/Users/u/botmux-roles/cli_other/users/ou_y/secret/CLAUDE.md').access).toBe('none');
expect(accessForPath(p.rules, '/Users/u/botmux-roles').access).toBe('none');
// the owner can still lock it back down: a user rule outranks the internal
// grant at the same path (user > internal), and a nested deny wins by depth.
const off = buildFsPolicy(ctx({
roleLibrarySubtree: '/Users/u/botmux-roles/cli_self',
userPaths: { deny: ['/Users/u/botmux-roles/cli_self'] },
}));
expect(accessForPath(off.rules, '/Users/u/botmux-roles/cli_self/shared/pm/CLAUDE.md').access).toBe('deny');
});

it('an ANCESTOR deny (owner or mandatory) suppresses the role-library grant — longest-prefix-wins must not punch a hole in it', () => {
// source rank only settles same-path conflicts; without the suppression the
// deeper internal rw would re-open <appId> under a denied library root.
for (const key of ['userPaths', 'mandatoryDenyPaths'] as const) {
const p = buildFsPolicy(ctx({
roleLibrarySubtree: '/Users/u/botmux-roles/cli_self',
...(key === 'userPaths'
? { userPaths: { deny: ['/Users/u/botmux-roles'] } }
: { mandatoryDenyPaths: ['/Users/u/botmux-roles'] }),
}));
expect(p.rules.some(r => r.path === '/Users/u/botmux-roles/cli_self')).toBe(false);
expect(accessForPath(p.rules, '/Users/u/botmux-roles/cli_self/shared/pm/CLAUDE.md').access).toBe('deny');
}
// baseline deny counts too (defence-in-depth: a canonical library path
// shouldn't be able to land inside a crown jewel, but that's not a check).
const inJewel = buildFsPolicy(ctx({ roleLibrarySubtree: '/Users/u/.ssh/roles/cli_self' }));
expect(inJewel.rules.some(r => r.path === '/Users/u/.ssh/roles/cli_self')).toBe(false);
expect(accessForPath(inJewel.rules, '/Users/u/.ssh/roles/cli_self/x').access).toBe('deny');
});

it('no role-library rule at all when the subtree is absent (bot never used roles)', () => {
const p = buildFsPolicy(ctx());
expect(p.rules.some(r => r.path.includes('botmux-roles'))).toBe(false);
expect(accessForPath(p.rules, '/Users/u/botmux-roles/cli_self/shared/default/CLAUDE.md').access).toBe('none');
});

it('user paths take precedence and support nested deny', () => {
const p = buildFsPolicy(ctx({
userPaths: {
Expand Down
Loading