Skip to content

fix(core): allow dot-prefixed plans directories - #5460

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/plans-dotdot-directory
Jun 20, 2026
Merged

fix(core): allow dot-prefixed plans directories#5460
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/plans-dotdot-directory

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Allows custom plans directories whose directory names start with dots, such as ..plans or ...triple, while keeping real parent-directory traversal rejected.

Why it's needed

The old boundary check treated any path.relative(...) result starting with .. as an escape. That accidentally rejected legitimate project subdirectories whose names merely begin with two dots. Users could not place plans in those directories even though they are still inside the project.

Reviewer Test Plan

How to verify

Review the new Storage.getPlansDir() regression coverage. The allowed case is a real project child named with a .. prefix; traversal cases such as .., ../plans, and paths that resolve outside the project should still throw.

Run:

npm --workspace packages/core test -- storage.test.ts --coverage.enabled=false
npx eslint packages/core/src/config/storage.ts packages/core/src/config/storage.test.ts
npm --workspace packages/core run typecheck

Evidence (Before & After)

Before: getPlansDir(projectRoot, "..plans") threw because the relative path started with ...

After: dot-prefixed child directory names resolve, while actual traversal still fails.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested locally; covered by path-based unit expectations
🐧 Linux ⚠️ not tested locally; covered by CI

Environment (optional)

Local Node/npm workspace tests.

Risk & Scope

  • Main risk or tradeoff: path-boundary logic is security-sensitive, so the fix only narrows the false-positive .. prefix check to actual parent segments.
  • Not validated / out of scope: changing where plans are stored by default.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #5459

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

中文说明

这个 PR 做了什么

允许使用目录名以点开头的自定义 plans 目录,例如 ..plans...triple,同时继续拒绝真正的父目录穿越。

为什么需要

旧的边界检查把所有 path.relative(...) 结果以 .. 开头的路径都当作逃逸路径,导致项目内真实存在的 .. 前缀子目录也被误拒绝。用户即使目录仍在项目内,也无法把 plans 放在那里。

Reviewer Test Plan

如何验证

查看新增的 Storage.getPlansDir() 回归测试。允许的用例是项目内名为 .. 前缀的真实子目录;..../plans 以及解析到项目外的路径仍应抛错。

运行:

npm --workspace packages/core test -- storage.test.ts --coverage.enabled=false
npx eslint packages/core/src/config/storage.ts packages/core/src/config/storage.test.ts
npm --workspace packages/core run typecheck

改动前后证据

改动前:getPlansDir(projectRoot, "..plans") 会因为相对路径以 .. 开头而抛错。

改动后:以点开头的子目录名可以正常解析,真正的路径穿越仍会失败。

本地测试平台

OS Status
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 本地未测;由路径单测覆盖
🐧 Linux ⚠️ 本地未测;由 CI 覆盖

环境

本地 Node/npm workspace 测试。

风险与范围

  • 主要风险或取舍:路径边界逻辑涉及安全,所以本修复只把误伤的 .. 前缀检查收窄到真实父目录片段。
  • 未验证 / 不在范围内:修改 plans 默认存储位置。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

Fixes #5459

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@wenshao wenshao 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.

No issues found. LGTM! ✅

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@wenshao
wenshao marked this pull request as ready for review June 20, 2026 10:21
@wenshao

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot 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.

Hi @tt-a1i, thanks for the PR — the fix itself looks targeted and reasonable.

However, the PR body doesn't follow our pull request template. Several required sections are missing:

  • ## What this PR does / ## Why it's needed (you have ## Summary instead)
  • ## Reviewer Test Plan with How to verify, Evidence (Before & After), and Tested on table
  • ## Risk & Scope
  • ## Linked Issues
  • <details>中文说明</details> translation block

Could you update the PR description to match the template? It helps reviewers (and future-you) understand the motivation, verify the fix, and assess risk at a glance. Happy to re-review once that's done. 🙏

中文说明

你好 @tt-a1i,感谢提交 PR — 修复本身看起来是合理且有针对性的。

不过 PR 正文没有按照我们的 PR 模板 来填写,缺少以下必填部分:

  • ## What this PR does / ## Why it's needed(目前用的是 ## Summary
  • ## Reviewer Test Plan,包含 How to verifyEvidence (Before & After)Tested on 表格
  • ## Risk & Scope
  • ## Linked Issues
  • <details>中文说明</details> 翻译区块

