Skip to content

Commit 53180a8

Browse files
committed
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.
1 parent 45024e8 commit 53180a8

File tree

1 file changed

+28
-3
lines changed

1 file changed

+28
-3
lines changed

packages/angular/cli/src/commands/mcp/tools/projects.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { readFile, readdir } from 'node:fs/promises';
9+
import { readFile, readdir, stat } from 'node:fs/promises';
1010
import path from 'node:path';
1111
import { fileURLToPath } from 'node:url';
1212
import semver from 'semver';
@@ -124,14 +124,26 @@ const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'out', 'coverage']);
124124

125125
/**
126126
* Iteratively finds all 'angular.json' files with controlled concurrency and directory exclusions.
127-
* This non-recursive implementation is suitable for very large directory trees
128-
* and prevents file descriptor exhaustion (`EMFILE` errors).
127+
* This non-recursive implementation is suitable for very large directory trees,
128+
* prevents file descriptor exhaustion (`EMFILE` errors), and handles symbolic link loops.
129129
* @param rootDir The directory to start the search from.
130130
* @returns An async generator that yields the full path of each found 'angular.json' file.
131131
*/
132132
async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
133133
const CONCURRENCY_LIMIT = 50;
134134
const queue: string[] = [rootDir];
135+
const seenInodes = new Set<number>();
136+
137+
try {
138+
const rootStats = await stat(rootDir);
139+
seenInodes.add(rootStats.ino);
140+
} catch (error) {
141+
assertIsError(error);
142+
if (error.code === 'EACCES' || error.code === 'EPERM' || error.code === 'ENOENT') {
143+
return; // Cannot access root, so there's nothing to do.
144+
}
145+
throw error;
146+
}
135147

136148
while (queue.length > 0) {
137149
const batch = queue.splice(0, CONCURRENCY_LIMIT);
@@ -148,6 +160,19 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
148160
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) {
149161
continue;
150162
}
163+
164+
// Check for symbolic link loops
165+
try {
166+
const entryStats = await stat(fullPath);
167+
if (seenInodes.has(entryStats.ino)) {
168+
continue; // Already visited this directory (symlink loop), skip.
169+
}
170+
seenInodes.add(entryStats.ino);
171+
} catch {
172+
// Ignore errors from stat (e.g., broken symlinks)
173+
continue;
174+
}
175+
151176
subdirectories.push(fullPath);
152177
} else if (entry.name === 'angular.json') {
153178
foundFilesInBatch.push(fullPath);

0 commit comments

Comments
 (0)