@@ -496,6 +496,59 @@ def swap_then_open(
496496 assert session .writes == {}
497497
498498
499+ @pytest .mark .asyncio
500+ async def test_local_dir_copy_rejects_swapped_file_if_no_follow_is_bypassed (
501+ monkeypatch : pytest .MonkeyPatch ,
502+ tmp_path : Path ,
503+ ) -> None :
504+ if not artifacts_module ._OPEN_SUPPORTS_DIR_FD or not artifacts_module ._HAS_O_DIRECTORY :
505+ pytest .skip ("safe dir_fd open pinning is unavailable on this platform" )
506+
507+ src_root = tmp_path / "src"
508+ src_root .mkdir ()
509+ src_file = src_root / "safe.txt"
510+ src_file .write_text ("safe" , encoding = "utf-8" )
511+ secret = tmp_path / "secret.txt"
512+ secret .write_text ("secret" , encoding = "utf-8" )
513+ session = _RecordingSession ()
514+ local_dir = LocalDir (src = Path ("src" ))
515+ original_open = os .open
516+ no_follow = getattr (os , "O_NOFOLLOW" , 0 )
517+ swapped = False
518+
519+ def swap_then_open (
520+ path : str | Path ,
521+ flags : int ,
522+ mode : int = 0o777 ,
523+ * ,
524+ dir_fd : int | None = None ,
525+ ) -> int :
526+ nonlocal swapped
527+ if (path == "safe.txt" or Path (path ) == src_file ) and not swapped :
528+ src_file .unlink ()
529+ _symlink_or_skip (src_file , secret )
530+ flags &= ~ no_follow
531+ swapped = True
532+ if dir_fd is None :
533+ return original_open (path , flags , mode )
534+ return original_open (path , flags , mode , dir_fd = dir_fd )
535+
536+ monkeypatch .setattr ("agents.sandbox.entries.artifacts.os.open" , swap_then_open )
537+
538+ with pytest .raises (LocalDirReadError ) as excinfo :
539+ await local_dir ._copy_local_dir_file (
540+ base_dir = tmp_path ,
541+ session = session ,
542+ src_root = src_root ,
543+ src = src_file ,
544+ dest_root = Path ("/workspace/copied" ),
545+ )
546+
547+ assert excinfo .value .context ["reason" ] == "path_changed_during_copy"
548+ assert excinfo .value .context ["child" ] == "safe.txt"
549+ assert session .writes == {}
550+
551+
499552@pytest .mark .asyncio
500553async def test_local_dir_copy_pins_parent_directories_during_open (
501554 monkeypatch : pytest .MonkeyPatch ,
@@ -548,6 +601,57 @@ def swap_parent_then_open(
548601 assert session .writes [Path ("/workspace/copied/nested/safe.txt" )] == b"safe"
549602
550603
604+ @pytest .mark .asyncio
605+ async def test_local_dir_apply_rejects_nested_dir_swap_during_listing_if_no_follow_is_bypassed (
606+ monkeypatch : pytest .MonkeyPatch ,
607+ tmp_path : Path ,
608+ ) -> None :
609+ if not artifacts_module ._OPEN_SUPPORTS_DIR_FD or not artifacts_module ._HAS_O_DIRECTORY :
610+ pytest .skip ("safe dir_fd open pinning is unavailable on this platform" )
611+
612+ src_root = tmp_path / "src"
613+ src_root .mkdir ()
614+ nested_dir = src_root / "nested"
615+ nested_dir .mkdir ()
616+ (nested_dir / "safe.txt" ).write_text ("safe" , encoding = "utf-8" )
617+ secret_dir = tmp_path / "secret-dir"
618+ secret_dir .mkdir ()
619+ (secret_dir / "secret.txt" ).write_text ("secret" , encoding = "utf-8" )
620+ session = _RecordingSession ()
621+ local_dir = LocalDir (src = Path ("src" ))
622+ original_open = os .open
623+ no_follow = getattr (os , "O_NOFOLLOW" , 0 )
624+ swapped = False
625+
626+ def swap_nested_then_open (
627+ path : str | Path ,
628+ flags : int ,
629+ mode : int = 0o777 ,
630+ * ,
631+ dir_fd : int | None = None ,
632+ ) -> int :
633+ nonlocal swapped
634+ if path == "nested" and not swapped :
635+ nested_dir .rename (src_root / "nested-original" )
636+ _symlink_or_skip (src_root / "nested" , secret_dir , target_is_directory = True )
637+ swapped = True
638+ if path == "nested" and swapped :
639+ flags &= ~ no_follow
640+ if dir_fd is None :
641+ return original_open (path , flags , mode )
642+ return original_open (path , flags , mode , dir_fd = dir_fd )
643+
644+ monkeypatch .setattr ("agents.sandbox.entries.artifacts.os.open" , swap_nested_then_open )
645+
646+ with pytest .raises (LocalDirReadError ) as excinfo :
647+ await local_dir .apply (session , Path ("/workspace/copied" ), tmp_path )
648+
649+ assert excinfo .value .context ["reason" ] == "path_changed_during_copy"
650+ assert excinfo .value .context ["child" ] == "nested"
651+ assert Path ("/workspace/copied/nested/secret.txt" ) not in session .writes
652+ assert session .writes == {}
653+
654+
551655@pytest .mark .asyncio
552656async def test_local_dir_copy_fallback_rejects_swapped_parent_directory (
553657 monkeypatch : pytest .MonkeyPatch ,
@@ -647,6 +751,54 @@ def swap_root_then_open(
647751 assert session .writes == {}
648752
649753
754+ @pytest .mark .asyncio
755+ async def test_local_dir_apply_rejects_source_root_swap_if_no_follow_is_bypassed (
756+ monkeypatch : pytest .MonkeyPatch ,
757+ tmp_path : Path ,
758+ ) -> None :
759+ if not artifacts_module ._OPEN_SUPPORTS_DIR_FD or not artifacts_module ._HAS_O_DIRECTORY :
760+ pytest .skip ("safe dir_fd open pinning is unavailable on this platform" )
761+
762+ src_root = tmp_path / "src"
763+ src_root .mkdir ()
764+ (src_root / "safe.txt" ).write_text ("safe" , encoding = "utf-8" )
765+ secret_dir = tmp_path / "secret-dir"
766+ secret_dir .mkdir ()
767+ (secret_dir / "secret.txt" ).write_text ("secret" , encoding = "utf-8" )
768+ session = _RecordingSession ()
769+ local_dir = LocalDir (src = Path ("src" ))
770+ original_open = os .open
771+ no_follow = getattr (os , "O_NOFOLLOW" , 0 )
772+ swapped = False
773+
774+ def swap_root_then_open (
775+ path : str | Path ,
776+ flags : int ,
777+ mode : int = 0o777 ,
778+ * ,
779+ dir_fd : int | None = None ,
780+ ) -> int :
781+ nonlocal swapped
782+ if path == "src" and not swapped :
783+ src_root .rename (tmp_path / "src-original" )
784+ _symlink_or_skip (tmp_path / "src" , secret_dir , target_is_directory = True )
785+ flags &= ~ no_follow
786+ swapped = True
787+ if dir_fd is None :
788+ return original_open (path , flags , mode )
789+ return original_open (path , flags , mode , dir_fd = dir_fd )
790+
791+ monkeypatch .setattr ("agents.sandbox.entries.artifacts.os.open" , swap_root_then_open )
792+
793+ with pytest .raises (LocalDirReadError ) as excinfo :
794+ await local_dir .apply (session , Path ("/workspace/copied" ), tmp_path )
795+
796+ assert excinfo .value .context ["reason" ] == "path_changed_during_copy"
797+ assert excinfo .value .context ["child" ] == "src"
798+ assert Path ("/workspace/copied/secret.txt" ) not in session .writes
799+ assert session .writes == {}
800+
801+
650802@pytest .mark .asyncio
651803async def test_local_dir_apply_fallback_rejects_source_root_swapped_to_symlink_after_validation (
652804 monkeypatch : pytest .MonkeyPatch ,
0 commit comments