Skip to content

fix(sandbox): oncall 文件沙盒下 auto-worktree 失败改为 fail-closed,杜绝回退真实目录 - #921

Merged
deepcoldy merged 2 commits into
deepcoldy:masterfrom
ITcathyh:fix-sandbox-escape
Oct 14, 2026
Merged

deepcoldy merged 2 commits into
deepcoldy:masterfrom
ITcathyh:fix-sandbox-escape

Conversation

@ITcathyh

@ITcathyh ITcathyh commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

优先级:P0

根因

文件沙盒会把工作目录以读写方式绑定到宿主机,auto-worktree 是避免 Agent 写入真实项目的隔离层。此前 auto-worktree 创建失败时会静默回退真实默认目录,导致沙盒会话的新文件直接落入真实项目。

改动

  • 本地文件沙盒实际生效时,auto-worktree 失败改为抛出 AutoWorktreeFailClosedError,关闭挂起会话并提示修复默认目录或通过 /repo 选择目录。
  • fail-closed 判定与 worker 的真实开关对齐,覆盖 config.sandbox、legacy readIsolation 和 BOTMUX_SANDBOX=1。
  • riff 是远端执行后端,不会启动本地 CLI 或写入本地工作目录,因此从整个本地沙盒 union 中豁免;沙盒关闭时仍保持原有降级行为。
  • 补齐 pendingRepo 在 worktree 创建期间被 notifier 接管的护栏,避免迟到的失败处理误关新会话。

影响面

涉及 auto-worktree 启动路径及本地 sandbox/readIsolation 会话;普通非沙盒会话继续允许降级,riff 远端后端行为不变。PTY/Tmux 的后续 worker 运行路径未改动。

验证

  • 新增 sandbox、legacy readIsolation、riff + sandbox、riff + BOTMUX_SANDBOX=1 以及接管竞态回归。
  • pnpm build 通过。
  • default-worktree、card-handler-repo-select、riff-sandbox-bypass、sandbox、dashboard-create-session 等 8 个测试文件 188 项通过。
  • trigger-session、notifier 等 6 个测试文件 103 项通过。

维护者跟进

为什么重写成一个提交

PR 分出去之后,master 已经前进了 900 多个提交:会话存储、文件沙盒(新增 off / oncall / scratch 三态)、remote-runner 后端都有大改。原来 7 个提交逐个 rebase 冲突太多,所以把它们合成一个提交,再按 master 现状重新适配。原作者保留 Co-authored-by 署名。

三处设计调整

  1. gate 改用三态共享解析器 resolveLocalSandboxMode(src/adapters/backend/sandbox.ts):在 resolveSandboxMode 外面包一层 localSandboxApplies 远端豁免。worker 的 sandboxMode 和 auto-worktree gate 调用同一个函数,以后不会再出现两边判定不一致。只有解析结果是 oncall 时才 fail-closed。远端后端(riff / remote-runner / 可证明全远端的 mojo)被豁免,覆盖 config、legacy readIsolation 和 BOTMUX_SANDBOX 全部来源。sandbox 值或 BOTMUX_SANDBOX 非法、bot 不存在时,gate 按 fail-closed 处理。
  2. 去掉 Session.readIsolation 新字段:freezeSessionSandboxDecision 直接沿用 master 中 forkWorker 原有的冻结逻辑:三态、legacy readIsolation 并入 oncall、scratch 选项、networkPolicy。SpawnOpts 的 readIsolation 保持 master 原样。原因是 PUT /api/bot-read-isolation 在该 bot 有任何活跃会话时都会拒绝,而 pendingRepo 占位会话就在 activeSessions 里,所以构建途中这个开关切不动。没有这道保护的 PUT /api/bot-sandbox 由冻结来兜住。
  3. gate 改用会话自己的 cliId 和后端判断(sandboxSnapshot):只有 CLI 本身是远端 CLI 时,reconcileRiffBackendType 才保留远端后端。会话自选本地 CLI 时,如果按 bot 级的 cliId 判断,会被误判成 riff 豁免。

