diff --git a/packages/angular/cli/src/commands/mcp/tools/projects.ts b/packages/angular/cli/src/commands/mcp/tools/projects.ts index e6abff0ca418..8c6eb5d332f6 100644 --- a/packages/angular/cli/src/commands/mcp/tools/projects.ts +++ b/packages/angular/cli/src/commands/mcp/tools/projects.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.dev/license */ +import { realpathSync } from 'node:fs'; import { readFile, readdir, stat } from 'node:fs/promises'; -import { dirname, extname, join, normalize, posix, resolve } from 'node:path'; +import { dirname, extname, isAbsolute, join, normalize, posix, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import semver from 'semver'; import { z } from 'zod'; @@ -148,15 +149,24 @@ their types, and their locations. }); const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'out', 'coverage']); +const IGNORED_FILE_SYSTEM_ERRORS = new Set(['EACCES', 'EPERM', 'ENOENT', 'EBUSY']); + +function isIgnorableFileError(error: Error & { code?: string }): boolean { + return !!error.code && IGNORED_FILE_SYSTEM_ERRORS.has(error.code); +} /** * Iteratively finds all 'angular.json' files with controlled concurrency and directory exclusions. * This non-recursive implementation is suitable for very large directory trees, * prevents file descriptor exhaustion (`EMFILE` errors), and handles symbolic link loops. * @param rootDir The directory to start the search from. + * @param allowedRealRoots A list of allowed real root directories (resolved paths) to restrict symbolic link traversal. * @returns An async generator that yields the full path of each found 'angular.json' file. */ -async function* findAngularJsonFiles(rootDir: string): AsyncGenerator { +async function* findAngularJsonFiles( + rootDir: string, + allowedRealRoots: ReadonlyArray, +): AsyncGenerator { const CONCURRENCY_LIMIT = 50; const queue: string[] = [rootDir]; const seenInodes = new Set(); @@ -166,7 +176,7 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator { seenInodes.add(rootStats.ino); } catch (error) { assertIsError(error); - if (error.code === 'EACCES' || error.code === 'EPERM' || error.code === 'ENOENT') { + if (isIgnorableFileError(error)) { return; // Cannot access root, so there's nothing to do. } throw error; @@ -182,24 +192,47 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator { const subdirectories: string[] = []; for (const entry of entries) { const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { + if (entry.isDirectory() || entry.isSymbolicLink()) { // Exclude dot-directories, build/cache directories, and node_modules if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) { continue; } - // Check for symbolic link loops + let entryStats; try { - const entryStats = await stat(fullPath); + entryStats = await stat(fullPath); if (seenInodes.has(entryStats.ino)) { continue; // Already visited this directory (symlink loop), skip. } - seenInodes.add(entryStats.ino); + // Only process actual directories or symlinks to directories. + if (!entryStats.isDirectory()) { + continue; + } } catch { // Ignore errors from stat (e.g., broken symlinks) continue; } + if (entry.isSymbolicLink()) { + try { + const targetPath = realpathSync(fullPath); + // Ensure the link target is within one of the allowed roots. + const isAllowed = allowedRealRoots.some((root) => { + const rel = relative(root, targetPath); + + return !rel.startsWith('..') && !isAbsolute(rel); + }); + + if (!isAllowed) { + continue; + } + } catch { + // Ignore broken links. + continue; + } + } + + seenInodes.add(entryStats.ino); subdirectories.push(fullPath); } else if (entry.name === 'angular.json') { foundFilesInBatch.push(fullPath); @@ -209,7 +242,7 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator { return subdirectories; } catch (error) { assertIsError(error); - if (error.code === 'EACCES' || error.code === 'EPERM') { + if (isIgnorableFileError(error)) { return []; // Silently ignore permission errors. } throw error; @@ -529,8 +562,20 @@ async function createListProjectsHandler({ server }: McpToolContext) { searchRoots = [process.cwd()]; } + // Pre-resolve allowed roots to handle their own symlinks or normalizations. + // We ignore failures here; if a root is broken, we simply won't match against it. + const realAllowedRoots = searchRoots + .map((r) => { + try { + return realpathSync(r); + } catch { + return null; + } + }) + .filter((r): r is string => r !== null); + for (const root of searchRoots) { - for await (const configFile of findAngularJsonFiles(root)) { + for await (const configFile of findAngularJsonFiles(root, realAllowedRoots)) { const { workspace, parsingError, versioningError } = await processConfigFile( configFile, root,