Skip to content

fix(core): keep no-follow reads protected where O_NOFOLLOW is missing - #10007

Merged
wenshao merged 21 commits into
QwenLM:mainfrom
yiliang114:fix/issue-8227-windows-nofollow
Aug 31, 2026
Merged

wenshao merged 21 commits into
QwenLM:mainfrom
yiliang114:fix/issue-8227-windows-nofollow

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a cross-platform "open without following symlinks" helper (openNoFollow / openSyncNoFollow in packages/core/src/utils/no-follow-open.ts) and routes the confirmed O_NOFOLLOW read call sites through it. On platforms that expose O_NOFOLLOW the helper simply ORs the kernel flag into the open flags — behavior is byte-for-byte unchanged. Where the constant does not exist (Windows: fs.constants.O_NOFOLLOW is undefined), the previous (O_RDONLY | (O_NOFOLLOW ?? 0)) expressions silently collapsed into a plain open that follows symlinks; the helper instead compensates with an lstat → open → fstat identity check that refuses symlinked final components, refuses opens whose dev/ino no longer matches the pre-open lstat (the swap race), and refuses filesystems that report ino: 0 because identity cannot be proven there. Every refusal carries code: 'ELOOP' so existing caller error handling applies unchanged.

Converged call sites: the validated @-file read path (readManyFiles.ts, both the text-handle read and the snapshot), session metadata reads (sessionStorageUtils.ts), background-shell output tails (backgroundShellRegistry.ts), untracked diff line counting and hunk synthesis (gitDiff.ts), the workspace registration store (cli/serve/workspace-registration-store.ts), and session-artifact workspace status (acp-bridge/sessionArtifacts.ts).

Why it's needed

PR #7206 hardened @-referenced file reads with symlink/TOCTOU protection, but on Windows that protection materially disappears because O_NOFOLLOW is undefined and the ?? 0 fallback drops the guarantee (issue #8227, claim 1). With the constant stubbed away (the Windows flag set), a symlink planted over a session file redirected the metadata read to the link target, and a symlink planted over a background-shell output file leaked its content into model context — both reproduced as red tests in this PR before the fix. Claim 2 of the issue (vacuous dev/ino checks) was already closed by #8290 and #9857; the fail-closed posture on ino: 0 established there is reused here via hasVerifiableInode.

Reviewer Test Plan

How to verify

Two new reproduction tests stub fs.constants.O_NOFOLLOW to undefined (the same seam session-start-profiler.test.ts already uses) and plant a symlink at the read path; they fail on main and pass with this PR:

cd packages/core
npx vitest run src/utils/sessionStorageUtils.test.ts src/services/backgroundShellRegistry.test.ts src/utils/no-follow-open.test.ts
  • Before: sessionStorageUtils ... expected undefined, received 'leaked-secret' and BackgroundShellRegistry ... expected '<task-notification>...' not to contain 'secret credentials'.
  • After: both pass; no-follow-open.test.ts additionally pins the native path (regular file opens, symlink → ELOOP, ENOENT passthrough) and the fallback path (symlink refusal, identity-mismatch refusal, ino: 0 refusal).

Broader targeted suites, all green: gitDiff.test.ts (124), readManyFiles.test.ts, sessionService.test.ts, session-transcript-reader.test.ts, gitDirect.test.ts, session-start-profiler.test.ts, fileReadCache.test.ts — 770 tests across 10 core files; cli workspace-registration-store.test.ts (26 passed / 1 skipped); acp-bridge sessionArtifacts.test.ts (126). tsc --noEmit clean for core, cli, and acp-bridge; ESLint clean on all changed files.

Evidence (Before & After)

N/A — no user-visible UI change (defense-in-depth on internal read paths).

Tested on

OS Status
🍏 macOS ⚠️ not tested (CI covers)
🪟 Windows ⚠️ not tested (CI covers; fallback path exercised via stubbed constants on Linux)
🐧 Linux ✅ tested

Environment (optional)

Unit tests only (vitest), Node v24.19.0.

Risk & Scope

  • Main risk or tradeoff: on platforms without O_NOFOLLOW the fallback adds one lstat + one fstat per open (low-frequency read paths only), and reads on ino: 0 volumes (FAT/exFAT, some SMB) now fail closed instead of opening without any no-follow guarantee — consistent with the posture decided in fix(core): fail closed on zero inode file cache #8290/fix(core): reject unverifiable validated read inodes #9857. POSIX behavior is unchanged (same kernel flag as before).
  • Not validated / out of scope: issue claim 3 (Windows CI runner coverage and un-skipping skipIf(process.platform === 'win32') tests) remains separate follow-up scope, as in fix(core): reject unverifiable validated read inodes #9857. Other O_NOFOLLOW call sites not confirmed in the issue thread (write paths such as skill-args-file.ts, gitUtils.ts, skill-curator.ts, session-writer-lease.ts; platform-guarded sites in session-start-profiler.ts / sessionService.ts; typeof-guarded voice-keyterms.ts / customBanner.ts) are intentionally left for a follow-up.
  • Breaking changes / migration notes: none.

Linked Issues

Refs #8227 (claim 1 compensating control; claim 3 remains follow-up). Claim 2 was closed by #8290 and #9857. Builds on the hardening introduced by #7206.

中文说明

本 PR 做了什么

新增跨平台"打开但不跟随符号链接"助手函数(packages/core/src/utils/no-follow-open.ts 中的 openNoFollow / openSyncNoFollow),并把已确认的 O_NOFOLLOW 读取调用点统一收敛过去。在提供 O_NOFOLLOW 的平台上,助手只是把内核标志按位或进打开标志——行为与之前完全一致。在该常量不存在的平台(Windows 上 fs.constants.O_NOFOLLOW 为 undefined),之前 (O_RDONLY | (O_NOFOLLOW ?? 0)) 的写法会静默塌缩成跟随符号链接的普通 open;助手改用 lstat → open → fstat 身份校验来补偿:拒绝末位成分是符号链接的路径,拒绝打开后 dev/ino 与打开前 lstat 不一致的情况(即替换竞态),并拒绝报告 ino: 0 的文件系统(因为无法证明文件身份)。所有拒绝都带 code: 'ELOOP',调用方现有的错误处理无需改动即可生效。

