Skip to content

fix(core): enforce shell directory workspace boundary#5454

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/shell-directory-workspace-boundary
Jun 20, 2026
Merged

fix(core): enforce shell directory workspace boundary#5454
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/shell-directory-workspace-boundary

Conversation

@tt-a1i

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

Copy link
Copy Markdown
Contributor

Summary

  • use WorkspaceContext.isPathWithinWorkspace for shell directory validation
  • add a regression test for sibling-prefix workspace paths such as /tmp/project-other

Fixes #5453

Validation

  • npx vitest run packages/core/src/tools/shell.test.ts --testNamePattern "sibling-prefix"
  • npx vitest run packages/core/src/tools/shell.test.ts
  • npx eslint packages/core/src/tools/shell.ts packages/core/src/tools/shell.test.ts
  • npx prettier --check packages/core/src/tools/shell.ts packages/core/src/tools/shell.test.ts && git diff --check
  • npm run typecheck --workspace=packages/core

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! ✅

— qwen3.7-max via Qwen Code /review

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

wenshao commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: content is all there under reorganized headings (Summary / Validation / Fixes #5453). Missing the formal "Risk & Scope" section and 中文说明 — not blocking for a focused security fix, but worth including next time.

On direction: this is a clear-cut security fix. The shell tool's directory parameter is a safety gate before auto/YOLO execution, and the naive startsWith prefix check lets sibling paths like /tmp/project-other slip through when the workspace is /tmp/project. Issue #5453 describes the exact scenario. Solid alignment with project security posture.

On approach: the fix is exactly right — WorkspaceContext.isPathWithinWorkspace() already exists and is used by grep, read-file, glob, monitor, ripGrep, and ls for the same purpose. It uses path.relative() under the hood, which correctly handles path boundaries (no false prefix matches, handles symlinks, handles .. traversal). Replacing the bespoke startsWith with the canonical method is the minimal correct fix. Scope is tight: 2 files, +25/-6, no drive-by changes. Moving on to code review. 🔍

中文说明

感谢贡献!

模板:内容齐全,只是标题用了 Summary/Validation 而非模板规定的格式,缺少 "Risk & Scope" 和中文说明部分。对于聚焦的安全修复不阻塞,但下次建议补全。

方向:这是一个明确的安全修复。shell 工具的 directory 参数是 auto/YOLO 模式执行前的安全门,朴素的 startsWith 前缀匹配会让 /tmp/project-other 这类兄弟路径绕过检查。Issue #5453 准确描述了该场景,与项目安全目标高度一致。

方案:修复方式完全正确——WorkspaceContext.isPathWithinWorkspace() 已经存在,并被 grep、read-file、glob、monitor 等多个工具用于同样的目的。底层使用 path.relative() 正确处理路径边界(无误前缀匹配、处理符号链接和 .. 遍历)。用规范方法替换临时的 startsWith 是最小化的正确修复。范围紧凑:2 个文件,+25/-6,无附带改动。进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

The fix is clean and minimal. Two observations from reading the code:

Independent proposal: I would have done exactly the same thing — replace the bespoke startsWith with the existing WorkspaceContext.isPathWithinWorkspace() that's already used by grep, read-file, glob, monitor, ripGrep, and ls. It uses path.relative() + boundary checks under the hood, which is the correct approach.

Diff comparison: the PR's implementation matches my proposal. The production change is 4 lines deleted, 2 lines added — swaps the manual loop+startsWith for a single method call. The regression test properly mocks isPathWithinWorkspace returning false and asserts the error message + that the method was called with the sibling-prefix path. No correctness issues, no style violations, follows project conventions.

One note on the test: the mock's default isPathWithinWorkspace implementation (from createMockWorkspaceContext) also uses startsWith — same bug pattern. The test overrides it explicitly with mockReturnValue(false), which is fine. Worth knowing for future test authors.

Testing

Unit tests (240 tests, all pass)

$ npx vitest run src/tools/shell.test.ts
 ✓ src/tools/shell.test.ts (240 tests) 1478ms
 Test Files  1 passed (1)
      Tests  240 passed (240)

Note: the new regression test from this PR (should reject sibling-prefix) isn't in main yet (cross-repo fork), so it can't be run from main. The existing 240 tests all pass.

Real-scenario verification

Direct verification of isPathWithinRoot (the method the fix delegates to) against the exact scenario from issue #5453:

$ node tmp/triage-test-102240/fix-verify.mjs
Direct verification of isPathWithinRoot (the fix):
  sibling-prefix /tmp/test-workspace-other: PASS (rejected)
  child /tmp/test-workspace/subdir: PASS (allowed)
  workspace itself /tmp/test-workspace: PASS (allowed)
  parent /tmp: PASS (rejected)
  traversal /tmp/test-workspace/../other: PASS (rejected)
  unrelated /etc: PASS (rejected)

Before/after comparison using the same test script:

$ node tmp/triage-test-102240/test-fix.mjs
=== Shell Directory Validation: Before vs After Fix ===

Workspace: /tmp/test-workspace

Sibling prefix: /tmp/test-workspace-other
  Before (startsWith):  ALLOWED ✗ (bug!)
  After  (path.relative): REJECTED ✓

Child path: /tmp/test-workspace/subdir
  Before (startsWith):  ALLOWED ✓
  After  (path.relative): ALLOWED ✓

All cases behave correctly with the fix. The sibling-prefix bug is resolved, and legitimate child paths still work.

中文说明

代码审查

修复简洁且最小化。

独立方案: 我会做完全一样的事——用已有的 WorkspaceContext.isPathWithinWorkspace() 替换临时的 startsWith,该方法已被 grep、read-file、glob、monitor 等多个工具使用。底层使用 path.relative() + 边界检查,是正确的做法。

Diff 对比: PR 的实现与我的方案一致。生产代码改动 4 行删除、2 行新增——将手动循环+startsWith 替换为单个方法调用。回归测试正确 mock 了 isPathWithinWorkspace 返回 false,并验证了错误信息和方法调用参数。无正确性问题,无风格违规,遵循项目惯例。

测试

  • 单元测试:240 个测试全部通过
  • 直接验证 isPathWithinRoot:6 个场景全部正确(兄弟前缀被拒绝、子路径被允许、路径遍历被拒绝)
  • 修复前后对比:startsWith 错误地允许了兄弟前缀,path.relative 正确地拒绝了

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

This is a textbook minimal security fix. The PR does exactly what I would have done: swap the bespoke startsWith check for the canonical isPathWithinWorkspace() that six other tools already use. No new abstractions, no extra scope, no drive-by changes.

The before/after verification confirms the bug is real and the fix works — sibling-prefix paths get rejected, child paths and the workspace itself still work. All 240 existing unit tests pass.

The one thing I'd flag for the author's awareness (not a blocker): the mock helper createMockWorkspaceContext has the same startsWith pattern in its default isPathWithinWorkspace implementation. The test works around this by overriding with mockReturnValue(false), but future tests that rely on the mock's default behavior would inherit the same bug. That's a separate cleanup, not something this PR needs to take on.

Approving. ✅

中文说明

这是一个教科书式的最小化安全修复。PR 的做法与我的独立方案完全一致:将临时的 startsWith 检查替换为已有六个工具在使用的规范方法 isPathWithinWorkspace()。无新抽象,无额外范围,无附带改动。

前后对比验证确认了 bug 真实存在且修复有效——兄弟前缀路径被拒绝,子路径和工作区本身仍然正常工作。全部 240 个现有单元测试通过。

一个非阻塞的观察:mock 辅助函数 createMockWorkspaceContext 的默认 isPathWithinWorkspace 实现也存在同样的 startsWith 模式。当前测试通过显式 mockReturnValue(false) 绕过了这个问题,但依赖 mock 默认行为的未来测试会继承同样的 bug。这是单独的清理工作,不需要本 PR 承担。

批准 ✅

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 commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

✅ Local verification — safe to merge

I verified this PR locally before merging. Tested PR head c88da853 on its base 5924ab3a, in an isolated git worktree with a clean npm ci (Node v22.22.2 / npm 10.9.7), with the suites driven under tmux.

Note: I deliberately used a fresh worktree of the PR head rather than my local branch — my working branch has diverged from main on shell.ts and workspaceContext.ts, which would have made the results unfaithful to what actually lands on main.

Results — all green

Check Command Result
Targeted regression test vitest run shell.test.ts -t "sibling-prefix" ✅ pass
Full shell suite vitest run shell.test.ts 241 passed
Lint eslint shell.ts shell.test.ts ✅ pass
Format prettier --check + git diff --check ✅ pass
Types npm run typecheck -w packages/core (tsc --noEmit) ✅ pass

The fix is load-bearing (before / after)

Running the new regression test against the pre-fix shell.ts:

× ShellTool > build > should reject sibling-prefix directories outside the workspace
  AssertionError: expected [Function] to throw an error

That is exactly the bug: the old params.directory.startsWith(wsDir) accepts /tmp/project-other for workspace /tmp/project. With the fix in place, it correctly throws and the test passes. ✔️

One nuance worth flagging (non-blocking)

The committed regression test mocks the very gate it depends on:

  • shell.test.ts:338vi.mocked(workspaceContext.isPathWithinWorkspace).mockReturnValue(false), and
  • createMockWorkspaceContext's default isPathWithinWorkspace is itself the buggy startsWith.

So the committed test proves that shell.ts delegates to and respects isPathWithinWorkspace — but it does not exercise the real sibling-prefix rejection. There is also no non-mocked sibling-prefix test elsewhere (none in workspaceContext.test.ts).

To close that gap, I ran a throwaway end-to-end test using a real WorkspaceContext backed by real on-disk directories (…/project, …/project-other, …/project/sub) — 8/8 passed:

  • real isPathWithinWorkspacerejects the sibling, accepts the workspace dir and a real child (guarded by a sanity assertion that the workspace actually registered the dir, so the rejection cannot pass vacuously);
  • real ShellTool.build()throws for the sibling, succeeds for the workspace dir and child.

The real behavior is correct because isPathWithinRoot already uses path.relative + .. checks. Optional suggestion (low priority): add one non-mocked case — e.g. /tmp/project vs /tmp/project-other in workspaceContext.test.ts — to lock in the real boundary, since the shell-tool test cannot catch a regression in the gate itself.

Verdict

The change is correct, minimal, and well-scoped; the bug is real and the fix closes it. All checks pass. LGTM 👍

🇨🇳 中文版(点击展开)

✅ 本地验证结论 —— 可以合并

合并前我在本地对该 PR 做了验证。在一个独立的 git worktree 中、基于 PR 头 c88da853(其基线为 5924ab3a)执行,使用全新的 npm ci(Node v22.22.2 / npm 10.9.7),并在 tmux 中运行各项测试。

说明:我特意使用了 PR 头的全新 worktree,而不是本地分支——我的工作分支在 shell.tsworkspaceContext.ts 上已与 main 分叉,直接在其上测试会导致结果无法真实反映最终合入 main 的情况。

验证结果 —— 全部通过

检查项 命令 结果
针对性回归测试 vitest run shell.test.ts -t "sibling-prefix" ✅ 通过
shell 全量套件 vitest run shell.test.ts 241 个通过
Lint eslint shell.ts shell.test.ts ✅ 通过
格式 prettier --check + git diff --check ✅ 通过
类型 npm run typecheck -w packages/coretsc --noEmit ✅ 通过

该修复确实是“关键且必要的”(前后对比)

新增的回归测试跑在修复前shell.ts 上:

× ShellTool > build > should reject sibling-prefix directories outside the workspace
  AssertionError: expected [Function] to throw an error

这正是该 bug:旧的 params.directory.startsWith(wsDir) 会把 /tmp/project-other 误判为在工作区 /tmp/project 之内。加入修复后,它能正确抛错,测试通过。✔️

一个值得提醒的点(不阻塞合并)

提交的回归测试把它所依赖的校验函数 mock 掉了

  • shell.test.ts:338vi.mocked(workspaceContext.isPathWithinWorkspace).mockReturnValue(false);并且
  • createMockWorkspaceContextisPathWithinWorkspace 的默认实现本身就是有 bug 的 startsWith

因此该测试只能证明 shell.ts 会调用并尊重 isPathWithinWorkspace 的返回值,但并未真正验证 sibling-prefix 的拒绝逻辑。仓库其他地方也没有非 mock 的 sibling-prefix 测试(workspaceContext.test.ts 中没有)。

为补上这个空白,我用真实的 WorkspaceContext + 真实的磁盘目录…/project…/project-other…/project/sub)跑了一个一次性的端到端测试 —— 8/8 全部通过

  • 真实的 isPathWithinWorkspace拒绝兄弟目录,接受工作区本身及其真实子目录(并加了一条 sanity 断言,确认工作区确实注册了该目录,避免“拒绝”因工作区为空而假性通过);
  • 真实的 ShellTool.build() → 对兄弟目录抛错,对工作区本身与子目录正常通过

