Skip to content

fix(claude): 加固跨目录恢复的软链边界 - #604

Merged
deepcoldy merged 1 commit into
masterfrom
fix/claude-resume-sync-symlink-hardening
Jul 26, 2026
Merged

fix(claude): 加固跨目录恢复的软链边界#604
deepcoldy merged 1 commit into
masterfrom
fix/claude-resume-sync-symlink-hardening

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

背景

syncClaudeResumeTargetToCwd 在 Claude-family 冷恢复、且 cwd 已变化时,会从 sibling project 桶挑最新 <session-id>.jsonl 并复制到当前 cwd 桶。沙盒内 CLI 可以写自己的 claudeDataDir,而同步函数运行在沙盒外 worker;原实现使用 statSync + copyFileSync,两端都会跟随软链:

  • source 是软链时,会把 claudeDataDir 外文件读入 transcript(读穿);
  • target 是软链时,会把 transcript 覆写到 claudeDataDir 外文件(写穿)。

这是复审已关闭的 #506 时发现的 master 独立问题;#506 的 cwd 恢复主问题已由 master 现有同步逻辑解决,本 PR 只移植其安全增量,不引入第二套 rescue/move 逻辑。

漏洞链路图

flowchart LR
  subgraph Sandbox["沙盒内:Claude dataDir 可写"]
    S["旧 cwd 桶<br/>source 软链"]
    T["新 cwd 桶<br/>target 软链"]
  end

  H1["dataDir 外文件<br/>secret"]
  H2["dataDir 外文件<br/>config"]
  W["沙盒外 worker<br/>syncClaudeResumeTargetToCwd"]
  C["目标 transcript"]

  H1 -. "软链指向" .-> S
  S -->|"stat/copy 跟随:读穿"| W
  W -->|"把外部内容复制进桶"| C
  W -->|"copy 跟随:写穿"| T
  T -. "软链指向并覆写" .-> H2
Loading

修复方案

flowchart LR
  I["session id / 候选路径"] --> U["UUID gate"]
  U --> L["lstat:拒绝软链和非普通文件"]
  L --> R["realpath:限制在 projects root"]
  R --> O["O_NOFOLLOW 打开并核对 inode"]
  O --> P["同目录私有临时文件<br/>0600 + O_EXCL"]
  P --> A["rename 原子替换 target leaf"]
  L -->|"不安全"| X["拒绝同步;不碰外部文件"]
  R -->|"越界"| X
Loading

具体包括:

  • 对 session id 做 UUID gate,阻断路径片段进入跨桶扫描;
  • source、target、projects root 和桶目录全部改为 lstat/类型检查,并做 realpath 包含性验证;
  • POSIX 上以 O_NOFOLLOW 打开 source,结合 dev/ino/size/mtime/ctime 校验扫描、打开、复制期间的对象一致性;
  • 先写同目录 0600 + O_EXCL 私有临时 inode,完整校验后 rename 原子替换 target leaf;即使 target leaf 在末刻被换成软链,rename 也替换软链本身,不会写穿其指向;
  • 保持 master 既有 COPY/保留旧桶语义,不加入另一套 MOVE/rescue;sidecar 迁移属于独立功能增量,不和本安全修复捆绑。

影响面

  • 模块 / CLI:只修改 claude-code 适配器的 transcript 同步 helper;影响所有显式提供 claudeDataDir 的 Claude-family 适配器(Claude Code、Seed/Genius 等)。其他 CLI 不走此路径。
  • 会话路径:只在 resume && !willReattachPersistent && claudeDataDir 的冷恢复路径触发;持久 pane 重连、全新会话、riff 远端会话不受影响。
  • 后端 / 沙盒:PTY/Tmux 最终复用同一 worker gate;重点封住“沙盒内可写 dataDir、沙盒外 worker 执行同步”的权限边界。非沙盒路径也获得相同防护。
  • 平台:Linux/macOS 使用 O_NOFOLLOW;Windows 保留兼容分支,并仍有 lstat、realpath、inode 与原子替换校验。daemon 生产路径(Linux)已编译和测试。
  • 兼容行为:普通 transcript 仍 COPY 到新 cwd,旧桶仍保留;直接把 dataDirprojects 根本身做成软链的配置会在冷同步时 fail closed,由后续既有 resume probe/fallback 决定是否恢复。

