@@ -339,6 +339,16 @@ async def close(self) -> None:
339339 """Terminate the underlying bash session owned by this shell."""
340340 await self ._session .close ()
341341
342+ def _resolve_path (self , path : str ) -> Path :
343+ """Resolve a file-operation path and require it to remain inside cwd."""
344+ root = self .cwd .resolve ()
345+ resolved = (root / path ).resolve ()
346+ try :
347+ resolved .relative_to (root )
348+ except ValueError as exc :
349+ raise ValueError (f"path escapes ShellTools cwd: { path } " ) from exc
350+ return resolved
351+
342352 async def run (
343353 self ,
344354 command : Annotated [str , spec (description = "Shell command to execute" )],
@@ -538,10 +548,10 @@ async def _harvest_matches(self, command: str, displayed_stdout: str) -> list[Ma
538548 out : list [Match ] = []
539549 for mpath , line_no in keep :
540550 if mpath not in file_cache :
541- resolved = (self .cwd / mpath ).resolve ()
542551 try :
552+ resolved = self ._resolve_path (mpath )
543553 file_cache [mpath ] = resolved .read_text ().splitlines (keepends = True )
544- except OSError :
554+ except ( OSError , ValueError ) :
545555 return None
546556 lines = file_cache [mpath ]
547557 if not (1 <= line_no <= len (lines )):
@@ -656,7 +666,7 @@ async def read(
656666 Returns:
657667 Match with .text, .numbered, .path, .start, .end.
658668 """
659- resolved = ( self .cwd / path ). resolve ( )
669+ resolved = self ._resolve_path ( path )
660670 content = resolved .read_text ()
661671 all_lines = content .splitlines (keepends = True )
662672 total = len (all_lines )
@@ -698,7 +708,7 @@ async def replace(
698708 """
699709 if isinstance (target , Match ):
700710 new_text = old_or_new
701- resolved = ( self .cwd / target .path ). resolve ( )
711+ resolved = self ._resolve_path ( target .path )
702712 content = resolved .read_text ()
703713 all_lines = content .splitlines (keepends = True )
704714
@@ -724,7 +734,7 @@ async def replace(
724734 "Did you mean replace(match, new_text)?"
725735 )
726736 old_text = old_or_new
727- resolved = ( self .cwd / target ). resolve ( )
737+ resolved = self ._resolve_path ( target )
728738 content = resolved .read_text ()
729739
730740 count = content .count (old_text )
@@ -762,7 +772,7 @@ async def write_file(
762772 path: File path (relative to cwd).
763773 content: Full file content.
764774 """
765- resolved = ( self .cwd / path ). resolve ( )
775+ resolved = self ._resolve_path ( path )
766776 resolved .parent .mkdir (parents = True , exist_ok = True )
767777 resolved .write_text (content )
768778 line_count = content .count ("\n " ) + (1 if content and not content .endswith ("\n " ) else 0 )
0 commit comments