麻烦按照模板补充一下 PR 描述,方便 reviewer 理解动机、验证修复和评估风险。补完之后我会重新审查。🙏

Qwen Code · qwen3.7-max

@wenshao

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

✅ Local runtime verification — PR #5460 (fix(core): allow dot-prefixed plans directories)

Verdict: PASS. The fix resolves #5459 and preserves every path-traversal / symlink-escape rejection. Verified by running the real Storage.getPlansDir() source (via tsx, no mocks) against a real on-disk fixture — actual ..-prefixed directories and real symlinks — as an A/B diff of origin/main vs the PR head (7382437), inside tmux.

How it was tested

  • Driver imports the genuine packages/core/src/config/storage.ts and calls getPlansDir(projectRoot, value) for 14 inputs, recording RESOLVED <path> vs THREW.
  • Fixture is a real filesystem under a project root: real dirs plans/, ..plans/, ...triple/, .hidden/, sub/..keep/, plus two real symlinksescape-link → <outside> and inside-link → plans/. This exercises the actual fs.realpathSync code path that the unit tests mock.
  • A/B = same driver, same fixture, run once on origin/main (before) and once on the PR's storage.ts (after). Only storage.ts was overlaid; the working tree was restored clean afterwards.

A/B differential (real filesystem)

# input expected main (before) PR (after)
01 ./..plans allow ❌ THREW ✅ RESOLVED — fixed
02 ..plans allow ❌ THREW ✅ RESOLVED — fixed
03 ./...triple allow ❌ THREW ✅ RESOLVED — fixed
07 <project>/..plans (absolute, inside) allow ❌ THREW ✅ RESOLVED — fixed
04 ./.hidden allow ✅ RESOLVED ✅ RESOLVED
05 ./plans allow ✅ RESOLVED ✅ RESOLVED
06 ./sub/..keep (dot-prefixed, nested) allow ✅ RESOLVED ✅ RESOLVED
08 ./inside-link (real symlink, inside) allow ✅ RESOLVED ✅ RESOLVED
09 ../plans reject 🛡️ THREW 🛡️ THREW
10 .. reject 🛡️ THREW 🛡️ THREW
11 ../ reject 🛡️ THREW 🛡️ THREW
12 ../..plans (dot-prefixed in parent) reject 🛡️ THREW 🛡️ THREW
13 <outside> (absolute, outside) reject 🛡️ THREW 🛡️ THREW
14 ./escape-link (real symlink → outside) reject 🛡️ THREW 🛡️ THREW

Committed tests + teeth

  • PR test suite: vitest run storage.test.ts63/63 passed (was 62 on main; +1 regression test).
  • Counterfactual (proves the test has teeth): the PR's new test allows project subdirectories whose names start with two dots, run against main's storage.ts, FAILS — so the test genuinely pins the fixed behavior, not a tautology.
  • eslint packages/core/src/config/storage.ts → clean (exit 0).

Why the fix is sound (reasoning)

path.relative(parent, child) only emits .. segments at the start, each followed by a separator or end-of-string. So a result can be exactly .., or start with .. + sep (genuine traversal), or be a plain name like ..plans (a real child). The old startsWith('..') conflated the third with the first two; the PR rejects only === '..', '..' + sep prefix, and absolute paths — precise and complete.

🇨🇳 中文版(点击展开)

✅ 本地运行时验证 — PR #5460fix(core): allow dot-prefixed plans directories

结论:通过。 该修复解决了 #5459,并且完整保留了所有“路径穿越 / 符号链接逃逸”的拦截。验证方式:用 tsx 直接运行真实的 Storage.getPlansDir() 源码(无任何 mock),针对一个真实的磁盘目录夹具——包含真实的以 .. 开头的目录和真实的符号链接——在 tmux 中对 origin/main 与 PR 头(7382437)做 A/B 对比。

测试方法

  • 驱动脚本导入真实的 packages/core/src/config/storage.ts,对 14 个输入调用 getPlansDir(projectRoot, value),记录是 RESOLVED <路径> 还是 THREW(抛错)。
  • 夹具是真实文件系统:项目根下真实创建了 plans/..plans/...triple/.hidden/sub/..keep/,外加两个真实符号链接——escape-link → <项目外>inside-link → plans/。这会真正走到单元测试里被 mock 掉的 fs.realpathSync 代码路径。
  • A/B = 同一驱动、同一夹具,分别在 origin/main(修复前)和 PR 的 storage.ts(修复后)各跑一次。仅覆盖替换了 storage.ts,结束后工作区已还原干净。

