11import errno
2+ from pathlib import Path
23from types import SimpleNamespace
34from unittest .mock import patch
45
89from deerflow .sandbox .local .local_sandbox_provider import LocalSandboxProvider
910
1011
12+ def _symlink_to (target , link , * , target_is_directory = False ):
13+ try :
14+ link .symlink_to (target , target_is_directory = target_is_directory )
15+ except (NotImplementedError , OSError ) as exc :
16+ pytest .skip (f"symlinks are not available: { exc } " )
17+
18+
1119class TestPathMapping :
1220 def test_path_mapping_dataclass (self ):
1321 mapping = PathMapping (container_path = "/mnt/skills" , local_path = "/home/user/skills" , read_only = True )
@@ -29,7 +37,7 @@ def test_resolve_path_exact_match(self):
2937 ],
3038 )
3139 resolved = sandbox ._resolve_path ("/mnt/skills" )
32- assert resolved == "/home/user/skills"
40+ assert resolved == str ( Path ( "/home/user/skills" ). resolve ())
3341
3442 def test_resolve_path_nested_path (self ):
3543 sandbox = LocalSandbox (
@@ -39,7 +47,7 @@ def test_resolve_path_nested_path(self):
3947 ],
4048 )
4149 resolved = sandbox ._resolve_path ("/mnt/skills/agent/prompt.py" )
42- assert resolved == "/home/user/skills/agent/prompt.py"
50+ assert resolved == str ( Path ( "/home/user/skills/agent/prompt.py" ). resolve ())
4351
4452 def test_resolve_path_no_mapping (self ):
4553 sandbox = LocalSandbox (
@@ -61,7 +69,7 @@ def test_resolve_path_longest_prefix_first(self):
6169 )
6270 resolved = sandbox ._resolve_path ("/mnt/skills/file.py" )
6371 # Should match /mnt/skills first (longer prefix)
64- assert resolved == "/home/user/skills/file.py"
72+ assert resolved == str ( Path ( "/home/user/skills/file.py" ). resolve ())
6573
6674 def test_reverse_resolve_path_exact_match (self , tmp_path ):
6775 skills_dir = tmp_path / "skills"
@@ -175,6 +183,157 @@ def test_update_file_blocked_on_read_only(self, tmp_path):
175183 assert exc_info .value .errno == errno .EROFS
176184
177185
186+ class TestSymlinkEscapes :
187+ def test_read_file_blocks_symlink_escape_from_mount (self , tmp_path ):
188+ mount_dir = tmp_path / "mount"
189+ mount_dir .mkdir ()
190+ outside_dir = tmp_path / "outside"
191+ outside_dir .mkdir ()
192+ (outside_dir / "secret.txt" ).write_text ("secret" )
193+ _symlink_to (outside_dir , mount_dir / "escape" , target_is_directory = True )
194+
195+ sandbox = LocalSandbox (
196+ "test" ,
197+ [
198+ PathMapping (container_path = "/mnt/data" , local_path = str (mount_dir ), read_only = False ),
199+ ],
200+ )
201+
202+ with pytest .raises (PermissionError ) as exc_info :
203+ sandbox .read_file ("/mnt/data/escape/secret.txt" )
204+
205+ assert exc_info .value .errno == errno .EACCES
206+
207+ def test_write_file_blocks_symlink_escape_from_mount (self , tmp_path ):
208+ mount_dir = tmp_path / "mount"
209+ mount_dir .mkdir ()
210+ outside_dir = tmp_path / "outside"
211+ outside_dir .mkdir ()
212+ victim = outside_dir / "victim.txt"
213+ victim .write_text ("original" )
214+ _symlink_to (outside_dir , mount_dir / "escape" , target_is_directory = True )
215+
216+ sandbox = LocalSandbox (
217+ "test" ,
218+ [
219+ PathMapping (container_path = "/mnt/data" , local_path = str (mount_dir ), read_only = False ),
220+ ],
221+ )
222+
223+ with pytest .raises (PermissionError ) as exc_info :
224+ sandbox .write_file ("/mnt/data/escape/victim.txt" , "changed" )
225+
226+ assert exc_info .value .errno == errno .EACCES
227+ assert victim .read_text () == "original"
228+
229+ def test_write_file_uses_matched_read_only_mount_for_symlink_target (self , tmp_path ):
230+ repo_dir = tmp_path / "repo"
231+ repo_dir .mkdir ()
232+ writable_dir = repo_dir / "writable"
233+ writable_dir .mkdir ()
234+ _symlink_to (writable_dir , repo_dir / "link-to-writable" , target_is_directory = True )
235+
236+ sandbox = LocalSandbox (
237+ "test" ,
238+ [
239+ PathMapping (container_path = "/mnt/repo" , local_path = str (repo_dir ), read_only = True ),
240+ PathMapping (container_path = "/mnt/repo/writable" , local_path = str (writable_dir ), read_only = False ),
241+ ],
242+ )
243+
244+ with pytest .raises (OSError ) as exc_info :
245+ sandbox .write_file ("/mnt/repo/link-to-writable/file.txt" , "bypass" )
246+
247+ assert exc_info .value .errno == errno .EROFS
248+ assert not (writable_dir / "file.txt" ).exists ()
249+
250+ def test_list_dir_does_not_follow_symlink_escape_from_mount (self , tmp_path ):
251+ mount_dir = tmp_path / "mount"
252+ mount_dir .mkdir ()
253+ outside_dir = tmp_path / "outside"
254+ outside_dir .mkdir ()
255+ (outside_dir / "secret.txt" ).write_text ("secret" )
256+ _symlink_to (outside_dir , mount_dir / "escape" , target_is_directory = True )
257+ (mount_dir / "visible.txt" ).write_text ("visible" )
258+
259+ sandbox = LocalSandbox (
260+ "test" ,
261+ [
262+ PathMapping (container_path = "/mnt/data" , local_path = str (mount_dir ), read_only = False ),
263+ ],
264+ )
265+
266+ entries = sandbox .list_dir ("/mnt/data" , max_depth = 2 )
267+
268+ assert "/mnt/data/visible.txt" in entries
269+ assert all ("secret.txt" not in entry for entry in entries )
270+ assert all ("outside" not in entry for entry in entries )
271+
272+ def test_list_dir_formats_internal_directory_symlink_like_directory (self , tmp_path ):
273+ mount_dir = tmp_path / "mount"
274+ nested_dir = mount_dir / "nested"
275+ linked_dir = nested_dir / "linked-dir"
276+ linked_dir .mkdir (parents = True )
277+ _symlink_to (linked_dir , mount_dir / "dir-link" , target_is_directory = True )
278+
279+ sandbox = LocalSandbox (
280+ "test" ,
281+ [
282+ PathMapping (container_path = "/mnt/data" , local_path = str (mount_dir ), read_only = False ),
283+ ],
284+ )
285+
286+ entries = sandbox .list_dir ("/mnt/data" , max_depth = 1 )
287+
288+ assert "/mnt/data/nested/" in entries
289+ assert "/mnt/data/nested/linked-dir/" in entries
290+ assert "/mnt/data/dir-link" not in entries
291+
292+ def test_write_file_blocks_symlink_into_nested_read_only_mount (self , tmp_path ):
293+ repo_dir = tmp_path / "repo"
294+ repo_dir .mkdir ()
295+ protected_dir = repo_dir / "protected"
296+ protected_dir .mkdir ()
297+ _symlink_to (protected_dir , repo_dir / "link-to-protected" , target_is_directory = True )
298+
299+ sandbox = LocalSandbox (
300+ "test" ,
301+ [
302+ PathMapping (container_path = "/mnt/repo" , local_path = str (repo_dir ), read_only = False ),
303+ PathMapping (container_path = "/mnt/repo/protected" , local_path = str (protected_dir ), read_only = True ),
304+ ],
305+ )
306+
307+ with pytest .raises (OSError ) as exc_info :
308+ sandbox .write_file ("/mnt/repo/link-to-protected/file.txt" , "bypass" )
309+
310+ assert exc_info .value .errno == errno .EROFS
311+ assert not (protected_dir / "file.txt" ).exists ()
312+
313+ def test_update_file_blocks_symlink_into_nested_read_only_mount (self , tmp_path ):
314+ repo_dir = tmp_path / "repo"
315+ repo_dir .mkdir ()
316+ protected_dir = repo_dir / "protected"
317+ protected_dir .mkdir ()
318+ existing = protected_dir / "file.txt"
319+ existing .write_bytes (b"original" )
320+ _symlink_to (protected_dir , repo_dir / "link-to-protected" , target_is_directory = True )
321+
322+ sandbox = LocalSandbox (
323+ "test" ,
324+ [
325+ PathMapping (container_path = "/mnt/repo" , local_path = str (repo_dir ), read_only = False ),
326+ PathMapping (container_path = "/mnt/repo/protected" , local_path = str (protected_dir ), read_only = True ),
327+ ],
328+ )
329+
330+ with pytest .raises (OSError ) as exc_info :
331+ sandbox .update_file ("/mnt/repo/link-to-protected/file.txt" , b"changed" )
332+
333+ assert exc_info .value .errno == errno .EROFS
334+ assert existing .read_bytes () == b"original"
335+
336+
178337class TestMultipleMounts :
179338 def test_multiple_read_write_mounts (self , tmp_path ):
180339 skills_dir = tmp_path / "skills"
0 commit comments