From becdc16a8745c8afe954ca0c2a33098ec0e9d2e7 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 28 Sep 2026 09:37:23 -0700 Subject: [PATCH 1/2] fix: reject missing files under a symlinked directory isRealPathInsideWorkspace treated ENOENT as a lexical in-workspace path. A directory symlink to outside then allowed escape/missing.txt. Walk to the nearest existing ancestor and realpath that before comparing to the workspace. Signed-off-by: Sebastien Tardif --- src/commands/quickActions.ts | 45 ++++++++++++++++++++++++++++------ test/unit/quickActions.test.ts | 13 ++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 78c9333..512d590 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -1961,11 +1961,42 @@ export function isPathInsideWorkspace(workspaceRoot: string, absolutePath: strin return isResolvedPathInsideWorkspace(workspaceRoot, absolutePath); } +function isEnoent(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === "ENOENT"; +} + +/** Real path of absolutePath, or of its nearest existing ancestor plus the missing suffix. */ +function realPathAllowingMissingSuffix(absolutePath: string): string | undefined { + const missing: string[] = []; + let current = path.resolve(absolutePath); + while (true) { + try { + const realAncestor = realpathSync(current); + if (missing.length === 0) { + return realAncestor; + } + return path.join(realAncestor, ...missing.slice().reverse()); + } catch (error) { + if (!isEnoent(error)) { + return undefined; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + missing.push(path.basename(current)); + current = parent; + } + } +} + /** * True when the real path stays inside the workspace. - * Missing or dangling targets fall back to the lexical path so a typed - * in-workspace miss can reach ensureWorkspaceFileReady instead of looking - * like an escape. Existing files that realpath outside stay rejected. + * A missing leaf uses the nearest existing ancestor's real path plus the + * missing suffix. Existing files that realpath outside stay rejected. */ export function isRealPathInsideWorkspace(workspaceRoot: string, absolutePath: string): boolean { let realRoot: string; @@ -1974,11 +2005,11 @@ export function isRealPathInsideWorkspace(workspaceRoot: string, absolutePath: s } catch { return false; } - try { - return isResolvedPathInsideWorkspace(realRoot, realpathSync(absolutePath)); - } catch { - return isResolvedPathInsideWorkspace(workspaceRoot, absolutePath); + const resolved = realPathAllowingMissingSuffix(absolutePath); + if (resolved === undefined) { + return false; } + return isResolvedPathInsideWorkspace(realRoot, resolved); } export interface StagedExternalPatch { diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 4ca70fa..c5c51a7 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -995,6 +995,19 @@ test("isRealPathInsideWorkspace follows symlinks and stays fail-closed", async ( return; } assert.equal(isRealPathInsideWorkspace(workspaceRoot, linkPath), false); + + const escapeDir = path.join(workspaceRoot, "escape"); + try { + await fs.symlink(outsideRoot, escapeDir, "dir"); + } catch { + t.skip("fs.symlink is not available on this platform"); + return; + } + assert.equal( + isRealPathInsideWorkspace(workspaceRoot, path.join(workspaceRoot, "escape", "missing.txt")), + false, + "missing file under a directory symlink to outside" + ); } finally { await fs.rm(workspaceRoot, { recursive: true, force: true }); await fs.rm(outsideRoot, { recursive: true, force: true }); From 86fb5d551114378f7debbca2ceb681b0f62d1099 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 28 Sep 2026 09:39:34 -0700 Subject: [PATCH 2/2] fix: keep the realpath test when directory symlinks are denied A failed directory symlink used to skip the whole test, including the file-symlink case that already passed. Only EPERM, EACCES, and ENOTSUP skip the extra assertion. Signed-off-by: Sebastien Tardif --- test/unit/quickActions.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index c5c51a7..3bf2a40 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -999,9 +999,14 @@ test("isRealPathInsideWorkspace follows symlinks and stays fail-closed", async ( const escapeDir = path.join(workspaceRoot, "escape"); try { await fs.symlink(outsideRoot, escapeDir, "dir"); - } catch { - t.skip("fs.symlink is not available on this platform"); - return; + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; + if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP") { + return; + } + throw error; } assert.equal( isRealPathInsideWorkspace(workspaceRoot, path.join(workspaceRoot, "escape", "missing.txt")),