A/B 差异对比(真实文件系统)

# 输入 期望 main(修复前) PR(修复后)
01 ./..plans 允许 ❌ 抛错 ✅ 解析 —已修复
02 ..plans 允许 ❌ 抛错 ✅ 解析 —已修复
03 ./...triple 允许 ❌ 抛错 ✅ 解析 —已修复
07 <项目>/..plans(绝对路径,在项目内) 允许 ❌ 抛错 ✅ 解析 —已修复
04 ./.hidden 允许 ✅ 解析 ✅ 解析
05 ./plans 允许 ✅ 解析 ✅ 解析
06 ./sub/..keep(更深一层的 .. 前缀目录) 允许 ✅ 解析 ✅ 解析
08 ./inside-link(真实符号链接,指向项目内) 允许 ✅ 解析 ✅ 解析
09 ../plans 拒绝 🛡️ 抛错 🛡️ 抛错
10 .. 拒绝 🛡️ 抛错 🛡️ 抛错
11 ../ 拒绝 🛡️ 抛错 🛡️ 抛错
12 ../..plans(父目录里的 .. 前缀目录) 拒绝 🛡️ 抛错 🛡️ 抛错
13 <项目外>(绝对路径,在项目外) 拒绝 🛡️ 抛错 🛡️ 抛错
14 ./escape-link(真实符号链接 → 项目外) 拒绝 🛡️ 抛错 🛡️ 抛错

已提交测试 + 有效性(teeth)

  • PR 测试套件: vitest run storage.test.ts63/63 通过(main 上为 62;新增 1 条回归测试)。
  • 反事实验证(证明测试确实有效): 把 PR 新增的测试 allows project subdirectories whose names start with two dots 放到 main 的 storage.ts 上跑 → 失败——说明该测试真正锁定了修复后的行为,而非永真断言。
  • eslint packages/core/src/config/storage.ts → 干净(退出码 0)。

修复为什么是正确的(推理)

path.relative(parent, child) 只会在开头产生 .. 片段,且每个 .. 后面要么跟分隔符、要么就是字符串结尾。因此结果只可能是:正好等于 ..、以 .. + 分隔符开头(真正的穿越)、或一个普通名字如 ..plans(真实子目录)。旧的 startsWith('..') 把第三种和前两种混为一谈;PR 只拒绝 === '..''..' + 分隔符 前缀、以及绝对路径——精确且完备。

Reproducible: tsx driver over a real-fs fixture (real ..-dirs + real symlinks), A/B origin/main vs PR 7382437, run in tmux. Working tree restored clean after the run.

@tt-a1i

tt-a1i commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

updated the PR body to match the template.

@wenshao

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Hi @tt-a1i, thanks for the fix!

Template looks good ✓ — all required sections present, including bilingual description and test plan.

On direction: this fixes a genuine bug where startsWith('..') over-matches legitimate child directory names like ..plans. The linked issue #5459 clearly describes the problem. Claude Code's CHANGELOG confirms plansDirectory is an existing user-facing setting, so users configuring dot-prefixed directories would hit this. P3 priority is appropriate.

On approach: the scope is minimal and focused — 2 files, +17/-5. The fix narrows the check from startsWith('..') to startsWith('..' + sep) || === '..', which correctly distinguishes path traversal segments from directory names that merely start with dots. Extracting isResolvedPathWithinDirectory as a standalone function makes the logic easy to test and reason about. No unrelated changes or scope creep.

Moving on to code review. 🔍

中文说明

你好 @tt-a1i,感谢修复!

模板完整 ✓ — 所有必填部分齐全,包含双语描述和测试计划。

方向:这个修复解决了一个真实的 bug — startsWith('..') 会误匹配 ..plans 这类合法子目录名。关联 issue #5459 清楚描述了问题。Claude Code 的 CHANGELOG 确认 plansDirectory 是现有的用户可配置项,所以配置了点开头的目录名会触发此问题。P3 优先级合理。

