Skip to content

Commit 3480966

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 13e1881 commit 3480966

File tree

1 file changed

+54
-9
lines changed

1 file changed

+54
-9
lines changed

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

Lines changed: 54 additions & 9 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,24 +192,47 @@ 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);
207+
// Only process actual directories or symlinks to directories.
208+
if (!entryStats.isDirectory()) {
209+
continue;
210+
}
198211
} catch {
199212
// Ignore errors from stat (e.g., broken symlinks)
200213
continue;
201214
}
202215

216+
if (entry.isSymbolicLink()) {
217+
try {
218+
const targetPath = realpathSync(fullPath);
219+
// Ensure the link target is within one of the allowed roots.
220+
const isAllowed = allowedRealRoots.some((root) => {
221+
const rel = relative(root, targetPath);
222+
223+
return !rel.startsWith('..') && !isAbsolute(rel);
224+
});
225+
226+
if (!isAllowed) {
227+
continue;
228+
}
229+
} catch {
230+
// Ignore broken links.
231+
continue;
232+
}
233+
}
234+
235+
seenInodes.add(entryStats.ino);
203236
subdirectories.push(fullPath);
204237
} else if (entry.name === 'angular.json') {
205238
foundFilesInBatch.push(fullPath);
@@ -209,7 +242,7 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
209242
return subdirectories;
210243
} catch (error) {
211244
assertIsError(error);
212-
if (error.code === 'EACCES' || error.code === 'EPERM') {
245+
if (isIgnorableFileError(error)) {
213246
return []; // Silently ignore permission errors.
214247
}
215248
throw error;
@@ -529,8 +562,20 @@ async function createListProjectsHandler({ server }: McpToolContext) {
529562
searchRoots = [process.cwd()];
530563
}
531564

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

0 commit comments

Comments
 (0)