From 1c76f65ea04b8f8b5c3a7d35211016766170779b Mon Sep 17 00:00:00 2001 From: xu4wang Date: Wed, 29 Jul 2026 00:06:14 +0700 Subject: [PATCH] =?UTF-8?q?fix(sandbox):=20=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E6=9C=AC=20bot=20=E8=A7=92=E8=89=B2=E5=BA=93?= =?UTF-8?q?=E5=AD=90=E6=A0=91=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=B2=99=E7=9B=92?= =?UTF-8?q?=E4=B8=8B=E8=A7=92=E8=89=B2=E7=B3=BB=E7=BB=9F=E6=95=B4=E4=BD=93?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开了 sandbox 的 bot 只有 workingDir(= 当前角色目录)在 readWrite 白名单里, 角色库的其余部分不在白名单 → deny-by-default 下「有哪些角色 / 切换角色 / 新建角色」全部 Operation not permitted: - 「有哪些角色 / 切换角色」要枚举兄弟角色目录、读各自 .botmux-dir.json - 「新建角色」要写 users///,并复制库根的 _role-protocol.md - 切换之后「沉淀知识」写的是新角色目录下的 knowledge/ 与 .botmux-dir.json buildFsPolicy 因此多一个 roleLibrarySubtree(`<角色库根>/`),按 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` 与 `` 各自 lstat、必须是真目录。任一段是链接就不产生 规则 —— 否则它被预先摆成指向 `~/.ssh`、`~/.botmux` 或**另一个 bot 的角色库** 时,跟随解析会把链接目标当成本 bot 子树直接授 rw:任意目录读写 + 跨 bot 越权。 返回值天然 canonical,调用方不得再 realpath(再跟一次就把校验作废)。 3. 任何 deny 覆盖即不产生规则:source rank 只裁**同路径**冲突,机主写 `deny: ["~/botmux-roles"]` 时更深的 internal rw 会按最长前缀胜重新开放 ``。现在 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 配置加载期按文件系统身份拒绝碰撞。 --- docs/file-sandbox.md | 4 +++ src/adapters/cli/fs-policy.ts | 46 +++++++++++++++++++++++++- src/core/role-library.ts | 56 ++++++++++++++++++++++++++++++-- src/worker.ts | 8 +++++ test/fs-policy.test.ts | 51 +++++++++++++++++++++++++++++ test/role-library.test.ts | 61 ++++++++++++++++++++++++++++++++++- 6 files changed, 222 insertions(+), 4 deletions(-) diff --git a/docs/file-sandbox.md b/docs/file-sandbox.md index 179293f78..048c1af59 100644 --- a/docs/file-sandbox.md +++ b/docs/file-sandbox.md @@ -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///` 并复制库根的 `_role-protocol.md`,切换后「沉淀知识」写的是**新**角色目录下的 `knowledge/`。给 `readWrite` 而非 `readOnly` 正是因为最后一条——只读的话枚举和切换都正常,等到写知识才 EPERM。按 appId 限定(不是整个角色库根):别的 bot 的角色目录、以及其中别的用户的私有角色,仍在白名单外。两道收口:① `botmux-roles` 与 `` 这**最后两段各自必须是真目录**,任一段是符号链接就不产生规则——否则被预先摆成指向 `~/.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`,所以: diff --git a/src/adapters/cli/fs-policy.ts b/src/adapters/cli/fs-policy.ts index 21022c44d..32b0b0091 100644 --- a/src/adapters/cli/fs-policy.ts +++ b/src/adapters/cli/fs-policy.ts @@ -72,6 +72,11 @@ export interface FsPolicyContext { sessionId?: string; /** This bot's BOT_HOME (`/bots/`) — always readWrite. */ botHome: string; + /** This bot's OWN role-library subtree (`/`) — 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; @@ -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'); @@ -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/`). 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///` + // 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 `` 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 `` 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 `/` 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'); diff --git a/src/core/role-library.ts b/src/core/role-library.ts index 5c7795bdd..07862fd2c 100644 --- a/src/core/role-library.ts +++ b/src/core/role-library.ts @@ -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>/`)——沙盒 readWrite 白名单用。 + * 任何一步不满足就返回 null:宁可不产生规则(角色系统不可用,机主看得见查得到), + * 也不能把 readWrite 授到角色库之外。 + * + * 三道校验,缺一不可: + * 1. appId 形状(单段目录名)——见 APP_ID_SEGMENT_RE。 + * 2. 只 realpath 角色库根的**父目录**,然后 `botmux-roles` 与 `` 这**最后两段 + * 各自 lstat、必须是真目录**。为什么这么切:沙盒两个引擎都按 canonical 路径匹配, + * `$HOME` 本身是符号链接的机器(`/home/u` → `/data00/home/u` 这一类)不归一会静默 + * fail-open——所以上层中间段必须 realpath、也允许它们是链接;但最后两段一旦跟链, + * 「授权本 bot 的角色库」就变成「授权链接指向的任意目录」。 + * 3. 因此末两段用 **lstat 而不是 stat/realpath**:若 `` 或 `/` 在 + * 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 时首个检查从其父目录开始,天然拒绝根本身。 */ diff --git a/src/worker.ts b/src/worker.ts index 911909e8b..0e3aba25c 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -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'; @@ -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, diff --git a/test/fs-policy.test.ts b/test/fs-policy.test.ts index d1ec4005d..a9d2b24cb 100644 --- a/test/fs-policy.test.ts +++ b/test/fs-policy.test.ts @@ -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/// + 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 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: { diff --git a/test/role-library.test.ts b/test/role-library.test.ts index e80b0b982..11d297a2a 100644 --- a/test/role-library.test.ts +++ b/test/role-library.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSyn import { homedir, tmpdir } from 'node:os'; import { join, relative } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { validateRoleLibraryPath } from '../src/core/role-library.js'; +import { roleLibrarySubtree, validateRoleLibraryPath } from '../src/core/role-library.js'; function setup() { const base = mkdtempSync(join(tmpdir(), 'rolelib-')); @@ -100,3 +100,62 @@ describe('validateRoleLibraryPath', () => { .toEqual({ ok: false, error: 'invalid_path_chars' }); }); }); + +describe('roleLibrarySubtree(沙盒白名单用)', () => { + it('真实目录 → 根 realpath 下的同名单段路径', () => { + const { root } = setup(); + mkdirSync(join(root, 'cli_abc123')); + expect(roleLibrarySubtree('cli_abc123', root)).toBe(join(realpathSync(root), 'cli_abc123')); + }); + it('拒绝任何会解析到角色库之外的 appId(分隔符 / .. / 空值 / 控制字符)', () => { + const { root } = setup(); + // join('/r', '../../.ssh') === '/.ssh' —— `..` 被 join 吃掉,normalizeFsPath + // 的 `..` 拦截够不到,所以必须在拼路径前就挡住。 + for (const bad of ['../../.ssh', '..', '.', 'a/b', '/abs', '', 'x\ny', 'a b']) { + expect(roleLibrarySubtree(bad, root)).toBeNull(); + } + expect(roleLibrarySubtree(undefined as unknown as string, root)).toBeNull(); + }); + it('子树是符号链接 → null(否则 realpath 会把链接目标当成本 bot 的子树授 rw)', () => { + const { base, root } = setup(); + // 库外的敏感目录(模拟 ~/.ssh),以及另一个 bot 的角色库 + const outside = join(base, 'secret'); mkdirSync(outside); + mkdirSync(join(root, 'cli_other', 'users', 'ou_y'), { recursive: true }); + symlinkSync(outside, join(root, 'cli_escape')); + symlinkSync(join(root, 'cli_other'), join(root, 'cli_crossbot')); + expect(roleLibrarySubtree('cli_escape', root)).toBeNull(); + expect(roleLibrarySubtree('cli_crossbot', root)).toBeNull(); + // 对照:同名真实目录照常放行 + mkdirSync(join(root, 'cli_real')); + expect(roleLibrarySubtree('cli_real', root)).not.toBeNull(); + }); + it('不存在、或是文件而非目录 → null', () => { + const { root } = setup(); + expect(roleLibrarySubtree('cli_missing', root)).toBeNull(); + writeFileSync(join(root, 'cli_file'), 'x'); + expect(roleLibrarySubtree('cli_file', root)).toBeNull(); + }); + it('角色库根不存在 → null', () => { + const { base } = setup(); + expect(roleLibrarySubtree('cli_x', join(base, 'no-such-root'))).toBeNull(); + }); + it('角色库根本身是符号链接 → null(末两段都不许跟链,否则整棵库被替换掉)', () => { + const { base, root } = setup(); + mkdirSync(join(root, 'cli_x')); + // 库外目录,里面也放一个同名 appId 目录:跟链的实现会返回它 + const fake = join(base, 'fake-roles'); mkdirSync(join(fake, 'cli_x'), { recursive: true }); + const link = join(base, 'roles-link'); + symlinkSync(fake, link); + expect(roleLibrarySubtree('cli_x', link)).toBeNull(); + }); + it('根之上的中间段是符号链接 → 照常放行,且返回 canonical 路径($HOME 本身是链接的机器)', () => { + // 只 realpath 根的父目录:/home/u → /data00/home/u 这类布局不能因此 fail-open, + // 也不能被误拒。这里用一个指向 base 的链接充当「符号链接的 $HOME」。 + const { base, root } = setup(); + mkdirSync(join(root, 'cli_x')); + const homeLink = join(mkdtempSync(join(tmpdir(), 'homelink-')), 'home'); + symlinkSync(base, homeLink); + expect(roleLibrarySubtree('cli_x', join(homeLink, 'botmux-roles'))) + .toBe(join(realpathSync(root), 'cli_x')); + }); +});