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,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