Skip to content

fix(subagent): re-root the state-path check when the state root is relocated - #6949

Open
SparkofSpike wants to merge 3 commits into
codewhale-hq:mainfrom
SparkofSpike:fix/subagent-state-junction-upstream
Open

SparkofSpike wants to merge 3 commits into
codewhale-hq:mainfrom
SparkofSpike:fix/subagent-state-junction-upstream

Conversation

@SparkofSpike

@SparkofSpike SparkofSpike commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A workspace whose .codewhale is a junction — the way to keep application
state on another volume — failed every sub-agent at step 0:

sub-agent state path must stay within state root:
  \\?\D:\CodeWhaleData\state\subagent-transcripts\ed6981e1....jsonl
failure_class=runtime_error, steps=0, elapsed_ms=283

The child never started reasoning, so no result was produced.

Cause

default_state_path and the transcript artifact writer both build
.codewhale/state/... under the state root. When <state_root>/.codewhale is
a junction, the child resolves into the junction's target — exactly where the
user keeps the state — while checked_subagent_state_path compares it against
the unresolved root and refuses.

The same user-owned link that the session-artifact path already honours (see
the sibling change to fleet/files.rs) was rejected here, and the refusal
lands before any work: the child cannot even write its own transcript.

Changes

  • effective_state_root(state_root, state_path) (new): when
    <state_root>/.codewhale is a link (reparse point on Windows, symlink
    elsewhere) and the child resolves inside its target, the target becomes the
    root for both the containment check and the link walk in
    checked_subagent_state_path. A child that resolves anywhere else keeps the
    stricter state root, so this re-roots one known, user-owned link and does not
    widen the boundary: an escape past both roots is still refused.
  • Uses the shared metadata_is_link_or_reparse predicate, because a Windows
    junction carries the reparse attribute without the symlink tag —
    FileType::is_symlink alone would miss exactly this case.
  • Both checks in checked_subagent_state_path now use the same root. Before
    this, a relocated state directory failed containment first and would have
    failed the link walk second.

Evidence

Reproduced end to end on 0.10.1 (Windows) before the fix: an installed
binary in a workspace whose .codewhale points at an outside target failed the
child with steps=0 and the exact message above. The same workspace without
the link works, and a junction whose target sits inside the workspace also
works — the failing case is specifically a relocated state root.

A Windows test (state_path_accepts_a_state_root_relocated_behind_a_junction)
creates a real junction and pins four directions: the relocated root passes and
the state lands in the junction target; an escape past both roots still fails;
and a junction whose target does not exist yet also passes (see the note
below). state_writes_follow_a_relocated_state_root drives the paths the child
actually takes: manager persist_state/load_state, then the transcript
writer's create and append.

Not run here: the repository's own gates. Note that CI on a fork-sourced pull
request starts in action_required and waits for a maintainer, so this
branch's checks have not executed; the same commits were run on the fork with
its Actions enabled, and that result is what backs the test claims above.

Review responses

Review found the re-root applied too narrowly — only
checked_subagent_state_path used the effective root, while the guards that
run after it still compared the resolved path against the unresolved root.
The containment check passed and the write still failed with the same message
on the child's first step: prepare_subagent_transcript_parent,
append_private_subagent_transcript, write_json_atomic and
read_subagent_state_file all reach it, so the reported failure survived the
fix.

125cf62d4 responds: reject_state_path_symlinks is now the guard those
state-path callers use, resolving the same root decision
checked_subagent_state_path made before running the unchanged link walk.
CoordinationProcessLock keeps rejecting a linked .codewhale on purpose —
its existing comment says create_dir_all must not populate the link target —
so the lock guards are untouched.

The Windows test now drives the paths the child actually takes: manager
persist_state and load_state, then the transcript writer's create and
append, all under a junction whose target is outside the workspace.

