diff --git a/src/filesystem/__tests__/lib.test.ts b/src/filesystem/__tests__/lib.test.ts index e0ae61224f..bc1ed8625b 100644 --- a/src/filesystem/__tests__/lib.test.ts +++ b/src/filesystem/__tests__/lib.test.ts @@ -190,21 +190,82 @@ describe('Lib Functions', () => { it('rejects when parent directory does not exist', async () => { const newFilePath = process.platform === 'win32' ? 'C:\\Users\\test\\nonexistent\\newfile.txt' : '/home/user/nonexistent/newfile.txt'; - + // Create errors with the ENOENT code const enoentError1 = new Error('ENOENT') as NodeJS.ErrnoException; enoentError1.code = 'ENOENT'; const enoentError2 = new Error('ENOENT') as NodeJS.ErrnoException; enoentError2.code = 'ENOENT'; - + mockFs.realpath .mockRejectedValueOnce(enoentError1) .mockRejectedValueOnce(enoentError2); - + await expect(validatePath(newFilePath)) .rejects.toThrow('Parent directory does not exist'); }); + it('allows non-existent ancestors when allowNonExistentAncestors is true', async () => { + // Simulate creating /home/user/a/b/c where a, b, c don't exist + const deepPath = process.platform === 'win32' ? 'C:\\Users\\test\\a\\b\\c' : '/home/user/a/b/c'; + const allowedDir = process.platform === 'win32' ? 'C:\\Users\\test' : '/home/user'; + + const enoentError = () => { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + return err; + }; + + mockFs.realpath + .mockRejectedValueOnce(enoentError()) // /home/user/a/b/c doesn't exist + .mockRejectedValueOnce(enoentError()) // /home/user/a/b doesn't exist (parent) + .mockRejectedValueOnce(enoentError()) // /home/user/a doesn't exist (walk up) + .mockResolvedValueOnce(allowedDir); // /home/user exists + + const result = await validatePath(deepPath, { allowNonExistentAncestors: true }); + expect(result).toBe(path.resolve(deepPath)); + }); + + it('rejects non-existent ancestors outside allowed directories', async () => { + const outsidePath = process.platform === 'win32' ? 'C:\\outside\\a\\b\\c' : '/outside/a/b/c'; + + await expect(validatePath(outsidePath, { allowNonExistentAncestors: true })) + .rejects.toThrow('Access denied - path outside allowed directories'); + }); + + it('still rejects non-existent parent without allowNonExistentAncestors', async () => { + const deepPath = process.platform === 'win32' ? 'C:\\Users\\test\\a\\b\\c' : '/home/user/a/b/c'; + + const enoentError = () => { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + return err; + }; + + mockFs.realpath + .mockRejectedValueOnce(enoentError()) // path doesn't exist + .mockRejectedValueOnce(enoentError()); // parent doesn't exist + + await expect(validatePath(deepPath)) + .rejects.toThrow('Parent directory does not exist'); + }); + + it('rejects when ancestor walks up to root without finding allowed dir', async () => { + const deepPath = process.platform === 'win32' ? 'C:\\Users\\test\\a\\b\\c' : '/home/user/a/b/c'; + + const enoentError = () => { + const err = new Error('ENOENT') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + return err; + }; + + // All ancestors fail with ENOENT until we reach root + mockFs.realpath.mockRejectedValue(enoentError()); + + await expect(validatePath(deepPath, { allowNonExistentAncestors: true })) + .rejects.toThrow('No existing ancestor directory found for'); + }); + it('resolves relative paths against allowed directories instead of process.cwd()', async () => { const relativePath = 'test-file.txt'; const originalCwd = process.cwd; diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 234605bb13..0fe9c3aee8 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -425,7 +425,7 @@ server.registerTool( annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: false } }, async (args: z.infer) => { - const validPath = await validatePath(args.path); + const validPath = await validatePath(args.path, { allowNonExistentAncestors: true }); await fs.mkdir(validPath, { recursive: true }); const text = `Successfully created directory ${args.path}`; return { diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index ce4af9f38a..bdb73297eb 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -95,8 +95,20 @@ function resolveRelativePathAgainstAllowedDirectories(relativePath: string): str return path.resolve(allowedDirectories[0], relativePath); } +// Options for validatePath +export interface ValidatePathOptions { + /** + * When true, allows paths whose ancestor directories do not yet exist. + * Walks up using path.dirname() until an existing directory is found, + * then validates that directory against the allowed directories list. + * This is needed for recursive directory creation (mkdir -p) where + * multiple levels of parent directories may not exist yet. + */ + allowNonExistentAncestors?: boolean; +} + // Security & Validation Functions -export async function validatePath(requestedPath: string): Promise { +export async function validatePath(requestedPath: string, options: ValidatePathOptions = {}): Promise { const expandedPath = expandHome(requestedPath); const absolute = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) @@ -132,6 +144,35 @@ export async function validatePath(requestedPath: string): Promise { } return absolute; } catch { + // If allowNonExistentAncestors is set, walk up until we find an + // existing ancestor directory and validate that instead. + // This supports recursive mkdir where multiple levels don't exist yet. + if (options.allowNonExistentAncestors) { + let current = parentDir; + while (true) { + const parent = path.dirname(current); + if (parent === current) { + // Reached filesystem root without finding an existing directory + throw new Error(`No existing ancestor directory found for: ${absolute}`); + } + current = parent; + try { + const realAncestorPath = await fs.realpath(current); + const normalizedAncestor = normalizePath(realAncestorPath); + if (!isPathWithinAllowedDirectories(normalizedAncestor, allowedDirectories)) { + throw new Error(`Access denied - ancestor directory outside allowed directories: ${realAncestorPath} not in ${allowedDirectories.join(', ')}`); + } + return absolute; + } catch (ancestorError) { + if ((ancestorError as NodeJS.ErrnoException).code === 'ENOENT') { + // This ancestor doesn't exist either, keep walking up + continue; + } + // Re-throw non-ENOENT errors (including our own access denied) + throw ancestorError; + } + } + } throw new Error(`Parent directory does not exist: ${parentDir}`); } }