Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions src/filesystem/__tests__/lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ server.registerTool(
annotations: { readOnlyHint: false, idempotentHint: true, destructiveHint: false, openWorldHint: false }
},
async (args: z.infer<typeof CreateDirectoryArgsSchema>) => {
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 {
Expand Down
43 changes: 42 additions & 1 deletion src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
export async function validatePath(requestedPath: string, options: ValidatePathOptions = {}): Promise<string> {
const expandedPath = expandHome(requestedPath);
const absolute = path.isAbsolute(expandedPath)
? path.resolve(expandedPath)
Expand Down Expand Up @@ -132,6 +144,35 @@ export async function validatePath(requestedPath: string): Promise<string> {
}
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}`);
}
}
Expand Down