实际验证

  • pnpm build:通过
  • pnpm exec vitest run test/command-handler.test.ts test/cli-adapters.test.ts test/seed-adapter.test.ts test/claude-code-cwd.test.ts:4 files / 525 tests passed
    • 覆盖正常跨 cwd 多 buffer copy、旧 target 刷新、最新 target 不动;
    • 新增 source 软链读穿、target 软链写穿、dangling target、projects root 软链、非 UUID 拒绝;
    • 验证 target 为普通文件且无临时文件残留。
  • pnpm test10651 passed / 5 skipped / 4 failed;4 个失败均在本 PR 未触及的 fs-policy root-mode 与 scheduler 时区断言。对精确 origin/master@e13ec929 的干净 worktree 复跑对应 3 个文件,得到相同 4 failures,确认为基线/环境失败。
  • pnpm daemon:restart:成功,全部进程恢复 online。

@deepcoldy

Copy link
Copy Markdown
Owner Author

过程摘要(codex):

  1. 复核 master 当前调用顺序与权限边界,确认风险只在 syncClaudeResumeTargetToCwd 的冷恢复 copy 路径,不新增 fix(claude): resume 预检跨桶兜底,修复 /cd 空窗重启后静默丢上下文 #506 的 rescue/move 方案。
  2. 在 source 与 target 两端加入 fail-closed 校验,并把直接 copyFileSync 改为“固定 source inode → 私有临时文件 → 原子 rename”。
  3. 增加 5 类安全回归(source 读穿、target 写穿、dangling target、projects 根软链、非法 session id),同时扩大正常 copy 用例到多 buffer,避免安全改造破坏既有语义。
  4. 针对 Claude-family 共用 worker 路径跑 525 项回归并通过;完整测试的 4 个失败已在精确 master 基线独立复现,非本分支引入。
  5. 已 rebase 最新 master、build 通过、daemon restart 后进程全部 online;当前等待 CI/CodeQL。

@deepcoldy

Copy link
Copy Markdown
Owner Author

收尾更新:CI build 与 CodeQL(actions / javascript-typescript / python)已全部通过。验证完成后已按多 checkout 规范把全局 shim 与 daemon 切回最新 canonical master(e13ec929),PM2 全部进程 online,PR worktree 不再承载 live daemon。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

首审结论(Claude):✅ 建议合入,0 阻塞项

我把这个 PR 的两个漏洞在 master 上亲自复现、再用本 PR 的真实编译产物验证修复,而不是采信描述。以下都是实测结果。


白话解释:这个 PR 到底在修什么

Claude Code 把每个会话的 transcript(对话记录 <session-id>.jsonl)按 cwd 的哈希分桶存放。也就是说同一个会话,在 /repoA 里跑和在 /repoB 里跑,记录会落在两个不同的目录里。

botmux 的 /cd 命令刻意保持会话 id 不变,所以换目录后会出现「会话还是那个会话,但 Claude 去新桶里找不到记录」的情况。syncClaudeResumeTargetToCwd 就是干这个的:冷恢复前,把最新的 transcript 从旧桶复制到新 cwd 对应的桶,这样 --resume 才能接上上下文。

问题出在权限边界:

这个复制动作跑在沙盒外的 worker(高权限),而沙盒内的 CLI 有权写自己的 claudeDataDir

也就是说,桶里的文件对高权限的 worker 来说全是攻击面。老代码用 statSync + copyFileSync,这两个 API 两端都跟随软链

  • source 是软链 → worker 会把 claudeDataDir 外面的文件(比如密钥)读进来,写成 transcript(读穿
  • target 是软链 → worker 会把 transcript 覆写到 claudeDataDir 外面的文件上(写穿,任意文件覆盖)

修法可以理解成给整条链路加了一串闸门:先用 UUID 校验挡住把路径片段塞进 session id;再把所有 statSync 换成 lstat不跟随软链)+ realpath 包含性检查;打开 source 时用 O_NOFOLLOW 并核对 inode,确保「扫描时看到的那个文件」和「真正读的那个文件」是同一个;最后不直接写 target,而是先在同目录写一个 0600 + O_EXCL 的私有临时文件,校验完再用 rename 原子替换。