收敛的调用点:@ 文件校验读路径(readManyFiles.ts 的文本句柄读取与快照两处)、会话元数据读取(sessionStorageUtils.ts)、后台 shell 输出尾部(backgroundShellRegistry.ts)、untracked diff 行数统计与 hunk 合成(gitDiff.ts)、工作区注册存储(cli/serve/workspace-registration-store.ts)、会话产物工作区状态(acp-bridge/sessionArtifacts.ts)。

为什么需要

PR #7206 为 @ 引用文件读取加了符号链接/TOCTOU 防护,但在 Windows 上这层防护实质失效:O_NOFOLLOW 为 undefined,?? 0 回退把保证丢掉了(issue #8227 的 claim 1)。把该常量 stub 掉(即 Windows 的标志集)后可以复现:在会话文件上植入符号链接,元数据读取会被重定向到链接目标;在后台 shell 输出文件上植入符号链接,其内容会泄漏进模型上下文——两者都作为红测试包含在本 PR 中,修复前失败、修复后通过。issue 的 claim 2(dev/ino 空检)已由 #8290 和 #9857 关闭;本 PR 复用它们确立的 ino: 0 fail-closed 立场(hasVerifiableInode)。

审阅者测试计划

如何验证

两个新的复现测试把 fs.constants.O_NOFOLLOW stub 成 undefined(与 session-start-profiler.test.ts 已有的测试接缝相同),并在读取路径上植入符号链接;它们在 main 上失败,在本 PR 上通过:

cd packages/core
npx vitest run src/utils/sessionStorageUtils.test.ts src/services/backgroundShellRegistry.test.ts src/utils/no-follow-open.test.ts
  • 修复前:sessionStorageUtils ... expected undefined, received 'leaked-secret' 以及 BackgroundShellRegistry ... expected '<task-notification>...' not to contain 'secret credentials'。
  • 修复后:两者通过;no-follow-open.test.ts 还固化了原生路径(正常文件可打开、符号链接 → ELOOP、ENOENT 透传)和回退路径(符号链接拒绝、身份不一致拒绝、ino: 0 拒绝)。

更大范围的目标测试套件全部通过:gitDiff.test.ts(124)、readManyFiles.test.ts、sessionService.test.ts、session-transcript-reader.test.ts、gitDirect.test.ts、session-start-profiler.test.ts、fileReadCache.test.ts——core 共 10 个文件 770 个测试;cli 的 workspace-registration-store.test.ts(26 通过 / 1 跳过);acp-bridge 的 sessionArtifacts.test.ts(126)。tsc --noEmit 在 core、cli、acp-bridge 三个包均通过;所有改动文件 ESLint 通过。

证据(前后对比)

N/A——无用户可见 UI 变化(内部读取路径的纵深防御)。

测试环境

系统 状态
🍏 macOS ⚠️ 未测试(CI 覆盖)
🪟 Windows ⚠️ 未测试(CI 覆盖;回退路径已在 Linux 上通过 stub 常量验证)
🐧 Linux ✅ 已测试

运行环境(可选)

仅单元测试(vitest),Node v24.19.0。

风险与范围

  • 主要风险或取舍:在没有 O_NOFOLLOW 的平台上,回退路径每次 open 多一次 lstat + 一次 fstat(仅低频读取路径);在 ino: 0 的卷(FAT/exFAT、部分 SMB)上,这些读取现在会 fail closed,而不是在毫无 no-follow 保证的情况下打开——与 fix(core): fail closed on zero inode file cache #8290/fix(core): reject unverifiable validated read inodes #9857 确定的立场一致。POSIX 行为不变(与之前相同的内核标志)。
  • 未验证 / 超出范围:issue 的 claim 3(Windows CI runner 覆盖、去掉 skipIf(process.platform === 'win32') 的测试)照 fix(core): reject unverifiable validated read inodes #9857 的做法留作后续单独跟进。未在 issue 线程中确认的其他 O_NOFOLLOW 调用点(写路径如 skill-args-file.ts、gitUtils.ts、skill-curator.ts、session-writer-lease.ts;平台守卫的 session-start-profiler.ts / sessionService.ts;typeof 守卫的 voice-keyterms.ts / customBanner.ts)有意留给后续 PR。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Refs #8227(claim 1 的补偿控制;claim 3 留作后续)。claim 2 已由 #8290 和 #9857 关闭。建立在 #7206 引入的加固之上。

