Skip to content

Commit 39c5da9

Browse files
authored
fix(sandbox): prevent local custom mount symlink escapes (bytedance#2558)
* fix(sandbox): prevent local custom mount symlink escapes Fixes bytedance#2506 * fix(sandbox): harden custom mount symlink handling * fix(sandbox): format internal symlink directory listings
1 parent 707ed32 commit 39c5da9

3 files changed

Lines changed: 242 additions & 23 deletions

File tree

backend/packages/harness/deerflow/sandbox/local/list_dir.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ def list_dir(path: str, max_depth: int = 2) -> list[str]:
2222
if not root_path.is_dir():
2323
return result
2424

25+
def _is_within_root(candidate: Path) -> bool:
26+
try:
27+
candidate.relative_to(root_path)
28+
return True
29+
except ValueError:
30+
return False
31+
2532
def _traverse(current_path: Path, current_depth: int) -> None:
2633
"""Recursively traverse directories up to max_depth."""
2734
if current_depth > max_depth:
@@ -32,8 +39,23 @@ def _traverse(current_path: Path, current_depth: int) -> None:
3239
if should_ignore_name(item.name):
3340
continue
3441

42+
if item.is_symlink():
43+
try:
44+
item_resolved = item.resolve()
45+
if not _is_within_root(item_resolved):
46+
continue
47+
except OSError:
48+
continue
49+
post_fix = "/" if item_resolved.is_dir() else ""
50+
result.append(str(item_resolved) + post_fix)
51+
continue
52+
53+
item_resolved = item.resolve()
54+
if not _is_within_root(item_resolved):
55+
continue
56+
3557
post_fix = "/" if item.is_dir() else ""
36-
result.append(str(item.resolve()) + post_fix)
58+
result.append(str(item_resolved) + post_fix)
3759

3860
# Recurse into subdirectories if not at max depth
3961
if item.is_dir() and current_depth < max_depth:

backend/packages/harness/deerflow/sandbox/local/local_sandbox.py

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import subprocess
66
from dataclasses import dataclass
77
from pathlib import Path
8+
from typing import NamedTuple
89

910
from deerflow.sandbox.local.list_dir import list_dir
1011
from deerflow.sandbox.sandbox import Sandbox
@@ -20,6 +21,11 @@ class PathMapping:
2021
read_only: bool = False
2122

2223

24+
class ResolvedPath(NamedTuple):
25+
path: str
26+
mapping: PathMapping | None
27+
28+
2329
class LocalSandbox(Sandbox):
2430
@staticmethod
2531
def _shell_name(shell: str) -> str:
@@ -91,30 +97,54 @@ def _is_read_only_path(self, resolved_path: str) -> bool:
9197

9298
return best_mapping.read_only
9399

94-
def _resolve_path(self, path: str) -> str:
100+
def _find_path_mapping(self, path: str) -> tuple[PathMapping, str] | None:
101+
path_str = str(path)
102+
103+
for mapping in sorted(self.path_mappings, key=lambda m: len(m.container_path.rstrip("/") or "/"), reverse=True):
104+
container_path = mapping.container_path.rstrip("/") or "/"
105+
if container_path == "/":
106+
if path_str.startswith("/"):
107+
return mapping, path_str.lstrip("/")
108+
continue
109+
110+
if path_str == container_path or path_str.startswith(container_path + "/"):
111+
relative = path_str[len(container_path) :].lstrip("/")
112+
return mapping, relative
113+
114+
return None
115+
116+
def _resolve_path_with_mapping(self, path: str) -> ResolvedPath:
95117
"""
96118
Resolve container path to actual local path using mappings.
97119
98120
Args:
99121
path: Path that might be a container path
100122
101123
Returns:
102-
Resolved local path
124+
Resolved local path and the matched mapping, if any
103125
"""
104126
path_str = str(path)
105127

106-
# Try each mapping (longest prefix first for more specific matches)
107-
for mapping in sorted(self.path_mappings, key=lambda m: len(m.container_path), reverse=True):
108-
container_path = mapping.container_path
109-
local_path = mapping.local_path
110-
if path_str == container_path or path_str.startswith(container_path + "/"):
111-
# Replace the container path prefix with local path
112-
relative = path_str[len(container_path) :].lstrip("/")
113-
resolved = str(Path(local_path) / relative) if relative else local_path
114-
return resolved
128+
mapping_match = self._find_path_mapping(path_str)
129+
if mapping_match is None:
130+
return ResolvedPath(path_str, None)
115131

116-
# No mapping found, return original path
117-
return path_str
132+
mapping, relative = mapping_match
133+
local_root = Path(mapping.local_path).resolve()
134+
resolved_path = (local_root / relative).resolve() if relative else local_root
135+
136+
try:
137+
resolved_path.relative_to(local_root)
138+
except ValueError as exc:
139+
raise PermissionError(errno.EACCES, "Access denied: path escapes mounted directory", path_str) from exc
140+
141+
return ResolvedPath(str(resolved_path), mapping)
142+
143+
def _resolve_path(self, path: str) -> str:
144+
return self._resolve_path_with_mapping(path).path
145+
146+
def _is_resolved_path_read_only(self, resolved: ResolvedPath) -> bool:
147+
return bool(resolved.mapping and resolved.mapping.read_only) or self._is_read_only_path(resolved.path)
118148

119149
def _reverse_resolve_path(self, path: str) -> str:
120150
"""
@@ -309,8 +339,14 @@ def execute_command(self, command: str) -> str:
309339
def list_dir(self, path: str, max_depth=2) -> list[str]:
310340
resolved_path = self._resolve_path(path)
311341
entries = list_dir(resolved_path, max_depth)
312-
# Reverse resolve local paths back to container paths in output
313-
return [self._reverse_resolve_paths_in_output(entry) for entry in entries]
342+
# Reverse resolve local paths back to container paths and preserve
343+
# list_dir's trailing "/" marker for directories.
344+
result: list[str] = []
345+
for entry in entries:
346+
is_dir = entry.endswith(("/", "\\"))
347+
reversed_entry = self._reverse_resolve_path(entry.rstrip("/\\")) if is_dir else self._reverse_resolve_path(entry)
348+
result.append(f"{reversed_entry}/" if is_dir and not reversed_entry.endswith("/") else reversed_entry)
349+
return result
314350

315351
def read_file(self, path: str) -> str:
316352
resolved_path = self._resolve_path(path)
@@ -329,8 +365,9 @@ def read_file(self, path: str) -> str:
329365
raise type(e)(e.errno, e.strerror, path) from None
330366

331367
def write_file(self, path: str, content: str, append: bool = False) -> None:
332-
resolved_path = self._resolve_path(path)
333-
if self._is_read_only_path(resolved_path):
368+
resolved = self._resolve_path_with_mapping(path)
369+
resolved_path = resolved.path
370+
if self._is_resolved_path_read_only(resolved):
334371
raise OSError(errno.EROFS, "Read-only file system", path)
335372
try:
336373
dir_path = os.path.dirname(resolved_path)
@@ -384,8 +421,9 @@ def grep(
384421
], truncated
385422

386423
def update_file(self, path: str, content: bytes) -> None:
387-
resolved_path = self._resolve_path(path)
388-
if self._is_read_only_path(resolved_path):
424+
resolved = self._resolve_path_with_mapping(path)
425+
resolved_path = resolved.path
426+
if self._is_resolved_path_read_only(resolved):
389427
raise OSError(errno.EROFS, "Read-only file system", path)
390428
try:
391429
dir_path = os.path.dirname(resolved_path)

backend/tests/test_local_sandbox_provider_mounts.py

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import errno
2+
from pathlib import Path
23
from types import SimpleNamespace
34
from unittest.mock import patch
45

@@ -8,6 +9,13 @@
89
from 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+
1119
class 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+
178337
class TestMultipleMounts:
179338
def test_multiple_read_write_mounts(self, tmp_path):
180339
skills_dir = tmp_path / "skills"

0 commit comments

Comments
 (0)