Repository navigation
fix(core): keep no-follow reads protected where O_NOFOLLOW is missing - #10007
Conversation
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]>
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>
|
Closeout — cap-4 round on the 12:31Z review (single non-force push
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 |
doudouOUC
left a comment
There was a problem hiding this comment.
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
-
The fallback is correct and fail-closed.
lstatrefuses a symlinked final component; the post-openfstatmust match dev/ino;ino: 0volumes are refused throughhasVerifiableInode. Each refusal closes the handle and carries eitherELOOP(symlink/race) orEUNVERIFIABLE(inode-0) so callers can distinguish. -
Caller error semantics survive.
workspace-registration-storekeeps its ENOENT → empty path;gitDiffandbackgroundShellRegistrykeep their fail-safe catches;readManyFilesandsessionArtifactskeep their validated-identity re-checks. -
POSIX is untouched. Where
O_NOFOLLOWexists the helper ORs the same flag — no behavioral change. The lazyfs.constants?.O_NOFOLLOWaccess also keeps strict vitest mocks loadable. -
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.
-
Test coverage is thorough. Two reproduction tests (session metadata leak, background-shell output leak) that fail on
mainand pass here. The newno-follow-open.test.tscovers the native path (open, ELOOP, ENOENT), the fallback path (symlink, identity mismatch, inode-0), and the sync/async variants.
Minor observations (none blocking)
- The
openUntrackedForDiffReadtwo-phase fallback (tryopenNoFollow→ if EUNVERIFIABLE → plainopen()) ingitDiff.tshas no dedicated test for the inode-0 degradation path. The behavior is correct and the existinggitDiff.test.tscovers the normal path, but the fallback is untested. - The
readManyFiles.tschanges don't explicitly handleEUNVERIFIABLEfromopenNoFollow— on inode-0 volumes the error propagates through existing error handling with a technical error message. This is consistent with theino: 0posture from #8290/#9857. - The
sessionStorageUtils.test.tssingle-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
left a comment
There was a problem hiding this comment.
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:
- Untested inode-0 degradation path in
gitDiff.ts— theEUNVERIFIABLEbranch whenhasVerifiableInodeis false lacks direct test coverage. EUNVERIFIABLEpropagation inreadManyFiles.ts— confirm that callers downstream of the text-handle read correctly distinguishEUNVERIFIABLEfromELOOPso legitimate files on FAT/exFAT/SMB volumes are not misclassified as symlink attacks.- Test setup duplication — the symlink-stubbing pattern is repeated across new tests; consider a shared helper.
Notes
- The
openNoFollow/openSyncNoFollowhelper 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 starton 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>
|
Closeout round: cleared the last unresolved finding (deferred S2).
|
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com> Co-authored-by: Qwen-Coder <[email protected]>
|
The Ubuntu failure was PR-caused, not an infra flake: Fixed in |
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
left a comment
There was a problem hiding this comment.
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) replacesELOOPfor 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)
…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>
|
CI note —
|
|
@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]>
|
@qwen-code /triage |
|
Merge conflict with Conflict: Verification at the merge commit:
Mergeable: now Note: |
Local verification of PR #10007 — real environment, real symlinksI built both arms locally ( How the Windows condition was reproducedThe interesting behaviour only exists where Two more real-condition simulators were built for the fail-closed paths:
What the attack actually does, per armBoth reproductions in the PR description hold on a real filesystem:
Driving the real bundled CLI, the session-metadata case is directly visible in
The attacker-controlled title Race behaviourA 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:
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
Suites, lint, typecheck, and a negative control
Non-blocking observationsN1 — the fallback's identity proof compares Remapping two files' inodes to that shape and re-running the race:
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 N2 — the N3 — the two N4 — the fail-closed cost is real but bounded and visible. 中文版PR #10007 本地验证 —— 真实环境、真实符号链接我在本地构建了两个臂(merge-base Windows 条件是怎么复现的只有在 另外为 fail-closed 路径做了两个真实条件模拟器:
攻击在各臂的真实效果PR 描述里的两个复现在真实文件系统上都成立:
驱动真实打包 CLI 时,会话元数据这一条在
竞态行为另起一个攻击者进程,在普通文件与符号链接之间来回 rename 读取路径,同时读取方高频调用元数据读取器。每臂约一百万次读取:
我还尝试通过让合法文件的 inode 被释放并被攻击者载荷复用来击破身份校验:ext4 上 1,072,354 次尝试,零命中 —— 分配器不会把刚释放的 inode 立刻发回,所以这个理论缺口在 ext4 上不可达。 POSIX 确实没被动过对两臂打开同一个会话文件做 测试、lint、typecheck 与反向对照
非阻塞观察N1 —— 回退路径的身份校验按 Number 比较 把两个文件的 inode 重映射成该形态后重跑竞态:
四个调用点,没有其他改动;合法读取的成功率不变。真实 NTFS 卷上 file id 是否普遍超过 2^53,取决于该卷 MFT sequence number 推进到了什么程度,这我在 Linux 上无法测量 —— 但这个前提正是本 PR 在别处已当作真实来处理的,而修复成本很低。不阻塞:相对 N2 —— N3 —— N4 —— fail-closed 的代价真实存在,但有界且可见。 🤖 Generated with Claude Code — Claude Opus 5 (1M context) |
…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]>