方案:范围最小且聚焦 — 2 个文件,+17/-5。修复将检查从 startsWith('..') 收窄为 startsWith('..' + sep) || === '..',正确区分路径穿越片段和仅以点开头的目录名。提取 isResolvedPathWithinDirectory 为独立函数便于测试和理解。无无关改动或范围蔓延。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

2a. Code Review

Wrote my own proposal before reading the diff: narrow startsWith('..') to distinguish actual traversal segments (.. alone, or .. + separator) from dot-prefixed child names. Extract a helper for testability. The PR's approach matches this exactly.

The diff is clean — one extracted function isResolvedPathWithinDirectory, one refined check, one added regression test. The symlink resolution via resolvePathThroughExistingAncestor is untouched and still catches real escape attacks. The path.sep usage is correct on both POSIX (/) and Windows (\).

No correctness bugs, no security regressions, no AGENTS.md violations. 63/63 unit tests pass, eslint clean, typecheck clean.

2b. Real-Scenario Testing (tmux, tsx driver)

Ran Storage.getPlansDir() directly via tsx against a real on-disk fixture with actual ..plans/, plans/, .hidden/ directories. Before = main's storage.ts, After = PR's storage.ts.

Before (installed build / main)

projectRoot: /home/runner/work/qwen-code/qwen-code/tmp/triage-plans/project
source: main (before PR)

#01 "./..plans" → ❌ UNEXPECTED THREW: plansDirectory must resolve within the project root.
#02 "..plans" → ❌ UNEXPECTED THREW: plansDirectory must resolve within the project root.
#03 "./plans" → ✅ RESOLVED: /home/runner/work/qwen-code/qwen-code/tmp/triage-plans/project/plans
#04 "./.hidden" → ✅ RESOLVED: /home/runner/work/qwen-code/qwen-code/tmp/triage-plans/project/.hidden
#05 "../plans" → 🛡️  THREW: plansDirectory must resolve within the project root.
#06 ".." → 🛡️  THREW: plansDirectory must resolve within the project root.
#07 "/home/runner/work/qwen-code/qwen-code/tmp/triage-plans/escape" → 🛡️  THREW: plansDirectory must resolve within the project root.

After (this PR)