真实行为之所以正确,是因为 isPathWithinRoot 本就使用了 path.relative + .. 的判断。可选建议(优先级低):补一个非 mock 的用例——例如在 workspaceContext.test.ts 中加入 /tmp/project vs /tmp/project-other——以锁定真实边界行为,因为 shell 工具层的测试无法捕捉校验函数自身的回归。

结论

改动正确、精简、范围清晰;bug 真实存在,且此修复确实将其闭合。所有检查均通过。LGTM 👍

@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 — a correct, minimal security fix. It closes a real sibling-prefix bypass (/tmp/project-other passed the old startsWith('/tmp/project') check) by delegating to the canonical isPathWithinWorkspace (realpath canonicalization + path.relative boundary), matching the 6 other workspace-bounded tools. Verified: it fails closed, resolves ../symlinks (both the child and the registered workspace dirs are realpath'd), and the new test genuinely guards the fix (it fails under the old startsWith code). 241/241 shell tests pass; CI green.

Non-blocking follow-up (separate, out of this diff): the shared test mock packages/core/src/test-utils/mockWorkspaceContext.ts still implements isPathWithinWorkspace with the same buggy startsWith semantics this PR removes — which is why the new test must override it with mockReturnValue(false). Pointing that mock at the real (exported, pure) isPathWithinRoot would let the test use the default mock honestly and stop other tools' tests from silently enshrining the sibling-prefix bug.

LGTM ✅

中文

无问题 —— 一个正确、最小的安全修复。它通过委托给规范的 isPathWithinWorkspace(realpath 归一化 + path.relative 边界判断,和另外 6 个受工作区约束的工具一致),堵住了一个真实的同前缀绕过(/tmp/project-other 能通过旧的 startsWith('/tmp/project') 检查)。已验证:它 fail-closed,会解析 ../符号链接(child 和已注册的工作区目录两侧都做了 realpath),且新测试确实守护了修复(在旧 startsWith 代码下会失败)。shell 测试 241/241 通过,CI 绿。

非阻塞的后续(独立、不在本 diff 内):共享测试 mock packages/core/src/test-utils/mockWorkspaceContext.ts 里的 isPathWithinWorkspace 仍是本 PR 要删掉的那套有 bug 的 startsWith 语义 —— 这正是新测试不得不用 mockReturnValue(false) 去覆盖它的原因。把那个 mock 改成委托给真正的(已导出、纯函数)isPathWithinRoot,既能让这个测试老实用默认 mock,也能避免其他工具的测试悄悄把同前缀 bug 固化下来。

LGTM ✅

— claude-opus-4-8 via Claude Code /qreview

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.

Shell directory workspace check matches sibling path prefixes

3 participants