Skip to content

Commit ba196ca

Browse files
committed
fix(sandbox): revalidate fallback parent directories
1 parent 5d16cdd commit ba196ca

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

src/agents/sandbox/entries/artifacts.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,9 @@ def _local_dir_open_error(
561561
def _open_local_dir_file_for_copy_fallback(
562562
self, *, base_dir: Path, src_root: Path, rel_child: Path
563563
) -> int:
564+
assert self.src is not None
564565
src = src_root / rel_child
566+
validation_dir = LocalDir(src=self.src / rel_child.parent)
565567
try:
566568
src_stat = src.lstat()
567569
except FileNotFoundError:
@@ -586,7 +588,7 @@ def _open_local_dir_file_for_copy_fallback(
586588
try:
587589
leaf_fd = os.open(src, file_flags)
588590
try:
589-
self._resolve_local_dir_src_root(base_dir)
591+
validation_dir._resolve_local_dir_src_root(base_dir)
590592
leaf_stat = os.fstat(leaf_fd)
591593
if not stat.S_ISREG(leaf_stat.st_mode) or not os.path.samestat(src_stat, leaf_stat):
592594
raise LocalDirReadError(
@@ -601,14 +603,14 @@ def _open_local_dir_file_for_copy_fallback(
601603
os.close(leaf_fd)
602604
raise
603605
except FileNotFoundError:
604-
self._resolve_local_dir_src_root(base_dir)
606+
validation_dir._resolve_local_dir_src_root(base_dir)
605607
raise LocalDirReadError(
606608
src=src_root,
607609
context={"reason": "path_changed_during_copy", "child": rel_child.as_posix()},
608610
) from None
609611
except OSError as e:
610612
try:
611-
self._resolve_local_dir_src_root(base_dir)
613+
validation_dir._resolve_local_dir_src_root(base_dir)
612614
except LocalDirReadError as root_error:
613615
raise root_error from e
614616
if e.errno == errno.ELOOP:

tests/sandbox/test_entries.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,60 @@ def swap_parent_then_open(
377377
assert session.writes[Path("/workspace/copied/nested/safe.txt")] == b"safe"
378378

379379

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+
380434
@pytest.mark.asyncio
381435
async def test_local_dir_apply_rejects_source_root_swapped_to_symlink_after_validation(
382436
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)