rename 这一步是整个方案的承载点:即使 target 在最后一刻被换成软链,rename 替换的是软链本身,不会写穿到它指向的文件


一、漏洞真实性:两个洞在 master 上均 CONFIRMED 复现

我逐字复刻 master 的实现单独跑:

HOLE1 (source 读穿):   copied=true | target 内容 = "TOP-SECRET-HOST-CONTENT\n"   => VULNERABLE
HOLE2 (target 写穿):   copied=true | dataDir 外文件 = "ATTACKER-TRANSCRIPT-CONTENT\n"  => VULNERABLE

这两个洞确实存在,不是理论推演。(背景:它们是我和 codex 复审已关闭的 #506 时挖出来的 master 独立问题,当时结论就是「#506 的 rescue/MOVE 对 canonical /cd 是死代码,但 master sync 自身的软链洞需要单独处理」。本 PR 只移植安全增量、不引入第二套 rescue/MOVE,方向和当时的收敛结论一致 👍)

二、修复有效性:用 PR 的 dist/ 产物验,两洞均 CLOSED

FIX HOLE1: {"copied":false}  → target 未创建,secret 未被读出          => CLOSED
FIX HOLE2: THREW "unsafe Claude resume target (expected a regular file)"
           → 外部文件保持 "ORIGINAL-MUST-NOT-CHANGE",target 仍是软链   => CLOSED

承载论证单独验证renameSync 到一个软链路径 → 外部文件内容不变,该路径变成普通文件。PR 描述里「rename 替换软链本身、不写穿」的说法成立

三、功能回归:核心 /cd 语义完整保留

  • 跨 cwd 复制 75000 字节 逐字节一致,无 .tmp 残留,旧桶保留(原生历史不丢)
  • 端到端 /cd A→B→C→B:上下文正确迁移、newest-wins 选择正确、回切不做多余复制
  • 数据根布局全绿:Seed 深层 .claude-runtime、Genius .genius、per-bot BOT_HOME/claude、trailing slash、./
  • 符号链接祖先目录/home/x → /data00/home/x 这类常见部署)仍正常工作
  • 0 字节 source、FIFO 作 source(不 hang,被 isFile() 挡住)、各 throw 路径无临时文件残留

性能(master 用内核级 copyFileSync,本 PR 换成 64KB 用户态循环,值得测):

大小 本 PR master copyFileSync
5MB 4ms 3ms
50MB 34ms 30ms
200MB 118ms 786ms

非但不退化,大文件反而更快。

四、对抗探针

  • UUID gate 打 14 个 payload(../../../../../etc/passwd..%2f、null 字节、换行拼接、en-dash 同形字、超短/非 hex/前导斜杠)→ 全部 rejected
  • 目标桶目录预置为指向外部的软链 → THROW,外部目录零写入mkdirSync 对已存在软链是 no-op,随后的 lstat 挡住)
  • projects 根软链 / dataDir 自身软链 → fail closed(见下 P3)

五、测试基线核对 ⚠️(PR 写 4 failed,我这边 10 failed —— 已查清不是回归

我实测 pnpm test10 failed / 10676 passed,和描述的 4 个对不上,所以专门定性了一下:

  • 精确 base commit e13ec929 的干净 worktree 跑同一批文件 → 同样 10 failed
  • 失败文件(fs-policy-bwrap 1、schedule-card-model 1、scheduler 2、v3-distillation-runner 6)全部不在本 PR 触碰范围,属时区/root-mode 敏感的环境基线
  • codex-app-threads 首轮 full run 失败,但隔离跑 11/11 绿、二次 full run 不复现 → 时间敏感 flake

结论:基线差异来自各人机器环境(时区/权限),非本 PR 回归。定向 4 文件 525/525 通过,与描述一致;pnpm build 通过;CI build + CodeQL 全 pass。


Findings

P3(唯一,非阻塞)— fail-closed 行为变更

dataDir 自身projects自身是软链时,行为从「静默跟随」变为 throw → 本次同步跳过 → 落回 probe,可能丢上下文起新会话。

但影响面很窄,且我确认是可接受的:

  • botmux 全仓只在 core/plugins/install.ts 建软链,从不把 Claude 数据根做成软链;本机 ~/.claude~/.claude/projects 均非软链
  • 符号链接祖先目录不受影响(已实测),常见的 symlinked home 部署没问题
  • 唯一调用点 src/worker.ts:6671 已包在 try/catch 内(6670-6679),只 log WARN 并落回既有 probe/两级 fallback,不会崩 resume
  • PR 的「影响面」章节已如实写明这一点

非 finding,但记录备查 — hardlink 读穿

攻击者可以把外部文件 hardlink 进桶(lstat 认普通文件、realpath 就是自身,绕过包含性检查),内容会被复制进 transcript。我实测确实可行。但不作为本 PR 的阻塞项

  1. master 上同样成立copyFileSync 照样复制)→ 非本 PR 引入的回归
  2. fs.protected_hardlinks=1(本机已确认开启)要求攻击者对目标本就有读写权,拿不到新东西
  3. 需同一文件系统

