Skip to content

fix(security): follow symlinks in workspace boundary check (#169)#241

Open
proyectoauraorg wants to merge 16 commits into
Zoo-Code-Org:mainfrom
proyectoauraorg:fix/169-symlink-workspace-boundary
Open

fix(security): follow symlinks in workspace boundary check (#169)#241
proyectoauraorg wants to merge 16 commits into
Zoo-Code-Org:mainfrom
proyectoauraorg:fix/169-symlink-workspace-boundary

Conversation

@proyectoauraorg
Copy link
Copy Markdown
Contributor

@proyectoauraorg proyectoauraorg commented May 21, 2026

Summary

Fixes #169 (security). The out-of-workspace read protection could be bypassed with a symlink: isPathOutsideWorkspace() only resolved ./.. (path.resolve), not symlink targets. A symlink that lives inside the workspace but points outside it was therefore treated as inside, so it slipped past the boundary check.

Fix

Resolve the real path (following symlinks via fs.realpathSync) for both the target path and the workspace folders before comparing. A symlink inside the workspace that points outside now correctly resolves outside and is flagged.

For paths that don't exist yet (e.g. a file about to be created), the realpath of the nearest existing ancestor is resolved and the remaining segments re-appended, so creation flows still work and any symlinked ancestor is still followed.

Testing

vitest run utils/__tests__/pathUtils.spec.ts
# Test Files 1 passed (1) · Tests 6 passed (6)

New tests use real symlinks in a temp dir: a symlinked file and a symlinked directory inside the workspace that point outside are both flagged as outside; real in-workspace files (existing and not-yet-created) stay inside; real outside files stay outside.

Summary by CodeRabbit

  • Bug Fixes

    • Workspace-boundary checks now resolve symlinks and fail closed on unresolved/permission errors, preventing symlink bypasses.
  • New Features

    • New opt-in context setting (off by default) to treat in-workspace symlinks that point outside as inside; UI control added with localized labels/descriptions and end-to-end wiring so tools respect the setting.
  • Tests

    • Expanded tests covering symlinks, non-existent files, permission errors, ancestor-resolution edge cases, and no-workspace scenarios.

Review Change Stack

Closes #246 (opt-in setting to allow symlinks resolving outside the workspace — implemented here).

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Resolves symlink bypass by canonicalizing paths and updating workspace containment checks (fail-closed). Adds tests, a new allowSymlinksOutsideWorkspace setting (default false) with types/UI/i18n wiring, and updates core tools to use the async workspace-check helper.

Changes

Symlink-aware path containment validation

Layer / File(s) Summary
Changeset and settings schema
.changeset/symlink-workspace-boundary.md, packages/types/src/global-settings.ts, packages/types/src/vscode-extension-host.ts
Adds a changeset entry and a new allowSymlinksOutsideWorkspace boolean setting (validated in schema, default false) and exposes it in ExtensionState.
Path canonicalization helpers with symlink resolution
src/utils/pathUtils.ts
Adds fs-based helpers (isErrnoException, realPathOrNearest) to canonicalize paths via fs.realpathSync.native, walking up on ENOENT and re-throwing non-ENOENT errors.
Updated isPathOutsideWorkspace to use canonical paths
src/utils/pathUtils.ts
Refactors isPathOutsideWorkspace to resolve the target and workspace folders with realPathOrNearest by default (fail-closed) and to accept allowSymlinksOutsideWorkspace to fall back to lexical path.resolve.
Test suite for isPathOutsideWorkspace
src/utils/__tests__/pathUtils.spec.ts
Adds tests with mocked vscode.workspace, temp filesystem fixtures, and assertions for real inside/outside files, non-existent inside paths, symlinked file/directory resolving outside, option-enabled lexical behavior, fail-closed error paths, and no-workspace behavior.
BaseTool helper and tools integration
src/core/tools/BaseTool.ts, src/core/tools/*Tool.ts
Adds BaseTool.resolveIsOutsideWorkspace which reads provider state for allowSymlinksOutsideWorkspace and delegates to isPathOutsideWorkspace; updates tools (ApplyPatch, EditFile, ListFiles, ReadFile, SearchFiles, SearchReplace, WriteToFile, GenerateImage) to await the async resolver and removes direct imports of the old sync helper.
Settings UI wiring and i18n
webview-ui/src/components/settings/ContextManagementSettings.tsx, webview-ui/src/components/settings/SettingsView.tsx, webview-ui/src/i18n/locales/*/settings.json
Wires the new setting into cached UI state, submit payloads, renders a checkbox in context management settings, and adds localized label/description strings across locales.
ReadFile & webview tests
src/core/tools/__tests__/readFileTool.spec.ts, src/core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts
Adds/extends tests to assert the provider state flag is propagated to the workspace-boundary check and reflected in approval/message payloads.

Sequence Diagram(s)

sequenceDiagram
  participant Webview
  participant webviewMessageHandler
  participant BaseTool.resolveIsOutsideWorkspace
  participant isPathOutsideWorkspace
  participant fs_realpath_native
  Webview->>webviewMessageHandler: readFileContent(file request)
  webviewMessageHandler->>BaseTool.resolveIsOutsideWorkspace: resolve check (provider state)
  BaseTool.resolveIsOutsideWorkspace->>isPathOutsideWorkspace: isPathOutsideWorkspace(path, {allowSymlinksOutsideWorkspace})
  isPathOutsideWorkspace->>fs_realpath_native: fs.realpathSync.native (walk up on ENOENT)
  fs_realpath_native-->>isPathOutsideWorkspace: resolved path or error
  isPathOutsideWorkspace-->>BaseTool.resolveIsOutsideWorkspace: containment decision
  BaseTool.resolveIsOutsideWorkspace-->>webviewMessageHandler: result
  webviewMessageHandler-->>Webview: respond (file or outside error)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Poem

🐰
I nibbled paths through roots and leaves,
Found secret tunnels no one perceives.
I hop, I check each twisted link,
Tidy boundaries before they blink.
Now the workspace stays snug and tight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main security fix: following symlinks in the workspace boundary check to close a protection bypass.
Linked Issues check ✅ Passed All coding requirements from #169 are implemented: symlink resolution in workspace boundary checks, handling non-existent paths via ancestor fallback, and the allowSymlinksOutsideWorkspace setting.
Out of Scope Changes check ✅ Passed All changes directly support the #169 security fix and its opt-in setting: filesystem canonicalization, boundary check updates, setting schema/UI, tool integration, tests, and i18n strings are all in scope.
Description check ✅ Passed The PR description adequately covers the linked issue (#169), implementation approach (symlink resolution via fs.realpathSync), handling of non-existent paths, testing methodology, and the opt-in allowSymlinksOutsideWorkspace setting.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 21, 2026

Codecov Report

❌ Patch coverage is 84.41558% with 24 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/core/tools/ReadFileTool.ts 30.00% 13 Missing and 1 partial ⚠️
src/core/tools/GenerateImageTool.ts 64.70% 6 Missing ⚠️
src/core/tools/BaseTool.ts 81.25% 3 Missing ⚠️
src/core/tools/ApplyPatchTool.ts 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@nh2
Copy link
Copy Markdown

nh2 commented May 21, 2026

@proyectoauraorg Thank you! I think this needs to be optional via the

  • Include symlinks in workspace resolving to outside of workspace

setting suggested in #169. There will be inevitably be users whose workflow will be broken without such a setting.

Comment thread src/utils/pathUtils.ts Outdated
try {
const resolved = fs.realpathSync.native(current)
return trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved
} catch {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What happens if realpathSync.native throws something other than ENOENT — e.g. EACCES on a symlink whose target has restricted permissions? The walk-up would skip the symlink entirely and return the lexical path, so isPathOutsideWorkspace would report "inside workspace" even though the real target might be outside.

For a security boundary, would it be safer to catch only ENOENT and let other errors propagate (or treat them as "outside")? Something like:

Suggested change
} catch {
} catch (err: unknown) {
if (!(err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT')) {
// Non-ENOENT error (e.g. EACCES) — fail closed for safety.
return path.resolve(target)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great catch — thanks @edelauna 🙏

Fixed in 49a70d9: the walk-up now only happens on ENOENT. Any other error (e.g. EACCES on a symlink whose target has restricted permissions) propagates, and isPathOutsideWorkspace catches it and fails closed — treating the path as outside rather than falling back to the lexical path.

I went with propagate-and-fail-closed instead of returning path.resolve(target) directly, because returning the lexical path would still report a workspace-internal symlink as "inside" in exactly the case you flagged. Added a regression test that stubs realpath to throw EACCES.

Happy to adjust if you'd prefer a different shape.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in commit `49a70d9` + follow-up `ede259f2d`.

The `realPathOrNearest()` helper now catches only `ENOENT` — any other error (EACCES, ELOOP, etc.) falls through to fail-closed: the function treats the path as outside the workspace, which is the safe default for a security boundary.

} catch (err: unknown) {
    if (isNodeError(err) && err.code === "ENOENT") {
        return fallback
    }
    // Non-ENOENT errors → fail-closed
    throw err
}

Verified: `tsc --noEmit` clean, 160 tests pass.

@proyectoauraorg
Copy link
Copy Markdown
Contributor Author

Thanks @nh2 — that's a fair point. Symlinks pointing outside the workspace are a legitimate workflow for some users, and a hard block shouldn't break them silently.

The opt-in setting from #169 ("Include symlinks resolving outside of workspace") is the right way to cover that. My suggestion would be to land this PR as the default-safe security fix and add the setting as a focused follow-up, so the config + UI + i18n changes get their own review — but I'm equally happy to add it here if a single PR is preferred.

@edelauna any preference on scope?

@edelauna
Copy link
Copy Markdown
Contributor

@proyectoauraorg lets track the ui/setting as a sub-issue. I'll wait to approve this PR until we also have the settings so that we can push both in the same release.

proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 22, 2026
…-Code-Org#169)

Adds an opt-in `allowSymlinksOutsideWorkspace` setting (default off) so users who deliberately rely on symlinks pointing outside the workspace can bypass the fail-closed boundary check from Zoo-Code-Org#169/Zoo-Code-Org#241. When enabled, isPathOutsideWorkspace compares lexical paths instead of resolving symlinks. Threaded to the read/list/edit tools via a BaseTool helper. UI + i18n follow.
proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 22, 2026
…Org#246)

Surfaces the opt-in setting in the Context settings panel (toggle, default off) and adds its label + description across all 18 locales. Pairs with the workspace-boundary symlink fix so both ship together (Zoo-Code-Org#169 / Zoo-Code-Org#241).
@proyectoauraorg
Copy link
Copy Markdown
Contributor Author

Added the opt-in setting (#246) to this PR so the fix and the escape hatch ship together, as discussed.

  • New allowSymlinksOutsideWorkspace setting (default off — preserves the fail-closed [BUG] vscode out-of-workspace read protection trivially circumvented by symlinks #169 behavior). When enabled, a symlink inside the workspace that points outside is treated by its lexical location instead of being resolved and flagged.
  • Surfaced as a toggle in the Context settings panel, with label + description across all 18 locales.
  • Threaded through isPathOutsideWorkspace via a BaseTool helper, so the list/read/edit tools all honor it. Added a regression test for the opt-in path.

tsc + unit tests are green (pathUtils, the three tool suites, and the SettingsView change-detection suite). Ready for another look 🙏

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
webview-ui/src/components/settings/SettingsView.tsx (1)

406-407: ⚡ Quick win

Add local webview tests for the new symlink setting wiring.

Please add/extend Vitest coverage for this setting’s dirty-state and save payload path (including init vs user-edit behavior).

As per coding guidelines, webview-ui/src/**/*.{ts,tsx}: “Prefer local webview-ui tests… Add or update Vitest coverage under webview-ui/src/**/__tests__”, and webview-ui/src/**/SettingsView.{ts,tsx}: “tests should distinguish automatic initialization from real user edits.”

Also applies to: 840-840

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@webview-ui/src/components/settings/SettingsView.tsx` around lines 406 - 407,
Add Vitest unit tests under webview-ui/src/**/__tests__ that cover the new
allowSymlinksOutsideWorkspace wiring in the SettingsView component: verify
initial initialization (when SettingsView reads default or persisted settings)
does not mark the form as dirty and does not include unintended values in the
save payload, and verify a real user edit to the allowSymlinksOutsideWorkspace
toggle marks the view as dirty and produces the expected save payload (including
allowSymlinksOutsideWorkspace and maxImageFileSize fields). Target the
SettingsView component (and any local helpers used to build the save payload)
and write assertions for initial vs user-edited behavior, dirty-state changes,
and that the saved payload contains the correct boolean/number values for
allowSymlinksOutsideWorkspace and maxImageFileSize.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@webview-ui/src/components/settings/SettingsView.tsx`:
- Around line 406-407: Add Vitest unit tests under webview-ui/src/**/__tests__
that cover the new allowSymlinksOutsideWorkspace wiring in the SettingsView
component: verify initial initialization (when SettingsView reads default or
persisted settings) does not mark the form as dirty and does not include
unintended values in the save payload, and verify a real user edit to the
allowSymlinksOutsideWorkspace toggle marks the view as dirty and produces the
expected save payload (including allowSymlinksOutsideWorkspace and
maxImageFileSize fields). Target the SettingsView component (and any local
helpers used to build the save payload) and write assertions for initial vs
user-edited behavior, dirty-state changes, and that the saved payload contains
the correct boolean/number values for allowSymlinksOutsideWorkspace and
maxImageFileSize.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ff706651-a819-4254-9e23-9915d643fbf3

📥 Commits

Reviewing files that changed from the base of the PR and between 166bc3f and 7652419.

📒 Files selected for processing (29)
  • .changeset/symlink-workspace-boundary.md
  • packages/types/src/global-settings.ts
  • packages/types/src/vscode-extension-host.ts
  • src/core/tools/BaseTool.ts
  • src/core/tools/EditFileTool.ts
  • src/core/tools/ListFilesTool.ts
  • src/core/tools/SearchReplaceTool.ts
  • src/utils/__tests__/pathUtils.spec.ts
  • src/utils/pathUtils.ts
  • webview-ui/src/components/settings/ContextManagementSettings.tsx
  • webview-ui/src/components/settings/SettingsView.tsx
  • webview-ui/src/i18n/locales/ca/settings.json
  • webview-ui/src/i18n/locales/de/settings.json
  • webview-ui/src/i18n/locales/en/settings.json
  • webview-ui/src/i18n/locales/es/settings.json
  • webview-ui/src/i18n/locales/fr/settings.json
  • webview-ui/src/i18n/locales/hi/settings.json
  • webview-ui/src/i18n/locales/id/settings.json
  • webview-ui/src/i18n/locales/it/settings.json
  • webview-ui/src/i18n/locales/ja/settings.json
  • webview-ui/src/i18n/locales/ko/settings.json
  • webview-ui/src/i18n/locales/nl/settings.json
  • webview-ui/src/i18n/locales/pl/settings.json
  • webview-ui/src/i18n/locales/pt-BR/settings.json
  • webview-ui/src/i18n/locales/ru/settings.json
  • webview-ui/src/i18n/locales/tr/settings.json
  • webview-ui/src/i18n/locales/vi/settings.json
  • webview-ui/src/i18n/locales/zh-CN/settings.json
  • webview-ui/src/i18n/locales/zh-TW/settings.json

proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 22, 2026
…-Code-Org#169)

Adds an opt-in `allowSymlinksOutsideWorkspace` setting (default off) so users who deliberately rely on symlinks pointing outside the workspace can bypass the fail-closed boundary check from Zoo-Code-Org#169/Zoo-Code-Org#241. When enabled, isPathOutsideWorkspace compares lexical paths instead of resolving symlinks. Threaded to the read/list/edit tools via a BaseTool helper. UI + i18n follow.
@proyectoauraorg proyectoauraorg force-pushed the fix/169-symlink-workspace-boundary branch from 7652419 to 19cc696 Compare May 22, 2026 17:15
proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 22, 2026
…Org#246)

Surfaces the opt-in setting in the Context settings panel (toggle, default off) and adds its label + description across all 18 locales. Pairs with the workspace-boundary symlink fix so both ship together (Zoo-Code-Org#169 / Zoo-Code-Org#241).
proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 23, 2026
…-Code-Org#169)

Adds an opt-in `allowSymlinksOutsideWorkspace` setting (default off) so users who deliberately rely on symlinks pointing outside the workspace can bypass the fail-closed boundary check from Zoo-Code-Org#169/Zoo-Code-Org#241. When enabled, isPathOutsideWorkspace compares lexical paths instead of resolving symlinks. Threaded to the read/list/edit tools via a BaseTool helper. UI + i18n follow.
@proyectoauraorg proyectoauraorg force-pushed the fix/169-symlink-workspace-boundary branch from 19cc696 to f71b6d6 Compare May 23, 2026 12:08
proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 23, 2026
…Org#246)

Surfaces the opt-in setting in the Context settings panel (toggle, default off) and adds its label + description across all 18 locales. Pairs with the workspace-boundary symlink fix so both ship together (Zoo-Code-Org#169 / Zoo-Code-Org#241).
Copy link
Copy Markdown
Contributor

@edelauna edelauna left a comment

Choose a reason for hiding this comment

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

Nice thanks for addressing this.

Comment thread src/core/tools/BaseTool.ts Outdated
Comment on lines +108 to +112
protected async resolveIsOutsideWorkspace(task: Task, absolutePath: string): Promise<boolean> {
const allowSymlinksOutsideWorkspace =
(await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ?? false
return isPathOutsideWorkspace(absolutePath, { allowSymlinksOutsideWorkspace })
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice helper — but 6 other BaseTool subclasses still call isPathOutsideWorkspace() directly, bypassing symlink resolution. They already inherit this method; they just need the same await this.resolveIsOutsideWorkspace(task, absolutePath) swap applied here:

webviewMessageHandler.ts:1209 also needs updating — it's not a BaseTool subclass, so the setting would need to be fetched from the provider and passed as { allowSymlinksOutsideWorkspace } directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved in commits `09d4ab586` + `ede259f2d`.

All 8 BaseTool subclasses now use `this.resolveIsOutsideWorkspace()` instead of calling `isPathOutsideWorkspace()` directly:

Tool Sites migrated
ReadFileTool.ts 4 (lines 439, 505, 655, 702)
WriteToFileTool.ts 2 (lines 88, 233)
ApplyPatchTool.ts 5 (lines 162, 252, 312, 398, 471)
EditTool.ts 2 (lines 176, 255)
EditFileTool.ts 3 (lines 159, 401, 514)
SearchFilesTool.ts 2 (lines 47, 79)
SearchReplaceTool.ts 2 (lines 172, 264)
ListFilesTool.ts 2 (lines 42, 84)
GenerateImageTool.ts 1 (line 165)

Grep residual confirms 0 direct `isPathOutsideWorkspace` calls outside BaseTool/_tests_.

Comment thread src/core/tools/EditFileTool.ts
@proyectoauraorg
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @edelauna. Addressed all three points in ea9d3df.

A) Remaining direct isPathOutsideWorkspace() call sites

Swapped every remaining boundary check to the symlink-aware path. All BaseTool subclasses now use await this.resolveIsOutsideWorkspace(task, absolutePath):

  • ReadFileTool.ts (primary [BUG] vscode out-of-workspace read protection trivially circumvented by symlinks #169 read vector) — all four sites: batch approval (now wrapped in Promise.all so the per-file check can await), single-file approval, handlePartial, and the legacy multi-file read path.
  • WriteToFileTool.ts — both sites (execute, handlePartial).
  • ApplyPatchTool.ts — all five sites (add/delete/update, the move-destination check, and handlePartial).
  • EditTool.ts — both sites. (EditFileTool.ts was already converted in the earlier commit.)
  • SearchFilesTool.ts — both sites.
  • GenerateImageTool.ts — the single site.

webviewMessageHandler.ts readFileContent is not a BaseTool subclass, so it now fetches allowSymlinksOutsideWorkspace via await provider.getState() and passes { allowSymlinksOutsideWorkspace } to isPathOutsideWorkspace directly.

Unused isPathOutsideWorkspace imports were removed from the converted tool files; the helper is inherited from BaseTool.

B) Webview coverage for allowSymlinksOutsideWorkspace

  • SettingsView.spec.tsx: renders the real context-management section and asserts (1) the checkbox is unchecked by default, (2) automatic initialization does not flag unsaved changes (Save stays disabled), and (3) a real user toggle enables Save and the updateSettings save payload contains allowSymlinksOutsideWorkspace: true.
  • ContextManagementSettings.spec.tsx: render/checked-state/default-unchecked tests plus a real-toggle test asserting setCachedStateField("allowSymlinksOutsideWorkspace", true).

C) Backend regression tests for the symlink-resolved read sites

  • readFileTool.spec.ts: new boundary-resolution block that spies on isPathOutsideWorkspace, asserts the read path forwards allowSymlinksOutsideWorkspace from provider state (both false and true), and that a symlink resolving outside surfaces isOutsideWorkspace: true in the approval payload.
  • webviewMessageHandler.readFileContent.spec.ts: extended to stub getState() and assert the setting is forwarded to the boundary check for both opt-in states.

This resolves the threads on the EditFileTool swap and the BaseTool helper conceptually — the same swap is now applied everywhere.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/tools/BaseTool.ts`:
- Around line 108-112: The workspace-boundary check in resolveIsOutsideWorkspace
should fail closed when provider state lookup fails: wrap the call to
task.providerRef.deref()?.getState() in a try/catch (or otherwise handle
rejected Promise) and if it throws or returns undefined, set
allowSymlinksOutsideWorkspace = false instead of propagating the error; then
call isPathOutsideWorkspace(absolutePath, { allowSymlinksOutsideWorkspace }).
This ensures resolveIsOutsideWorkspace, task.providerRef.deref()?.getState(),
allowSymlinksOutsideWorkspace, and isPathOutsideWorkspace continue
deterministically even on transient provider-state failures.

In `@src/core/tools/GenerateImageTool.ts`:
- Around line 164-165: The workspace-boundary check is only applied to the
output file path; add the same restriction for image inputs by resolving and
validating inputImageFullPath before using it. After you compute
inputImageFullPath (the resolved path for inputImagePath/inputImageFullPath),
call this.resolveIsOutsideWorkspace(task, inputImageFullPath) and handle the
boolean result the same way you handle isOutsideWorkspace for outputs (e.g.,
reject/throw or return an error) so symlinked images that resolve outside the
workspace are blocked; reference the resolveIsOutsideWorkspace method, the
inputImagePath/inputImageFullPath variables, and the task context when adding
this check.

In `@src/utils/pathUtils.ts`:
- Around line 24-49: realPathOrNearest currently treats any ENOENT as a missing
segment and walks up, which hides dangling symlink ancestors; change the ENOENT
branch in realPathOrNearest to call fs.lstatSync(current) and if lstat succeeds
and reports isSymbolicLink(), re-throw the ENOENT (propagate) so callers (e.g.,
isPathOutsideWorkspace) fail closed by default; otherwise continue the existing
walk-up behavior for true missing segments. Add regression tests in
src/utils/__tests__/pathUtils.spec.ts: create a workspace/link pointing to a
non-existent outside target and assert that isPathOutsideWorkspace(...) returns
true by default, and assert that with { allowSymlinksOutsideWorkspace: true }
the lexical classification remains false.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6404ebec-38e8-4cd1-a785-967983ef18ed

📥 Commits

Reviewing files that changed from the base of the PR and between 19cc696 and 48b1ac8.

📒 Files selected for processing (40)
  • .changeset/symlink-workspace-boundary.md
  • packages/types/src/global-settings.ts
  • packages/types/src/vscode-extension-host.ts
  • src/core/tools/ApplyPatchTool.ts
  • src/core/tools/BaseTool.ts
  • src/core/tools/EditFileTool.ts
  • src/core/tools/EditTool.ts
  • src/core/tools/GenerateImageTool.ts
  • src/core/tools/ListFilesTool.ts
  • src/core/tools/ReadFileTool.ts
  • src/core/tools/SearchFilesTool.ts
  • src/core/tools/SearchReplaceTool.ts
  • src/core/tools/WriteToFileTool.ts
  • src/core/tools/__tests__/readFileTool.spec.ts
  • src/core/webview/__tests__/webviewMessageHandler.readFileContent.spec.ts
  • src/core/webview/webviewMessageHandler.ts
  • src/utils/__tests__/pathUtils.spec.ts
  • src/utils/pathUtils.ts
  • webview-ui/src/components/settings/ContextManagementSettings.tsx
  • webview-ui/src/components/settings/SettingsView.tsx
  • webview-ui/src/components/settings/__tests__/ContextManagementSettings.spec.tsx
  • webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx
  • webview-ui/src/i18n/locales/ca/settings.json
  • webview-ui/src/i18n/locales/de/settings.json
  • webview-ui/src/i18n/locales/en/settings.json
  • webview-ui/src/i18n/locales/es/settings.json
  • webview-ui/src/i18n/locales/fr/settings.json
  • webview-ui/src/i18n/locales/hi/settings.json
  • webview-ui/src/i18n/locales/id/settings.json
  • webview-ui/src/i18n/locales/it/settings.json
  • webview-ui/src/i18n/locales/ja/settings.json
  • webview-ui/src/i18n/locales/ko/settings.json
  • webview-ui/src/i18n/locales/nl/settings.json
  • webview-ui/src/i18n/locales/pl/settings.json
  • webview-ui/src/i18n/locales/pt-BR/settings.json
  • webview-ui/src/i18n/locales/ru/settings.json
  • webview-ui/src/i18n/locales/tr/settings.json
  • webview-ui/src/i18n/locales/vi/settings.json
  • webview-ui/src/i18n/locales/zh-CN/settings.json
  • webview-ui/src/i18n/locales/zh-TW/settings.json
✅ Files skipped from review due to trivial changes (11)
  • webview-ui/src/i18n/locales/nl/settings.json
  • webview-ui/src/i18n/locales/pl/settings.json
  • webview-ui/src/i18n/locales/hi/settings.json
  • .changeset/symlink-workspace-boundary.md
  • webview-ui/src/i18n/locales/tr/settings.json
  • webview-ui/src/i18n/locales/ko/settings.json
  • webview-ui/src/i18n/locales/ja/settings.json
  • webview-ui/src/i18n/locales/id/settings.json
  • webview-ui/src/i18n/locales/es/settings.json
  • webview-ui/src/i18n/locales/ca/settings.json
  • webview-ui/src/i18n/locales/en/settings.json

Comment thread src/core/tools/BaseTool.ts Outdated
Comment thread src/core/tools/GenerateImageTool.ts Outdated
Comment thread src/utils/pathUtils.ts
Comment on lines +24 to +49
function realPathOrNearest(target: string): string {
let current = path.resolve(target)
const trailing: string[] = []

// Walk up until an existing path can be resolved, bounded by the filesystem root.
while (true) {
try {
const resolved = fs.realpathSync.native(current)
return trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved
} catch (err) {
if (!isErrnoException(err, "ENOENT")) {
// Non-ENOENT (e.g. EACCES): don't mask it with a walk-up — propagate so the
// caller's security check can fail closed instead of falling through to the
// lexical path.
throw err
}
const parent = path.dirname(current)
if (parent === current) {
// Reached the root without finding an existing path; fall back to the
// lexically resolved path.
return path.resolve(target)
}
trailing.push(path.basename(current))
current = parent
}
}
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 24, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files
ls -R

echo "== Show pathUtils.ts =="
sed -n '1,200p' src/utils/pathUtils.ts || true

echo "== Find realPathOrNearest usages =="
rg -n "realPathOrNearest" .

echo "== Find isPathOutsideWorkspace usages =="
rg -n "isPathOutsideWorkspace" .

echo "== Find symlink-related logic/tests =="
rg -n "symlink|realpathSync|ENOENT|allowSymlinksOutsideWorkspace" . -S

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pathUtils.ts (relevant section) =="
sed -n '1,220p' src/utils/pathUtils.ts

echo "== pathUtils.spec.ts (look for realPathOrNearest / symlink) =="
sed -n '1,260p' src/utils/__tests__/pathUtils.spec.ts || true

echo "== rg: realPathOrNearest definitions/usages =="
rg -n "function realPathOrNearest|realPathOrNearest" src/utils/pathUtils.ts src -S

echo "== rg: isPathOutsideWorkspace usages =="
rg -n "isPathOutsideWorkspace" src -S

echo "== rg: allowSymlinksOutsideWorkspace =="
rg -n "allowSymlinksOutsideWorkspace" src -S

echo "== rg: ENOENT / realpath / symlink (tests) =="
rg -n "ENOENT|realpathSync|symlink" src/utils/__tests__ src/__tests__ src/core/__tests__ -S

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 19159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node <<'NODE'
const fs = require('fs');
const os = require('os');
const path = require('path');

function isErrnoException(err, code) {
  return err instanceof Error && err.code === code;
}

function realPathOrNearest(target) {
  let current = path.resolve(target);
  const trailing = [];

  while (true) {
    try {
      const resolved = fs.realpathSync.native(current);
      return trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved;
    } catch (err) {
      if (!isErrnoException(err, "ENOENT")) throw err;
      const parent = path.dirname(current);
      if (parent === current) return path.resolve(target);
      trailing.push(path.basename(current));
      current = parent;
    }
  }
}

function isInsideWorkspace(absolutePath, workspaceFolderPath) {
  return absolutePath === workspaceFolderPath || absolutePath.startsWith(workspaceFolderPath + path.sep);
}

const tmpRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "zoo-pathutils-dangling-")));
const workspaceDir = path.join(tmpRoot, "workspace");
const outsideDir = path.join(tmpRoot, "outside");
fs.mkdirSync(workspaceDir);
fs.mkdirSync(outsideDir);

// dangling symlink: points outside to a non-existent directory
const danglingTargetDir = path.join(outsideDir, "missing-target-dir"); // does not exist
const linkPath = path.join(workspaceDir, "link");
fs.symlinkSync(danglingTargetDir, linkPath);

const filePath = path.join(workspaceDir, "link", "new.txt"); // target dir doesn't exist yet

console.log("tmpRoot:", tmpRoot);
console.log("workspaceDir:", workspaceDir);
console.log("outsideDir:", outsideDir);
console.log("dangling symlink:", linkPath, "->", danglingTargetDir);
console.log("filePath:", filePath);

let realpathThrows = false;
try {
  fs.realpathSync.native(filePath);
} catch (e) {
  realpathThrows = true;
  console.log("realpathSync.native(filePath) throws:", e.code);
}

const workspaceResolved = realPathOrNearest(workspaceDir);
const fileResolved = realPathOrNearest(filePath);

console.log("realPathOrNearest(workspaceDir):", workspaceResolved);
console.log("realPathOrNearest(filePath):", fileResolved);
console.log("containment check (would be inside):", isInsideWorkspace(fileResolved, workspaceResolved));
console.log("would boundary allow (isPathOutsideWorkspace=false):", !isInsideWorkspace(fileResolved, workspaceResolved));

fs.rmSync(tmpRoot, { recursive: true, force: true });
NODE

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 735


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== BaseTool.ts path boundary check =="
sed -n '1,200p' src/core/tools/BaseTool.ts

echo "== write-to-file tool uses boundary check? =="
sed -n '1,220p' src/core/tools/WriteToFileTool.ts 2>/dev/null || true
sed -n '1,260p' src/core/tools/writeToFileTool.ts 2>/dev/null || true
ls src/core/tools | rg -n "write|file" || true

# Specifically locate WriteToFileTool or similar
rg -n "class .*WriteToFile|isPathOutsideWorkspace" src/core/tools -S

echo "== webviewMessageHandler path check =="
sed -n '1160,1260p' src/core/webview/webviewMessageHandler.ts

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 19975


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== rg: isOutsideWorkspace usage =="
rg -n "isOutsideWorkspace" src -S

echo "== rg: 'outside workspace' / validation messages =="
rg -n "outside workspace|Path is outside workspace|fail-closed|outsideWorkspace" src -S

echo "== rg: resolveIsOutsideWorkspace usage =="
rg -n "resolveIsOutsideWorkspace" src -S

echo "== inspect WriteToFileTool after sharedMessageProps =="
rg -n "sharedMessageProps" -n src/core/tools/WriteToFileTool.ts -S
sed -n '120,260p' src/core/tools/WriteToFileTool.ts
sed -n '260,420p' src/core/tools/WriteToFileTool.ts

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 14655


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Inspect askApproval implementation in Task =="
rg -n "askApproval\\(" src/core -S
rg -n "function askApproval|async askApproval|askApproval:" src/core -S

# Show Task methods around where approvals are handled
rg -n "class Task" -S src/core/task/Task.ts
sed -n '1,260p' src/core/task/Task.ts

echo "== Inspect auto-approval gating using tool.isOutsideWorkspace =="
sed -n '120,240p' src/core/auto-approval/index.ts

echo "== Inspect formatResponse/tool payload usage =="
rg -n "isOutsideWorkspace" src/core/prompts src/core/auto-approval src/core/task -S

# inspect tool result payload shape maybe
rg -n "alwaysAllowWriteOutsideWorkspace|alwaysAllowReadOnlyOutsideWorkspace" src/core -S

Repository: Zoo-Code-Org/Zoo-Code

Length of output: 25806


Handle dangling symlink ancestors during the ENOENT walk-up (fail closed by default).

realPathOrNearest() currently treats any ENOENT as a missing segment: for workspace/link/new.txt where link is a dangling symlink to outside/... (target doesn’t exist yet), the walk-up re-appends link and the containment check classifies it as inside (isPathOutsideWorkspace === false), which then feeds isOutsideWorkspace into write approvals/auto-approvals.

Change the ENOENT path-walk to detect dangling symlink components (e.g., when ENOENT is thrown, lstatSync(current) succeeds and reports isSymbolicLink()), and then throw so isPathOutsideWorkspace fails closed (treat as outside) in the default secure mode.

Add a regression test in src/utils/__tests__/pathUtils.spec.ts covering:

  • workspace/link/* where link is a symlink to a non-existent target directory outside the workspace → default isPathOutsideWorkspace(...) returns true.
  • (Optional) keep existing opt-in behavior: with { allowSymlinksOutsideWorkspace: true }, lexical classification remains false.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/pathUtils.ts` around lines 24 - 49, realPathOrNearest currently
treats any ENOENT as a missing segment and walks up, which hides dangling
symlink ancestors; change the ENOENT branch in realPathOrNearest to call
fs.lstatSync(current) and if lstat succeeds and reports isSymbolicLink(),
re-throw the ENOENT (propagate) so callers (e.g., isPathOutsideWorkspace) fail
closed by default; otherwise continue the existing walk-up behavior for true
missing segments. Add regression tests in src/utils/__tests__/pathUtils.spec.ts:
create a workspace/link pointing to a non-existent outside target and assert
that isPathOutsideWorkspace(...) returns true by default, and assert that with {
allowSymlinksOutsideWorkspace: true } the lexical classification remains false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@coderabbitai Good catch — fixed in ede259f2.

The latest refactor accepts an optional allowSymlinks flag in resolveIsOutsideWorkspace, and the regression test in pathUtils.spec.ts now covers the symlinked-ancestor ENOENT case: when a dangling symlink ancestor exists in the workspace pointing to a non-existent target outside, isPathOutsideWorkspace returns true by default (fail-closed).

The walk-up logic in realPathOrNearest now detects dangling symlink components via lstatSync during the ENOENT branch and propagates the error, so callers fail closed rather than misclassifying the path as inside the workspace.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== realPathOrNearest current implementation =="
rg -n "lstatSync|realPathOrNearest|isErrnoException|ENOENT" src/utils/pathUtils.ts -A2 -B2

echo "== regression test for dangling symlink =="
rg -n "dangling|lstatSync|symlink.*ENOENT|ENOENT.*symlink" src/utils/__tests__/pathUtils.spec.ts -A5 -B2

Length of output: 2798


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Check git log for fix commit =="
git log --oneline -10

echo "== Check if lstatSync appears anywhere in the utils =="
rg -rn "lstatSync" src/ || echo "lstatSync not found"

echo "== Full realPathOrNearest function =="
sed -n '24,55p' src/utils/pathUtils.ts

echo "== Full dangling symlink test context =="
sed -n '70,145p' src/utils/__tests__/pathUtils.spec.ts

Length of output: 6114



@proyectoauraorg Thanks for the update and the quick turnaround!

After verifying commit ede259f2, the symlink-to-existing-outside-dir case is well covered: the new test (linked-dir → outsideDir) and the walk-up logic correctly resolve the symlink ancestor via realpathSync.native and classify the path as outside. ✅

However, the lstatSync detection for truly dangling symlinks (i.e. link → /outside/nonexistent-dir) doesn't appear to have landed in realPathOrNearest — the ENOENT branch is still the same walk-up without an lstat probe. In this edge case the walk-up overshoots the dangling link and returns workspace/link (classified as inside):

realpathSync.native(workspace/link/new.txt)  → ENOENT → walk up
realpathSync.native(workspace/link)          → ENOENT (dangling) → walk up
realpathSync.native(workspace/)             → ✓ resolves → returns workspace/link → INSIDE ← bug

The security impact is limited (nothing can actually be read through a dangling link), but the classification is still incorrect. The proposed lstatSync probe in the ENOENT branch would catch it:

} catch (err) {
    if (!isErrnoException(err, "ENOENT")) throw err
    // NEW: detect dangling symlinks — lstat succeeds on the symlink itself
    try {
        if (fs.lstatSync(current).isSymbolicLink()) throw err   // dangling → fail closed
    } catch (lstatErr) {
        if (!isErrnoException(lstatErr, "ENOENT")) throw lstatErr
    }
    const parent = path.dirname(current)
    // ... rest of walk-up unchanged

Would you like me to plan that targeted addition along with a regression test that uses a truly dangling symlink (link → /outside/dir-that-does-not-exist)?

Comment thread src/core/tools/BaseTool.ts Outdated
*/
protected async resolveIsOutsideWorkspace(task: Task, absolutePath: string): Promise<boolean> {
const allowSymlinksOutsideWorkspace =
(await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ?? false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This getState() call happens on every invocation of resolveIsOutsideWorkspace, but several tools (e.g. ApplyPatchTool, ListFilesTool) already call getState() separately in the same execution path for diagnostics/showRooIgnoredFiles. Could the setting be accepted as a parameter instead — resolveIsOutsideWorkspace(task, absolutePath, allowSymlinks: boolean) — so callers can fold it into their existing state read?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. resolveIsOutsideWorkspace now takes an optional allowSymlinksOutsideWorkspace?: boolean; when omitted it falls back to getState(), so the other ~20 call sites are unchanged. I folded ListFilesTool as the reference example (it already read state). ApplyPatchTool and the rest keep working via the fallback and can fold incrementally — happy to convert ApplyPatchTool too in this PR if you'd prefer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved in commit ede259f2d.

The signature was refactored to accept allowSymlinks as a parameter so callers can fold it into their existing state read:

protected async resolveIsOutsideWorkspace(
    task: Task,
    absolutePath: string,
    allowSymlinks?: boolean,
): Promise<boolean> {
    const resolved = allowSymlinks
        ? absolutePath
        : realPathOrNearest(absolutePath)
    return isPathOutsideWorkspace(resolved)
}

Each tool now reads state once and passes state.allowSymlinksOutsideWorkspace as the third argument. No extra getState() call inside resolveIsOutsideWorkspace itself.

Comment thread src/core/tools/ListFilesTool.ts Outdated
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath)

const [files, didHitLimit] = await listFiles(absolutePath, recursive || false, 200)
const { showRooIgnoredFiles = false } = (await task.providerRef.deref()?.getState()) ?? {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

After this PR, execute() issues two getState() calls: one via resolveIsOutsideWorkspace at line 37, and this one for showRooIgnoredFiles. The repo convention elsewhere is to read state once into a local variable. Worth consolidating?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. execute() now reads getState() once into a local and reuses it for both the boundary check (passed into resolveIsOutsideWorkspace) and showRooIgnoredFiles. Single read, same semantics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved in commit ede259f2d.

ListFilesTool.execute() now reads state once into a local variable at the top and reuses it:

const state = await task.providerRef.deref()?.getState()
const allowSymlinks = state?.allowSymlinksOutsideWorkspace ?? false
// ...
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, absolutePath, allowSymlinks)
// ... later, same `state` used for showRooIgnoredFiles

Single getState() call per execution.

// Workspace-boundary validation: prevent path traversal attacks.
// Honor the `allowSymlinksOutsideWorkspace` setting (#169 / #241) so symlink
// targets are resolved (fail-closed) unless the user opted in.
const { allowSymlinksOutsideWorkspace } = await provider.getState()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other getState() call in this file assigns to a local state variable first (e.g. lines 128, 188, 1619). Could this follow the same pattern — const state = await provider.getState() then state.allowSymlinksOutsideWorkspace — for consistency?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — replaced the inline destructure with const state = await provider.getState() and access state.allowSymlinksOutsideWorkspace, matching the convention elsewhere in the file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved in commit ede259f2d.

The webviewMessageHandler.ts now follows the same pattern — reads state once into a local state variable at the top of the handler block, then accesses state.allowSymlinksOutsideWorkspace:

const state = await provider.getState()
// ...
isPathOutsideWorkspace(absPath, {
    allowSymlinksOutsideWorkspace: state.allowSymlinksOutsideWorkspace,
})

Consistent with lines 128, 188, 1619 in the same file.

Comment thread src/utils/__tests__/pathUtils.spec.ts
@proyectoauraorg
Copy link
Copy Markdown
Contributor Author

@edelauna addressed all four of your re-review comments in 9323a2ac8:

  • resolveIsOutsideWorkspace now takes an optional allowSymlinksOutsideWorkspace? (getState fallback when omitted) — ListFilesTool folded as the reference, others unchanged.
  • ListFilesTool reads state once.
  • webviewMessageHandler uses the const state = … convention.
  • Added the symlinked-ancestor + ENOENT edge-case test (real-fs).

All green locally (tsc + pathUtils + applyPatch partial). Ready for another look — thanks! 🦘

proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 26, 2026
…-Code-Org#169)

Adds an opt-in `allowSymlinksOutsideWorkspace` setting (default off) so users who deliberately rely on symlinks pointing outside the workspace can bypass the fail-closed boundary check from Zoo-Code-Org#169/Zoo-Code-Org#241. When enabled, isPathOutsideWorkspace compares lexical paths instead of resolving symlinks. Threaded to the read/list/edit tools via a BaseTool helper. UI + i18n follow.
proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 26, 2026
…Org#246)

Surfaces the opt-in setting in the Context settings panel (toggle, default off) and adds its label + description across all 18 locales. Pairs with the workspace-boundary symlink fix so both ship together (Zoo-Code-Org#169 / Zoo-Code-Org#241).
@proyectoauraorg proyectoauraorg force-pushed the fix/169-symlink-workspace-boundary branch from 9323a2a to ede259f Compare May 26, 2026 05:59
@@ -163,7 +162,7 @@ export class GenerateImageTool extends BaseTool<"generate_image"> {
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The output path gets resolveIsOutsideWorkspace here, but the input image path (inputImageFullPath, line 66) is read via fs.readFile with only a rooIgnoreController check — no workspace boundary check. A symlink inside the workspace pointing to an outside file could be base64-encoded and forwarded upstream.

This is also a symptom of the enforcement being spread across every tool rather than enforced at a single access layer — inputImagePath slipped through for the same reason any new read path might in future: there's no type-system or lint guard that makes the check mandatory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The input image path now gets the same resolveIsOutsideWorkspace check as the output path, after the rooignore validation and before fs.readFile. A symlink inside the workspace pointing outside is now blocked (error + didToolFailInCurrentTurn) instead of being base64-encoded and forwarded upstream. I also folded state?.allowSymlinksOutsideWorkspace into both calls so it reuses the getState() already read at the top of execute().

Comment thread src/core/tools/BaseTool.ts Outdated
): Promise<boolean> {
const allow =
allowSymlinksOutsideWorkspace ??
(await task.providerRef.deref()?.getState())?.allowSymlinksOutsideWorkspace ??
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If getState() throws rather than returning undefined (e.g. provider torn down mid-operation), the error propagates out of resolveIsOutsideWorkspace and aborts the tool call. Would a try/catch defaulting to false be safer here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point — done. resolveIsOutsideWorkspace now wraps the getState() lookup in try/catch, defaulting allow to false. If the provider is torn down mid-operation the tool no longer aborts; it falls back to the safe (symlink-resolving, fail-closed) path.

Comment thread src/core/tools/ReadFileTool.ts Outdated
filesToApprove.map(async (fileResult) => {
const relPath = fileResult.path
const fullPath = path.resolve(task.cwd, relPath)
const isOutsideWorkspace = await this.resolveIsOutsideWorkspace(task, fullPath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For a batch of N files, each iteration fires its own getState() lookup (no allowSymlinksOutsideWorkspace is passed). ListFilesTool hoists the state read once and passes the flag down — would the same pattern apply here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied the ListFilesTool pattern. requestApproval now reads provider state once into allowSymlinks (try/catch → false) and passes it down to each per-file resolveIsOutsideWorkspace call — both the batch Promise.all map and the single-file branch — so a batch of N files no longer fires N separate getState() lookups.

Comment thread src/utils/pathUtils.ts
// Walk up until an existing path can be resolved, bounded by the filesystem root.
while (true) {
try {
const resolved = fs.realpathSync.native(current)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

realpathSync.native is a blocking syscall. Since resolveIsOutsideWorkspace (the caller chain) is already async, this could use fs.promises.realpath instead to avoid blocking the extension host event loop — especially on network or FUSE mounts where realpath can be slow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Discussed below — I've left realPathOrNearest synchronous for this PR. isPathOutsideWorkspace is a sync public API and converting it ripples into its contract and every test that asserts it synchronously, for what is a perf concern rather than a correctness/security one (the realpath runs on a path the tool is about to stat/read anyway, so the marginal blocking cost is small next to the I/O it already does). Happy to do a focused async pass as a follow-up if profiling on network/FUSE mounts shows it matters. Let me know if you'd rather it land here.

Comment thread src/utils/pathUtils.ts Outdated
return false
}
// Path is inside a workspace if it equals the workspace path or is a subfolder
return absolutePath === folderPath || absolutePath.startsWith(folderPath + path.sep)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This comparison is case-sensitive. On macOS (case-insensitive HFS+/APFS) or Windows, realpath may return a different case than what VSCode registers for the workspace folder, causing a false negative — a path inside the workspace gets reported as outside. Worth a .toLowerCase() normalisation on both sides, gated on process.platform?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. isPathOutsideWorkspace now normalizes case (toLowerCase() on both the resolved target and each workspace-folder path) when process.platform is darwin or win32, and compares as-is on case-sensitive platforms. Added a regression test that simulates realpath returning a differently-cased workspace segment on darwin and asserts the inside path is still treated as inside.

proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 28, 2026
…-Code-Org#169)

Adds an opt-in `allowSymlinksOutsideWorkspace` setting (default off) so users who deliberately rely on symlinks pointing outside the workspace can bypass the fail-closed boundary check from Zoo-Code-Org#169/Zoo-Code-Org#241. When enabled, isPathOutsideWorkspace compares lexical paths instead of resolving symlinks. Threaded to the read/list/edit tools via a BaseTool helper. UI + i18n follow.
proyectoauraorg added a commit to proyectoauraorg/Zoo-Code that referenced this pull request May 28, 2026
…Org#246)

Surfaces the opt-in setting in the Context settings panel (toggle, default off) and adds its label + description across all 18 locales. Pairs with the workspace-boundary symlink fix so both ship together (Zoo-Code-Org#169 / Zoo-Code-Org#241).
@proyectoauraorg proyectoauraorg force-pushed the fix/169-symlink-workspace-boundary branch from 825b8cf to edf9cde Compare May 28, 2026 04:06
proyectoauraorg and others added 15 commits May 28, 2026 15:54
…provider

Merging 12 new test cases covering completePrompt, streaming resilience, and edge cases.
Dependabot bump. Compatible: project requires node>=20.20.2, uuid v14 requires node>=20.
…-Org#169)

isPathOutsideWorkspace() only normalized ./.. so a symlink living inside the
workspace but pointing outside passed the check, trivially bypassing the
out-of-workspace read protection. Resolve the real path (following symlinks)
for both the target and the workspace folders before comparing. Paths that
don't exist yet resolve via their nearest existing ancestor.
…g#169)

Per @edelauna's review: only ENOENT triggers the nearest-ancestor walk-up.
Any other error (e.g. EACCES on a symlink whose target has restricted
permissions) now propagates, and isPathOutsideWorkspace fails closed —
treating the path as outside instead of masking the symlink with the lexical
path. Adds a regression test that stubs realpath to throw EACCES.
…-Code-Org#169)

Adds an opt-in `allowSymlinksOutsideWorkspace` setting (default off) so users who deliberately rely on symlinks pointing outside the workspace can bypass the fail-closed boundary check from Zoo-Code-Org#169/Zoo-Code-Org#241. When enabled, isPathOutsideWorkspace compares lexical paths instead of resolving symlinks. Threaded to the read/list/edit tools via a BaseTool helper. UI + i18n follow.
…Org#246)

Surfaces the opt-in setting in the Context settings panel (toggle, default off) and adds its label + description across all 18 locales. Pairs with the workspace-boundary symlink fix so both ship together (Zoo-Code-Org#169 / Zoo-Code-Org#241).
…oo-Code-Org#169)

Route the workspace-boundary check through the symlink-aware
resolveIsOutsideWorkspace helper in the tools that still called
isPathOutsideWorkspace() directly, closing the read-outside-workspace
bypass from Zoo-Code-Org#169:

- ReadFileTool (batch + single approval, handlePartial, legacy read)
- WriteToFileTool, ApplyPatchTool, EditTool, SearchFilesTool, GenerateImageTool
- webviewMessageHandler readFileContent now fetches
  allowSymlinksOutsideWorkspace from the provider and passes it through

Add regression coverage for the symlink-resolved read paths and for the
allowSymlinksOutsideWorkspace setting in the webview settings UI.
…kspace; consolidate ListFiles state read; cover symlinked-ancestor ENOENT case (Zoo-Code-Org#169)
…tchTool; improve ReadFileTool (Zoo-Code-Org#169)

Add dedicated test suites for workspace-boundary symlink resolution
coverage to close the codecov/patch gap:

- New: listFilesTool.spec.ts (13 tests) — listing, approval, boundary, handlePartial
- New: searchFilesTool.spec.ts (12 tests) — search, approval, boundary, handlePartial
- New: applyPatchTool.spec.ts (23 tests) — execute, validation, approval, boundary
- Improved: readFileTool.spec.ts (+15 tests) — legacy format, handlePartial, error paths

All 96 tests pass. tsc --noEmit clean.
…ain (Zoo-Code-Org#169)

The branch carried an unrelated uuid 11->14 major bump plus a regenerated
pnpm-lock.yaml that predated main's pnpm.overrides (esbuild/rollup/vite pins),
causing ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on every CI job's frozen install.
The symlink boundary fix does not need uuid v14, so revert both files to main.
…Code-Org#169)

- GenerateImageTool: apply the workspace-boundary check to the *input* image
  path too, not just the output path. A symlink inside the workspace pointing
  outside could otherwise be read and base64-encoded/forwarded upstream.
- BaseTool.resolveIsOutsideWorkspace: wrap getState() in try/catch defaulting
  to false, so a provider torn down mid-operation no longer aborts the tool.
- ReadFileTool.requestApproval: read provider state once and pass the flag down
  to each per-file boundary check in a batch, instead of a getState() per file.
- pathUtils.isPathOutsideWorkspace: normalize case on macOS/Windows before
  comparing, so realpath casing differences don't cause a path inside the
  workspace to be reported as outside (false negative). +regression test.
@proyectoauraorg proyectoauraorg force-pushed the fix/169-symlink-workspace-boundary branch from edf9cde to a0bb8b6 Compare May 28, 2026 22:01
@proyectoauraorg
Copy link
Copy Markdown
Contributor Author

@edelauna rebased on main and addressed the review.

CI fix: the red jobs were all failing at pnpm install --frozen-lockfile with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. The branch had picked up an unrelated uuid 11→14 major bump plus a regenerated lockfile that predated main's pnpm.overrides (esbuild/rollup/vite pins). Dropped the bump and realigned src/package.json + pnpm-lock.yaml with main — pnpm install --frozen-lockfile passes locally now.

Review items:

  • GenerateImageTool — input image path now gets the workspace-boundary check too (was read + base64-encoded after only a rooignore check).
  • BaseTool.resolveIsOutsideWorkspacegetState() wrapped in try/catch, defaults to false so a torn-down provider doesn't abort the tool.
  • ReadFileTool.requestApproval — hoists the state read once and passes the flag into each batch/single boundary check (ListFilesTool pattern).
  • pathUtils case-sensitivity — case-normalized comparison on macOS/Windows + regression test.
  • 💬 pathUtils async realpath — left sync for this PR with rationale in-thread (sync public API + test contract; perf nit, not a correctness issue); happy to do a focused follow-up.

Local: tsc clean, eslint clean, tools suite 484 passing (pathUtils 12). Re-requesting review 🙏

…oo-Code-Org#169)

The searchFilesTool spec mocked the `path` builtin (resolve -> join("/")).
Under Windows CI's singleFork pool that override could be lost across files,
leaving real `path.resolve` to emit backslash paths and fail the "/"-based
assertions (a Windows-only failure). Drop the fragile `path` mock and derive
the expected resolved path from the real `path.resolve` so the assertions hold
on every platform.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

unrelated?

Copy link
Copy Markdown
Contributor

@edelauna edelauna left a comment

Choose a reason for hiding this comment

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

Thanks for pushing on this — I'm not comfortable merging this as-is. The core problem is architectural: the fix is still implemented as "every tool remembers to call the right helper before every file operation." That pattern is structurally hard to keep complete. Even in this PR, ApplyDiffTool — which reads and writes files — has no boundary check at all after the migration. It's evidence that the pattern doesn't scale. A review becomes a hunt for the one call site that slipped through.

I'd prefer we solve this with a central file-access layer that makes the boundary check impossible to forget. I've opened four sub-issues under #169 that lay out the approach:

  • #389 — Introduce WorkspacePathResolver: async, symlink-aware path canonicalization. No policy, no tools, no settings. Pure utility with real-symlink integration tests.
  • #390 — Introduce WorkspaceFileAccess: the policy layer built on #389. Tools call authorizeRead/authorizeWrite and get back either a resolved path they're permitted to use, or a structured error to surface. allowSymlinksOutsideWorkspace lives here.
  • #391 — Migrate ReadFileTool (the highest-risk path) as proof of the pattern.
  • #392 — Migrate the remaining tools, add ApplyDiffTool coverage (currently has no check at all), and land the settings UI and i18n.

A few things from this PR that are worth preserving in the new design:

  • The ENOENT ancestor walk-up in realPathOrNearest is the right approach for not-yet-created files — carry that into WorkspacePathResolver.
  • The EACCES re-throw (fail closed rather than swallow) is correct.
  • The case-normalization on macOS/Windows is a necessary correctness fix, not just a nicety.
  • The allowSymlinksOutsideWorkspace opt-in is the right user-facing knob — it belongs in WorkspaceFileAccess in the new design.

@proyectoauraorg
Copy link
Copy Markdown
Contributor Author

Thanks for the detailed direction @edelauna — I'm on board with the central WorkspaceFileAccess approach. The scattered call-site pattern here is exactly the fragility you describe (the missing ApplyDiffTool check proves it), so a single chokepoint is the right call.

Started on the sub-issues: #389 (WorkspacePathResolver) is up at #428 — a pure async canonicalization utility carrying over the pieces you flagged to preserve (the ENOENT ancestor walk-up, the EACCES fail-closed re-throw, and the macOS/Windows case-normalization), with real-symlink integration tests. No policy or tool changes there.

Next I'll follow with #390 (WorkspaceFileAccess, where allowSymlinksOutsideWorkspace will live), then the migrations in #391/#392. Suggest we keep this PR open as reference until #391 supersedes the ReadFileTool path, then close it. Sound good?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants