Skip to content

Commit a166290

Browse files
committed
fix(sandbox): validate LocalFile sources before hashing
Signed-off-by: matthewflint <277024436+matthewflint@users.noreply.github.com>
1 parent ae2aec6 commit a166290

File tree

2 files changed

+45
-16
lines changed

2 files changed

+45
-16
lines changed

src/agents/sandbox/entries/artifacts.py

Lines changed: 6 additions & 6 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:
@@ -114,11 +113,6 @@ async def apply(
114113
local_dir = LocalDir(src=self.src.parent)
115114
rel_child = Path(self.src.name)
116115
fd: int | None = None
117-
try:
118-
checksum = sha256_file(src)
119-
except OSError as e:
120-
raise LocalChecksumError(src=src, cause=e) from e
121-
await session.mkdir(Path(dest).parent, parents=True)
122116
try:
123117
src_root = local_dir._resolve_local_dir_src_root(base_dir)
124118
fd = local_dir._open_local_dir_file_for_copy(
@@ -128,6 +122,12 @@ async def apply(
128122
)
129123
with os.fdopen(fd, "rb") as f:
130124
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)
131131
await session.write(dest, f)
132132
except LocalDirReadError as e:
133133
context = dict(e.context)

tests/sandbox/test_entries.py

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,25 @@ async def test_local_file_rejects_symlinked_source_leaf(tmp_path: Path) -> None:
223223
assert session.writes == {}
224224

225225

226+
@pytest.mark.asyncio
227+
async def test_local_file_rejects_symlinked_source_before_checksum(tmp_path: Path) -> None:
228+
target_dir = tmp_path / "secret-dir"
229+
target_dir.mkdir()
230+
_symlink_or_skip(tmp_path / "link.txt", target_dir, target_is_directory=True)
231+
session = _RecordingSession()
232+
233+
with pytest.raises(LocalFileReadError) as excinfo:
234+
await LocalFile(src=Path("link.txt")).apply(
235+
session,
236+
Path("/workspace/copied.txt"),
237+
tmp_path,
238+
)
239+
240+
assert excinfo.value.context["reason"] == "symlink_not_supported"
241+
assert excinfo.value.context["child"] == "link.txt"
242+
assert session.writes == {}
243+
244+
226245
@pytest.mark.asyncio
227246
async def test_local_dir_copy_falls_back_when_safe_dir_fd_open_unavailable(
228247
monkeypatch: pytest.MonkeyPatch,
@@ -255,6 +274,9 @@ async def test_local_dir_copy_revalidates_swapped_paths_during_open(
255274
monkeypatch: pytest.MonkeyPatch,
256275
tmp_path: Path,
257276
) -> None:
277+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
278+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
279+
258280
src_root = tmp_path / "src"
259281
src_root.mkdir()
260282
src_file = src_root / "safe.txt"
@@ -276,7 +298,7 @@ def swap_then_open(
276298
nonlocal swapped
277299
if (path == "safe.txt" or Path(path) == src_file) and not swapped:
278300
src_file.unlink()
279-
src_file.symlink_to(secret)
301+
_symlink_or_skip(src_file, secret)
280302
swapped = True
281303
if dir_fd is None:
282304
return original_open(path, flags, mode)
@@ -306,6 +328,9 @@ async def test_local_dir_copy_pins_parent_directories_during_open(
306328
monkeypatch: pytest.MonkeyPatch,
307329
tmp_path: Path,
308330
) -> None:
331+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
332+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
333+
309334
src_root = tmp_path / "src"
310335
src_root.mkdir()
311336
nested_dir = src_root / "nested"
@@ -330,7 +355,7 @@ def swap_parent_then_open(
330355
nonlocal swapped
331356
if path == "safe.txt" and not swapped:
332357
(src_root / "nested").rename(src_root / "nested-original")
333-
(src_root / "nested").symlink_to(secret_dir, target_is_directory=True)
358+
_symlink_or_skip(src_root / "nested", secret_dir, target_is_directory=True)
334359
swapped = True
335360
if dir_fd is None:
336361
return original_open(path, flags, mode)
@@ -355,6 +380,9 @@ async def test_local_dir_apply_rejects_source_root_swapped_to_symlink_after_vali
355380
monkeypatch: pytest.MonkeyPatch,
356381
tmp_path: Path,
357382
) -> None:
383+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
384+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
385+
358386
src_root = tmp_path / "src"
359387
src_root.mkdir()
360388
(src_root / "safe.txt").write_text("safe", encoding="utf-8")
@@ -420,7 +448,7 @@ def swap_root_then_open(
420448
nonlocal swapped
421449
if Path(path) == src_root / "safe.txt" and not swapped:
422450
src_root.rename(tmp_path / "src-original")
423-
(tmp_path / "src").symlink_to(secret_dir, target_is_directory=True)
451+
_symlink_or_skip(tmp_path / "src", secret_dir, target_is_directory=True)
424452
swapped = True
425453
if dir_fd is None:
426454
return original_open(path, flags, mode)
@@ -492,7 +520,7 @@ async def test_local_dir_rejects_symlinked_source_ancestors(tmp_path: Path) -> N
492520
nested_dir = target_dir / "sub"
493521
nested_dir.mkdir()
494522
(nested_dir / "secret.txt").write_text("secret", encoding="utf-8")
495-
(tmp_path / "link").symlink_to(target_dir, target_is_directory=True)
523+
_symlink_or_skip(tmp_path / "link", target_dir, target_is_directory=True)
496524
session = _RecordingSession()
497525

498526
with pytest.raises(LocalDirReadError) as excinfo:
@@ -508,7 +536,7 @@ async def test_local_dir_rejects_symlinked_source_root(tmp_path: Path) -> None:
508536
target_dir = tmp_path / "secret-dir"
509537
target_dir.mkdir()
510538
(target_dir / "secret.txt").write_text("secret", encoding="utf-8")
511-
(tmp_path / "src").symlink_to(target_dir, target_is_directory=True)
539+
_symlink_or_skip(tmp_path / "src", target_dir, target_is_directory=True)
512540
session = _RecordingSession()
513541

514542
with pytest.raises(LocalDirReadError) as excinfo:
@@ -526,7 +554,7 @@ async def test_local_dir_rejects_symlinked_files(tmp_path: Path) -> None:
526554
(src_root / "safe.txt").write_text("safe", encoding="utf-8")
527555
secret = tmp_path / "secret.txt"
528556
secret.write_text("secret", encoding="utf-8")
529-
(src_root / "link.txt").symlink_to(secret)
557+
_symlink_or_skip(src_root / "link.txt", secret)
530558
session = _RecordingSession()
531559

532560
with pytest.raises(LocalDirReadError) as excinfo:
@@ -545,7 +573,7 @@ async def test_local_dir_rejects_symlinked_directories(tmp_path: Path) -> None:
545573
target_dir = tmp_path / "secret-dir"
546574
target_dir.mkdir()
547575
(target_dir / "secret.txt").write_text("secret", encoding="utf-8")
548-
(src_root / "linked-dir").symlink_to(target_dir, target_is_directory=True)
576+
_symlink_or_skip(src_root / "linked-dir", target_dir, target_is_directory=True)
549577
session = _RecordingSession()
550578

551579
with pytest.raises(LocalDirReadError) as excinfo:
@@ -591,10 +619,11 @@ async def test_git_repo_uses_fetch_checkout_path_for_commit_refs() -> None:
591619
@pytest.mark.asyncio
592620
async def test_dir_metadata_strips_file_type_bits_before_chmod() -> None:
593621
session = _RecordingSession()
622+
dest = Path("/workspace/dir")
594623

595-
await Dir()._apply_metadata(session, Path("/workspace/dir"))
624+
await Dir()._apply_metadata(session, dest)
596625

597-
assert ("chmod", "0755", "/workspace/dir") in session.exec_calls
626+
assert ("chmod", "0755", str(dest)) in session.exec_calls
598627

599628

600629
@pytest.mark.asyncio
@@ -625,5 +654,5 @@ async def test_apply_manifest_raises_on_chgrp_failure() -> None:
625654
with pytest.raises(ExecNonZeroError):
626655
await session.apply_manifest()
627656

628-
assert ("chgrp", "sandbox-user", "/workspace/copied.txt") in session.exec_calls
657+
assert ("chgrp", "sandbox-user", str(Path("/workspace/copied.txt"))) in session.exec_calls
629658
assert not any(call[0] == "chmod" for call in session.exec_calls)

0 commit comments

Comments
 (0)