What this PR does
Adds a cross-platform "open without following symlinks" helper (
openNoFollow/openSyncNoFollowinpackages/core/src/utils/no-follow-open.ts) and routes the confirmedO_NOFOLLOWread call sites through it. On platforms that exposeO_NOFOLLOWthe 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_NOFOLLOWisundefined), the previous(O_RDONLY | (O_NOFOLLOW ?? 0))expressions silently collapsed into a plain open that follows symlinks; the helper instead compensates with anlstat→open→fstatidentity check that refuses symlinked final components, refuses opens whose dev/ino no longer matches the pre-openlstat(the swap race), and refuses filesystems that reportino: 0because identity cannot be proven there. Every refusal carriescode: '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 becauseO_NOFOLLOWisundefinedand the?? 0fallback 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 onino: 0established there is reused here viahasVerifiableInode.Reviewer Test Plan
How to verify
Two new reproduction tests stub
fs.constants.O_NOFOLLOWtoundefined(the same seamsession-start-profiler.test.tsalready uses) and plant a symlink at the read path; they fail onmainand pass with this PR:sessionStorageUtils ... expected undefined, received 'leaked-secret'andBackgroundShellRegistry ... expected '<task-notification>...' not to contain 'secret credentials'.no-follow-open.test.tsadditionally pins the native path (regular file opens, symlink →ELOOP, ENOENT passthrough) and the fallback path (symlink refusal, identity-mismatch refusal,ino: 0refusal).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;cliworkspace-registration-store.test.ts(26 passed / 1 skipped);acp-bridgesessionArtifacts.test.ts(126).tsc --noEmitclean forcore,cli, andacp-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
Environment (optional)
Unit tests only (
vitest), Node v24.19.0.Risk & Scope
O_NOFOLLOWthe fallback adds onelstat+ onefstatper open (low-frequency read paths only), and reads onino: 0volumes (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).skipIf(process.platform === 'win32')tests) remains separate follow-up scope, as in fix(core): reject unverifiable validated read inodes #9857. OtherO_NOFOLLOWcall sites not confirmed in the issue thread (write paths such asskill-args-file.ts,gitUtils.ts,skill-curator.ts,session-writer-lease.ts; platform-guarded sites insession-start-profiler.ts/sessionService.ts; typeof-guardedvoice-keyterms.ts/customBanner.ts) are intentionally left for a follow-up.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: 0fail-closed 立场(hasVerifiableInode)。审阅者测试计划
如何验证
两个新的复现测试把
fs.constants.O_NOFOLLOWstub 成undefined(与session-start-profiler.test.ts已有的测试接缝相同),并在读取路径上植入符号链接;它们在main上失败,在本 PR 上通过: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 变化(内部读取路径的纵深防御)。
测试环境
运行环境(可选)
仅单元测试(
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 行为不变(与之前相同的内核标志)。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 引入的加固之上。