Part of #169. Sub-issue 1 of 4.
Context
Issue #169 identified that isPathOutsideWorkspace() only resolves ./.. via path.resolve, so a symlink inside the workspace that points outside it is treated as inside and slips past the boundary check. PR #241 attempted a fix but spreads enforcement across many tool call sites — meaning every review becomes a hunt for the one path that was missed (ApplyDiffTool had no boundary check at all after the migration). The root cause is that raw fs operations and path resolution are interleaved throughout individual tools, with no central choke point.
This is the first of four sub-issues that introduce a central WorkspaceFileAccess layer to make the boundary check structurally impossible to forget.
Developer Note
Create src/utils/WorkspacePathResolver.ts (or equivalent location consistent with project conventions). This module owns only path canonicalization — no policy, no tool logic.
Requirements:
- Async (
fs.promises.realpath / lstat) — no synchronous filesystem calls; the extension host event loop must not be blocked.
- Walk up to the nearest existing ancestor on
ENOENT and re-append the trailing segments, so not-yet-created files under a symlinked ancestor are still resolved correctly.
- Re-throw non-
ENOENT errors (e.g. EACCES, ELOOP) so callers can fail closed — do not swallow and fall back to a lexical path.
- Resolve workspace folder paths too (they may themselves be reached via a symlink).
- Case-normalize the result on
darwin and win32 (lowercase) before returning, so comparisons against VS Code's uri.fsPath are reliable on case-insensitive filesystems.
- Export a single async function with a clear verb-noun name (e.g.
resolveRealPath(target: string): Promise<string>).
Tests (src/utils/__tests__/WorkspacePathResolver.spec.ts):
- Symlink inside workspace pointing to a file outside → resolved path is outside.
- Symlink inside workspace pointing to a directory outside → resolved path is outside.
- Not-yet-created file under a symlinked ancestor → ancestor resolved, trailing segment re-appended.
EACCES on symlink target → error re-thrown (not swallowed).
ELOOP (circular symlink) → error re-thrown.
- No workspace folders → function still resolves correctly (policy is not its concern).
- Case normalization on macOS path with mixed case.
Use real symlinks in a temp directory for all tests — do not mock fs. Clean up in afterEach.
Does not change any tool behavior. No settings, no UI, no tool migrations in this PR.
Part of #169. Sub-issue 1 of 4.
Context
Issue #169 identified that
isPathOutsideWorkspace()only resolves./..viapath.resolve, so a symlink inside the workspace that points outside it is treated as inside and slips past the boundary check. PR #241 attempted a fix but spreads enforcement across many tool call sites — meaning every review becomes a hunt for the one path that was missed (ApplyDiffToolhad no boundary check at all after the migration). The root cause is that rawfsoperations and path resolution are interleaved throughout individual tools, with no central choke point.This is the first of four sub-issues that introduce a central
WorkspaceFileAccesslayer to make the boundary check structurally impossible to forget.Developer Note
Create
src/utils/WorkspacePathResolver.ts(or equivalent location consistent with project conventions). This module owns only path canonicalization — no policy, no tool logic.Requirements:
fs.promises.realpath/lstat) — no synchronous filesystem calls; the extension host event loop must not be blocked.ENOENTand re-append the trailing segments, so not-yet-created files under a symlinked ancestor are still resolved correctly.ENOENTerrors (e.g.EACCES,ELOOP) so callers can fail closed — do not swallow and fall back to a lexical path.darwinandwin32(lowercase) before returning, so comparisons against VS Code'suri.fsPathare reliable on case-insensitive filesystems.resolveRealPath(target: string): Promise<string>).Tests (
src/utils/__tests__/WorkspacePathResolver.spec.ts):EACCESon symlink target → error re-thrown (not swallowed).ELOOP(circular symlink) → error re-thrown.Use real symlinks in a temp directory for all tests — do not mock
fs. Clean up inafterEach.Does not change any tool behavior. No settings, no UI, no tool migrations in this PR.