11from __future__ import annotations
22
3+ import hashlib
34import io
45import os
56from collections .abc import Awaitable , Callable , Sequence
1011import agents .sandbox .entries .artifacts as artifacts_module
1112from agents .sandbox import SandboxConcurrencyLimits
1213from agents .sandbox .entries import Dir , File , GitRepo , LocalDir , LocalFile , resolve_workspace_path
13- from agents .sandbox .errors import ExecNonZeroError , InvalidManifestPathError , LocalDirReadError
14+ from agents .sandbox .errors import (
15+ ExecNonZeroError ,
16+ InvalidManifestPathError ,
17+ LocalDirReadError ,
18+ LocalFileReadError ,
19+ )
1420from agents .sandbox .manifest import Manifest
1521from agents .sandbox .materialization import MaterializedFile
1622from agents .sandbox .session .base_sandbox_session import BaseSandboxSession
@@ -148,6 +154,15 @@ def test_resolve_workspace_path_rejects_absolute_symlink_escape_for_host_root(
148154 assert exc_info .value .context == {"rel" : escaped .as_posix (), "reason" : "absolute" }
149155
150156
157+ def _symlink_or_skip (path : Path , target : Path , * , target_is_directory : bool = False ) -> None :
158+ try :
159+ path .symlink_to (target , target_is_directory = target_is_directory )
160+ except OSError as e :
161+ if os .name == "nt" and getattr (e , "winerror" , None ) == 1314 :
162+ pytest .skip ("symlink creation requires elevated privileges on Windows" )
163+ raise
164+
165+
151166@pytest .mark .asyncio
152167async def test_base_sandbox_session_uses_current_working_directory_for_local_file_sources (
153168 monkeypatch : pytest .MonkeyPatch ,
@@ -165,9 +180,70 @@ async def test_base_sandbox_session_uses_current_working_directory_for_local_fil
165180 result = await session .apply_manifest ()
166181
167182 assert result .files [0 ].path == Path ("/workspace/copied.txt" )
183+ assert result .files [0 ].sha256 == hashlib .sha256 (b"hello" ).hexdigest ()
168184 assert session .writes [Path ("/workspace/copied.txt" )] == b"hello"
169185
170186
187+ @pytest .mark .asyncio
188+ async def test_local_file_rejects_symlinked_source_ancestors (tmp_path : Path ) -> None :
189+ target_dir = tmp_path / "secret-dir"
190+ target_dir .mkdir ()
191+ nested_dir = target_dir / "sub"
192+ nested_dir .mkdir ()
193+ (nested_dir / "secret.txt" ).write_text ("secret" , encoding = "utf-8" )
194+ _symlink_or_skip (tmp_path / "link" , target_dir , target_is_directory = True )
195+ session = _RecordingSession ()
196+
197+ with pytest .raises (LocalFileReadError ) as excinfo :
198+ await LocalFile (src = Path ("link/sub/secret.txt" )).apply (
199+ session ,
200+ Path ("/workspace/copied.txt" ),
201+ tmp_path ,
202+ )
203+
204+ assert excinfo .value .context ["reason" ] == "symlink_not_supported"
205+ assert excinfo .value .context ["child" ] == "link"
206+ assert session .writes == {}
207+
208+
209+ @pytest .mark .asyncio
210+ async def test_local_file_rejects_symlinked_source_leaf (tmp_path : Path ) -> None :
211+ secret = tmp_path / "secret.txt"
212+ secret .write_text ("secret" , encoding = "utf-8" )
213+ _symlink_or_skip (tmp_path / "link.txt" , secret )
214+ session = _RecordingSession ()
215+
216+ with pytest .raises (LocalFileReadError ) as excinfo :
217+ await LocalFile (src = Path ("link.txt" )).apply (
218+ session ,
219+ Path ("/workspace/copied.txt" ),
220+ tmp_path ,
221+ )
222+
223+ assert excinfo .value .context ["reason" ] == "symlink_not_supported"
224+ assert excinfo .value .context ["child" ] == "link.txt"
225+ assert session .writes == {}
226+
227+
228+ @pytest .mark .asyncio
229+ async def test_local_file_rejects_symlinked_source_before_checksum (tmp_path : Path ) -> None :
230+ target_dir = tmp_path / "secret-dir"
231+ target_dir .mkdir ()
232+ _symlink_or_skip (tmp_path / "link.txt" , target_dir , target_is_directory = True )
233+ session = _RecordingSession ()
234+
235+ with pytest .raises (LocalFileReadError ) as excinfo :
236+ await LocalFile (src = Path ("link.txt" )).apply (
237+ session ,
238+ Path ("/workspace/copied.txt" ),
239+ tmp_path ,
240+ )
241+
242+ assert excinfo .value .context ["reason" ] == "symlink_not_supported"
243+ assert excinfo .value .context ["child" ] == "link.txt"
244+ assert session .writes == {}
245+
246+
171247@pytest .mark .asyncio
172248async def test_local_dir_copy_falls_back_when_safe_dir_fd_open_unavailable (
173249 monkeypatch : pytest .MonkeyPatch ,
@@ -200,6 +276,9 @@ async def test_local_dir_copy_revalidates_swapped_paths_during_open(
200276 monkeypatch : pytest .MonkeyPatch ,
201277 tmp_path : Path ,
202278) -> None :
279+ if not artifacts_module ._OPEN_SUPPORTS_DIR_FD or not artifacts_module ._HAS_O_DIRECTORY :
280+ pytest .skip ("safe dir_fd open pinning is unavailable on this platform" )
281+
203282 src_root = tmp_path / "src"
204283 src_root .mkdir ()
205284 src_file = src_root / "safe.txt"
@@ -221,7 +300,7 @@ def swap_then_open(
221300 nonlocal swapped
222301 if (path == "safe.txt" or Path (path ) == src_file ) and not swapped :
223302 src_file .unlink ()
224- src_file . symlink_to ( secret )
303+ _symlink_or_skip ( src_file , secret )
225304 swapped = True
226305 if dir_fd is None :
227306 return original_open (path , flags , mode )
@@ -251,6 +330,9 @@ async def test_local_dir_copy_pins_parent_directories_during_open(
251330 monkeypatch : pytest .MonkeyPatch ,
252331 tmp_path : Path ,
253332) -> None :
333+ if not artifacts_module ._OPEN_SUPPORTS_DIR_FD or not artifacts_module ._HAS_O_DIRECTORY :
334+ pytest .skip ("safe dir_fd open pinning is unavailable on this platform" )
335+
254336 src_root = tmp_path / "src"
255337 src_root .mkdir ()
256338 nested_dir = src_root / "nested"
@@ -275,7 +357,7 @@ def swap_parent_then_open(
275357 nonlocal swapped
276358 if path == "safe.txt" and not swapped :
277359 (src_root / "nested" ).rename (src_root / "nested-original" )
278- (src_root / "nested" ). symlink_to ( secret_dir , target_is_directory = True )
360+ _symlink_or_skip (src_root / "nested" , secret_dir , target_is_directory = True )
279361 swapped = True
280362 if dir_fd is None :
281363 return original_open (path , flags , mode )
@@ -295,11 +377,68 @@ def swap_parent_then_open(
295377 assert session .writes [Path ("/workspace/copied/nested/safe.txt" )] == b"safe"
296378
297379
380+ @pytest .mark .asyncio
381+ async def test_local_dir_copy_fallback_rejects_swapped_parent_directory (
382+ monkeypatch : pytest .MonkeyPatch ,
383+ tmp_path : Path ,
384+ ) -> None :
385+ src_root = tmp_path / "src"
386+ src_root .mkdir ()
387+ nested_dir = src_root / "nested"
388+ nested_dir .mkdir ()
389+ src_file = nested_dir / "safe.txt"
390+ src_file .write_text ("safe" , encoding = "utf-8" )
391+ secret_dir = tmp_path / "secret-dir"
392+ secret_dir .mkdir ()
393+ (secret_dir / "safe.txt" ).write_text ("secret" , encoding = "utf-8" )
394+ session = _RecordingSession ()
395+ local_dir = LocalDir (src = Path ("src" ))
396+ original_open = os .open
397+ swapped = False
398+
399+ monkeypatch .setattr ("agents.sandbox.entries.artifacts._OPEN_SUPPORTS_DIR_FD" , False )
400+ monkeypatch .setattr ("agents.sandbox.entries.artifacts._HAS_O_DIRECTORY" , False )
401+
402+ def swap_parent_then_open (
403+ path : str | Path ,
404+ flags : int ,
405+ mode : int = 0o777 ,
406+ * ,
407+ dir_fd : int | None = None ,
408+ ) -> int :
409+ nonlocal swapped
410+ if Path (path ) == src_file and not swapped :
411+ nested_dir .rename (src_root / "nested-original" )
412+ _symlink_or_skip (src_root / "nested" , secret_dir , target_is_directory = True )
413+ swapped = True
414+ if dir_fd is None :
415+ return original_open (path , flags , mode )
416+ return original_open (path , flags , mode , dir_fd = dir_fd )
417+
418+ monkeypatch .setattr ("agents.sandbox.entries.artifacts.os.open" , swap_parent_then_open )
419+
420+ with pytest .raises (LocalDirReadError ) as excinfo :
421+ await local_dir ._copy_local_dir_file (
422+ base_dir = tmp_path ,
423+ session = session ,
424+ src_root = src_root ,
425+ src = src_file ,
426+ dest_root = Path ("/workspace/copied" ),
427+ )
428+
429+ assert excinfo .value .context ["reason" ] == "symlink_not_supported"
430+ assert excinfo .value .context ["child" ] == "src/nested"
431+ assert session .writes == {}
432+
433+
298434@pytest .mark .asyncio
299435async def test_local_dir_apply_rejects_source_root_swapped_to_symlink_after_validation (
300436 monkeypatch : pytest .MonkeyPatch ,
301437 tmp_path : Path ,
302438) -> None :
439+ if not artifacts_module ._OPEN_SUPPORTS_DIR_FD or not artifacts_module ._HAS_O_DIRECTORY :
440+ pytest .skip ("safe dir_fd open pinning is unavailable on this platform" )
441+
303442 src_root = tmp_path / "src"
304443 src_root .mkdir ()
305444 (src_root / "safe.txt" ).write_text ("safe" , encoding = "utf-8" )
@@ -365,7 +504,7 @@ def swap_root_then_open(
365504 nonlocal swapped
366505 if Path (path ) == src_root / "safe.txt" and not swapped :
367506 src_root .rename (tmp_path / "src-original" )
368- (tmp_path / "src" ). symlink_to ( secret_dir , target_is_directory = True )
507+ _symlink_or_skip (tmp_path / "src" , secret_dir , target_is_directory = True )
369508 swapped = True
370509 if dir_fd is None :
371510 return original_open (path , flags , mode )
@@ -437,7 +576,7 @@ async def test_local_dir_rejects_symlinked_source_ancestors(tmp_path: Path) -> N
437576 nested_dir = target_dir / "sub"
438577 nested_dir .mkdir ()
439578 (nested_dir / "secret.txt" ).write_text ("secret" , encoding = "utf-8" )
440- (tmp_path / "link" ). symlink_to ( target_dir , target_is_directory = True )
579+ _symlink_or_skip (tmp_path / "link" , target_dir , target_is_directory = True )
441580 session = _RecordingSession ()
442581
443582 with pytest .raises (LocalDirReadError ) as excinfo :
@@ -453,7 +592,7 @@ async def test_local_dir_rejects_symlinked_source_root(tmp_path: Path) -> None:
453592 target_dir = tmp_path / "secret-dir"
454593 target_dir .mkdir ()
455594 (target_dir / "secret.txt" ).write_text ("secret" , encoding = "utf-8" )
456- (tmp_path / "src" ). symlink_to ( target_dir , target_is_directory = True )
595+ _symlink_or_skip (tmp_path / "src" , target_dir , target_is_directory = True )
457596 session = _RecordingSession ()
458597
459598 with pytest .raises (LocalDirReadError ) as excinfo :
@@ -471,7 +610,7 @@ async def test_local_dir_rejects_symlinked_files(tmp_path: Path) -> None:
471610 (src_root / "safe.txt" ).write_text ("safe" , encoding = "utf-8" )
472611 secret = tmp_path / "secret.txt"
473612 secret .write_text ("secret" , encoding = "utf-8" )
474- (src_root / "link.txt" ). symlink_to ( secret )
613+ _symlink_or_skip (src_root / "link.txt" , secret )
475614 session = _RecordingSession ()
476615
477616 with pytest .raises (LocalDirReadError ) as excinfo :
@@ -490,7 +629,7 @@ async def test_local_dir_rejects_symlinked_directories(tmp_path: Path) -> None:
490629 target_dir = tmp_path / "secret-dir"
491630 target_dir .mkdir ()
492631 (target_dir / "secret.txt" ).write_text ("secret" , encoding = "utf-8" )
493- (src_root / "linked-dir" ). symlink_to ( target_dir , target_is_directory = True )
632+ _symlink_or_skip (src_root / "linked-dir" , target_dir , target_is_directory = True )
494633 session = _RecordingSession ()
495634
496635 with pytest .raises (LocalDirReadError ) as excinfo :
@@ -536,8 +675,9 @@ async def test_git_repo_uses_fetch_checkout_path_for_commit_refs() -> None:
536675@pytest .mark .asyncio
537676async def test_dir_metadata_strips_file_type_bits_before_chmod () -> None :
538677 session = _RecordingSession ()
678+ dest = Path ("/workspace/dir" )
539679
540- await Dir ()._apply_metadata (session , Path ( "/workspace/dir" ) )
680+ await Dir ()._apply_metadata (session , dest )
541681
542682 assert ("chmod" , "0755" , "/workspace/dir" ) in session .exec_calls
543683
0 commit comments