66 * found in the LICENSE file at https://angular.dev/license
77 */
88
9+ import { realpathSync } from 'node:fs' ;
910import { 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' ;
1112import { fileURLToPath } from 'node:url' ;
1213import semver from 'semver' ;
1314import { z } from 'zod' ;
@@ -148,15 +149,24 @@ their types, and their locations.
148149} ) ;
149150
150151const 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