Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions packages/angular/cli/src/commands/mcp/tools/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string> {
async function* findAngularJsonFiles(
rootDir: string,
allowedRealRoots: ReadonlyArray<string>,
): AsyncGenerator<string> {
const CONCURRENCY_LIMIT = 50;
const queue: string[] = [rootDir];
const seenInodes = new Set<number>();
Expand All @@ -166,7 +176,7 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
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;
Expand All @@ -182,24 +192,47 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
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);
Expand All @@ -209,7 +242,7 @@ async function* findAngularJsonFiles(rootDir: string): AsyncGenerator<string> {
return subdirectories;
} catch (error) {
assertIsError(error);
if (error.code === 'EACCES' || error.code === 'EPERM') {
if (isIgnorableFileError(error)) {
return []; // Silently ignore permission errors.
}
throw error;
Expand Down Expand Up @@ -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,
Expand Down