A second review round raised three further points, answered here:

  • "The fix misses the branch where the junction target does not exist yet."
    Checked against real Rust std rather than the review's Python stand-in, and
    it does not hold. std::fs::canonicalize returns NotFound for a dangling
    junction (Python's os.path.realpath resolves it instead), so both the link
    and the child fall back to the link's own spelling and containment passes:

    junction target exists=false
      resolve(linked)  = \\?\...\ws\.codewhale        <- the link's spelling
      state_path       = \\?\...\ws\.codewhale\state\...
      effective root   = \\?\...\ws\.codewhale
      verdict          = ACCEPTED
    

    The case is real (mklink /J accepts a missing target, and creating the
    junction before the first run is a normal setup order), so 00687a4ac pins
    it as a regression case rather than leaving it argued in prose.

  • A test that could not fail was removed.
    state_path_stays_in_the_workspace_without_a_link took the no-link branch —
    the behaviour before this branch — so it passed without the fix, and its
    assertion depended on the test process's working directory. 00687a4ac
    drops it.

  • CoordinationProcessLock was not weakened. It never goes through
    checked_subagent_state_path, so effective_state_root cannot reach it;
    the Unix test that pins its refusal still passes.

On the no-new-tests rule

A reviewer flagged that this branch adds tests and comments, against
AGENTS.md's "No new tests and no new code comments unless explicitly asked".
Both are kept here on purpose, and can be dropped on request:

  • The tests are regression cover for a reported failure — a reproduced
    child-at-step-0 error — and they drive the paths the child actually takes
    (manager persist/load, transcript create/append), which is what the first
    revision of this branch missed. The rule's own carve-out for a requested
    regression test applies.
  • The comments state the boundary of effective_state_root (one known,
    user-owned link; every other resolution keeps the stricter root) and why
    CoordinationProcessLock deliberately stays strict. Without them the
    asymmetry reads as an oversight and invites a later "fix" that widens it.

CI status on this branch

The checks are red, and every failing job is a failure of the base, not of
this branch:

Job Cause
Lint crates/tui/src/core/authority/auto_review.rs: path_canonicalize sites 3 > budget 0 — a file this branch does not touch, left over from the authority move in d776dcb61
Test (macos-latest) conformance::prompt::model_visible_prefix_bytes_match_goldens — the file_search tool was added to the catalog and the golden was not re-recorded
Test (windows-latest) / (ubuntu-latest) acp_server::tests::acp_full_access_keeps_repo_law_without_permission_modal and two core::engine::tests::*_full_access_* (deadline elapsed) — none in this branch's files
Documentation two private intra-doc links in crates/commands/ (only reaches this job because it was dispatched manually; the job is if: schedule || workflow_dispatch)
Version drift the fork does not carry refs/tags/v0.10.1

The decisive evidence: upstream main's own CI on the two commits preceding
this branch's base (f723f64f3, 3893d73c0) fails with the same set —
Integrations, Lint, Test (windows-latest). This branch cannot be green
while its base is red.

What the branch's own tests did do, on a real Windows runner in run
37980841780:

PASS [0.081s] (12993/18394) codewhale-tui tools::subagent::tests::state_path_accepts_a_state_root_relocated_behind_a_junction
PASS [0.095s] (12994/18394) codewhale-tui tools::subagent::tests::state_writes_follow_a_relocated_state_root

The branch changes exactly two files, both under
crates/tui/src/tools/subagent/.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Related Issues

No-Issue: observed in an operator session on 0.10.1 (Windows); the state
directory relocated behind a junction failed every sub-agent at step 0.

@SparkofSpike
SparkofSpike requested a review from Hmbown as a code owner October 9, 2026 16:36

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@SparkofSpike

Copy link
Copy Markdown
Contributor Author

结论

反驳不动核心修复,但这个 PR 不能原样合并:它在一个已经不可靠的前提(测试进程的 CWD 恰好是仓库根)之上新引入了一个同形状的确定性失败路径(窗口期清理),并把一条“按设计必须拒绝”的断言从文档里写没了。建议:合并前补 effective_state_root 的目标不存在分支(顺带修掉窗口期),并把新测试里的断言收窄。

以下事实与推测分开标注;行号基于我 checkout 的 290e21d(git diff origin/main...HEAD)。


发现清单

F1【高 · 确定】修复只覆盖了 resolve_path_for_containment 成功的那一支,窗口期目标不存在时仍失败

effective_state_root(crates/tui/src/tools/subagent/mod.rs:10482-10497):

match resolve_path_for_containment(&linked) {
    Ok(resolved) if path_is_within_state_root(state_path, &resolved) => resolved,
    _ => state_root.to_path_buf(),   // ← 目标不存在时走这里
}

链接存在、但目标尚未创建时,resolve_path_for_containment(&linked) 逐级回退,最终返回 normalize_path_components(path) —— 也就是链接自身的字面路径(该函数 :10502-10528 的 NotFound 分支),不会报错。于是 resolved == <state_root>/.codewhale,而 state_path 解析后在目标里,path_is_within_state_root 为 false,函数落回未解析的 state_root,checked_subagent_state_path 在 :10460 抛 sub-agent state path must stay within state root。

我把这段逻辑按原样复刻成脚本验证过(本机无 rustc,用 os.path.realpath + 同样的“最深层已存在祖先”回退):

case2(链接存在、目标不存在)state_path: /tmp/.../relocated/state/subagent-transcripts/x.jsonl
case2 within(link target) = False      ← 修复在这里不生效
case2 within(state_root)  = False      ← 旧 root 也判不在界内

为什么这在真实场景里会发生:Windows 上 mklink /J 可以指向一个不存在的目标。运维常见的顺序是先建 junction(或把 junction 留在仓库里/放进部署脚本),再让 Codewhale 首次运行去创建状态目录 —— 这恰好是 PR 描述里那个 \\?\D:\CodeWhaleData\state\... 的场景。此时仍然是 steps=0、同一条错误消息,和没修一样。

为什么会周期性地发生(推测,但机制确定):cleanup_for_session → cleanup_inner 在 :9596-9608 会对已完全退休的 agent 调用 remove_subagent_transcript_artifact,而它只删那一个 jsonl 文件,不删父目录(subagent-transcripts/、subagent-results/)。UI 的 Op::ListSubAgents(core/engine.rs:3628-3636)按 SUBAGENT_LIST_CLEANUP_MIN_INTERVAL 触发这条路径。所以最后一个 transcript 被清理掉之后,<state_root>/.codewhale/state/subagent-transcripts/ 会消失;下一个子代理的 SubAgentTranscriptArtifactWriter::create(mod.rs:10244-10247,即 step 0)又要重新 create_dir_all。在目录不存在的那一瞬,resolve_path_for_containment(&linked) 会停在 junction 之上、返回链接自身的拼写 → 修复失效。

注意第二个修正项:window 只在目录不存在时存在。一旦某次创建成功,state/ 与 subagent-transcripts/ 就留下来,resolve_path_for_containment(&linked) 才解析到 junction 目标。也就是说这是一条自愈的窗口期,不是永久失败 —— 但它对“第一次就炸”的首次运行和“清理后再开”的用户是确定的回归面,而且正是这个 PR 声称要修的那条消息。

我无法在本机跑 Windows 二进制,所以“窗口期真实触发”属于推测;resolve_path_for_containment 在目标不存在时返回链接自身拼写这一条是读代码得到的确定结论(:10502-10528),复刻脚本也一致。

建议:Err(_) | Ok(resolved) if ... 之外再补一支 —— 当 <state_root>/.codewhale 是链接且目标尚不存在时,用 read_link(Windows 上 junction 需要用 fsutil reparsepoint / std::fs::read_link 的等价物,或直接比较 path_is_within_state_root(state_path, &linked) 之后取 <state_root>/.codewhale 的已解析前缀)来得到目标,或至少让 resolve_path_for_containment 在“最深层已存在祖先就是链接本身”时返回链接目标而不是链接路径。否则窗口期会原样复现本 PR 要修的 bug。

F2【中 · 确定】新测试钉死的前提(cwd == 仓库根)本身很脆,且 state_path_stays_in_the_workspace_without_a_link 在旧代码上不会失败

state_path_stays_in_the_workspace_without_a_link(tests.rs:11670-11688)走的是没有链接的分支:effective_state_root 在 symlink_metadata 失败时立即返回 state_root(:10484-10486),所以这个测试在改动前也一样通过。按仓库自己的规矩(AGENTS.md「Claiming a test passed」:Prefer proving a regression test fails without the fix),它没有钉住任何东西。

它真正的价值是回归护栏:它钉住“普通工作区里 checked_subagent_state_path 的 CWD 相对行为”。而这条护栏今天只在“测试进程的 CWD 恰好是仓库根、且仓库根没有 .codewhale/state/subagent-transcripts/”时成立 —— cargo test -p codewhale-tui 正好是这个前提。我核对了 scripts/with-hermetic-test-home.sh:它只隔离 HOME/APPDATA/XDG,不改 CWD(tests.rs:1-10 也没有任何 CWD 设置)。

一个具体的破坏方式(机制确定,触发是推测):crates/tui/src/tools/verifier.rs:1608-1635 的 gate_output_over_1mib_keeps_head_and_tail_and_writes_full_log 通过 with_test_home(truncate.rs:1166)只设了 TEST_SPILLOVER_ROOT / TEST_ARTIFACT_SESSIONS_ROOT,没有用 SealedHome;而 session_artifact_absolute_path(artifacts.rs:97-113)在 TEST_ARTIFACT_SESSIONS_ROOT 为 Some 时直接返回它 —— 该测试设置的是 Some,所以它不会在 CWD 下写东西。但 gate_output_that_fits_is_kept_whole_without_a_log 之类的邻居我没逐个读完;结论是:这个新测试把一个进程全局状态(CWD)当成了契约,一旦有人在测试里落下一个 CWD 相对的 .codewhale/state/subagent-transcripts/,它会以“unrelated failure”的形式红掉,而且看不出和本 PR 的关系。

建议:断言收窄为只检查返回值不报错、且 state_path 以 workspace 的 canonical 形式开头(tests.rs:11682-11686 的 starts_with(&workspace) 请改成 starts_with(&workspace.canonicalize()?),否则在 $TMPDIR 是 symlink 的机器上会误报);或者干脆把这个测试改成 #[cfg(windows)],与 junction 测试同源。

F3【低 · 确定】被删除的既有保证没有在新测试里补上

coordination_lock_refuses_linked_codewhale_without_writing_through_it(tests.rs:11789-11810)在 PR 之后仍然成立,我核过:CoordinationProcessLock::acquire(mod.rs:3665-3682)走的是 reject_root_relative_symlinks(&requested_root, &lock_dir.join(SUBAGENT_STATE_LOCK_FILE)),不经过 checked_subagent_state_path,所以 effective_state_root 影响不到它。这条“linked .codewhale 必须被拒绝”的保证没有被削弱。

但它现在只由一条 #[cfg(unix)] 测试守着,而本 PR 恰好把同一形态在 Windows 上的语义改成了“接受”。没有任何测试覆盖“<state_root>/.codewhale 是链接、但子路径不在目标内 → 必须继续拒绝”这条应当保留的路径(新 junction 测试里的 .. 逃逸是相对于 workspace 的,effective_state_root 直接返回 state_root,没有走到“链接目标不匹配”那一支)。

F4【低 · 确定】PR 描述里“Both checks … now use the same root”这句对异步调用链不成立

checked_subagent_state_path 内部确实自洽(:10459-10466 同一个 state_root 变量)。但 SubAgentTranscriptArtifactWriter::create(:10244-10247)自己先做一次 normalize_subagent_workspace(state_root),然后把未重定根的 state_root 存进 self.state_root(:10255);后续 sync_messages → append_private_subagent_transcript(:10301)在每次 append 时重新调用 reject_root_relative_symlinks(&self.state_root, &self.path),此时 path 已经是解析后的真实目标路径,而 root 仍是链接路径。这依赖 reject_root_relative_symlinks 内部的 strip_prefix 失败回退分支(:10666-10718)去 resolve root 和 path parent —— 能用,但“两处检查用同一个 root”的表述在这里是不准确的。竞态方面:如果链接在两次调用之间被替换,reject_root_relative_symlinks 会用 symlink_metadata 逐组件重新走(:10721-10732),不会被 effective_state_root 的陈旧结果掩盖;这一点我认为是安全的。

F5【信息】安全边界:我没有找到可利用的放宽

逐条回答见下。核心:重定根只放宽了“那个链接的目标所在的那棵树”,而那一棵树就是用户自己的状态根。state_path 的叶子名由 default_state_path / subagent_transcript_artifact_relative_path / digest_artifact_relative_path 内部构造(常量 + sha256),不接受模型输入;能控制 <state_root>/.codewhale 的人已经能在该卷上任意写。结论:我认为没有新增的越界写入面。


对 6 组问题的逐条回答

1. 安全边界(是否放宽了本该拒绝的情况)

  • 前提是:攻击者已经能在 <state_root> 下创建 .codewhale 这个链接。此时他本就能把链接指向任意位置并让进程写进去(create_dir_all 会跟随链接);effective_state_root 没有给他任何新能力。
  • 条件 path_is_within_state_root(state_path, &resolved) 是必需的:没有它,<state_root>/.codewhale → C:\Windows 会把状态写进系统目录。它挡住了这一条。
  • (a) reject_root_relative_symlinks 用新 root 之后,仍在被拒之列的是:新 root 之下的任何 symlink 组件。而 <state_root>/.codewhale 这一个组件被排除 —— 正是被有意重定根的那一个。实测(复刻脚本)在“目标已存在”时 within(state_path, link_target) = True,与代码一致。
  • (b) 没有发现“越界被误判为在界内”的形状:.. 在 checked_subagent_state_path 里是先 resolve_path_for_containment(parent) 再比较,解析后必然离开 target;8.3 / verbatim 前缀由 path_is_within_state_root 的 resolve + windows_components_within 大小写折叠处理;大小写由同一处折叠。这一组我反驳不动。
  • 唯一的真实削弱是 F3:本 PR 之后,一个由别的程序放进仓库的 .codewhale 链接,在 Windows 上从“拒绝”变成了“接受并把状态写进链接目标”。我认为这个取舍是对的(Unix 本来就是同样的语义),但 PR 描述里“does not widen the boundary”说得太满,而覆盖它的测试只有 Unix 一侧。

2. 语义与既有保证

  • checked_subagent_state_path 内部两处检查现在同一个 root,自洽(:10459-10466)。
  • 竞态(链接在两次调用间被替换):不会产生越界写入。path_is_within_state_root 用的是解析后的 state_path 与刚刚解析的 target;reject_root_relative_symlinks 用 symlink_metadata 逐组件重走,会用真实(新)形态判定。最坏结果是报错,不是越界。
  • 但见 F4:SubAgentTranscriptArtifactWriter 这条异步链上的“同一个 root”并不成立,靠的是 reject_root_relative_symlinks 的回退分支兜底。

3. 是否有更小的修法
有两条更小的路,我评估如下,并认为作者选了现在这条是合理的:

  • (a) 在 default_state_path 一处解决:把它改成“若 <state_root>/.codewhale 是链接则返回目标路径”。不成立 —— default_state_path 的返回值只是 state_path,每次 persist_state / load_state 都会再经过 checked_subagent_state_path(&self.state_root, path)(:5564、:5729)重新做 containment,链接拼写又回来了。要修就得连调用点一起改,等于把改动摊到更多地方。
  • (b) 在 normalize_subagent_workspace 里重定根:这是行为更差的方案 —— 该函数被 transcript 路径、lock 路径、write_json_atomic 等十几处复用(:3666/3675/10245/10348/10395/10425/10438/10741/10882),在那里把 root 换成链接目标会静默改变所有那些调用点的语义(包括 CoordinationProcessLock 那句 “Refuse a linked .codewhale before creating anything” 的注释会变成假话)。集中在 checked_subagent_state_path 里做,作用域最小、可解释。这一条我同意作者的选择。
  • 真正更小且应该做的是 F1 的那个补丁:同一个函数、多一支 match 分支。

4. 平台一致性

  • 目标已存在时,Unix 与 Windows 同构:metadata_is_link_or_reparse 在 Unix 就是 is_symlink(path_identity.rs:19-22),fs::symlink_metadata 对 symlink 返回链接自身的元数据,因此 Unix 的“.codewhale 是 symlink 到外部目录”同样会走到重定根分支 —— 修复在 Unix 上也是通的,只是没有测试。
  • 未被覆盖的平台差异(F1):Windows junction 可以指向不存在的目标(mklink /J 不校验),Unix symlink() 同样可以(悬空 symlink)。两边在“目标不存在”时都会落回旧行为 —— 这不是平台差异,是共同的漏洞。
  • 另一处未覆盖:Windows 上 .codewhale 若是 mount point / volume mount point 或 OneDrive 的 reparse point,metadata_is_link_or_reparse(attribute 0x400)会把它当链接,但 resolve_path_for_containment 对 mount point 的 canonicalize 行为与 junction 不同 —— 我没有 Windows 环境验证,列为未验证风险。

5. 测试有效性

  • state_path_accepts_a_state_root_relocated_behind_a_junction:在旧代码上会失败(旧代码没有 effective_state_root,:10460 直接拿未解析 root 比 → 报错)—— 有效。
  • state_path_stays_in_the_workspace_without_a_link:在旧代码上不会失败(无链接分支本来就是原行为)—— 不是回归测试,只是护栏,且护栏的前提脆(F2)。
  • mklink /J 在 GitHub windows runner 上可用:cmd.exe 存在,mklink /J 不需要管理员权限,C:\Users\RUNNER~1\AppData\Local\Temp 在同一卷上,/J 的 junction 无卷限制 → 可用。但同一 PR 系列的 fleet/files.rs(fix(artifacts): resolve a linked state root so a relocated Codewhale home keeps working #6947)里已经有一个同样的 make_junction helper,本 PR 又抄了一份 Command::new("cmd") —— 两份重复实现,后续任一处要改成 fsutil 就会漂移。
  • “链接存在但子路径不在目标内”这条应当继续拒绝的路径没有被覆盖(F3)。新测试里的 .. 逃逸走的是 effective_state_root 的默认分支,不是“链接目标不匹配”分支。

6. 与相邻修复的关系 / 还有第几处同类遗漏
我在 checkout 里 grep 了所有对“state root 下路径”做 containment 的位置:

  • fleet/files.rs(fix(artifacts): resolve a linked state root so a relocated Codewhale home keeps working #6947 用 open_resolved_root)—— 独立机制,已修。
  • subagent/mod.rs 本 PR —— 独立机制,已修(但有 F1 的窗口期)。
  • 仍然会拒绝“relocated state root”的地方(同类遗漏,未修):
    1. CoordinationProcessLock::acquire(mod.rs:3665-3682):先 reject_root_relative_symlinks(&requested_root, &lock_dir.join(SUBAGENT_STATE_LOCK_FILE)),再 create_dir_all(&lock_dir)。当 .codewhale/state 已存在时,strip_prefix(requested_root, lock_path) 成功,所以它只拒 root 之下的链接组件,不会误拒 .codewhale 本身 —— 这条其实是安全的;但 require_coordination_process_lock 失败只是 warn(:5469、:10990),会把“写能力子代理要求 durable coordination state path”在 :8716 直接变成硬错误。也就是说 F1 的窗口期不仅让 transcript 失败,还会让写能力子代理在 step 0 之前就以“requires a durable coordination state path”失败(default_state_path 报错 → state_path = None → :8716 拒绝)。这一条把 F1 的严重性从“transcript 写不了”抬到“写能力子代理直接起不来”。
    2. runtime_api/sessions.rs / runtime_api/workspace.rs:fix(artifacts): resolve a linked state root so a relocated Codewhale home keeps working #6947 已经用 open_confined_file_resolved 覆盖。
    3. artifacts::session_artifact_absolute_path(artifacts.rs:126-142)不做任何 containment 检查,只拼路径;它跟随 artifact_sessions_root(),所以不是“遗漏”,是设计如此。
    4. state/src/lib.rs::default_state_db_path(:1799-1813)同样只拼路径、不检查 —— 同上,不算遗漏。
  • 结论:同类遗漏至少还有 1 处实质性的(F1 的窗口期会同时打穿 transcript 写入与 coordination lock/state_path 两条链),另有 1 处形式相似但实际安全的(CoordinationProcessLock),以及 2 处本就不做 containment 检查的(不应算作遗漏)。PR 描述里“one known, user-owned link”这个说法低估了影响面。

补充:我没有验证的(诚实边界)

  • 本机没有 Rust 工具链(rustc/cargo 均不存在),所以我没有编译,也没有运行这两个测试。以上关于 resolve_path_for_containment 行为与 path_is_within_state_root 判定的结论,来自逐行读代码 + 用 Python 按同样算法复刻的对照实验(case1 目标存在 → within=True;case2 目标不存在 → within(link)=False、within(state_root)=False)。
  • 本 PR 的 CI 状态:gh pr checks 6949 只显示 gate(Contribution gate)pass;CI / DCO / CodeQL 等 6 个 run 全部停在 action_required 且 jobs.total_count == 0,pending_deployments 为空。我对比了同期 fork PR(chore: drop 30 dead_code allows that no longer cover anything #6946、fix(execpolicy): a redirect is not a command separator #6929)与 main 上的 push run,同样是 action_required,所以这看起来是仓库级的 fork-PR 审批配置,不是本 PR 的问题。但这意味着这个 PR 的 Windows 测试目前从未被 CI 执行过,PR 描述里“CI is the gate for this branch”在事实上不成立。

署名

🤖 由 SpikeBot 003(ClaudeCode-JP) 生成

@SparkofSpike

Copy link
Copy Markdown
Contributor Author

追加:三条独立于 F1 的新发现(第一条是安全边界,请优先看)

上一条评论之后我把 checked_subagent_state_path 的两个调用方逐行读完,发现三件事。第一条推翻了我上一条里「没有新增越界写入面」的结论。


S1【中 · 确定】checked_subagent_state_path 的返回值在“<state_root>/.codewhale 是链接”时不在 <state_root> 之内 —— 而它被当作 state_root 用于非原子、无 O_NOFOLLOW 的写入

path_is_within_state_root 的第一行是宽松前缀(crates/tui/src/tools/subagent/mod.rs:10533-10535):

fn path_is_within_state_root(candidate: &Path, root: &Path) -> bool {
    if candidate.starts_with(root) {   // 纯字符串前缀,先短路
        return true;
    }
    ...

这条前缀在 effective_state_root 之前就已经在用了,不是本 PR 引入的。但本 PR 把它的后果变成了可达路径:

SubAgentTranscriptArtifactWriter::create(:10244-10262)里,state_root 是未解析的 <workspace>,path 是已解析的 <link-target>/state/subagent-transcripts/<digest>.jsonl。二者恰好共享 <workspace> 前缀,于是第一行直接 true,不再做任何解析(resolve_path_for_containment 那次根本不会执行)。reject_root_relative_symlinks 的 strip_prefix 同样成功(同样是纯前缀),于是它从 <workspace> 开始逐组件走 —— .codewhale 这个链接组件用 symlink_metadata 看是链接(:10721-10732),但循环从 walk_root 的子组件开始,不检查 root 自身,所以漏过。结果:

checked_subagent_state_path 返回 <link-target>/state/subagent-transcripts/<digest>.jsonl
                            (不在 <state_root> 之下)

而 Self { state_root, path, .. }(:10255)把未解析的 state_root 存了下来,后续每一次 append 都走 append_private_subagent_transcript(&self.state_root, &self.path, ..)(:10301):

#[cfg(unix)]
fn open_private_subagent_transcript(path: &Path, append: bool) -> Result<fs::File> {
    options.write(true).append(append).create(!append).truncate(!append)
        .custom_flags(libc::O_NOFOLLOW)   // 只保护**叶子**,不保护中间组件
    ...
}

也就是说:按设计本该在 state root 之内的写,实际落在 state root 之外,而且那条链上没有任何一层能证明它真的在界内。

对照证据(同一份代码里两种做法并存):

  • write_json_atomic → crate::utils::write_atomic → write_atomic_scoped(crates/tui/src/utils.rs:524-650)是原子 rename(tempfile::persist / Windows 上的 rename_windows_opened),对“目录被换成链接/挂载点”有天然抵抗;
  • open_subagent_state_file(读路径,:10766-10774)也带 O_NOFOLLOW;
  • 只有 transcript 的 create/append 是“非原子 + 只对叶子 O_NOFOLLOW”的直写。

这是不是本 PR 引入的?不是 —— 在旧代码里,这条路径在 <state_root>/.codewhale 是链接时直接报错(containment 失败),所以“写到自己认不出的地方”不可达。本 PR 把它变成可达了,同时没有更新那个“返回路径必然在 state root 内”的不变量(也没有留下说明这一点的注释或已知限制)。

攻击场景(具体,机制全部来自上面读到的代码):

  1. 前提一:<state_root>/.codewhale 已经是链接(本 PR 正是为这种用户而写)。这一步就足以触发下面所有内容。
  2. 前提二(可选,用来把影响放大到“任意路径写”):<link-target>/state/subagent-transcripts/ 存在,且 <digest>.jsonl 是一个指向别处的 hardlink。reject_root_relative_symlinks 只看 is_symlink()(:10726),hardlink 不是 symlink,所以它能过;O_NOFOLLOW 只拦叶子 symlink,不拦 hardlink;而这条路径上没有任何 write_atomic,所以 utils.rs 里为工作区写入准备的 hardlink 守卫(write_atomic_workspace 的 hard_link_count 检查)不会生效。随后 append 直接改写那个 inode。
    • 与既有结论的关系:docs/architecture/delegated-coordination.md 记着“state 目录放在别卷是用户自己的选择,不是应用信任边界的一部分”。所以我把它定为中、而不是高:能写 <link-target>/state/subagent-transcripts/ 的人通常已经在该卷上有写权限。但“该卷上的另一个普通用户”(例如 D:\CodeWhaleData 的 ACL 允许写入、或 Windows 的 %ProgramData% 式共享目录)就足以把内容写进你的 worker transcript 里 —— 而 transcript 会被 load_subagent_transcript_artifact 原样读回、交给 resume_from 作为子代理的对话历史(:12578-12584)。这是“向模型注入文本”的一条路。
  3. 真正的“任意路径”变体(我没能在这份 checkout 里证明可达,诚实标注):如果 <link-target>/state/subagent-transcripts/ 是链接或挂载点,那么 create_private_subagent_transcript 的 fs::create_dir_all(parent)(:10786)会跟随它,而两次 reject_root_relative_symlinks(:10782、:10789)都因为纯前缀短路只从 <workspace> 往下走 —— 目录组件 <link-target>/state/subagent-transcripts 的中间层没有任何一层被验证。我没有找到从 resolve_path_for_containment 得到“路径内部含链接组件”的构造(它的 canonicalize 会消掉中间链接,而 NotFound 回退只追加不解析的尾部),所以这一段是推测,不是结论。但 S1 本身就足以说明:这条链缺少一层“最终路径确实在 root 之内”的证明。

建议(最小改法):在 checked_subagent_state_path 里让 state_root 与 state_path 用同一个拼写域 —— 要么把重定根后的 root 一并返回给调用方,要么在 path_is_within_state_root 里先做 resolve 再做前缀判断(去掉那条宽松短路对“root 是链接”情形的适用)。另外把 transcript 的 create/append 换成 write_atomic 家族,或至少补 hard_link_count 守卫 —— 现有代码已经在别处这么做了,这里是唯一漏掉的一条。


S2【低 · 确定】checked_subagent_state_path 每次调用都读 symlink_metadata 并按结果重定根 —— 不是 TOCTOU 漏洞,但结果不可缓存、且同一逻辑在文件里被复制了两遍

checked_subagent_state_path 会被 build_persist_payload(:5564)、load_state(:5729、:5736)、SubAgentTranscriptArtifactWriter::create(:10334)、budget_handback::write_digest_artifact(budget_handback.rs:236)反复调用。每次都要一次 symlink_metadata(<state_root>/.codewhale) + 一次 resolve_path_for_containment。

这不是 TOCTOU 漏洞:链接在两次调用之间被替换只会导致报错,不会导致越界写入(reject_root_relative_symlinks 用 symlink_metadata 逐组件重走,会看到真实形态)。所以我不认为这是安全问题,只是把 F1 的窗口期放大成周期性:链接目标是空的那一瞬(例如上一个 transcript 被 remove_subagent_transcript_artifact(:10394-10410)清掉、父目录随之消失)每一次调用都会失败,而不是只失败一次。

顺带:这段“取 <state_root>/.codewhale 的链接目标”的解析在 CoordinationProcessLock::acquire(:3665-3682)里被独立重写了一遍,两处对“链接”的处理并不一致(那里在 create_dir_all 之前就拒绝,这里是解析后接受)。F1 的补丁如果只加在 effective_state_root,第二处仍会漂移。


S3【信息 · 确定】checked_subagent_state_path 的返回值是“记录用拼写”,但写路径沿用它 —— 与 #6947 对 session_artifact_absolute_path 的处理相反

#6947 专门给 session_artifact_absolute_path 加了一段文档,说它只是记录/展示用拼写,不可用于打开,读者必须走 open_session_relative(crates/tui/src/artifacts.rs 里那段注释)。checked_subagent_state_path 现在处于完全相同的位置 —— 它返回链接目标的真实拼写,既被记录(state_path、relative_path、receipt)又被用于写入 —— 但没有同样的说明,也没有区分“记录用”与“打开用”的接口。两个 sibling PR 对同一个问题给出了两套语义,长期看会漂移。


对我上一条评论的修正

  • 上一条 F5 里我写「我认为没有新增的越界写入面」——这句在 S1 面前不成立,请以 S1 为准。结论修正为:没有新增“任意路径写”的能力,但新增了“写到自己认不出的地方”的路径,且那条路径上的唯一残余守卫(O_NOFOLLOW)只保护叶子。
  • 上一条 F1 的严重性再抬一档:default_state_path 在窗口期报错 → state_path = None(mod.rs:10964-10972)→ 写能力子代理在 :8716 直接被拒(write-capable sub-agent launch requires a durable coordination state path)。所以 F1 不只让 transcript 写不了,它会让写能力子代理完全起不来。
  • 上一条 F5 第 6 条里我说 CoordinationProcessLock “其实是安全的”—— 这一条维持:strip_prefix(requested_root, lock_path) 成功时它从 root 的子组件开始走,不检查 root 自身,所以不会误拒 .codewhale 链接;这恰好也说明 S1 里那条宽松前缀是同一种已知形状,本 PR 让它第一次产生了实际后果。

仍未验证的(保持诚实)

  • 本机没有 Rust 工具链(rustc/cargo 均不存在),以上全部是读代码 + 用 Python 复刻 resolve_path_for_containment/path_is_within_state_root 的对照实验,我没有编译、没有运行任何测试、没有 Windows 环境验证 junction 语义。
  • S1 的“hardlink 放大”与“目录组件是链接”两段,前者机制确定(is_symlink + O_NOFOLLOW + 无 write_atomic 三处代码都读过),后者是推测(我未能构造出可达路径)。
  • 这个 PR 的 CI / DCO / CodeQL / OpenHarmony / Web Frontend 全部停在 action_required、jobs.total_count == 0;同期 fork PR(chore: drop 30 dead_code allows that no longer cover anything #6946、fix(execpolicy): a redirect is not a command separator #6929)与 main 的 push run 状态一致,判断为仓库级 fork-PR 审批配置而非本 PR 问题 —— 但这也意味着这两个新测试至今没有在任何 CI 上跑过。

署名

🤖 由 SpikeBot 003(ClaudeCode-JP) 生成

@SparkofSpike

Copy link
Copy Markdown
Contributor Author

勘误(行号,只此一处)

前两条评论里我写错了两处行号,其余引用我已逐行核对过:

  • 「default_state_path 报错 → state_path = None → 写能力子代理在 :8716 被拒」中的 mod.rs:10964-10972 应为 :10958-10964(default_state_path(&state_root) 的 match 与 None 分支)。:8716 的 write-capable sub-agent launch requires a durable coordination state path 核对无误。
  • SubAgentTranscriptArtifactWriter::create 的 :10244-10262 应为 :10244-10262 内的 :10244-10261(Ok(Self { state_root, path, .. }) 的右括号在 :10261)。
  • 新测试 state_path_stays_in_the_workspace_without_a_link 的断言在 tests.rs:11686(state_path.starts_with(&workspace)),测试体为 :11671-11690。我上一条里写的 11670-11688 是测试体范围,断言行请以 11686 为准。

其余行号(10482-10497、10459-10466、10502-10528、10533-10535、10781-10796、10798-10804、10824-10838、3665-3682、5564、5729、5736、10301、10334、10394-10410、12581-12582、budget_handback.rs:236、artifacts.rs:126-142、utils.rs:524-650、fleet/files.rs:772+、core/engine.rs:3628-3636)均为我在 290e21d 上 sed -n 核对过的内容行。

署名

🤖 由 SpikeBot 003(ClaudeCode-JP) 生成

…located

A workspace whose `.codewhale` is a junction — the documented way to keep
application state on another volume — failed every sub-agent at step 0 with
"sub-agent state path must stay within state root:
\\?\D:\...\state\subagent-transcripts\....jsonl".

default_state_path and the transcript artifact writer both build
`.codewhale/state/...` under the state root, so the junction resolves the
child into its target — exactly where that user keeps the state — while the
containment check still compares against the unresolved root and refuses.

effective_state_root re-roots only that known, user-owned link: when
`<state_root>/.codewhale` is a link and the child resolves inside its target,
the target becomes the root for both the containment check and the link walk.
Any other resolution keeps the stricter root, so an escape past both roots is
still refused.

Reproduced end to end before the fix: an installed 0.10.1 in a workspace with
.codewhale pointing at an outside target failed the child with steps=0 and
that exact message.
Review of this branch found the re-root applied too narrowly: only
checked_subagent_state_path used the effective root, while the guards that run
after it still compared the resolved path against the unresolved root. The
containment check passed and the write still failed with the same "must stay
within state root" message on the child's first step — prepare_subagent_transcript_parent,
append_private_subagent_transcript, write_json_atomic and read_subagent_state_file
all reach it, so the reported failure survived the fix.

reject_state_path_symlinks is now the guard those state-path callers use: it
resolves the same root decision checked_subagent_state_path made and then runs
the unchanged link walk. CoordinationProcessLock keeps rejecting a linked
.codewhale on purpose — its comment says create_dir_all must not populate the
link target — so the lock guards are left as they are.

The Windows test now drives the paths the child actually takes: manager
persist_state and load_state, then the transcript writer's create and append,
all under a junction whose target is outside the workspace.
…oved nothing

Review findings addressed:

- "The re-root only covers the branch where the target resolves" — checked
  against real Rust std rather than the Python stand-in the review used, and
  it does not hold. `std::fs::canonicalize` returns NotFound for a dangling
  junction (Python's os.path.realpath resolves it instead), so both the link
  and the child fall back to the link's own spelling and containment passes.
  The case is real — mklink /J accepts a target that does not exist, and
  creating the junction before the first run is a normal setup order — so it
  is now pinned as a regression case in the relocated-root test.

- state_path_stays_in_the_workspace_without_a_link is removed: it took the
  no-link branch, which is the behaviour before this branch, so it could not
  fail without the fix, and its assertion depended on the test process's
  working directory.
@SparkofSpike
SparkofSpike force-pushed the fix/subagent-state-junction-upstream branch from 00687a4 to 6021aec Compare October 9, 2026 19:31

This branch has not been deployed

No deployments
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