可作为后续观察项,不必在本 PR 处理。

关于并发 append(我一度怀疑是 P2,实测降级为非问题)

新代码在 source 于扫描后变化时会 throw。我用真外部进程紧密循环 append → 10/10 全 throw,看着吓人。但按真实 Claude 写入节奏(200ms~1s 一行)重测:busy 5MB / idle 5MB / busy 200KB 各 10/10 全绿,零 throw

更关键的是:该路径是 effectiveResume && !willReattachPersistent冷恢复——老 Claude 进程此时已经死了,source 是静态的。生产上 source 被并发写的前提基本不成立;即便真 throw,也只是 WARN + 落回 probe。→ 非阻塞。


补充事实(供复审参考)

  • session id 唯一铸造点 src/services/session-store.ts:173 sessionId: randomUUID() → 全是 UUID
  • Claude-family 的 cliSessionId 只来自 resolveJsonlFromPidclaude-code.ts:485,237 行已 UUID 校验)和 findOpenClaudeSessionIds(305/311 已校验)→ UUID gate 不会误伤
  • 非 UUID 的 cliSessionId 来源(Cursor chatId、Kiro、Grok/Traex)都不是 Claude-family,不走此路径
  • 影响面仅 Claude-family(claude-code / Seed / Genius,即显式提供 claudeDataDir 的适配器),其它 20+ CLI 不经过这里;仅冷恢复触发,持久 pane 重连 / 全新会话 / riff 远端会话不受影响
  • merge-base == master tip,无 fork 旧基点陷阱

结论:修的是真问题,方案克制(只移植安全增量、保持 master COPY + 保留旧桶语义),实现严谨,测试覆盖到位。建议合入。

已 @ codex 做复审。在申晗确认前不合码。

@deepcoldy
deepcoldy merged commit 274792d into master Jul 26, 2026
6 checks passed
@deepcoldy

Copy link
Copy Markdown
Owner Author

✅ 已合入(merge commit 274792d5,申晗拍板授权 admin-merge)

合码前安全核对(master 已从 review 时前移,逐项验过)

首审要点回顾(详见上方 review)

修的是真问题:statSync+copyFileSync 两端跟随软链 → source 读穿(把 dataDir 外文件读进 transcript)/ target 写穿(把 transcript 覆写到 dataDir 外文件)。两个洞我在 master 上逐字复刻实现均 CONFIRMED 复现,再用 PR 真实 dist/ 产物验证均 CLOSED。方案克制:只移植 #506 复审时挖出的安全增量,不引第二套 rescue/MOVE,保持 master COPY + 保留旧桶语义。

唯一 P3(非阻塞):dataDir/projects自身是软链时改为 fail closed;符号链接祖先目录不受影响,且 botmux 从不把数据根做成软链。hardlink 读穿为 master 既有行为(非本 PR 回归)+ fs.protected_hardlinks=1 缓解。

后续

  • codex 复审当时在进行中,申晗直接拍板合码。若 codex 复审提出新点,可另开 follow-up。
  • 无需 live 手测(纯 helper + 单测覆盖)。真要部署走 pnpm switch:here && pnpm daemon:restart

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