Skip to content

Commit dc5f851

Browse files
committed
fix: harden local directory copy against symlink swaps
1 parent 656baf8 commit dc5f851

2 files changed

Lines changed: 261 additions & 60 deletions

File tree

src/agents/sandbox/entries/artifacts.py

Lines changed: 109 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,10 @@ def _list_local_dir_files_from_dir_fd(
397397
try:
398398
child_fd = os.open(entry.name, dir_flags, dir_fd=dir_fd)
399399
child_stat = os.fstat(child_fd)
400-
if not stat.S_ISDIR(child_stat.st_mode):
400+
if not stat.S_ISDIR(child_stat.st_mode) or not os.path.samestat(
401+
entry_stat,
402+
child_stat,
403+
):
401404
raise LocalDirReadError(
402405
src=src_root,
403406
context={
@@ -499,48 +502,25 @@ def _open_local_dir_file_for_copy(
499502
dir_fds.append(current_fd)
500503
for part in rel_child.parts[:-1]:
501504
current_rel = current_rel / part if current_rel.parts else Path(part)
502-
try:
503-
next_fd = os.open(part, dir_flags, dir_fd=current_fd)
504-
except OSError as e:
505-
raise self._local_dir_open_error(
506-
src_root=src_root,
507-
parent_fd=current_fd,
508-
entry_name=part,
509-
rel_child=current_rel,
510-
expect_dir=True,
511-
error=e,
512-
) from e
513-
next_stat = os.fstat(next_fd)
514-
if not stat.S_ISDIR(next_stat.st_mode):
515-
raise LocalDirReadError(
516-
src=src_root,
517-
context={
518-
"reason": "path_changed_during_copy",
519-
"child": rel_child.as_posix(),
520-
},
521-
)
522-
dir_fds.append(next_fd)
523-
current_fd = next_fd
524-
525-
try:
526-
leaf_fd = os.open(rel_child.name, file_flags, dir_fd=current_fd)
527-
except OSError as e:
528-
raise self._local_dir_open_error(
505+
next_fd = self._open_local_dir_entry_checked(
529506
src_root=src_root,
530507
parent_fd=current_fd,
531-
entry_name=rel_child.name,
532-
rel_child=rel_child,
533-
expect_dir=False,
534-
error=e,
535-
) from e
536-
leaf_stat = os.fstat(leaf_fd)
537-
if not stat.S_ISREG(leaf_stat.st_mode):
538-
os.close(leaf_fd)
539-
raise LocalDirReadError(
540-
src=src_root,
541-
context={"reason": "path_changed_during_copy", "child": rel_child.as_posix()},
508+
entry_name=part,
509+
rel_child=current_rel,
510+
expect_dir=True,
511+
flags=dir_flags,
542512
)
543-
return leaf_fd
513+
dir_fds.append(next_fd)
514+
current_fd = next_fd
515+
516+
return self._open_local_dir_entry_checked(
517+
src_root=src_root,
518+
parent_fd=current_fd,
519+
entry_name=rel_child.name,
520+
rel_child=rel_child,
521+
expect_dir=False,
522+
flags=file_flags,
523+
)
544524
except FileNotFoundError:
545525
raise LocalDirReadError(
546526
src=src_root,
@@ -587,26 +567,14 @@ def _open_local_dir_src_root_fd(
587567
dir_fds.append(current_fd)
588568
for part in parts:
589569
current_rel = current_rel / part if current_rel.parts else Path(part)
590-
try:
591-
next_fd = os.open(part, dir_flags, dir_fd=current_fd)
592-
except OSError as e:
593-
raise self._local_dir_open_error(
594-
src_root=src_root,
595-
parent_fd=current_fd,
596-
entry_name=part,
597-
rel_child=current_rel,
598-
expect_dir=True,
599-
error=e,
600-
) from e
601-
next_stat = os.fstat(next_fd)
602-
if not stat.S_ISDIR(next_stat.st_mode):
603-
raise LocalDirReadError(
604-
src=src_root,
605-
context={
606-
"reason": "path_changed_during_copy",
607-
"child": current_rel.as_posix(),
608-
},
609-
)
570+
next_fd = self._open_local_dir_entry_checked(
571+
src_root=src_root,
572+
parent_fd=current_fd,
573+
entry_name=part,
574+
rel_child=current_rel,
575+
expect_dir=True,
576+
flags=dir_flags,
577+
)
610578
dir_fds.append(next_fd)
611579
current_fd = next_fd
612580
return dir_fds.pop()
@@ -620,6 +588,87 @@ def _open_local_dir_src_root_fd(
620588
for dir_fd in reversed(dir_fds):
621589
os.close(dir_fd)
622590

591+
def _open_local_dir_entry_checked(
592+
self,
593+
*,
594+
src_root: Path,
595+
parent_fd: int,
596+
entry_name: str,
597+
rel_child: Path,
598+
expect_dir: bool,
599+
flags: int,
600+
) -> int:
601+
entry_stat = self._stat_local_dir_entry(
602+
src_root=src_root,
603+
parent_fd=parent_fd,
604+
entry_name=entry_name,
605+
rel_child=rel_child,
606+
expect_dir=expect_dir,
607+
)
608+
try:
609+
entry_fd = os.open(entry_name, flags, dir_fd=parent_fd)
610+
except OSError as e:
611+
raise self._local_dir_open_error(
612+
src_root=src_root,
613+
parent_fd=parent_fd,
614+
entry_name=entry_name,
615+
rel_child=rel_child,
616+
expect_dir=expect_dir,
617+
error=e,
618+
) from e
619+
620+
try:
621+
opened_stat = os.fstat(entry_fd)
622+
if not self._local_dir_entry_stat_matches(
623+
opened_stat,
624+
expect_dir=expect_dir,
625+
) or not os.path.samestat(entry_stat, opened_stat):
626+
raise LocalDirReadError(
627+
src=src_root,
628+
context={"reason": "path_changed_during_copy", "child": rel_child.as_posix()},
629+
)
630+
return entry_fd
631+
except Exception:
632+
os.close(entry_fd)
633+
raise
634+
635+
def _stat_local_dir_entry(
636+
self,
637+
*,
638+
src_root: Path,
639+
parent_fd: int,
640+
entry_name: str,
641+
rel_child: Path,
642+
expect_dir: bool,
643+
) -> os.stat_result:
644+
try:
645+
entry_stat = os.stat(entry_name, dir_fd=parent_fd, follow_symlinks=False)
646+
except FileNotFoundError:
647+
raise LocalDirReadError(
648+
src=src_root,
649+
context={"reason": "path_changed_during_copy", "child": rel_child.as_posix()},
650+
) from None
651+
except OSError as e:
652+
raise LocalDirReadError(src=src_root, cause=e) from e
653+
654+
if stat.S_ISLNK(entry_stat.st_mode):
655+
raise LocalDirReadError(
656+
src=src_root,
657+
context={"reason": "symlink_not_supported", "child": rel_child.as_posix()},
658+
)
659+
if not self._local_dir_entry_stat_matches(entry_stat, expect_dir=expect_dir):
660+
raise LocalDirReadError(
661+
src=src_root,
662+
context={"reason": "path_changed_during_copy", "child": rel_child.as_posix()},
663+
)
664+
return entry_stat
665+
666+
@staticmethod
667+
def _local_dir_entry_stat_matches(entry_stat: os.stat_result, *, expect_dir: bool) -> bool:
668+
if expect_dir:
669+
return stat.S_ISDIR(entry_stat.st_mode)
670+
return stat.S_ISREG(entry_stat.st_mode)
671+
623672
def _local_dir_open_error(
624673
self,
625674
*,

tests/sandbox/test_entries.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,59 @@ def swap_then_open(
496496
assert session.writes == {}
497497

498498

499+
@pytest.mark.asyncio
500+
async def test_local_dir_copy_rejects_swapped_file_if_no_follow_is_bypassed(
501+
monkeypatch: pytest.MonkeyPatch,
502+
tmp_path: Path,
503+
) -> None:
504+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
505+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
506+
507+
src_root = tmp_path / "src"
508+
src_root.mkdir()
509+
src_file = src_root / "safe.txt"
510+
src_file.write_text("safe", encoding="utf-8")
511+
secret = tmp_path / "secret.txt"
512+
secret.write_text("secret", encoding="utf-8")
513+
session = _RecordingSession()
514+
local_dir = LocalDir(src=Path("src"))
515+
original_open = os.open
516+
no_follow = getattr(os, "O_NOFOLLOW", 0)
517+
swapped = False
518+
519+
def swap_then_open(
520+
path: str | Path,
521+
flags: int,
522+
mode: int = 0o777,
523+
*,
524+
dir_fd: int | None = None,
525+
) -> int:
526+
nonlocal swapped
527+
if (path == "safe.txt" or Path(path) == src_file) and not swapped:
528+
src_file.unlink()
529+
_symlink_or_skip(src_file, secret)
530+
flags &= ~no_follow
531+
swapped = True
532+
if dir_fd is None:
533+
return original_open(path, flags, mode)
534+
return original_open(path, flags, mode, dir_fd=dir_fd)
535+
536+
monkeypatch.setattr("agents.sandbox.entries.artifacts.os.open", swap_then_open)
537+
538+
with pytest.raises(LocalDirReadError) as excinfo:
539+
await local_dir._copy_local_dir_file(
540+
base_dir=tmp_path,
541+
session=session,
542+
src_root=src_root,
543+
src=src_file,
544+
dest_root=Path("/workspace/copied"),
545+
)
546+
547+
assert excinfo.value.context["reason"] == "path_changed_during_copy"
548+
assert excinfo.value.context["child"] == "safe.txt"
549+
assert session.writes == {}
550+
551+
499552
@pytest.mark.asyncio
500553
async def test_local_dir_copy_pins_parent_directories_during_open(
501554
monkeypatch: pytest.MonkeyPatch,
@@ -548,6 +601,57 @@ def swap_parent_then_open(
548601
assert session.writes[Path("/workspace/copied/nested/safe.txt")] == b"safe"
549602

550603

604+
@pytest.mark.asyncio
605+
async def test_local_dir_apply_rejects_nested_dir_swap_during_listing_if_no_follow_is_bypassed(
606+
monkeypatch: pytest.MonkeyPatch,
607+
tmp_path: Path,
608+
) -> None:
609+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
610+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
611+
612+
src_root = tmp_path / "src"
613+
src_root.mkdir()
614+
nested_dir = src_root / "nested"
615+
nested_dir.mkdir()
616+
(nested_dir / "safe.txt").write_text("safe", encoding="utf-8")
617+
secret_dir = tmp_path / "secret-dir"
618+
secret_dir.mkdir()
619+
(secret_dir / "secret.txt").write_text("secret", encoding="utf-8")
620+
session = _RecordingSession()
621+
local_dir = LocalDir(src=Path("src"))
622+
original_open = os.open
623+
no_follow = getattr(os, "O_NOFOLLOW", 0)
624+
swapped = False
625+
626+
def swap_nested_then_open(
627+
path: str | Path,
628+
flags: int,
629+
mode: int = 0o777,
630+
*,
631+
dir_fd: int | None = None,
632+
) -> int:
633+
nonlocal swapped
634+
if path == "nested" and not swapped:
635+
nested_dir.rename(src_root / "nested-original")
636+
_symlink_or_skip(src_root / "nested", secret_dir, target_is_directory=True)
637+
swapped = True
638+
if path == "nested" and swapped:
639+
flags &= ~no_follow
640+
if dir_fd is None:
641+
return original_open(path, flags, mode)
642+
return original_open(path, flags, mode, dir_fd=dir_fd)
643+
644+
monkeypatch.setattr("agents.sandbox.entries.artifacts.os.open", swap_nested_then_open)
645+
646+
with pytest.raises(LocalDirReadError) as excinfo:
647+
await local_dir.apply(session, Path("/workspace/copied"), tmp_path)
648+
649+
assert excinfo.value.context["reason"] == "path_changed_during_copy"
650+
assert excinfo.value.context["child"] == "nested"
651+
assert Path("/workspace/copied/nested/secret.txt") not in session.writes
652+
assert session.writes == {}
653+
654+
551655
@pytest.mark.asyncio
552656
async def test_local_dir_copy_fallback_rejects_swapped_parent_directory(
553657
monkeypatch: pytest.MonkeyPatch,
@@ -647,6 +751,54 @@ def swap_root_then_open(
647751
assert session.writes == {}
648752

649753

754+
@pytest.mark.asyncio
755+
async def test_local_dir_apply_rejects_source_root_swap_if_no_follow_is_bypassed(
756+
monkeypatch: pytest.MonkeyPatch,
757+
tmp_path: Path,
758+
) -> None:
759+
if not artifacts_module._OPEN_SUPPORTS_DIR_FD or not artifacts_module._HAS_O_DIRECTORY:
760+
pytest.skip("safe dir_fd open pinning is unavailable on this platform")
761+
762+
src_root = tmp_path / "src"
763+
src_root.mkdir()
764+
(src_root / "safe.txt").write_text("safe", encoding="utf-8")
765+
secret_dir = tmp_path / "secret-dir"
766+
secret_dir.mkdir()
767+
(secret_dir / "secret.txt").write_text("secret", encoding="utf-8")
768+
session = _RecordingSession()
769+
local_dir = LocalDir(src=Path("src"))
770+
original_open = os.open
771+
no_follow = getattr(os, "O_NOFOLLOW", 0)
772+
swapped = False
773+
774+
def swap_root_then_open(
775+
path: str | Path,
776+
flags: int,
777+
mode: int = 0o777,
778+
*,
779+
dir_fd: int | None = None,
780+
) -> int:
781+
nonlocal swapped
782+
if path == "src" and not swapped:
783+
src_root.rename(tmp_path / "src-original")
784+
_symlink_or_skip(tmp_path / "src", secret_dir, target_is_directory=True)
785+
flags &= ~no_follow
786+
swapped = True
787+
if dir_fd is None:
788+
return original_open(path, flags, mode)
789+
return original_open(path, flags, mode, dir_fd=dir_fd)
790+
791+
monkeypatch.setattr("agents.sandbox.entries.artifacts.os.open", swap_root_then_open)
792+
793+
with pytest.raises(LocalDirReadError) as excinfo:
794+
await local_dir.apply(session, Path("/workspace/copied"), tmp_path)
795+
796+
assert excinfo.value.context["reason"] == "path_changed_during_copy"
797+
assert excinfo.value.context["child"] == "src"
798+
assert Path("/workspace/copied/secret.txt") not in session.writes
799+
assert session.writes == {}
800+
801+
650802
@pytest.mark.asyncio
651803
async def test_local_dir_apply_fallback_rejects_source_root_swapped_to_symlink_after_validation(
652804
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)