2525import os
2626import re
2727from abc import ABC , abstractmethod
28- from collections .abc import Mapping , MutableMapping
28+ from collections .abc import Callable , Mapping , MutableMapping
2929from pathlib import Path
3030from typing import Any , cast
3131
5353
5454# Hard cap on the length of a user-supplied search regex. Python's ``re`` module
5555# has no built-in timeout, so a catastrophic-backtracking pattern (such as
56- # ``(a+)+$``) submitted by the model could block the event loop indefinitely.
57- # Capping pattern length is a simple, predictable mitigation; pathological
58- # ReDoS patterns are typically far shorter than this limit, so the cap mostly
59- # rejects obviously-malformed input while still allowing realistic queries.
56+ # ``(a+)+$``) submitted by the model could spin the CPU indefinitely. The cap
57+ # alone does not stop short pathological patterns, so :meth:`search_files`
58+ # additionally executes the regex scan in a worker thread and bounds the wall
59+ # clock with :data:`_SEARCH_TIMEOUT_SECONDS`. The thread itself cannot be
60+ # safely interrupted from Python, so a runaway scan continues until the
61+ # regex engine returns, but the caller and event loop stay responsive.
6062_MAX_SEARCH_PATTERN_LENGTH = 256
63+ _SEARCH_TIMEOUT_SECONDS = 10.0
6164
6265
6366def _compile_search_regex (pattern : str ) -> re .Pattern [str ]:
@@ -75,6 +78,24 @@ def _compile_search_regex(pattern: str) -> re.Pattern[str]:
7578 return re .compile (pattern , flags = re .IGNORECASE )
7679
7780
81+ async def _run_search_with_timeout (
82+ fn : Callable [[], list [FileSearchResult ]],
83+ ) -> list [FileSearchResult ]:
84+ """Run ``fn`` in a worker thread with a bounded wall-clock timeout.
85+
86+ Raises:
87+ ValueError: When the search does not complete within
88+ :data:`_SEARCH_TIMEOUT_SECONDS` seconds.
89+ """
90+ try :
91+ return await asyncio .wait_for (asyncio .to_thread (fn ), timeout = _SEARCH_TIMEOUT_SECONDS )
92+ except TimeoutError as exc :
93+ raise ValueError (
94+ f"Regex search did not complete within { _SEARCH_TIMEOUT_SECONDS :g} seconds. "
95+ "Use a more specific pattern (avoid nested quantifiers such as '(a+)+')."
96+ ) from exc
97+
98+
7899def _normalize_relative_path (path : str , * , is_directory : bool = False ) -> str :
79100 """Normalize and validate a relative store path.
80101
@@ -316,12 +337,22 @@ class AgentFileStore(ABC):
316337 """
317338
318339 @abstractmethod
319- async def write_file (self , path : str , content : str ) -> None :
320- """Write ``content`` to the file at ``path``, creating or overwriting it .
340+ async def write_file (self , path : str , content : str , * , overwrite : bool = True ) -> None :
341+ """Write ``content`` to the file at ``path``.
321342
322343 Args:
323344 path: The relative path of the file to write.
324345 content: The content to write to the file.
346+
347+ Keyword Args:
348+ overwrite: When ``True`` (default) any existing file is replaced.
349+ When ``False`` the implementation must perform an atomic
350+ exclusive create and raise :class:`FileExistsError` if a file
351+ already exists at ``path``.
352+
353+ Raises:
354+ FileExistsError: When ``overwrite`` is ``False`` and a file already
355+ exists at ``path``.
325356 """
326357
327358 @abstractmethod
@@ -413,10 +444,17 @@ def __init__(self) -> None:
413444 def _key (path : str ) -> str :
414445 return _normalize_relative_path (path ).lower ()
415446
416- async def write_file (self , path : str , content : str ) -> None :
417- """Write ``content`` to the file at ``path``."""
447+ async def write_file (self , path : str , content : str , * , overwrite : bool = True ) -> None :
448+ """Write ``content`` to the file at ``path``.
449+
450+ When ``overwrite`` is ``False`` the check-and-write happens under the
451+ store lock so concurrent callers cannot both observe a missing file
452+ and race to create it.
453+ """
418454 key = self ._key (path )
419455 async with self ._lock :
456+ if not overwrite and key in self ._files :
457+ raise FileExistsError (f"File already exists: { path !r} " )
420458 self ._files [key ] = content
421459
422460 async def read_file (self , path : str ) -> str | None :
@@ -452,7 +490,12 @@ async def search_files(
452490 regex_pattern : str ,
453491 file_pattern : str | None = None ,
454492 ) -> list [FileSearchResult ]:
455- """Search file contents for ``regex_pattern`` matches."""
493+ """Search file contents for ``regex_pattern`` matches.
494+
495+ Snapshots the entries under the store lock and offloads the regex scan
496+ to a worker thread with a bounded timeout so a pathological pattern
497+ cannot stall the event loop.
498+ """
456499 prefix = _normalize_relative_path (directory , is_directory = True ).lower ()
457500 if prefix and not prefix .endswith ("/" ):
458501 prefix += "/"
@@ -461,19 +504,22 @@ async def search_files(
461504 async with self ._lock :
462505 entries = list (self ._files .items ())
463506
464- results : list [FileSearchResult ] = []
465- for key , file_content in entries :
466- if not key .startswith (prefix ):
467- continue
468- relative_name = key [len (prefix ) :]
469- if "/" in relative_name :
470- continue
471- if not _matches_glob (relative_name , file_pattern ):
472- continue
473- result = _search_file_content (relative_name , file_content , regex )
474- if result is not None :
475- results .append (result )
476- return results
507+ def scan () -> list [FileSearchResult ]:
508+ results : list [FileSearchResult ] = []
509+ for key , file_content in entries :
510+ if not key .startswith (prefix ):
511+ continue
512+ relative_name = key [len (prefix ) :]
513+ if "/" in relative_name :
514+ continue
515+ if not _matches_glob (relative_name , file_pattern ):
516+ continue
517+ result = _search_file_content (relative_name , file_content , regex )
518+ if result is not None :
519+ results .append (result )
520+ return results
521+
522+ return await _run_search_with_timeout (scan )
477523
478524 async def create_directory (self , path : str ) -> None :
479525 """No-op: directories are implicit from file paths in the in-memory store."""
@@ -567,26 +613,38 @@ def _throw_if_contains_symlink(self, candidate: Path) -> None:
567613 for segment in relative_parts :
568614 current = current / segment
569615 try :
570- if current .is_symlink ():
571- raise ValueError ("Invalid path: the resolved path contains a symbolic link or reparse point." )
572- except OSError :
573- # Permission errors and similar transient OS errors during the
574- # symlink probe should not silently allow the access; treat as
575- # missing and stop checking so the underlying I/O surfaces the
576- # real error.
577- break
616+ is_link = current .is_symlink ()
617+ except OSError as exc :
618+ # Fail closed: if we cannot verify whether a segment is a
619+ # symlink/reparse point we refuse the operation rather than
620+ # silently allow access that may escape the root.
621+ raise ValueError (
622+ f"Invalid path: unable to verify whether '{ segment } ' is a symbolic link or reparse point."
623+ ) from exc
624+ if is_link :
625+ raise ValueError ("Invalid path: the resolved path contains a symbolic link or reparse point." )
578626 if not current .exists ():
579627 break
580628
581- async def write_file (self , path : str , content : str ) -> None :
582- """Write ``content`` to the file at ``path``."""
629+ async def write_file (self , path : str , content : str , * , overwrite : bool = True ) -> None :
630+ """Write ``content`` to the file at ``path``.
631+
632+ When ``overwrite`` is ``False`` the file is created using ``mode="x"``
633+ so the underlying ``open`` call performs an atomic exclusive create
634+ (``O_EXCL`` on POSIX, ``CREATE_NEW`` on Windows) and raises
635+ :class:`FileExistsError` if a file already exists.
636+ """
583637 full_path = self ._resolve_safe_path (path )
584- await asyncio .to_thread (self ._write_file_sync , full_path , content )
638+ await asyncio .to_thread (self ._write_file_sync , full_path , content , overwrite )
585639
586640 @staticmethod
587- def _write_file_sync (full_path : Path , content : str ) -> None :
641+ def _write_file_sync (full_path : Path , content : str , overwrite : bool ) -> None :
588642 full_path .parent .mkdir (parents = True , exist_ok = True )
589- full_path .write_text (content , encoding = "utf-8" )
643+ if overwrite :
644+ full_path .write_text (content , encoding = "utf-8" )
645+ return
646+ with full_path .open ("x" , encoding = "utf-8" ) as handle :
647+ handle .write (content )
590648
591649 async def read_file (self , path : str ) -> str | None :
592650 """Return the file content, or ``None`` if the file does not exist."""
@@ -646,7 +704,7 @@ async def search_files(
646704 """Search file contents for ``regex_pattern`` matches."""
647705 full_dir = self ._resolve_safe_directory_path (directory )
648706 regex = _compile_search_regex (regex_pattern )
649- return await asyncio . to_thread ( self ._search_files_sync , full_dir , regex , file_pattern )
707+ return await _run_search_with_timeout ( lambda : self ._search_files_sync ( full_dir , regex , file_pattern ) )
650708
651709 @staticmethod
652710 def _search_files_sync (full_dir : Path , regex : re .Pattern [str ], file_pattern : str | None ) -> list [FileSearchResult ]:
@@ -736,9 +794,10 @@ async def before_run(
736794 async def file_access_save_file (file_name : str , content : str , overwrite : bool = False ) -> str :
737795 """Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.""" # noqa: E501
738796 normalized = _normalize_relative_path (file_name )
739- if not overwrite and await self .store .file_exists (normalized ):
797+ try :
798+ await self .store .write_file (normalized , content , overwrite = overwrite )
799+ except FileExistsError :
740800 return f"File '{ file_name } ' already exists. To replace it, save again with overwrite set to true."
741- await self .store .write_file (normalized , content )
742801 return f"File '{ file_name } ' saved."
743802
744803 @tool (name = "file_access_read_file" , approval_mode = "never_require" )
0 commit comments