Skip to content

Commit 75ba2ce

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

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
@@ -168,6 +168,25 @@ async def test_local_file_rejects_symlinked_source_leaf(tmp_path: Path) -> None:
168168
assert session.writes == {}
169169

170170

171+
@pytest.mark.asyncio
172+
async def test_local_file_rejects_symlinked_source_before_checksum(tmp_path: Path) -> None:
173+
target_dir = tmp_path / "secret-dir"
174+
target_dir.mkdir()
175+
_symlink_or_skip(tmp_path / "link.txt", target_dir, target_is_directory=True)
176+
session = _RecordingSession()
177+
178+
with pytest.raises(LocalFileReadError) as excinfo:
179+
await LocalFile(src=Path("link.txt")).apply(
180+
session,
181+
Path("/workspace/copied.txt"),
182+
tmp_path,
183+
)
184+
185+
assert excinfo.value.context["reason"] == "symlink_not_supported"
186+
assert excinfo.value.context["child"] == "link.txt"
187+
assert session.writes == {}
188+
189+
171190
@pytest.mark.asyncio
172191
async def test_local_dir_copy_falls_back_when_safe_dir_fd_open_unavailable(
173192
monkeypatch: pytest.MonkeyPatch,
@@ -200,6 +219,9 @@ async def test_local_dir_copy_revalidates_swapped_paths_during_open(
200219
monkeypatch: pytest.MonkeyPatch,
201220
tmp_path: Path,
202221
) -> None:
222+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
223+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
224+
203225
src_root = tmp_path / "src"
204226
src_root.mkdir()
205227
src_file = src_root / "safe.txt"
@@ -221,7 +243,7 @@ def swap_then_open(
221243
nonlocal swapped
222244
if path == "safe.txt" and not swapped:
223245
src_file.unlink()
224-
src_file.symlink_to(secret)
246+
_symlink_or_skip(src_file, secret)
225247
swapped = True
226248
if dir_fd is None:
227249
return original_open(path, flags, mode)
@@ -251,6 +273,9 @@ async def test_local_dir_copy_pins_parent_directories_during_open(
251273
monkeypatch: pytest.MonkeyPatch,
252274
tmp_path: Path,
253275
) -> None:
276+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
277+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
278+
254279
src_root = tmp_path / "src"
255280
src_root.mkdir()
256281
nested_dir = src_root / "nested"
@@ -275,7 +300,7 @@ def swap_parent_then_open(
275300
nonlocal swapped
276301
if path == "safe.txt" and not swapped:
277302
(src_root / "nested").rename(src_root / "nested-original")
278-
(src_root / "nested").symlink_to(secret_dir, target_is_directory=True)
303+
_symlink_or_skip(src_root / "nested", secret_dir, target_is_directory=True)
279304
swapped = True
280305
if dir_fd is None:
281306
return original_open(path, flags, mode)
@@ -300,6 +325,9 @@ async def test_local_dir_apply_rejects_source_root_swapped_to_symlink_after_vali
300325
monkeypatch: pytest.MonkeyPatch,
301326
tmp_path: Path,
302327
) -> None:
328+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
329+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
330+
303331
src_root = tmp_path / "src"
304332
src_root.mkdir()
305333
(src_root / "safe.txt").write_text("safe", encoding="utf-8")
@@ -321,7 +349,7 @@ def swap_root_then_open(
321349
nonlocal swapped
322350
if path == "src" and dir_fd is not None and not swapped:
323351
src_root.rename(tmp_path / "src-original")
324-
(tmp_path / "src").symlink_to(secret_dir, target_is_directory=True)
352+
_symlink_or_skip(tmp_path / "src", secret_dir, target_is_directory=True)
325353
swapped = True
326354
if dir_fd is None:
327355
return original_open(path, flags, mode)
@@ -393,7 +421,7 @@ async def test_local_dir_rejects_symlinked_source_ancestors(tmp_path: Path) -> N
393421
nested_dir = target_dir / "sub"
394422
nested_dir.mkdir()
395423
(nested_dir / "secret.txt").write_text("secret", encoding="utf-8")
396-
(tmp_path / "link").symlink_to(target_dir, target_is_directory=True)
424+
_symlink_or_skip(tmp_path / "link", target_dir, target_is_directory=True)
397425
session = _RecordingSession()
398426

399427
with pytest.raises(LocalDirReadError) as excinfo:
@@ -409,7 +437,7 @@ async def test_local_dir_rejects_symlinked_source_root(tmp_path: Path) -> None:
409437
target_dir = tmp_path / "secret-dir"
410438
target_dir.mkdir()
411439
(target_dir / "secret.txt").write_text("secret", encoding="utf-8")
412-
(tmp_path / "src").symlink_to(target_dir, target_is_directory=True)
440+
_symlink_or_skip(tmp_path / "src", target_dir, target_is_directory=True)
413441
session = _RecordingSession()
414442

415443
with pytest.raises(LocalDirReadError) as excinfo:
@@ -427,7 +455,7 @@ async def test_local_dir_rejects_symlinked_files(tmp_path: Path) -> None:
427455
(src_root / "safe.txt").write_text("safe", encoding="utf-8")
428456
secret = tmp_path / "secret.txt"
429457
secret.write_text("secret", encoding="utf-8")
430-
(src_root / "link.txt").symlink_to(secret)
458+
_symlink_or_skip(src_root / "link.txt", secret)
431459
session = _RecordingSession()
432460

433461
with pytest.raises(LocalDirReadError) as excinfo:
@@ -446,7 +474,7 @@ async def test_local_dir_rejects_symlinked_directories(tmp_path: Path) -> None:
446474
target_dir = tmp_path / "secret-dir"
447475
target_dir.mkdir()
448476
(target_dir / "secret.txt").write_text("secret", encoding="utf-8")
449-
(src_root / "linked-dir").symlink_to(target_dir, target_is_directory=True)
477+
_symlink_or_skip(src_root / "linked-dir", target_dir, target_is_directory=True)
450478
session = _RecordingSession()
451479

452480
with pytest.raises(LocalDirReadError) as excinfo:
@@ -492,10 +520,11 @@ async def test_git_repo_uses_fetch_checkout_path_for_commit_refs() -> None:
492520
@pytest.mark.asyncio
493521
async def test_dir_metadata_strips_file_type_bits_before_chmod() -> None:
494522
session = _RecordingSession()
523+
dest = Path("/workspace/dir")
495524

496-
await Dir()._apply_metadata(session, Path("/workspace/dir"))
525+
await Dir()._apply_metadata(session, dest)
497526

498-
assert ("chmod", "0755", "/workspace/dir") in session.exec_calls
527+
assert ("chmod", "0755", str(dest)) in session.exec_calls
499528

500529

501530
@pytest.mark.asyncio
@@ -526,5 +555,5 @@ async def test_apply_manifest_raises_on_chgrp_failure() -> None:
526555
with pytest.raises(ExecNonZeroError):
527556
await session.apply_manifest()
528557

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

0 commit comments

Comments
 (0)