有意保留的两处设计

  • scratch 放行(照常回退):scratch 是全根 COW overlay / APFS clone,所有写入(包括 cwd)都落进一次性的会话层,宿主不会被改动,回退到真实默认目录也不会污染真实项目。会写穿到真实目录的只有 oncall(DIRECT 读写直绑)。
  • 非 /tw 的沙盒拒绝直接关闭挂起会话:auto-worktree 的入口包括新话题、dashboard 创建和 webhook/trigger,后两者没有人会回来执行 /repo 自救,挂着不关会越积越多。显式 /tw 是用户主动发起的,保持 master 原有语义:会话停在 pendingRepo,并提示用 /repo 选择仓库。

已处理的 review 意见

  • 全远端 mojo 被误拒:已复用 worker 的同一个解析器,mojo 配置按 worker 的方式构造(buildEffectiveMojoConfig + botInjectedEnv)。
  • apiOnly / 无通道会话的强制 readIsolation:master fix(sandbox): 去掉无飞书通道会话的强制文件读隔离,改为跟随本地 sandbox 配置 #899 已经去掉了强制隔离,不需要处理,测试已覆盖。
  • 拒绝后的清理和接管竞态:await notify 前后各检查一次 pendingRepo,测试已覆盖。
  • 构建途中 sandbox off→on 的 fail-open 窗口:runAutoWorktreeCommit 在第一个 await 之前就冻结,gate 和 fork 用同一份快照。新增回归测试:构建途中翻转 live 配置后,断言 fork 时 session.sandbox 仍为 false。

影响面

  • 涉及公共层 core/worker-pool.ts(冻结逻辑抽成函数,行为不变)、worker.ts(sandboxMode 改走共享解析器,结果与原逻辑等价)、adapters/backend/sandbox.ts。
  • 行为只在以下组合下变化:auto-worktree 默认目录 + 本地 oncall 沙盒 + worktree 创建失败,从原来的回退改为拒绝。非沙盒、scratch、远端后端、显式 /tw 的行为都不变。与 CLI 种类无关,PTY 和持久后端(tmux 等)走同一条路径。

测试

  • bun run build:通过(包括 tsc、typecheck:scripts、typecheck:test-mocks、dist 审计)。
  • bun run vitest run --project unit default-worktree session-sandbox-freeze riff-sandbox-bypass card-handler-repo-select api-only-mode-wiring sandbox-mode backend-gate read-isolation trigger-session dashboard-create-session codex-notifier pending-repo scratch:36 个文件、828 项全部通过。
  • 在更大的 sandbox 相关集合里,plugin-registry-sandbox-read 和 plugin-mcp-sandbox 有失败:前者在未改动的 master 上同样失败,后者在 canonical checkout 里能通过,与本改动无关。
  • 还没做飞书 live 验证。建议验证:oncall bot + 非 git 默认目录时被拒绝并收到提示;scratch 和非沙盒 bot 照常回退。

🤖 Generated with Claude Code

@ITcathyh
ITcathyh requested a review from deepcoldy as a code owner August 18, 2026 14:34

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 13a8c62ecc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/services/default-worktree.ts Outdated
c.cliId, undefined, c.backendType, config.daemon.backendType,
) === 'riff';
if (riff) return false;
return c.sandbox === true || c.readIsolation === true || process.env.BOTMUX_SANDBOX === '1';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include API-only isolation in fail-closed detection

For an apiOnly: true bot with no explicit sandbox flags, this condition leaves failClosed false, but forkWorker later forces readIsolation for every no-transport session in src/core/worker-pool.ts:6859-6860. If auto-worktree creation fails during a Dashboard-created session, this helper therefore falls back to the real default directory and launches the forced sandbox there, preserving the exact real-project write exposure this change is intended to prevent; include the no-transport/API-only isolation condition here as well.

AGENTS.md reference: AGENTS.md:L68-L68

Useful? React with 👍 / 👎.