O_NOFOLLOW does not exist on Windows: fs.constants.O_NOFOLLOW is undefined, so the `(O_RDONLY | (O_NOFOLLOW ?? 0))` flag expressions silently collapse into a plain open that follows symlinks, dropping the symlink/TOCTOU hardening added for @-referenced file reads (QwenLM#7206). Add a cross-platform open helper that uses the kernel flag where present and otherwise compensates with an lstat -> open -> fstat identity check, refusing symlinked paths, identity races, and zero-inode filesystems (fail-closed, matching QwenLM#8290/QwenLM#9857). Route the confirmed no-follow read call sites through it: validated @-file reads, session metadata reads, background-shell output tails, untracked diff line counts, the workspace registration store, and session-artifact workspace status.

Co-authored-by: Qwen-Coder <[email protected]>
yiliang114 and others added 4 commits August 25, 2026 21:29
openSyncNoFollow bound node:fs through a namespace import, which vitest
resolves to its own copy of the externalized CJS module. Suites that spy
the fs object — sessionService.rename.test.ts stubs openSync/readSync for
fabricated session paths — never intercept that copy, so the open threw on
the mocked paths and the catch-all reported "no title" (10 of 19 tests red,
the CI Test failure). Take the fs binding through the default import the
way the callers' suites spy it, teach the doMock factories to carry the
stub on the default binding too, and stub lstatSync/fstatSync in the rename
suite so the Windows lstat -> open -> fstat fallback accepts the fabricated
paths as well.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
The fallback's inode-0 fail-closed refusal carried code 'ELOOP' like a
genuine symlink refusal, so consumers with ELOOP-specific handling
misfired on LEGITIMATE files on inode-0 volumes (Windows FAT/exFAT/SMB,
where Node reports ino 0): session-artifact workspace status flagged a
contained file as an escape, the workspace registration store reported a
regular store as "must be a regular file", and untracked text files
rendered as binary with dropped hunks. Give the inode-unverifiable
refusal its own code (EUNVERIFIABLE) plus an isUnverifiableIdentityError
guard, keep ELOOP for genuine symlink refusals and identity races, and
adjust the three consumers: the artifact status degrades to plain
'missing', the store surfaces an identity-unverifiable error, and the
untracked diff read falls back to the pre-QwenLM#8227 plain read (its lstat
gate already rejected symlinks and non-regular files).

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
…opens