projectRoot: /home/runner/work/qwen-code/qwen-code/tmp/triage-plans/project
source: worktree (PR #5460)

#01 "./..plans" → ✅ RESOLVED: /home/runner/work/qwen-code/qwen-code/tmp/triage-plans/project/..plans
#02 "..plans" → ✅ RESOLVED: /home/runner/work/qwen-code/qwen-code/tmp/triage-plans/project/..plans
#03 "./plans" → ✅ RESOLVED: /home/runner/work/qwen-code/qwen-code/tmp/triage-plans/project/plans
#04 "./.hidden" → ✅ RESOLVED: /home/runner/work/qwen-code/qwen-code/tmp/triage-plans/project/.hidden
#05 "../plans" → 🛡️  THREW: plansDirectory must resolve within the project root.
#06 ".." → 🛡️  THREW: plansDirectory must resolve within the project root.
#07 "/home/runner/work/qwen-code/qwen-code/tmp/triage-plans/escape" → 🛡️  THREW: plansDirectory must resolve within the project root.

Summary: Before — 2/4 allow cases incorrectly rejected (the bug). After — all 4 allow cases pass, all 3 reject cases still blocked. Security posture unchanged.

中文说明

2a. 代码审查

在阅读 diff 之前先写了自己的方案:将 startsWith('..') 收窄,区分真正的路径穿越片段(单独的 .... + 分隔符)和以点开头的子目录名。提取辅助函数便于测试。PR 的方案与此完全一致。

Diff 干净 — 一个提取的函数 isResolvedPathWithinDirectory,一个精炼的检查,一条新增回归测试。通过 resolvePathThroughExistingAncestor 的符号链接解析未改动,仍能拦截真正的逃逸攻击。path.sep 在 POSIX(/)和 Windows(\)上都正确。

无正确性 bug、无安全回归、无 AGENTS.md 违规。63/63 单元测试通过,eslint 干净,typecheck 干净。

2b. 真实场景测试(tmux, tsx 驱动)

通过 tsx 直接调用 Storage.getPlansDir(),针对磁盘上真实存在的 ..plans/plans/.hidden/ 目录夹具。修复前 = main 的 storage.ts,修复后 = PR 的 storage.ts

修复前 — 4 个应允许的用例中有 2 个被错误拒绝(即 bug)。修复后 — 所有 4 个允许用例通过,所有 3 个拒绝用例仍被拦截。安全态势未改变。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a clean, well-scoped bugfix. The PR does exactly one thing — fix an over-broad startsWith('..') check that rejected legitimate dot-prefixed directory names — and does it with the minimum code necessary.

My independent proposal before reading the diff was identical: narrow the check to actual traversal segments, extract a testable helper. The PR matches this, which is a good sign the approach is the obvious right one.

The before/after tmux output tells the story: two cases that wrongly threw now resolve, while every security rejection stays intact. The regression test has teeth (fails on main's code, passes on the PR's). 63/63 tests pass, lint and typecheck clean.

The extracted isResolvedPathWithinDirectory function is a small readability win — it makes the intent explicit and would be easy to reuse if other path-boundary checks need the same fix later.

Approving. ✅

中文说明

这是一个干净、范围恰当的 bugfix。PR 只做一件事 — 修复过于宽泛的 startsWith('..') 检查(该检查会拒绝合法的以点开头的目录名)— 并且用了最少的代码来实现。

我在阅读 diff 之前独立提出的方案与此完全一致:将检查收窄到真正的路径穿越片段,提取一个可测试的辅助函数。PR 与此吻合,说明这是显而易见的正确方案。

tmux 前后对比清楚地展示了:两个原本错误抛错的用例现在能正常解析,同时所有安全拒绝保持不变。回归测试有效(在 main 代码上失败,在 PR 代码上通过)。63/63 测试通过,lint 和 typecheck 干净。

提取的 isResolvedPathWithinDirectory 函数是一个小的可读性提升 — 使意图明确,如果以后其他路径边界检查需要同样的修复也易于复用。

批准。✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot 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.

LGTM, looks ready to ship. ✅

@wenshao wenshao 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.

No issues found. LGTM! ✅

— DeepSeek/deepseek-v4-pro via Qwen Code /review

@wenshao

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

✅ Local end-to-end verification (real filesystem, tmux)

Verified by calling the real built Storage.getPlansDir against a real temp project that contains real ..plans / ...triple / ..plans/nested subdirectories and a real symlink escaping the project — so the fs.realpathSync canonicalization in resolvePathThroughExistingAncestor actually runs (no mocks). Both sides built to real packages/core/dist:

  • BASE = merge-base 5924ab3a (!relativePath.startsWith('..'))
  • PR = head 7382437e (!startsWith('..'+sep) && !== '..' && !isAbsolute)

A/B result (real Storage.getPlansDir(projectRoot, …))

plansDirectory input BASE 5924ab3a PR 7382437e kind
./..plans, ..plans, ...triple, ..plans/nested REJECT (the bug) ALLOW<proj>/… bug-fix
normalplans, . ✅ ALLOW ✅ ALLOW control
.., ../plans, ../../etc, /etc/qwen-plans ✅ REJECT ✅ REJECT traversal
..plans/../../etc (adversarial) ✅ REJECT ✅ REJECT no-hole
evil → symlink outside project ✅ REJECT ✅ REJECT symlink escape
========== BASE (5924ab3a) ==========
  ❌→ ./..plans         REJECT (FatalConfigError)   dot-prefixed child (the bug)
     ..plans/../../etc  REJECT (FatalConfigError)   ADVERSARIAL dot-prefix then traversal
     evil               REJECT (FatalConfigError)   symlink escaping project (realpath)
  RESULT: all 12 probes matched expectation for BASE
========== PR (7382437e) ==========
  ✅→ ./..plans         ALLOW -> <proj>/..plans     dot-prefixed child (the bug)
     ..plans/../../etc  REJECT (FatalConfigError)   ADVERSARIAL dot-prefix then traversal
     evil               REJECT (FatalConfigError)   symlink escaping project (realpath)
  RESULT: all 12 probes matched expectation for PR

So BASE wrongly rejects a project subdirectory literally named ..plans/...triple (the issue), while PR resolves it inside the project. Crucially, every real escape stays rejected on both builds.., ../…, absolutes, the adversarial ..plans/../../etc (which resolves to ../etc), and a symlink pointing outside the project. The fix narrows a false-positive without opening a false-negative.

Unit test + revert-proof + static

  • vitest run storage.test.ts -t "getPlansDir" on the PR source → 15/15 pass.
  • Revert-proof: running the PR's new test against the base source fails only allows project subdirectories whose names start with two dots ❌ — the pre-existing security tests (rejects relative … escape, rejects absolute … outside, rejects symlink pointing outside, allows legitimate symlink that stays within, rejects missing nested path under symlink that escapes) all still pass on both, confirming the boundary's defense is preserved.
  • ESLint clean (exit 0); Prettier clean; git diff --check clean.

Note

The bug is the classic startsWith('..') vs startsWith('..' + path.sep) distinction: '..plans'.startsWith('..') is true, so the old check treated the legitimate child as an escape. The new helper rejects only an exact .., a ../… prefix, or an absolute relative result — which is what an actual parent-directory traversal produces. Because the check runs on the realpath-resolved child, any traversal through a dot-prefixed name (..plans/../../etc) and any symlink escape still resolve to a ../… relative and are rejected.

Verdict: LGTM — fixes the false-positive rejection of dot-prefixed plans directories while fully preserving traversal and symlink-escape protection.

🇨🇳 中文版本(点击展开)

✅ 本地端到端验证(真实文件系统,tmux)

通过对真实编译产物中的 Storage.getPlansDir 进行验证:在一个真实临时项目里创建真实的 ..plans / ...triple / ..plans/nested 子目录,以及一个真实指向项目外部的符号链接,从而让 resolvePathThroughExistingAncestor 里的 fs.realpathSync 规范化真正执行(无 mock)。两个版本都编译为真实的 packages/core/dist

  • BASE = merge-base 5924ab3a!relativePath.startsWith('..')
  • PR = head 7382437e!startsWith('..'+sep) && !== '..' && !isAbsolute

A/B 结果(真实 Storage.getPlansDir(projectRoot, …)

plansDirectory 输入 BASE 5924ab3a PR 7382437e 类别
./..plans..plans...triple..plans/nested 拒绝(即 bug) 允许<proj>/… 修复点
normalplans. ✅ 允许 ✅ 允许 对照
..../plans../../etc/etc/qwen-plans ✅ 拒绝 ✅ 拒绝 穿越
..plans/../../etc(对抗) ✅ 拒绝 ✅ 拒绝 无漏洞
evil → 指向项目外的软链 ✅ 拒绝 ✅ 拒绝 软链逃逸

也就是说:BASE 会错误拒绝一个名为 ..plans/...triple 的项目内真实子目录(即该 issue),而 PR 能正确解析到项目内部。关键在于:所有真正的逃逸在两个版本上都仍被拒绝——..../…、绝对路径、对抗用例 ..plans/../../etc(它会解析成 ../etc)以及指向项目外的符号链接。本修复只收窄了误报,没有引入漏报

单元测试 + 反向验证 + 静态检查

  • 在 PR 源码上 vitest run storage.test.ts -t "getPlansDir"15/15 通过
  • 反向验证:把 PR 新增测试放到 base 源码上跑,只有 allows project subdirectories whose names start with two dots ❌ 失败——既有的安全测试(拒绝相对路径逃逸、拒绝绝对路径越界、拒绝指向外部的符号链接、允许停留在项目内的合法符号链接、拒绝经由逃逸符号链接的缺失嵌套路径)在两边都仍然通过,说明边界防御得到了保留。
  • ESLint 通过(exit 0);Prettier 通过;git diff --check 通过。

补充说明

该 bug 是经典的 startsWith('..')startsWith('..' + path.sep) 的区别:'..plans'.startsWith('..')true,所以旧检查把合法子目录当成了逃逸。新 helper 只拒绝精确的 ..../… 前缀,或绝对的相对结果——这正是真正的父目录穿越会产生的形态。由于检查作用在经 realpath 解析后的 child 上,任何经由点开头目录名的穿越(..plans/../../etc)以及任何符号链接逃逸仍会解析成 ../… 相对路径并被拒绝。

结论:LGTM —— 修复了对点开头 plans 目录的误拒,同时完整保留了路径穿越与符号链接逃逸的防护。

@wenshao
wenshao merged commit 6a02b65 into QwenLM:main Jun 20, 2026
43 checks passed
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.

plansDirectory rejects project subdirectories whose names start with two dots

3 participants