Skip to content

Commit 061f145

Browse files
committed
fix(@angular/cli): enhance list_projects MCP tool file system traversal and symlink handling
This commit introduces several improvements to the `findAngularJsonFiles` function within the `list_projects` tool. An `isIgnorableFileError` helper was introduced to consistently handle file system errors (EACCES, EPERM, ENOENT, EBUSY) across `stat` and `readdir` calls, making the traversal more resilient to transient issues and unavailable directories. A check was implemented to ensure that symbolic links are only traversed if their resolved target paths are valid and remain within the defined search roots.
1 parent f1ab1d3 commit 061f145

File tree

1 file changed

+53
-10
lines changed

1 file changed

+53
-10
lines changed

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

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

9+
import { realpathSync } from 'node:fs';
910
import { readFile, readdir, stat } from 'node:fs/promises';
10-
import { dirname, extname, join, normalize, posix, resolve } from 'node:path';
11+
import { dirname, extname, isAbsolute, join, normalize, posix, relative, resolve } from 'node:path';
1112
import { fileURLToPath } from 'node:url';
1213
import semver from 'semver';
1314
import { z } from 'zod';
@@ -148,15 +149,24 @@ their types, and their locations.
148149
});
149150

150151
const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'out', 'coverage']);
152+
const IGNORED_FILE_SYSTEM_ERRORS = new Set(['EACCES', 'EPERM', 'ENOENT', 'EBUSY']);
153+
154+
function isIgnorableFileError(error: Error & { code?: string }): boolean {
155+
return !!error.code && IGNORED_FILE_SYSTEM_ERRORS.has(error.code);
156+
}
151157

152158
/**
153159
* Iteratively finds all 'angular.json' files with controlled concurrency and directory exclusions.
154160
* This non-recursive implementation is suitable for very large directory trees,
155161
* prevents file descriptor exhaustion (`EMFILE` errors), and handles symbolic link loops.
156162
* @param rootDir The directory to start the search from.
163+
* @param allowedRealRoots A list of allowed real root directories (resolved paths) to restrict symbolic link traversal.
157164
* @returns An async generator that yields the full path of each found 'angular.json' file.
158165
*/
159-
async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
166+
async function* findAngularJsonFiles(
167+
rootDir: string,
168+
allowedRealRoots: ReadonlyArray<string>,
169+
): AsyncGenerator<string> {
160170
const CONCURRENCY_LIMIT = 50;
161171
const queue: string[] = [rootDir];
162172
const seenInodes = new Set<number>();
@@ -166,7 +176,7 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
166176
seenInodes.add(rootStats.ino);
167177
} catch (error) {
168178
assertIsError(error);
169-
if (error.code === 'EACCES' || error.code === 'EPERM' || error.code === 'ENOENT') {
179+
if (isIgnorableFileError(error)) {
170180
return; // Cannot access root, so there's nothing to do.
171181
}
172182
throw error;
@@ -182,25 +192,46 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
182192
const subdirectories: string[] = [];
183193
for (const entry of entries) {
184194
const fullPath = join(dir, entry.name);
185-
if (entry.isDirectory()) {
195+
if (entry.isDirectory() || entry.isSymbolicLink()) {
186196
// Exclude dot-directories, build/cache directories, and node_modules
187197
if (entry.name.startsWith('.') || EXCLUDED_DIRS.has(entry.name)) {
188198
continue;
189199
}
190200

191-
// Check for symbolic link loops
201+
let entryStats;
192202
try {
193-
const entryStats = await stat(fullPath);
203+
entryStats = await stat(fullPath);
194204
if (seenInodes.has(entryStats.ino)) {
195205
continue; // Already visited this directory (symlink loop), skip.
196206
}
197-
seenInodes.add(entryStats.ino);
198207
} catch {
199208
// Ignore errors from stat (e.g., broken symlinks)
200209
continue;
201210
}
202211

203-
subdirectories.push(fullPath);
212+
if (entry.isSymbolicLink()) {
213+
try {
214+
const targetPath = realpathSync(fullPath);
215+
// Ensure the link target is within one of the allowed roots.
216+
const isAllowed = allowedRealRoots.some((root) => {
217+
const rel = relative(root, targetPath);
218+
219+
return !rel.startsWith('..') && !isAbsolute(rel);
220+
});
221+
222+
if (!isAllowed) {
223+
continue;
224+
}
225+
} catch {
226+
// Ignore broken links.
227+
continue;
228+
}
229+
}
230+
231+
if (entryStats.isDirectory()) {
232+
seenInodes.add(entryStats.ino);
233+
subdirectories.push(fullPath);
234+
}
204235
} else if (entry.name === 'angular.json') {
205236
foundFilesInBatch.push(fullPath);
206237
}
@@ -209,7 +240,7 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
209240
return subdirectories;
210241
} catch (error) {
211242
assertIsError(error);
212-
if (error.code === 'EACCES' || error.code === 'EPERM') {
243+
if (isIgnorableFileError(error)) {
213244
return []; // Silently ignore permission errors.
214245
}
215246
throw error;
@@ -529,8 +560,20 @@ async function createListProjectsHandler({ server }: McpToolContext) {
529560
searchRoots = [process.cwd()];
530561
}
531562

563+
// Pre-resolve allowed roots to handle their own symlinks or normalizations.
564+
// We ignore failures here; if a root is broken, we simply won't match against it.
565+
const realAllowedRoots = searchRoots
566+
.map((r) => {
567+
try {
568+
return realpathSync(r);
569+
} catch {
570+
return null;
571+
}
572+
})
573+
.filter((r): r is string => r !== null);
574+
532575
for (const root of searchRoots) {
533-
for await (const configFile of findAngularJsonFiles(root)) {
576+
for await (const configFile of findAngularJsonFiles(root, realAllowedRoots)) {
534577
const { workspace, parsingError, versioningError } = await processConfigFile(
535578
configFile,
536579
root,

0 commit comments

Comments
 (0)