-
Notifications
You must be signed in to change notification settings - Fork 99
feat(security): introduce WorkspacePathResolver — async symlink-aware path canonicalization (#389) #428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
edelauna
merged 3 commits into
Zoo-Code-Org:main
from
proyectoauraorg:feat/389-workspace-path-resolver
Jun 4, 2026
+201
−0
Merged
feat(security): introduce WorkspacePathResolver — async symlink-aware path canonicalization (#389) #428
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import * as path from "path" | ||
| import * as fs from "fs/promises" | ||
|
|
||
| /** Narrow an unknown error to a Node errno exception with the given `code`. */ | ||
| function isErrnoException(err: unknown, code: string): boolean { | ||
| return err instanceof Error && (err as NodeJS.ErrnoException).code === code | ||
| } | ||
|
|
||
| // macOS APFS/HFS+ and Windows are case-insensitive: `realpath` can return a different case | ||
| // than the one VS Code registered, so we lowercase the result before returning to keep later | ||
| // comparisons (e.g. against `uri.fsPath`) reliable on those platforms only. Platform is read at | ||
| // call time (not cached) so the behavior stays correct and testable. | ||
| function normalizeCase(p: string): string { | ||
| const caseInsensitive = process.platform === "darwin" || process.platform === "win32" | ||
| return caseInsensitive ? p.toLowerCase() : p | ||
| } | ||
|
|
||
| /** | ||
| * Resolve a filesystem path to its canonical, symlink-followed form. | ||
| * | ||
| * This is the canonicalization primitive for the workspace boundary check (issue #169). It owns | ||
| * **only** path resolution — no workspace policy, no settings, no tool logic. The authorization | ||
| * decision (and the `allowSymlinksOutsideWorkspace` opt-in) lives in `WorkspaceFileAccess`. | ||
| * | ||
| * Behavior: | ||
| * - **Async only** (`fs.promises.realpath`); never blocks the extension host event loop. | ||
| * - If `target` does not exist yet (e.g. a file about to be created), the realpath of the nearest | ||
| * existing ancestor is resolved and the remaining segments are re-appended, so a symlink | ||
| * anywhere along the path is still followed while not-yet-created paths can still be evaluated. | ||
| * - Only `ENOENT` triggers the walk-up. Any other error (e.g. `EACCES`, `ELOOP`) is **re-thrown** | ||
| * so a caller performing a security check can fail closed. Silently walking up would mask the | ||
| * symlink and could make an out-of-workspace target look "inside" (#169). | ||
| * - The result is case-normalized on case-insensitive filesystems (macOS, Windows). | ||
| * | ||
| * Workspace folder paths should be resolved through this same function by callers, since a folder | ||
| * may itself be reached via a symlink. Callers should always compare two `resolveRealPath()` results | ||
| * rather than mixing with raw `uri.fsPath` — `arePathsEqual()` does not case-fold on macOS. | ||
| */ | ||
| export async function resolveRealPath(target: string): Promise<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 = await fs.realpath(current) | ||
| const joined = trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved | ||
| return normalizeCase(joined) | ||
| } catch (err) { | ||
| if (!isErrnoException(err, "ENOENT")) { | ||
| // Non-ENOENT (e.g. EACCES, ELOOP, ENOTDIR): 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 filesystem root without finding an existing path; fall back to the | ||
| // lexically resolved path (still case-normalized for consistent comparisons). | ||
| return normalizeCase(path.resolve(target)) | ||
| } | ||
|
|
||
| trailing.push(path.basename(current)) | ||
| current = parent | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import * as os from "os" | ||
| import * as path from "path" | ||
| import * as fs from "fs/promises" | ||
|
|
||
| import { resolveRealPath } from "../WorkspacePathResolver" | ||
|
|
||
| // These tests use real symlinks in a real temp directory (no fs mocking, per #389). Some | ||
| // scenarios can't be reproduced everywhere: symlink creation needs privileges on Windows, and | ||
| // chmod-based EACCES is meaningless as root. Such cases are skipped at runtime rather than mocked. | ||
| const isWindows = process.platform === "win32" | ||
| const isRoot = typeof process.getuid === "function" && process.getuid() === 0 | ||
|
|
||
| /** Lowercase on case-insensitive filesystems, matching the resolver's own normalization. */ | ||
| const expectCase = (p: string) => (process.platform === "darwin" || process.platform === "win32" ? p.toLowerCase() : p) | ||
|
|
||
| describe("resolveRealPath", () => { | ||
| let tmpRoot: string | ||
| let workspace: string | ||
| let outside: string | ||
| let symlinksSupported = false | ||
|
|
||
| beforeEach(async () => { | ||
| // realpath the temp root so comparisons aren't tripped up by /var -> /private/var (macOS). | ||
| tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-wpr-"))) | ||
| workspace = path.join(tmpRoot, "workspace") | ||
| outside = path.join(tmpRoot, "outside") | ||
| await fs.mkdir(workspace, { recursive: true }) | ||
| await fs.mkdir(outside, { recursive: true }) | ||
|
|
||
| // Probe symlink support once so symlink-dependent cases can skip cleanly on locked-down hosts. | ||
| const probeTarget = path.join(tmpRoot, "probe-target") | ||
| const probeLink = path.join(tmpRoot, "probe-link") | ||
| await fs.writeFile(probeTarget, "probe") | ||
| try { | ||
| await fs.symlink(probeTarget, probeLink) | ||
| symlinksSupported = true | ||
| } catch { | ||
| symlinksSupported = false | ||
| } | ||
| }) | ||
|
|
||
| afterEach(async () => { | ||
| // Restore permissions on any restricted dir (EACCES test) so cleanup can remove it. | ||
| await fs.chmod(path.join(workspace, "restricted"), 0o755).catch(() => {}) | ||
| await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}) | ||
| }) | ||
|
|
||
| it("resolves a symlink inside the workspace that points to a file outside, to the outside path", async () => { | ||
| if (!symlinksSupported) return | ||
| const secret = path.join(outside, "secret.txt") | ||
| await fs.writeFile(secret, "x") | ||
| const link = path.join(workspace, "link.txt") | ||
| await fs.symlink(secret, link) | ||
|
|
||
| const resolved = await resolveRealPath(link) | ||
|
|
||
| expect(resolved).toBe(expectCase(await fs.realpath(secret))) | ||
| expect(resolved.startsWith(expectCase(workspace) + path.sep)).toBe(false) | ||
| }) | ||
|
|
||
| it("resolves a symlink inside the workspace that points to a directory outside, to the outside path", async () => { | ||
| if (!symlinksSupported) return | ||
| const outsideDir = path.join(outside, "dir") | ||
| await fs.mkdir(outsideDir) | ||
| const linkDir = path.join(workspace, "linkdir") | ||
| await fs.symlink(outsideDir, linkDir) | ||
|
|
||
| const resolved = await resolveRealPath(linkDir) | ||
|
|
||
| expect(resolved).toBe(expectCase(await fs.realpath(outsideDir))) | ||
| }) | ||
|
|
||
| it("resolves a not-yet-created file under a symlinked ancestor by resolving the ancestor and re-appending", async () => { | ||
| if (!symlinksSupported) return | ||
| const outsideDir = path.join(outside, "dir") | ||
| await fs.mkdir(outsideDir) | ||
| const linkDir = path.join(workspace, "linkdir") | ||
| await fs.symlink(outsideDir, linkDir) | ||
|
|
||
| // Neither "nested" nor "new.txt" exists yet — the walk-up must resolve `linkDir` and | ||
| // re-append the trailing segments. | ||
| const notYetCreated = path.join(linkDir, "nested", "new.txt") | ||
| const resolved = await resolveRealPath(notYetCreated) | ||
|
|
||
| expect(resolved).toBe(expectCase(path.join(await fs.realpath(outsideDir), "nested", "new.txt"))) | ||
| }) | ||
|
|
||
| it("re-throws EACCES instead of swallowing it (fail closed)", async () => { | ||
| if (isWindows || isRoot) return | ||
| const restricted = path.join(workspace, "restricted") | ||
| await fs.mkdir(restricted) | ||
| const target = path.join(restricted, "file.txt") | ||
| await fs.writeFile(target, "x") | ||
| await fs.chmod(restricted, 0o000) | ||
|
|
||
| await expect(resolveRealPath(target)).rejects.toMatchObject({ code: "EACCES" }) | ||
| }) | ||
|
|
||
| it("re-throws ELOOP for a circular symlink chain", async () => { | ||
| if (!symlinksSupported) return | ||
| const a = path.join(workspace, "a") | ||
| const b = path.join(workspace, "b") | ||
| // a -> b and b -> a is a cycle realpath cannot resolve. | ||
| await fs.symlink(b, a) | ||
| await fs.symlink(a, b) | ||
|
|
||
| await expect(resolveRealPath(a)).rejects.toMatchObject({ code: "ELOOP" }) | ||
| }) | ||
|
|
||
| it("resolves correctly even with no workspace context (it owns no policy)", async () => { | ||
| const real = path.join(outside, "plain.txt") | ||
| await fs.writeFile(real, "x") | ||
|
|
||
| const resolved = await resolveRealPath(real) | ||
|
|
||
| expect(resolved).toBe(expectCase(await fs.realpath(real))) | ||
| }) | ||
|
|
||
| it("case-normalizes the resolved path to lowercase on case-insensitive platforms (e.g. darwin)", async () => { | ||
| const mixed = path.join(outside, "MixedCase.txt") | ||
| await fs.writeFile(mixed, "x") | ||
| const realMixed = await fs.realpath(mixed) | ||
|
|
||
| const originalPlatform = process.platform | ||
| Object.defineProperty(process, "platform", { value: "darwin", configurable: true }) | ||
| try { | ||
| const resolved = await resolveRealPath(mixed) | ||
| expect(resolved).toBe(realMixed.toLowerCase()) | ||
| expect(resolved).toContain("mixedcase.txt") | ||
| } finally { | ||
| Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }) | ||
| } | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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