No call site and no test ever passes flags or mode — repo-wide, every
invocation opens by path alone — yet the fs.open-shaped signature was
exported through the core index and invited write/create flags through a
helper whose docs, lstat -> open -> fstat semantics, and tests cover only
the read-only case (O_WRONLY | O_CREAT would create on POSIX while the
Windows fallback's pre-open lstat throws ENOENT for the same input).
Hardcode the read-only base flags and delete resolveBaseFlags; a PR that
first needs more can re-add them with a caller and tests.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
readLastJsonStringFieldsSync was rerouted through the no-follow helper in
the same pass as the single-field variant, but only the latter got a
symlink-refusal test — reverting the plural open to a plain fs.openSync
kept the whole suite green. Mirror the Windows-flag-set symlinked-session
test for the plural variant, asserting the all-undefined empty result;
the test is red on that mutant (leaked-secret surfaces) and green on the
restored code.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Closeout — cap-4 round on the 12:31Z review (single non-force push c067b924..47d0757fb to the fork branch):

  • C1 FIXED (bdecd4c): reproduced at head — sessionService.rename.test.ts 10 failed | 9 passed. Root cause: the no-follow helper bound node:fs via namespace import; vitest externalizes CJS namespace imports with their own module copy, so the suite's vi.spyOn(fs, ...) never intercepted it. Fix keeps the helper spy-mockable. Suite now 19/19 (= the red required CI Test).
  • C2 FIXED (84df828): inode-0 fail-closed refusal no longer carries code: 'ELOOP' — distinguishable from genuine symlink refusals, so consumer ELOOP handling (sessionArtifacts, workspace-registration-store, gitDiff) stops misfiring on legitimate files on inode-0 volumes (FAT/exFAT/SMB). The refusal posture itself (fix(core): fail closed on zero inode file cache #8290/fix(core): reject unverifiable validated read inodes #9857) unchanged. Note: spans 6 files (>3 ceiling) because the finding itself names 4 fix sites + test + index export; each hunk small and mechanical.
  • S1 FIXED (55ce9d7): dropped the never-used speculative flags?/mode? params from the no-follow opens (no caller repo-wide).
  • S3 FIXED (47d0757): plural readLastJsonStringFieldsSync symlink-refusal regression added; mutation red (plain fs.openSync revert leaks the secret).
  • S2 DEFERRED to next round (untouched, within cap): async TOCTOU re-check pinning test — valid, bot probe design ready.

Verification: rename suite 19/19, no-follow-open 10/10, sessionStorageUtils 55/55, 411 passed across 6 directly-hit files, cli workspace-registration-store 26 passed; typecheck clean core/cli/acp-bridge; eslint+prettier clean. Not run locally: gitDiff fixture tests (host git 2.19 lacks git init -b; fails identically without these changes). Push note: this host's git-over-https to github.com was blocked, so the push went through the GitHub Git Data API with every tree/commit SHA verified byte-identical before the fork ref fast-forward.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review of PR #10007 — fix(core): keep no-follow reads protected where O_NOFOLLOW is missing

Reviewed at: 47d0757 (HEAD), 13 files, +748/−81

Verdict: no blocking issues found.

Summary

This PR adds a cross-platform openNoFollow/openSyncNoFollow helper to replace the collapsing idiom (O_RDONLY ?? 0) | (O_NOFOLLOW ?? 0) used across six read call sites. On POSIX the helper ORs the same kernel flag — byte-for-byte unchanged. On Windows, where O_NOFOLLOW is undefined, the helper compensates with an lstat → open → fstat identity check that refuses to follow symlinks. Well-scoped, well-tested, and correct.

What I verified

  1. The fallback is correct and fail-closed. lstat refuses a symlinked final component; the post-open fstat must match dev/ino; ino: 0 volumes are refused through hasVerifiableInode. Each refusal closes the handle and carries either ELOOP (symlink/race) or EUNVERIFIABLE (inode-0) so callers can distinguish.

  2. Caller error semantics survive. workspace-registration-store keeps its ENOENT → empty path; gitDiff and backgroundShellRegistry keep their fail-safe catches; readManyFiles and sessionArtifacts keep their validated-identity re-checks.

  3. POSIX is untouched. Where O_NOFOLLOW exists the helper ORs the same flag — no behavioral change. The lazy fs.constants?.O_NOFOLLOW access also keeps strict vitest mocks loadable.

  4. Scope is disciplined. Six confirmed read sites converge; four redundant per-site flag helpers are deleted; write paths and platform-guarded sites are explicitly deferred per the issue thread.

  5. Test coverage is thorough. Two reproduction tests (session metadata leak, background-shell output leak) that fail on main and pass here. The new no-follow-open.test.ts covers the native path (open, ELOOP, ENOENT), the fallback path (symlink, identity mismatch, inode-0), and the sync/async variants.

Minor observations (none blocking)

  • The openUntrackedForDiffRead two-phase fallback (try openNoFollow → if EUNVERIFIABLE → plain open()) in gitDiff.ts has no dedicated test for the inode-0 degradation path. The behavior is correct and the existing gitDiff.test.ts covers the normal path, but the fallback is untested.
  • The readManyFiles.ts changes don't explicitly handle EUNVERIFIABLE from openNoFollow — on inode-0 volumes the error propagates through existing error handling with a technical error message. This is consistent with the ino: 0 posture from #8290/#9857.
  • The sessionStorageUtils.test.ts single-field and multi-field reproduction tests have nearly identical setup code. Minor style concern.

Conclusion

Clean, focused fix for a real Windows security gap. No regressions on POSIX. The two reproduction tests are real evidence that the fix works. Ready to merge once CI on the latest commit (47d0757) lands green.

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two-phase code review summary (round 1 only)

PR: #10007 — fix(core): keep no-follow reads protected where O_NOFOLLOW is missing
Head reviewed: 47d0757fb2e0ed2d9bec9b485d74e603142f2ec8
Round 1 model: deepseek-v4-flash
Round 2: skipped because round 1 reported findings

Verdict

No blocking issues, but 3 minor observations were raised:

  1. Untested inode-0 degradation path in gitDiff.ts — the EUNVERIFIABLE branch when hasVerifiableInode is false lacks direct test coverage.
  2. EUNVERIFIABLE propagation in readManyFiles.ts — confirm that callers downstream of the text-handle read correctly distinguish EUNVERIFIABLE from ELOOP so legitimate files on FAT/exFAT/SMB volumes are not misclassified as symlink attacks.
  3. Test setup duplication — the symlink-stubbing pattern is repeated across new tests; consider a shared helper.

Notes

  • The openNoFollow/openSyncNoFollow helper design looks correct: fail-closed on Windows, byte-for-byte unchanged on POSIX.
  • All six converged call sites route through the helper, and four redundant per-site flag helpers are removed.
  • Caller error semantics (ENOENT → empty, ELOOP → binary-row/<error>) are preserved.
  • The full review pipeline could not run due to a network failure (getaddrinfo() thread failed to start on git operations), so this pass was performed from the downloaded diff and relevant source files.

The async fallback's TOCTOU identity re-check in openNoFollow
(assertSameIdentity plus close-on-rejection) was pinned by no test:
deleting the whole try/catch block kept the suite green, because the
async symlink tests reject at the earlier isSymbolicLink() check and
only the sync variant's re-check was driven. A refactor dropping that
block would ship green while a path swapped for a symlink between
lstat and open gets read through on Windows and the rejection-path
handle leaks unclosed.

Add the async identity-change test using the same prototype trick as
the sync one, applied to fs.promises.lstat (the real opened
FileHandle's stat() cannot be intercepted through fs mocks): the
doctored before-stats carry ino + 1, so the real handle's stat
mismatches and the open must reject with code ELOOP after closing the
handle (asserted through a close spy). The test is red on the mutant
(block deleted: 1 failed | 10 passed) and green on restored code
(11 passed).

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Closeout round: cleared the last unresolved finding (deferred S2).

  • 88d16cb50 pins the async TOCTOU identity re-check of openNoFollow: fs.promises.lstat is mocked with doctored stats (ino + 1, same prototype trick as the sync test) so the real opened handle's stat mismatches before; the test asserts the ELOOP rejection and exactly one handle.close().
  • Mutation check: deleting the try { assertSameIdentity(...) } catch { await handle.close() } block fails exactly the new test (1 failed | 10 passed); restored code green. Suite 11/11.
  • Pushed 47d0757fb..88d16cb50 (non-force); thread replied with SHA evidence + resolved. 0 unresolved now; review lane auto-triggered by the push.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

Co-authored-by: Qwen-Coder <[email protected]>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

The Ubuntu failure was PR-caused, not an infra flake: check:serve-fast-path-bundle found the ACP pre-listen closure pulling in the core barrel, including shell.ts, fzf, @iarna/toml, chokidar, and glob.

Fixed in 7354a1f2b by exporting the no-follow helper through a leaf core subpath and importing that subpath from sessionArtifacts.ts, keeping the ACP path off the eager barrel. The directly hit bundle-check suite passes (35 passed). Full core typecheck/build could not be reproduced in this existing worktree because its shared dependencies are stale/incomplete (missing OTel/fdir/etc.); no install was run. Fresh CI is now running on the new head.

yiliang114 and others added 8 commits August 26, 2026 09:52
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Both integration-tests and acp-bridge import the new
@qwen-code/qwen-code-core/noFollowOpen subpath but had no paths entry
for it, so typecheck resolved it through core's pre-built dist and
failed with TS2307 on dist-less trees. Add the source entries next to
the sibling subpaths, following the documented 'keep in sync with the
exports maps' invariant.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Three pins for the no-follow fallback's guarantee surface:
- spy fs.closeSync in both sync rejection tests (identity change,
  inode 0) so a dropped close in openSyncNoFollow's catch turns red,
  mirroring the async closeSpy pin
- add a dev-mismatch identity variant so deleting the dev comparison
  from assertSameIdentity turns red (ino alone is unique per device)
- add sync+async tests that perturb every lstat after the first call
  and expect the open to SUCCEED, pinning that the identity re-check
  compares against the pre-open lstat snapshot rather than a fresh
  post-open lstat

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>

@doudouOUC doudouOUC left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review of PR #10007 — fix(core): keep no-follow reads protected where O_NOFOLLOW is missing

Reviewed at: e2b59655 (HEAD), 19 files, +1029/−81
Previous rounds: 2 rounds (CHANGES_REQUESTED twice), 15 findings posted (3 Criticals, 12 Suggestions); this re-review at commit e2b59655

Previous findings — status

3 Criticals — all resolved:

  • R1-1 (sessionStorageUtils.ts rerouting breaks sessionService.rename.test.ts): fixed in bdecd4c8 — lstatSync/fstatSync spies added to the rename suite; default-import binding ensures vitest spies intercept the helper. ✓
  • R1-2 (inode-0 carries ELOOP, misclassifying legitimate files): fixed in 84df828c — UNVERIFIABLE_IDENTITY_CODE (EUNVERIFIABLE) replaces ELOOP for inode-0 refusals; all three consumers (sessionArtifacts, workspace-registration-store, gitDiff) distinguish the two codes. ✓
  • R2-1 (cli vitest config missing subpath alias, breaking 63 tests): fixed in d6e4a63a — alias entries in both cli/vitest.config.ts and acp-bridge/vitest.config.ts. ✓

12 Suggestions — 10 resolved, 2 standing (see below):

  • R1-3 (flags/mode params): doc comment clarifies read-only-only. ✓
  • R1-4 (async TOCTOU re-check): tested in no-follow-open.test.ts. ✓
  • R1-5 (multi-field symlink refusal): tested in sessionStorageUtils.test.ts. ✓
  • R2-2 (default-import mockability): test factories set default: modified. ✓
  • R2-3 (sessionArtifacts EUNVERIFIABLE branch untested): still standing — no test exercises the isUnverifiableIdentityError branch in sessionArtifacts.ts.
  • R2-4 (cli workspace-registration-store EUNVERIFIABLE branch untested): now tested ("reports an unverifiable store identity as a store error"). ✓
  • R2-5 (gitDiff inode-0 degradation path untested): still standing — the openUntrackedForDiffRead fallback to plain open() on EUNVERIFIABLE has no test in gitDiff.test.ts.
  • R2-6 (sync rejection-path fd close unpinned): now tested (closeSpy). ✓
  • R2-7/R2-8 (subpath export config): package.json export entry + tsconfig/vitest aliases all present. ✓
  • R2-9 (dev identity check untested): now tested. ✓
  • R2-10 (pre-open snapshot comparison untested): now tested. ✓

My review — no new issues found

I reviewed the full diff (1421 lines across 19 files) and the prior review threads. The implementation is correct and well-tested. Key observations:

1. Helper design is correct. The openNoFollow/openSyncNoFollow functions are cleanly split between the POSIX fast path (kernel O_NOFOLLOW — byte-for-byte unchanged) and the fallback path (lstat → open → fstat identity check). The fallback correctly:

  • Refuses symlinked final components via pre-open lstat
  • Refuses dev/ino identity mismatches (TOCTOU swap race)
  • Refuses inode-0 volumes via hasVerifiableInode with a distinct error code (EUNVERIFIABLE)

2. Error code separation is consistent. EUNVERIFIABLE is used ONLY for the inode-0 case; all genuine symlink/race refusals carry ELOOP. All three consumers that distinguish them (sessionArtifacts.ts, workspace-registration-store.ts, gitDiff.ts) use the correct code checks. The remaining three consumers (sessionStorageUtils.ts, backgroundShellRegistry.ts, readManyFiles.ts) do not need to distinguish — their catch-all handling is correct for either code.

3. Cross-package integration is complete. The four subpath export entries (package.json, both vitest.config.ts files, acp-bridge tsconfig.json, integration-tests tsconfig.json) are all present. The serve-fast-path-bundle-check.test.js confirms the leaf import does not pull the core barrel.

4. Test coverage is thorough. The no-follow-open.test.ts (468 lines) covers both the native and fallback paths with all error variants. The reproduction tests in sessionStorageUtils.test.ts and backgroundShellRegistry.test.ts prove the fix works by failing on main and passing here. The sessionService.rename.test.ts compatibility test confirms the helper does not break existing mock patterns.

5. Two minor Suggestions remain standing (from previous rounds, not new):

  • R2-3 (Suggestion): sessionArtifacts.ts isUnverifiableIdentityError branch is untested. The sessionArtifacts.test.ts file is not in this PR's diff. Adding a test there would require setting up O_NOFOLLOW=undefined + ino: 0 in the acp-bridge test suite.
  • R2-5 (Suggestion): gitDiff.ts openUntrackedForDiffRead inode-0 degradation path is untested in gitDiff.test.ts. The fallback behavior (plain open() on EUNVERIFIABLE) is correct but has no dedicated test.

Verdict

No blocking issues. The 3 Criticals from previous rounds are all verified fixed at this HEAD. The 2 remaining Suggestions are minor and non-blocking. The implementation is correct, well-tested, and consistent with the established #8290/#9857 posture. Ready to merge (pending CI green).

— Independent review (no worktree: git fetch blocked on Windows; reviewed from full diff, 15 prior finding threads, and PR context)

yiliang114 and others added 2 commits August 26, 2026 16:02
…gitDiff

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
The ino-mismatch and dev-mismatch identity-change tests never plant a
symlink: they write a plain file and perturb fstatSync via vi.doMock.
The itNoSymlink guard therefore only skipped them on win32, the one
platform where the lstat/open/fstat fallback is the production path.
Switch both to plain it, matching the async twin.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
packages/cli imports @qwen-code/qwen-code-core/noFollowOpen in
workspace-registration-store.ts but had no paths entry for the leaf,
so cli typecheck resolved it through core's compiled dist and breaks
in deep-cleaned worktrees. Mirror the entry already added for
acp-bridge and integration-tests.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

CI note — Test (ubuntu-latest, Node 22.x) failure on run 32945995633 is an infra flake, not PR-caused:

  • Log shows Test Files 65 passed (65) / Tests 1743 passed | 11 skipped — zero assertion failures.
  • The only error is Error: [vitest-worker]: Timeout calling "onTaskUpdate" (vitest worker RPC hang in Unhandled Errors).
  • CI's own deflake machinery classified it: deflake issue creation failed for #42; the rerun stands and it retries on the next flaky occurrence: 502 from GitHub.
    Conclusion: known flake TypeError: Cannot read properties of undefined (reading 'value') #42; rerun needed.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

Extract the repeated O_NOFOLLOW-less node:fs mock skeleton into a shared
mockNoFollowFs factory (the load-bearing `default` member stays) plus a
perturbedStats helper, and drop the six per-test doUnmock/resetModules
blocks already covered by the describe-level afterEach. Add sync, async,
and inode-0 variants asserting a failing rejection-path close never masks
the pinned ELOOP / EUNVERIFIABLE refusal codes.

Co-authored-by: Qwen-Coder <[email protected]>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

2 similar comments
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Merge conflict with main resolved by merging main into the branch (merge commit e2c80ed, plain non-force push — branch history preserved).

Conflict: packages/acp-bridge/src/sessionArtifacts.ts only. Main switched the workspace identity check to bigint stats (NTFS 64-bit file-id precision); this PR replaced the raw O_NOFOLLOW open with openNoFollow(). Resolution keeps both: bigint lstat for the pre-open identity, and the openNoFollow() helper for the open, so no-follow protection stays intact on platforms without O_NOFOLLOW (#8227).

Verification at the merge commit:

  • core: no-follow-open.test.ts, sessionStorageUtils.test.ts, gitDiff.test.ts (127/127), backgroundShellRegistry.test.ts, sessionService.rename.test.ts — all pass
  • acp-bridge: sessionArtifacts.test.ts — 131/131 pass
  • cli: workspace-registration-store.test.ts — 27 pass / 1 skipped
  • typecheck clean for core, acp-bridge, cli

Mergeable: now MERGEABLE (was CONFLICTING).

Note: reviewDecision shows CHANGES_REQUESTED, but the only CR reviews on this PR are two stale ones from qwen-code-ci-bot; the latest human review (@doudouOUC, 2026-08-26) found no blocking issues.

@wenshao

wenshao commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Local verification of PR #10007 — real environment, real symlinks

I built both arms locally (main at merge-base 2bd0ff9 vs. this PR at e2c80ed) and verified the claim end to end rather than only re-running the suite. Verdict up front: the fix does what it says, no blocking issue found. Two non-blocking observations are at the bottom, one of which is a one-line hardening I'd suggest before merge but would not hold the PR for.

How the Windows condition was reproduced

The interesting behaviour only exists where fs.constants.O_NOFOLLOW is undefined, so instead of mocking fs I removed the constant process-wide with an ESM loader hook (node --import) that resolves every node:fs / fs import to a shim: the real module, with exactly one property replaced. Everything else stays real — real files, real openat, real symlinks, the real bundled CLI. A control probe confirms a plain open still follows a symlink under the shim, i.e. only the flag is gone, not the kernel semantics.

Two more real-condition simulators were built for the fail-closed paths:

  • an LD_PRELOAD shim intercepting the syscall(SYS_statx, …) wrapper libuv actually calls (glibc's statx() wrapper is bypassed by libuv, so the naive interposer does nothing) to make one directory subtree report st_ino == 0, the way FAT/exFAT does;
  • the same interposer remapping two files' inodes to the NTFS shape — adjacent 64-bit ids above 2^53.

What the attack actually does, per arm

evidence board

Both reproductions in the PR description hold on a real filesystem:

  • Session metadata — a symlink planted at the session path redirects the read. On main under the Windows flag set the attacker-controlled title reaches the caller; with the PR it is refused. Same for the multi-field reader and for a symlink pointing outside the chats directory.
  • Background-shell output tail — on main under the Windows flag set the symlink target's content arrives verbatim inside the <output-tail> element of the task notification, i.e. in model context. I used AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG as the payload; with the PR the element degrades to <output-tail error="unreadable" />.
  • Controls do not over-refuse — an ordinary regular file and a hard link to one are read normally in all four quadrants.

Driving the real bundled CLI, the session-metadata case is directly visible in /resume. A session-shaped symlink was planted in a real chats/ directory pointing at an attacker file outside it; a genuine session sits next to it as a control.

main, Windows flag set PR #10007, Windows flag set
main pr

The attacker-controlled title PWNED-BY-SYMLINK-ATTACK is listed on main; with the PR it is gone and the entry falls back to its first user message, while the genuine session Refactor the billing module renders identically in both. For completeness, the same main build on native POSIX does not show it — confirming this was a Windows-only exposure, not a general regression:

main on posix

Race behaviour

A separate attacker process renamed the read path back and forth between a regular file and a symlink while a reader hammered the metadata reader. Over roughly a million reads per arm:

Arm Flag set Reads Attacker flips Leaks
main Windows 1,165,838 1,386,798 968,005
PR #10007 Windows 1,382,065 1,405,386 0
main POSIX 952,132 1,043,250 0
PR #10007 POSIX 678,087 1,054,766 0

I also tried to defeat the identity proof by getting the legitimate file's inode freed and reused by the attacker's payload: 1,072,354 attempts on ext4, zero inode-reuse hits — the allocator does not hand a just-freed inode straight back, so that theoretical gap is not reachable there.

POSIX is genuinely untouched

strace on both arms opening the same session file produces an identical sequence — one statx, then openat(AT_FDCWD, …, O_RDONLY|O_NOFOLLOW|O_CLOEXEC). No extra lstat/fstat, same flags. The "byte-for-byte unchanged on POSIX" claim is confirmed at the syscall level, not just by reading the code.

Suites, lint, typecheck, and a negative control

  • core: 332 passed across no-follow-open, sessionStorageUtils, backgroundShellRegistry, gitDiff, readManyFiles, sessionService.rename. cli workspace-registration-store: 27 passed / 1 skipped. acp-bridge sessionArtifacts: 131 passed.
  • ESLint --max-warnings 0 clean on all changed sources; tsc --noEmit exit 0 for core, cli and acp-bridge.
  • Negative control: reverting only the six call sites to merge-base while keeping the PR's tests flips exactly 7 tests red (4 core, 1 cli, 2 acp-bridge) and nothing else. The new tests discriminate the fix rather than merely passing.

Non-blocking observations

N1 — the fallback's identity proof compares ino as a Number, and it only ever runs on Windows.
assertSameIdentity compares before.ino !== after.ino from plain lstat/fstat. Stats.ino is a double; on Windows it carries the 64-bit NTFS file id, and above 2^53 adjacent ids collapse onto the same Number. That is the same hazard sessionArtifacts.ts calls out in this very PR — "NTFS file ids are 64-bit and the Number spelling loses precision above 2^53, so two files created close together can round to the SAME numeric ino and defeat the swap check" — and solves with { bigint: true }. The fallback, which is the only identity check that runs on Windows, kept the Number spelling.

Remapping two files' inodes to that shape and re-running the race:

Helper Inode shape Reads Leaks Benign reads
PR as-is real ext4 inodes 243,614 0 8,880
PR as-is NTFS-shaped, ids > 2^53 199,374 35,766 6,036
same helper, lstat/fstat → { bigint: true } NTFS-shaped, ids > 2^53 235,233 0 6,936

Four call sites, no other change; legitimate reads still succeed at the same rate. Whether real NTFS volumes commonly carry file ids past 2^53 depends on how far the volume's MFT sequence numbers have advanced, which I can't measure from Linux — but the precondition is the one this PR already treats as real elsewhere, and the fix is cheap. Not blocking, since relative to main (no protection at all on Windows) the PR is a strict improvement either way.

N2 — the gitDiff degradation comment is stronger than the behaviour.
openUntrackedForDiffRead says the fallback "cannot follow a symlink the gate did not already accept". Under a concurrent attacker on an ino-0 volume it can: 2,255 diff runs, 76 of which counted the symlink target's 40 lines instead of the 3-line file inside the worktree (0/2,287 with normal inodes, where the helper is enforced). The window is exactly the pre-#8227 one and the PR does not make it worse, so this is a wording nit rather than a defect — something like "the window is the same one that existed before #8227" would be accurate.

N3 — the two readManyFiles conversions are pure defence in depth.
Driving a real @-reference at a symlink through the bundled CLI, the file content never reaches the wire on either arm, on either flag set — an earlier realpath gate rejects it first. Correct to convert, but the observable attack-surface reduction in this PR comes from sessionStorageUtils and backgroundShellRegistry.

N4 — the fail-closed cost is real but bounded and visible.
On an ino-0 volume under the Windows flag set, a legitimate session file's title is no longer read (main returns it, the PR returns undefined), so /resume falls back to the first user message. Nothing crashes and POSIX is unaffected — the same probe on native POSIX still returns the title. This matches the posture the PR describes; flagging it only so the tradeoff is on the record.

中文版

PR #10007 本地验证 —— 真实环境、真实符号链接

我在本地构建了两个臂(merge-base 2bd0ff9 的 main 与本 PR 的 e2c80ed),端到端验证了 PR 的主张,而不只是重跑测试套件。结论先说:修复确实做到了它声称的事,未发现阻塞问题。 文末两条非阻塞观察,其中一条是我建议合并前顺手加上的一行加固,但不足以拦住本 PR。

Windows 条件是怎么复现的

只有在 fs.constants.O_NOFOLLOW 为 undefined 时才存在待验证行为,所以我没有 mock fs,而是用 ESM loader hook(node --import)在整个进程范围把该常量拿掉:所有 node:fs / fs 的 import 都解析到一个 shim —— 真实模块,只替换一个属性。其余全是真的:真实文件、真实 openat、真实符号链接、真实打包后的 CLI。对照探针确认 shim 下普通 open 仍会跟随符号链接,即只有标志消失了,内核语义没变。

另外为 fail-closed 路径做了两个真实条件模拟器:

  • 一个 LD_PRELOAD shim,拦截 libuv 实际调用的 syscall(SYS_statx, …) 包装(libuv 绕过 glibc 的 statx() 包装,所以朴素的 interposer 不起作用),让某个目录子树报告 st_ino == 0,即 FAT/exFAT 的形态;
  • 同一 interposer 把两个文件的 inode 重映射成 NTFS 形态 —— 大于 2^53 的相邻 64 位 id。

攻击在各臂的真实效果

PR 描述里的两个复现在真实文件系统上都成立:

  • 会话元数据 —— 在会话路径上植入符号链接可重定向读取。main 在 Windows 标志集下,攻击者控制的标题会到达调用方;本 PR 则拒绝。多字段读取器、以及指向 chats 目录之外的符号链接,结果相同。
  • 后台 shell 输出尾 —— main 在 Windows 标志集下,符号链接目标的内容原样出现在 task notification 的 <output-tail> 元素里,也就是进入了模型上下文。我用 AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG 作为载荷;本 PR 下该元素降级为 <output-tail error="unreadable" />。
  • 对照没有被过度拒绝 —— 普通文件与指向普通文件的硬链接,在四个象限里都能正常读取。

驱动真实打包 CLI 时,会话元数据这一条在 /resume 里直接可见。我在真实的 chats/ 目录里植入了一个 session 形状的符号链接,指向目录外的攻击者文件;旁边是一个真实会话作为对照。

main 上会列出攻击者控制的标题 PWNED-BY-SYMLINK-ATTACK;本 PR 下它消失,该条目回退到首条用户消息,而真实会话 Refactor the billing module 在两臂渲染完全一致。作为补充,同一个 main 构建在原生 POSIX 下不会显示它 —— 确认这是 Windows 独有的暴露面,而非普遍回归。

竞态行为

另起一个攻击者进程,在普通文件与符号链接之间来回 rename 读取路径,同时读取方高频调用元数据读取器。每臂约一百万次读取:

臂 标志集 读取次数 攻击者交换次数 泄漏次数
main Windows 1,165,838 1,386,798 968,005
PR #10007 Windows 1,382,065 1,405,386 0
main POSIX 952,132 1,043,250 0
PR #10007 POSIX 678,087 1,054,766 0

我还尝试通过让合法文件的 inode 被释放并被攻击者载荷复用来击破身份校验:ext4 上 1,072,354 次尝试,零命中 —— 分配器不会把刚释放的 inode 立刻发回,所以这个理论缺口在 ext4 上不可达。

POSIX 确实没被动过

对两臂打开同一个会话文件做 strace,得到完全相同的序列 —— 一次 statx,然后 openat(AT_FDCWD, …, O_RDONLY|O_NOFOLLOW|O_CLOEXEC)。没有多出 lstat/fstat,标志一致。"POSIX 上逐字节不变"这一主张在系统调用层得到确认,而不只是读代码推断。

测试、lint、typecheck 与反向对照

  • core:no-follow-open、sessionStorageUtils、backgroundShellRegistry、gitDiff、readManyFiles、sessionService.rename 共 332 通过。cli 的 workspace-registration-store:27 通过 / 1 跳过。acp-bridge 的 sessionArtifacts:131 通过。
  • 所有改动源文件 ESLint --max-warnings 0 通过;core、cli、acp-bridge 的 tsc --noEmit 均退出 0。
  • 反向对照:只把六个调用点回退到 merge-base、保留本 PR 的测试,恰好 7 个测试翻红(core 4、cli 1、acp-bridge 2),无其他变化。说明新测试是在辨别这个修复,而不只是恰好为绿。

非阻塞观察

N1 —— 回退路径的身份校验按 Number 比较 ino,而它只会在 Windows 上运行。
assertSameIdentity 用普通 lstat/fstat 比较 before.ino !== after.ino。Stats.ino 是 double;在 Windows 上它承载 64 位 NTFS file id,超过 2^53 后相邻 id 会塌缩到同一个 Number。这正是本 PR 的 sessionArtifacts.ts 自己点名的隐患 —— "NTFS file ids are 64-bit and the Number spelling loses precision above 2^53, so two files created close together can round to the SAME numeric ino and defeat the swap check" —— 并用 { bigint: true } 解决了。而这个回退路径,是 Windows 上唯一会跑的身份校验,却保留了 Number 写法。

把两个文件的 inode 重映射成该形态后重跑竞态:

Helper inode 形态 读取次数 泄漏次数 合法读取
PR 原样 真实 ext4 inode 243,614 0 8,880
PR 原样 NTFS 形态,id > 2^53 199,374 35,766 6,036
同一 helper,lstat/fstat → { bigint: true } NTFS 形态,id > 2^53 235,233 0 6,936

四个调用点,没有其他改动;合法读取的成功率不变。真实 NTFS 卷上 file id 是否普遍超过 2^53,取决于该卷 MFT sequence number 推进到了什么程度,这我在 Linux 上无法测量 —— 但这个前提正是本 PR 在别处已当作真实来处理的,而修复成本很低。不阻塞:相对 main(Windows 上完全没有保护),本 PR 无论如何都是严格改进。

N2 —— gitDiff 降级路径的注释比实际行为说得更满。
openUntrackedForDiffRead 写的是回退*"cannot follow a symlink the gate did not already accept"*。在 ino-0 卷上遇到并发攻击者时它是会的:2,255 次 diff 运行中有 76 次统计到了符号链接目标的 40 行,而不是工作区内那个 3 行文件(正常 inode 下为 0/2,287,此时 helper 强制生效)。这个窗口与 #8227 之前完全相同,本 PR 并没有让它变差,所以这是措辞问题而非缺陷 —— 改成"该窗口与 #8227 之前的一致"就准确了。

N3 —— readManyFiles 的两处改造是纯纵深防御。
用打包 CLI 驱动真实的 @ 引用指向符号链接,两个臂、两种标志集下,文件内容都没有上线 —— 更早的 realpath 门先把它拒了。改造本身是对的,但本 PR 里可观察到的攻击面缩减,来自 sessionStorageUtils 和 backgroundShellRegistry。

N4 —— fail-closed 的代价真实存在,但有界且可见。
在 ino-0 卷 + Windows 标志集下,一个合法会话文件的标题不再能被读出(main 返回标题,本 PR 返回 undefined),于是 /resume 回退到首条用户消息。不会崩溃,POSIX 也不受影响 —— 同一探针在原生 POSIX 上仍返回标题。这与 PR 描述的立场一致;列出来只是把这个取舍记录在案。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@wenshao
wenshao added this pull request to the merge queue Aug 31, 2026
Merged via the queue into QwenLM:main with commit 954f3a9 Aug 31, 2026
63 of 69 checks passed
pull Bot pushed a commit to mcx/qwen-code that referenced this pull request Sep 13, 2026
…ssion mocks (QwenLM#11750)

The no-follow open fallback used when O_NOFOLLOW is unavailable performs an lstat/open/fstat identity check while opening the file, adding one fstat before the first tail read. Two tests inject a stale size on the first observed fstat to simulate growth between the stat and the read; on Windows the injection now lands on the identity fstat, so the first tail read sees the final file and one case fails deterministically in the nightly Windows lane.

Skip the open-time identity fstat (one call, only where O_NOFOLLOW is missing) so the stale size reaches the first real tail read on every platform. POSIX behavior is unchanged, and the sibling re-read case now actually exercises its intended stale-tail race on Windows.

Regression from QwenLM#10007, which landed after the QwenLM#10064 Windows lane sweep.

Co-authored-by: F415643 <[email protected]>
Co-authored-by: Shaojin Wen <[email protected]>
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.

3 participants