From 71b0b2a81b071957fdafc45d68a78be14b8818e8 Mon Sep 17 00:00:00 2001 From: Parker Fawcett Date: Sun, 23 Aug 2026 09:56:34 -0600 Subject: [PATCH] fix(filesystem): reject Windows-style paths on POSIX hosts instead of writing literal filenames On POSIX, a path like C:\Users\me\notes\file.md is not absolute, so validatePath() sent it down the relative-path branch and path.resolve() placed it inside the allowed root as a single entry literally named 'C:\Users\me\notes\file.md' (or created a C:/ directory tree). Callers saw success and only discovered the mistake when inspecting disk - reported in #4686. validatePath() now rejects drive-letter forms (^[A-Za-z]:(?:[\\/]|$)) up front when process.platform is not win32, with an explicit access-denied error. The check is one-directional and platform-guarded so Windows behavior is untouched and POSIX paths are never rewritten (no #3628 regression). Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/filesystem/__tests__/lib.test.ts | 24 ++++++++++++++++++++++++ src/filesystem/lib.ts | 5 +++++ 2 files changed, 29 insertions(+) diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index e0ae61224f..cd830c486b 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -228,6 +228,30 @@ describe('Lib Functions', () => { process.cwd = originalCwd; } }); + + describe.skipIf(process.platform === 'win32')('Windows-style paths on POSIX hosts', () => { + it.each([ + 'C:\\Users\\me\\notes\\file.md', + 'C:/Users/me/file.md', + 'Z:\\', + 'C:' + ])('rejects %s instead of treating it as a relative path', async (windowsPath) => { + await expect(validatePath(windowsPath)) + .rejects.toThrow('Access denied - Windows-style path received on a POSIX host'); + }); + + it('rejects before touching the filesystem', async () => { + await expect(validatePath('C:\\Users\\me\\notes\\file.md')).rejects.toThrow(); + expect(mockFs.realpath).not.toHaveBeenCalled(); + }); + }); + + it('still resolves relative paths that merely contain a colon after the first character', async () => { + const colonPath = process.platform === 'win32' ? 'C:\\Users\\test\\notes\\file:C.md' : 'notes/file:C.md'; + const result = await validatePath(colonPath); + const expectedBase = process.platform === 'win32' ? 'C:\\Users\\test' : '/home/user'; + expect(result).toBe(path.resolve(expectedBase, colonPath)); + }); }); }); diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index ce4af9f38a..4a80eba349 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -97,6 +97,11 @@ function resolveRelativePathAgainstAllowedDirectories(relativePath: string): str // Security & Validation Functions export async function validatePath(requestedPath: string): Promise { + // Security: reject Windows drive-letter paths on POSIX hosts; treating them as + // relative paths would silently create literal files like "C:\Users\me\file" in the allowed root. + if (process.platform !== 'win32' && /^[A-Za-z]:(?:[\\/]|$)/.test(requestedPath)) { + throw new Error(`Access denied - Windows-style path received on a POSIX host: ${requestedPath}`); + } const expandedPath = expandHome(requestedPath); const absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath)