Skip to content

Commit ba889de

Browse files
authored
fix: reject symlinked LocalFile sources (#2972)
1 parent 5df41d3 commit ba889de

2 files changed

Lines changed: 180 additions & 20 deletions

File tree

src/agents/sandbox/entries/artifacts.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
)
2424
from ..materialization import MaterializedFile, gather_in_order
2525
from ..types import ExecResult, User
26-
from ..util.checksums import sha256_file
2726
from .base import BaseEntry
2827

2928
if TYPE_CHECKING:
@@ -109,17 +108,36 @@ async def apply(
109108
dest: Path,
110109
base_dir: Path,
111110
) -> list[MaterializedFile]:
112-
src = (base_dir / self.src).resolve()
113-
try:
114-
checksum = sha256_file(src)
115-
except OSError as e:
116-
raise LocalChecksumError(src=src, cause=e) from e
117-
await session.mkdir(Path(dest).parent, parents=True)
111+
src = base_dir / self.src
112+
src = src if src.is_absolute() else src.absolute()
113+
local_dir = LocalDir(src=self.src.parent)
114+
rel_child = Path(self.src.name)
115+
fd: int | None = None
118116
try:
119-
with src.open("rb") as f:
117+
src_root = local_dir._resolve_local_dir_src_root(base_dir)
118+
fd = local_dir._open_local_dir_file_for_copy(
119+
base_dir=base_dir,
120+
src_root=src_root,
121+
rel_child=rel_child,
122+
)
123+
with os.fdopen(fd, "rb") as f:
124+
fd = None
125+
try:
126+
checksum = _sha256_handle(f)
127+
f.seek(0)
128+
except OSError as e:
129+
raise LocalChecksumError(src=src, cause=e) from e
130+
await session.mkdir(Path(dest).parent, parents=True)
120131
await session.write(dest, f)
132+
except LocalDirReadError as e:
133+
context = dict(e.context)
134+
context.pop("src", None)
135+
raise LocalFileReadError(src=src, context=context, cause=e.cause) from e
121136
except OSError as e:
122137
raise LocalFileReadError(src=src, cause=e) from e
138+
finally:
139+
if fd is not None:
140+
os.close(fd)
123141
await self._apply_metadata(session, dest)
124142
return [MaterializedFile(path=dest, sha256=checksum)]
125143

@@ -543,7 +561,9 @@ def _local_dir_open_error(
543561
def _open_local_dir_file_for_copy_fallback(
544562
self, *, base_dir: Path, src_root: Path, rel_child: Path
545563
) -> int:
564+
assert self.src is not None
546565
src = src_root / rel_child
566+
validation_dir = LocalDir(src=self.src / rel_child.parent)
547567
try:
548568
src_stat = src.lstat()
549569
except FileNotFoundError:
@@ -568,7 +588,7 @@ def _open_local_dir_file_for_copy_fallback(
568588
try:
569589
leaf_fd = os.open(src, file_flags)
570590
try:
571-
self._resolve_local_dir_src_root(base_dir)
591+
validation_dir._resolve_local_dir_src_root(base_dir)
572592
leaf_stat = os.fstat(leaf_fd)
573593
if not stat.S_ISREG(leaf_stat.st_mode) or not os.path.samestat(src_stat, leaf_stat):
574594
raise LocalDirReadError(
@@ -583,14 +603,14 @@ def _open_local_dir_file_for_copy_fallback(
583603
os.close(leaf_fd)
584604
raise
585605
except FileNotFoundError:
586-
self._resolve_local_dir_src_root(base_dir)
606+
validation_dir._resolve_local_dir_src_root(base_dir)
587607
raise LocalDirReadError(
588608
src=src_root,
589609
context={"reason": "path_changed_during_copy", "child": rel_child.as_posix()},
590610
) from None
591611
except OSError as e:
592612
try:
593-
self._resolve_local_dir_src_root(base_dir)
613+
validation_dir._resolve_local_dir_src_root(base_dir)
594614
except LocalDirReadError as root_error:
595615
raise root_error from e
596616
if e.errno == errno.ELOOP:

tests/sandbox/test_entries.py

Lines changed: 149 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import hashlib
34
import io
45
import os
56
from collections.abc import Awaitable, Callable, Sequence
@@ -10,7 +11,12 @@
1011
import agents.sandbox.entries.artifacts as artifacts_module
1112
from agents.sandbox import SandboxConcurrencyLimits
1213
from 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+
)
1420
from agents.sandbox.manifest import Manifest
1521
from agents.sandbox.materialization import MaterializedFile
1622
from 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
152167
async 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
172248
async 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
299435
async 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
537676
async 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

Comments
 (0)