Comment on lines +887 to +889
try {
await notify(t('worktree.auto_fail_closed', { dir: baseDir, error: e.reason }, localeForBot(larkAppId)));
} catch { /* notices are best-effort — never mask the refusal */ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make refusal cleanup atomic with the takeover check

When pendingRepo is still true at the check above but a notifier adoption consumes it while this awaited network notification is in flight, execution resumes and removes the now-live session from activeSessions and marks its durable record closed. This reopens the takeover race the preceding guard is meant to prevent and leaves the adopted worker orphaned; perform the cleanup before awaiting the notice or recheck pendingRepo immediately afterward under the relevant admission/ownership guard.

Useful? React with 👍 / 👎.

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个修复 🙏 方向完全正确——文件沙盒 DIRECT 模式把 workingDir 读写直绑真实宿主目录,auto-worktree 是唯一的隔离屏障,失败时静默回退真实目录确实是逃逸,改成 fail-closed 是对的。构建通过、测试覆盖也很扎实。

自动评审时发现一处会在合入当前 master 时立即触发的问题,供参考(这是初步的自动评审意见,最终以维护者审阅为准):

failClosed 谓词只豁免了 backend === 'riff',漏了 fully-remote 的 mojo。

PR 的 base 早于 mojo 后端(#803)合入,在那个 base 上 worker 的豁免就等价于「非 riff」,所以写死 === 'riff' 当时是精确的。但当前 master 已经有 mojo,worker 侧的真实判定是 !localSandboxApplies(backend, mojoConfig)——它会同时豁免 riff 和「可证明 fully-remote」的 mojo。

把本 PR 合到当前 master 后实测:一个 mojo { cloud: true }(fully-remote)+ sandbox 的 bot,在非 git 默认目录上会被 failClosed 误拒,但 worker 侧 localSandboxApplies 判定它 本就不施加本地沙盒(远端执行,没有本地进程会写真实目录)。这正是 riff 豁免要防的「远端后端被误 brick」,只是换成了 mojo。

建议修法(避免手抄 worker 的 union 再次漂移):把 worker 那段 sandbox 判定抽成一个依赖轻的共享纯谓词,failClosed 与 worker 复用同一份,形状类似:

localSandboxApplies(effectiveBackend, effectiveMojoConfig) && (sandbox || readIsolation || <no-transport 强制隔离> || BOTMUX_SANDBOX)

两点要点:① 远端豁免要包住整个 union(含 env 强制项);② mojo 的远端证明不止 cloud/localDaemon,还包含顶层 wrapperCli 和合并后的 top-level/mojo env——所以不能简单 === 'mojo' 一律豁免(本地 mojo 仍需 fail-closed),要传完整的 effective mojo 配置。

另外 @ 的自动评审已在 PR 里留了两条 inline(apiOnly / 无 Lark transport 会话会被 forkWorker 强制 readIsolation,同样要纳入谓词;以及 refusal cleanup 在 await notify() 之后不是原子的、接管竞态下可能误关已被接管的会话),可一并参考。再次感谢!

@ITcathyh
ITcathyh force-pushed the fix-sandbox-escape branch from 13a8c62 to 2b5547f Compare August 19, 2026 01:04
@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见(以维护者最终审阅为准):R2 的共享谓词修复了静态判定漂移,但目前仍有一个可达的 fail-open 时间窗口。

maybeCreateDefaultWorktree 在 src/services/default-worktree.ts 中于任何 git/通知 await 之前,从 live bot config 一次性计算 failClosed。但 pending session 的沙盒决策尚未冻结;真正 fork 时,src/core/worker-pool.ts 才在 ds.session.sandbox === undefined 时读取届时的 botCfg.sandbox。与此同时,Dashboard 的 PUT /api/bot-sandbox 可在 worktree 构建期间更新 live config,且该路径没有 bot-turn mutation/admission fence。

最小运行时复现(当前 master 合并 PR head):

  1. pty + auto-worktree + sandbox:false,默认目录为空 git repo(worktree 必然创建失败);
  2. 在 auto_creating notice 在途时调用生产路径 updateBotSandbox(app, true);
  3. helper 仍按旧快照降级返回真实 baseDir;随后 worker 将采用的新配置使 localSandboxRequested(...) === true。

因此 gate 可按旧值允许回退,而 worker 按新值在真实项目目录开启本地文件沙盒,原本要堵住的写入逃逸仍可能发生。建议在 pending session 建立时冻结 worker 将采用的安全决策输入,并让 auto-worktree gate 与 fork 都消费该 session 快照;仅在 fallback 后重读 live config 仍会留下 recheck→fork 的 await 窗口。建议补一条 worktree/notice in-flight 时 sandbox off→on 的回归测试。

@ITcathyh
ITcathyh force-pushed the fix-sandbox-escape branch from 2b5547f to 21f415e Compare August 19, 2026 08:45
deepcoldy added a commit to ITcathyh/botmux that referenced this pull request Oct 13, 2026
oncall 文件沙盒(bwrap/Seatbelt DIRECT 模式)把 workingDir 读写直绑宿主真实目录,
auto-worktree 是阻止 Agent 写入真实项目的唯一隔离层。此前 worktree 创建失败(非 git /
git 失败)会静默回退真实默认目录,沙盒会话的写入直接落进真实项目。

- default-worktree:新增 AutoWorktreeFailClosedError 与 autoWorktreeMustFailClosed;
  本次 spawn 实际会跑在本地 oncall 沙盒下时,失败直接拒绝而非回退。scratch(全根 COW,
  宿主零改动)与远端后端(riff / remote-runner / 可证明全远端的 mojo)不受影响、照常回退;
  显式 /tw(force)保持原有 fail-closed 语义(会话留在 pendingRepo)。
- sandbox:抽出 resolveLocalSandboxMode 共享解析器(localSandboxApplies 远端豁免包住
  整个 resolveSandboxMode),worker 的 sandboxMode 与 gate 复用同一函数,避免再次漂移;
  非法 sandbox 值 / BOTMUX_SANDBOX 在 gate 侧按 fail-closed 处理。
- worker-pool:把 forkWorker 内联的沙盒决策冻结抽成 freezeSessionSandboxDecision;
  runAutoWorktreeCommit 在任何 await 之前先冻结,gate 与后续 fork 消费同一快照,关闭
  「构建途中 PUT /api/bot-sandbox off→on,gate 按旧值回退、fork 按新值在真实目录开沙盒」
  的 fail-open 窗口。gate 同时使用会话自身的 cliId / backend stamp 解析有效后端。
- card-handler:fail-closed 时关闭未启动的挂起会话并提示修复方式;await 通知前后两次
  检查 pendingRepo,避免误关被 notifier 接管的会话。

基于原 PR deepcoldy#921 的实现 rebase 到当前 master 并适配三态沙盒 / remote-runner 等新形态。

Co-authored-by: huangyuhang.edu <huangyuhang.edu@bytedance.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@deepcoldy deepcoldy changed the title fix(sandbox): 文件沙盒开启时 auto-worktree 失败改为 fail-closed,杜绝回退真实目录 fix(sandbox): oncall 文件沙盒下 auto-worktree 失败改为 fail-closed,杜绝回退真实目录 Oct 13, 2026
@deepcoldy

Copy link
Copy Markdown
Owner

已 rebase 到最新 master。因为冲突较多,重写成了一个提交,原作者保留 Co-authored-by 署名。主要调整:gate 改用与 worker 共享的三态解析器 resolveLocalSandboxMode,只有 oncall 时 fail-closed,scratch 和远端后端(riff / remote-runner / 全远端 mojo)照常回退;在 pending 会话建立时先冻结沙盒决策,关闭构建途中切换沙盒开关造成的 fail-open 窗口,并补了回归测试;gate 改用会话自己的 cliId 和后端判断;去掉了 Session.readIsolation 新字段。bun run build 通过,相关 36 个测试文件 828 项全部通过,飞书 live 验证还没做。详细说明见 PR 描述中的「维护者跟进」一节。感谢原作者的修复!

🤖 Generated with Claude Code

@deepcoldy

Copy link
Copy Markdown
Owner

自动评审初步意见(以维护者最终审阅为准)。已基于最新 master(4be4e6f6a)本地 rebase 复审新提交 2f5da62。

历史评审意见核对:均已解决。 三态共享解析器 resolveLocalSandboxMode(worker 与 gate 同一函数,远端豁免包住整个 union)、pending 会话建立时冻结沙盒决策(gate 与 ≤30s 后的 fork 消费同一快照)、fail-closed 关挂起会话 + 通知前后双重 pendingRepo 检查防误关接管,均与设计一致;变异验证(删 freeze 调用 / gate 改读 live)能精确打挂对应测试。本地 rebase 后 build 绿,PR 四个测试文件 133 测全绿,沙盒/mojo blast-radius 约 400 测通过(其中 mojo-launcher-env-quarantine 2 个红在未改动的 pristine master 上同样红,是 root 下 EACCES 环境问题)。

两点新意见 + 一个 rebase 注意项:

  1. (P2)冻结覆盖了沙盒三态,但 gate 的 mojo「远端证明」输入仍是两个时刻读 live,残留一个同族 TOCTOU 残口。 gate 在构建前从当时 live 配置构造 effective mojo config(mojo 身份块 + 合并后的顶层 env + 顶层 wrapperCli),而 fork 在构建后重新构造;新会话的 mojoIdentity 要到 forkWorker 内才首次冻结(daemon 新建路径不冻结),mojo 块与 /api/bot-env、wrapperCli 的写入路径(applyConfigField)对 pending/active 会话没有 fence。用生产函数探针实证:mojo{cloud:true} + sandbox:oncall + 非 git 默认目录时,T0 gate 判可证远端 → 放行回退真实目录;构建途中把 cloud 翻成 false(或注入带 PATH 的 env),T1 worker 解析为本地 oncall → 在真实默认目录上启用本地沙盒,即本 PR 要消除的写入逃逸(反向翻转只造成误拒,不逃逸)。单独翻 backendType 不可达(mojo cli 强制配对远端后端,改 cliId 的路由有 fence)。建议修法(二选一或组合):① pending 建立时把 mojoIdentity 与证明相关输入(合并 env 键 + wrapperCli)一并冻结,两边消费同一份;② 把 gate 的 fail-closed 判定结果随会话冻结,fork 侧对 pendingRepo 会话只许加强、不许减弱(union),保证不会在 gate 放行的真实目录上事后补开本地沙盒。

  2. (P3,测试加固) default-worktree.test.ts 的两条 "fail-open window" 用例是在第一条 notify 回调里才翻 live 配置,此刻 gate 的一次性 verdict 已经算出,所以即使把 gate 改成无视 sandboxSnapshot、直接读 live,两条用例仍然全绿。建议补「进入 gate 时 snapshot 与 live 已分歧」的用例:frozen ON/live OFF 必须拒绝、frozen OFF/live ON 必须回退。(card-handler 层的 freeze 接线/翻转集成测是有效的,变异能精确打挂。)

  3. rebase 注意项: 当前 head 对最新 master 为 CONFLICTING。冲突本身只有 card-handler.ts / worker.ts 两处 import 行(keep-both 即可),但有一处语义适配:master 的 refactor: 全仓死代码与杂物清理并开启 noUnusedLocals(约 −11k 行) #1849 已把 publishClosedSessionPatch 当死导入从 card-handler 的 session-activity 导入中删除,而本 PR 新增了它的真实调用点——rebase 时需要把它加回该导入,否则 tsc 报 TS2304;worker.ts 一侧则是随主干删掉不再使用的 resolveSandboxMode / ScratchPathMapping 导入。以上适配本地已验证 build + 测试全绿,未推送、未改作者分支。

@deepcoldy

Copy link
Copy Markdown
Owner

补充上一条的 P2:该 fail-open 残口已由第二位评审用独立运行时探针复现,不是纯静态推断——直接调用生产函数 resolveLocalSandboxMode:

  • T0(构建前 gate):live 配置 mojo{cloud:true} + oncall + 非 git 默认目录 → 解析为 off → failClosed=false → 放行回退真实目录;
  • 构建途中把 cloud 翻为 false(mojo env 身份在 T1 fork 内才首次冻结,取的是届时 live);
  • T1(fork):解析为 oncall → worker 在真实默认目录上启用本地沙盒,写入落进真实项目 = 逃逸确认。

即合入前需:① 修 P2(pending 建立时连 mojo 身份与证明输入一并冻结,或冻结 gate 结论、fork 侧只许加强不许减弱);② 补 P3「进入 gate 时 snapshot 与 live 已分歧」两个方向的用例。

oncall 文件沙盒(bwrap/Seatbelt DIRECT 模式)把 workingDir 读写直绑宿主真实目录,
auto-worktree 是阻止 Agent 写入真实项目的唯一隔离层。此前 worktree 创建失败(非 git /
git 失败)会静默回退真实默认目录,沙盒会话的写入直接落进真实项目。

- default-worktree:新增 AutoWorktreeFailClosedError 与 autoWorktreeMustFailClosed;
  本次 spawn 实际会跑在本地 oncall 沙盒下时,失败直接拒绝而非回退。scratch(全根 COW,
  宿主零改动)与远端后端(riff / remote-runner / 可证明全远端的 mojo)不受影响、照常回退;
  显式 /tw(force)保持原有 fail-closed 语义(会话留在 pendingRepo)。
- sandbox:抽出 resolveLocalSandboxMode 共享解析器(localSandboxApplies 远端豁免包住
  整个 resolveSandboxMode),worker 的 sandboxMode 与 gate 复用同一函数,避免再次漂移;
  非法 sandbox 值 / BOTMUX_SANDBOX 在 gate 侧按 fail-closed 处理。
- worker-pool:把 forkWorker 内联的沙盒决策冻结抽成 freezeSessionSandboxDecision;
  runAutoWorktreeCommit 在任何 await 之前先冻结,gate 与后续 fork 消费同一快照,关闭
  「构建途中 PUT /api/bot-sandbox off→on,gate 按旧值回退、fork 按新值在真实目录开沙盒」
  的 fail-open 窗口。gate 同时使用会话自身的 cliId / backend stamp 解析有效后端。
- card-handler:fail-closed 时关闭未启动的挂起会话并提示修复方式;await 通知前后两次
  检查 pendingRepo,避免误关被 notifier 接管的会话。

基于原 PR deepcoldy#921 的实现 rebase 到当前 master 并适配三态沙盒 / remote-runner 等新形态。

Co-authored-by: huangyuhang.edu <huangyuhang.edu@bytedance.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner

已再次 rebase 到最新 master,并解决了与 #1849(死代码清理 / noUnusedLocals)的冲突:补回 card-handler 中 fail-closed 收尾所需的 publishClosedSessionPatch import,并删去 worker.ts 中已不再使用的 import。设计没有变化:oncall 时 fail-closed,scratch 和远端后端放行,沙盒决策在 pending 阶段冻结。typecheck 和 dist 审计通过,相关 36 个测试文件 829 项全部通过。

🤖 Generated with Claude Code

gate 在构建前判定「不施加本地沙盒」并回退真实默认目录后,mojo 远端证明等输入
仍可能在构建途中被改(如 cloud 翻为 false),fork 时 worker 解析为本地 oncall,
从而在真实目录上读写直绑。现在回退时把结论持久化到会话(autoWorktreeFallbackDir),
worker 在解析出 oncall 且 workingDir 即该目录时拒绝启动;off / scratch 不受影响。

同时补齐「进入 gate 时快照与 live 已不一致」两个方向的用例,并用复现场景写回归测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner

已追加提交 a37996c,针对上面两条意见:

P2(mojo 远端证明的 TOCTOU):gate 判定可以回退到真实默认目录时,会把这个结论持久化到会话上(Session.autoWorktreeFallbackDir),并在 fork 之前写入。fork 侧对这个结论只能加强、不能放宽:worker 用 resolveLocalSandboxMode 解析出本地 oncall,且 workingDir 正好是这个回退目录时,直接拒绝启动(autoWorktreeFallbackSandboxError),不会在真实目录上读写直绑。off 和 scratch 不受影响。这样一来,所有 gate 没法冻结的输入(mojo 身份块、合并后的 env、wrapperCli、BOTMUX_SANDBOX 等)在构建途中被改,都会被 fork 侧拦下,不用把这些输入逐项冻结。已按复现场景补了回归测试:mojo{cloud:true} + oncall + 非 git 目录 → gate 回退 → 构建途中 cloud 翻为 false → worker 解析出 oncall → guard 拒绝。同时加了接线测试:会话标记会在 fork 前持久化,forkWorker 会透传它,worker 会在 sandboxMode 之后抛错。

P3:补了「进入 gate 时快照和 live 已经不一致」两个方向的用例:快照 ON / live OFF 必须拒绝,快照 OFF / live ON 必须回退。变异验证:把 gate 改回读 live 后,这两条加上 scratch 快照用例共 3 条变红;去掉 worker 侧的 throw、去掉 card-handler 的标记写入,也会分别让对应的测试变红。

bun run build 通过,相关 36 个测试文件 837 项全部通过。

🤖 Generated with Claude Code

@deepcoldy

Copy link
Copy Markdown
Owner

自动评审跟进(初步意见,以维护者最终审阅为准):新提交 a37996c 已基于最新 master(3f5838f87,两提交,rebase 干净、build 通过),上一轮两点均已解决,本地复审未发现新问题。

P2(mojo 证明输入 TOCTOU 逃逸残口)— 已修。 采用了「fork 侧只许加强」方案:gate 在 T0 判定回退真实默认目录时,把该结论持久化到会话(Session.autoWorktreeFallbackDir,fork 前写盘);worker 在解析出 oncall 且本次 workingDir 与该目录(realpath 归一后)一致时直接拒绝启动,off / scratch / 其它目录 / 无标记均不受影响。该守卫后端无关——不仅堵住 mojo 云端证明在构建途中失效,任何「T0 不施加本地沙盒、T1 却在同一真实目录解析出 oncall」的输入漂移(env、wrapperCli、远端后端翻转)都被同一结论封住;worker 内部重启路径(...cfg 展开)也携带该标记。已核对:①targetSubdir 只存在于显式 /tw force 流,该流失败即抛错不回退,不会落标,无「cwd 是子目录导致比较失配」残口;②影子会话不继承该字段(符合语义);③持久化走整行 JSON,不会被字段表过滤;④拒绝抛错点与既有 spawnCli 启动校验(如 riff 配置、零注入)同形,走既有的启动失败呈现。

P3(测试缺口)— 已补。 新增「进入 gate 时快照与 live 已分歧」两个方向用例,并用复现场景写了 T0 回退 → 构建中 cloud 翻 false → T1 oncall 被拒的端到端回归;另含标记持久化/真 worktree 不落标/守卫纯函数(off/scratch/异目录/无标记放行)与接线测试。

变异验证(均在本地新基线上做、做完即还原):

  • gate 改成无视快照读 live → 精确打挂 2 条新增入口分歧用例;
  • fork 守卫改成恒放行 → 精确打挂「oncall 同目录拒绝」单测与 mojo 翻转复现用例,off/scratch/异目录两条保持绿;
  • card-handler 删掉落标 → 精确打挂标记持久化用例,「真 worktree 不落标」保持绿。

测试: PR 四测试文件 141 全绿;本地沙盒/mojo blast-radius 除 2 个在 pristine master 同样红的 root EACCES 环境用例外全绿;CI 十腿(build / bun-test / test×3 分片 / network-boundary / bun-binary×3)全绿。

@deepcoldy
deepcoldy merged commit 2037a17 into deepcoldy:master Oct 14, 2026
10 checks passed
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.44.0

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