Skip to content

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

Merged
edelauna merged 3 commits into
Zoo-Code-Org:mainfrom
proyectoauraorg:feat/389-workspace-path-resolver
Jun 4, 2026
Merged

feat(security): introduce WorkspacePathResolver — async symlink-aware path canonicalization (#389)#428
edelauna merged 3 commits 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 on macOS and Windows to ensure consistent path handling.
    • Added robust error handling for permission failures and circular symlink scenarios.
  • Tests

    • Added end-to-end tests covering symlink resolution, non-existent-path behavior, permission errors, loop detection, and case-normalization.

… 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d41712c9-f049-43bb-bf03-c11f26292666

📥 Commits

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

📒 Files selected for processing (1)
  • src/utils/WorkspacePathResolver.ts

📝 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 tests 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

🐰 I hop through symlinks, soft and sly,
Lowercase paths beneath the sky,
Walk up, follow where links unwind,
Re-append the bits that fate left behind,
Canonical trails, neat and spry. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers key implementation details, testing approach, and behavior, but is missing the GitHub issue number in the 'Closes:' field and lacks the pre-submission checklist completion. Add the issue number to 'Closes: #389' and complete the pre-submission checklist items to fully match the required template.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing WorkspacePathResolver with async symlink-aware path canonicalization, directly matching the changeset.
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

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!

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 ty for breaking this up!

Comment thread src/utils/WorkspacePathResolver.ts Outdated
Comment thread src/utils/WorkspacePathResolver.ts Outdated
@edelauna edelauna enabled auto-merge June 4, 2026 11:59
@edelauna edelauna added this pull request to the merge queue Jun 4, 2026
Merged via the queue into Zoo-Code-Org:main with commit 5bf98fc Jun 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants