From 9b192e9925c698a6df7399ee14bd9c0e7a358c76 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:47:17 -0400 Subject: [PATCH] refactor(@angular/cli): make list_projects tool resilient to symlink loops The file traversal logic in the `list_projects` MCP tool did not previously handle symbolic link loops. In a repository with a symlink pointing to a parent directory, this could cause the tool to enter an infinite loop, consuming CPU and memory until it crashed or was terminated. This commit enhances the `findAngularJsonFiles` function to make it resilient to such loops. It now uses `fs.stat` to retrieve the inode of each directory and tracks visited inodes in a Set. If a directory with a previously seen inode is encountered, it is skipped, effectively breaking the infinite loop. This change adds a minor performance overhead due to the extra `stat` call per directory, but this is a trade-off for the increase in robustness and stability when operating on complex or untrusted file systems. --- .../cli/src/commands/mcp/tools/projects.ts | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/angular/cli/src/commands/mcp/tools/projects.ts b/packages/angular/cli/src/commands/mcp/tools/projects.ts index e5b171587024..a2d82ee55858 100644 --- a/packages/angular/cli/src/commands/mcp/tools/projects.ts +++ b/packages/angular/cli/src/commands/mcp/tools/projects.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { readFile, readdir } from 'node:fs/promises'; +import { readFile, readdir, stat } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import semver from 'semver'; @@ -124,14 +124,26 @@ const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'out', 'coverage']); /** * Iteratively finds all 'angular.json' files with controlled concurrency and directory exclusions. - * This non-recursive implementation is suitable for very large directory trees - * and prevents file descriptor exhaustion (`EMFILE` errors). + * 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. * @returns An async generator that yields the full path of each found 'angular.json' file. */ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator { const CONCURRENCY_LIMIT = 50; const queue: string[] = [rootDir]; + const seenInodes = new Set(); + + try { + const rootStats = await stat(rootDir); + seenInodes.add(rootStats.ino); + } catch (error) { + assertIsError(error); + if (error.code === 'EACCES' || error.code === 'EPERM' || error.code === 'ENOENT') { + return; // Cannot access root, so there's nothing to do. + } + throw error; + } while (queue.length > 0) { const batch = queue.splice(0, CONCURRENCY_LIMIT); @@ -148,6 +160,19 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator { if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) { continue; } + + // Check for symbolic link loops + try { + const entryStats = await stat(fullPath); + if (seenInodes.has(entryStats.ino)) { + continue; // Already visited this directory (symlink loop), skip. + } + seenInodes.add(entryStats.ino); + } catch { + // Ignore errors from stat (e.g., broken symlinks) + continue; + } + subdirectories.push(fullPath); } else if (entry.name === 'angular.json') { foundFilesInBatch.push(fullPath);