Skip to content

feat(security): introduce WorkspacePathResolver — async symlink-aware path canonicalization (#389)#428

Open
proyectoauraorg wants to merge 1 commit into
Zoo-Code-Org:mainfrom
proyectoauraorg:feat/389-workspace-path-resolver
Open

feat(security): introduce WorkspacePathResolver — async symlink-aware path canonicalization (#389)#428
proyectoauraorg wants to merge 1 commit into
Zoo-Code-Org:mainfrom
proyectoauraorg:feat/389-workspace-path-resolver

Conversation

@proyectoauraorg
Copy link
Copy Markdown
Contributor

@proyectoauraorg proyectoauraorg commented Jun 1, 2026

Closes #389. First of four sub-issues under #169 toward a central WorkspaceFileAccess layer that makes the workspace boundary check structurally impossible to forget.

Adds src/utils/WorkspacePathResolver.ts — a single async resolveRealPath(target) that canonicalizes a path by following symlinks. It owns only path resolution: no workspace policy, no settings, no tool changes (those land in #390 / #391 / #392).

Behavior

Carried over from the proven logic in #241, made async per the issue:

  • Async fs.promises.realpath — never blocks the extension host event loop.
  • ENOENT walk-up: resolves the nearest existing ancestor and re-appends the trailing segments, so a not-yet-created file under a symlinked ancestor still resolves correctly.
  • Fail-closed: re-throws non-ENOENT errors (EACCES, ELOOP) so a caller performing a security check can fail closed instead of falling back to a lexical path ([BUG] vscode out-of-workspace read protection trivially circumvented by symlinks #169).
  • Case-normalizes the result on macOS/Windows for reliable comparison against uri.fsPath.

Tests

src/utils/__tests__/WorkspacePathResolver.spec.ts — real symlinks in a temp directory, no fs mocking:

  • symlink → file outside, symlink → directory outside
  • not-yet-created file under a symlinked ancestor (walk-up + re-append)
  • EACCES re-thrown, ELOOP (circular symlink) re-thrown
  • resolves without any workspace context (it owns no policy)
  • case normalization on a case-insensitive platform

Symlink/permission cases skip cleanly on hosts that can't reproduce them (Windows without privileges, root).

Does not change any tool behavior. tsc, eslint, and the spec (7 tests) pass locally.

Summary by CodeRabbit

  • Bug Fixes
    • Improved workspace path resolution to correctly follow symbolic links and prevent path-related issues across platforms.
    • Enhanced case normalization support for macOS and Windows file systems to ensure consistent path handling.
    • Added robust error handling for permission failures and circular symlink detection scenarios.

… path canonicalization (Zoo-Code-Org#389)

First of four sub-issues under Zoo-Code-Org#169. Adds `src/utils/WorkspacePathResolver.ts` with a single
async `resolveRealPath(target)` that canonicalizes a path by following symlinks via
`fs.promises.realpath`:

- Walks up to the nearest existing ancestor on ENOENT and re-appends the trailing segments,
  so not-yet-created files under a symlinked ancestor still resolve correctly.
- Re-throws non-ENOENT errors (EACCES, ELOOP) so callers can fail closed rather than fall back
  to a lexical path.
- Case-normalizes the result on macOS/Windows for reliable comparison against uri.fsPath.

Pure utility — no workspace policy, settings, or tool changes (those land in WorkspaceFileAccess
(Zoo-Code-Org#390) and the migrations in Zoo-Code-Org#391/Zoo-Code-Org#392). Covered by integration tests using real symlinks in a
temp directory.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces resolveRealPath(), an async utility for canonicalizing workspace paths by resolving symlinks, handling non-existent path ancestors via a walk-up strategy, and normalizing case on case-insensitive platforms. The implementation rethrows non-ENOENT errors to preserve security-relevant failures.

Changes

Path Canonicalization with Symlink Resolution

Layer / File(s) Summary
resolveRealPath implementation
src/utils/WorkspacePathResolver.ts
Exports async resolveRealPath(target) that canonicalizes paths by attempting fs.promises.realpath, walking up to the nearest existing ancestor on ENOENT, re-appending remaining segments to preserve symlink traversal, rethrowing non-ENOENT errors, and lowercasing results on case-insensitive platforms using isErrnoException and normalizeCase helpers.
Test suite with real filesystem validation
src/utils/__tests__/WorkspacePathResolver.spec.ts
Comprehensive Jest test suite using real temp directories with symlink support detection, setup/cleanup, and tests covering: symlinks pointing outside workspace (files/directories), non-existent paths under symlinked ancestors, permission failures (EACCES fail-closed), circular symlinks (ELOOP), operation without workspace context, and case normalization via process.platform override.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

Poem

🐰 A rabbit hops through symlink land,
Where paths twist and bend,
With case-aware paws, we understand,
Real paths round each bend.
Walk up, resolve, and normalize true—
Canonicalization through and through! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: introducing WorkspacePathResolver with async symlink-aware path canonicalization, matching the key additions in the changeset.
Description check ✅ Passed The description follows the template structure, includes the linked issue, clear implementation details, test coverage explanation, and verification that standards pass, but lacks explicit pre-submission checklist confirmation and explicit documentation impact statement.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/utils/WorkspacePathResolver.ts

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

src/utils/__tests__/WorkspacePathResolver.spec.ts

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


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.

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: 1

🤖 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/utils/WorkspacePathResolver.ts`:
- Around line 13-16: The normalizeCase function currently lowercases paths based
solely on process.platform which is unsafe on macOS with case-sensitive APFS;
update normalizeCase to avoid global lowercasing and instead canonicalize
per-path using filesystem-aware APIs (e.g., fs.realpathSync / fs.realpath or
other OS canonicalization) to obtain the actual on-disk case for the given path,
and only fall back to a platform heuristic if canonicalization fails; locate and
modify the normalizeCase function and any callers that rely on its behavior so
comparisons use the fs-canonicalized path or an explicit per-mount
case-sensitivity probe rather than always lowercasing when process.platform ===
"darwin" || "win32".
🪄 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: bfd718fc-24c9-4c57-a666-d0272f1a3f45

📥 Commits

Reviewing files that changed from the base of the PR and between 71db2e6 and a3f6b40.

📒 Files selected for processing (2)
  • src/utils/WorkspacePathResolver.ts
  • src/utils/__tests__/WorkspacePathResolver.spec.ts

Comment on lines +13 to +16
function normalizeCase(p: string): string {
const caseInsensitive = process.platform === "darwin" || process.platform === "win32"
return caseInsensitive ? p.toLowerCase() : p
}
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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Can APFS volumes on macOS be configured as case-sensitive, and is it valid to assume all darwin paths are case-insensitive for security path comparisons?

💡 Result:

Yes. APFS volumes on macOS can be formatted as either case-insensitive or case-sensitive (with case-insensitive being the default on macOS) [1]. No, you should not assume that “all darwin paths” are case-insensitive for security path comparisons. macOS runs on top of filesystems/volumes that can be configured case-sensitively; Apple explicitly documents that APFS has both case-sensitive and case-insensitive variants on macOS [1]. Therefore, security-sensitive comparisons must be based on the actual filesystem/volume behavior for the path being validated, not just the OS (“Darwin”) itself. A common example is software behavior (e.g., nix) that breaks when it assumes Darwin implies a case-insensitive filesystem, because users can mount case-sensitive volumes on macOS [2]. Practical implication for path-boundary checks: treat case-folding/case-normalization as filesystem-dependent, or perform comparisons on canonicalized paths resolved by the OS (e.g., after resolving symlinks and walking to the real target) rather than trusting raw string equality/case-insensitive heuristics. Relying on blanket “case-insensitive” assumptions can create security bugs when the target volume is case-sensitive [2].

Citations:


Avoid platform-wide lowercasing in normalizeCase()

Lowercasing whenever process.platform === "darwin" || "win32" assumes those platforms are case-insensitive, but APFS on macOS can be configured as case-sensitive. This can conflate distinct paths on case-sensitive volumes and break security/path-boundary comparisons. Normalize/compare using filesystem-actual case behavior (e.g., detect case-sensitivity per root/mount or rely on OS-level canonicalization) rather than a Darwin/Windows heuristic.

🤖 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/WorkspacePathResolver.ts` around lines 13 - 16, The normalizeCase
function currently lowercases paths based solely on process.platform which is
unsafe on macOS with case-sensitive APFS; update normalizeCase to avoid global
lowercasing and instead canonicalize per-path using filesystem-aware APIs (e.g.,
fs.realpathSync / fs.realpath or other OS canonicalization) to obtain the actual
on-disk case for the given path, and only fall back to a platform heuristic if
canonicalization fails; locate and modify the normalizeCase function and any
callers that rely on its behavior so comparisons use the fs-canonicalized path
or an explicit per-mount case-sensitivity probe rather than always lowercasing
when process.platform === "darwin" || "win32".

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 1, 2026

Codecov Report

❌ Patch coverage is 93.33333% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/utils/WorkspacePathResolver.ts 93.33% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

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.

feat(security): introduce WorkspacePathResolver — safe async symlink-aware path canonicalization

1 participant