from __future__ import annotations import io import os import tarfile import zipfile from pathlib import Path import pytest from agents.sandbox import SandboxArchiveLimits from agents.sandbox.entries import GCSMount, InContainerMountStrategy, MountpointMountPattern from agents.sandbox.errors import ( InvalidCompressionSchemeError, InvalidManifestPathError, WorkspaceArchiveWriteError, ) from agents.sandbox.files import EntryKind, FileEntry from agents.sandbox.manifest import Manifest from agents.sandbox.sandboxes.unix_local import ( UnixLocalSandboxSession, UnixLocalSandboxSessionState, ) from agents.sandbox.session.archive_extraction import zipfile_compatible_stream from agents.sandbox.session.archive_ops import extract_archive from agents.sandbox.session.base_sandbox_session import BaseSandboxSession from agents.sandbox.snapshot import NoopSnapshot from agents.sandbox.types import ExecResult, Permissions def _build_session(tmp_path: Path) -> UnixLocalSandboxSession: state = UnixLocalSandboxSessionState( manifest=Manifest(root=str(tmp_path / "workspace")), snapshot=NoopSnapshot(id="noop"), ) return UnixLocalSandboxSession.from_state(state) class _CountingExtractSession(BaseSandboxSession): def __init__(self, workspace_root: Path) -> None: self.state = UnixLocalSandboxSessionState( manifest=Manifest(root=str(workspace_root)), snapshot=NoopSnapshot(id="noop"), ) self.ls_calls: list[Path] = [] async def _exec_internal( self, *command: str | Path, timeout: float | None = None, ) -> ExecResult: _ = (command, timeout) raise AssertionError("exec() should not be called in this test") async def read(self, path: Path, *, user: object = None) -> io.IOBase: _ = user return self.normalize_path(path).open("rb") async def write(self, path: Path, data: io.IOBase, *, user: object = None) -> None: _ = user workspace_path = self.normalize_path(path) workspace_path.parent.mkdir(parents=True, exist_ok=True) payload = data.read() if isinstance(payload, str): payload = payload.encode("utf-8") workspace_path.write_bytes(payload) async def running(self) -> bool: return True async def persist_workspace(self) -> io.IOBase: return io.BytesIO() async def hydrate_workspace(self, data: io.IOBase) -> None: _ = data async def shutdown(self) -> None: return async def mkdir( self, path: Path | str, *, parents: bool = False, user: object = None, ) -> None: _ = user self.normalize_path(path).mkdir(parents=parents, exist_ok=True) async def ls( self, path: Path | str, *, user: object = None, ) -> list[FileEntry]: _ = user directory = self.normalize_path(path) self.ls_calls.append(directory) if not directory.exists(): raise AssertionError(f"ls() called for missing directory: {directory}") entries: list[FileEntry] = [] for child in directory.iterdir(): if child.is_symlink(): kind = EntryKind.SYMLINK elif child.is_dir(): kind = EntryKind.DIRECTORY else: kind = EntryKind.FILE entries.append( FileEntry( path=str(child), permissions=Permissions(), owner="root", group="root", size=0, kind=kind, ) ) return entries def _tar_bytes(*, members: dict[str, bytes]) -> io.BytesIO: buf = io.BytesIO() with tarfile.open(fileobj=buf, mode="w") as archive: for name, payload in members.items(): info = tarfile.TarInfo(name=name) info.size = len(payload) archive.addfile(info, io.BytesIO(payload)) buf.seek(0) return buf def _zip_bytes(*, members: dict[str, bytes]) -> io.BytesIO: buf = io.BytesIO() with zipfile.ZipFile(buf, mode="w") as archive: for name, payload in members.items(): archive.writestr(name, payload) buf.seek(0) return buf async def _assert_extract_rejects_member( tmp_path: Path, archive_name: str, data: io.IOBase, *, expected_member: str, expected_reason: str, ) -> Path: session = _build_session(tmp_path) await session.start() try: workspace = Path(session.state.manifest.root) with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract(archive_name, data) assert exc_info.value.context["member"] == expected_member assert exc_info.value.context["reason"] == expected_reason return workspace finally: await session.shutdown() @pytest.mark.asyncio async def test_extract_tar_writes_archive_and_unpacks_contents(tmp_path: Path) -> None: session = _build_session(tmp_path) await session.start() try: await session.extract( "bundle.tar", _tar_bytes(members={"nested/hello.txt": b"hello from tar"}), ) finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert (workspace / "bundle.tar").is_file() assert (workspace / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello from tar" @pytest.mark.asyncio async def test_extract_zip_writes_archive_and_unpacks_contents(tmp_path: Path) -> None: session = _build_session(tmp_path) await session.start() try: await session.extract( "bundle.zip", _zip_bytes(members={"nested/hello.txt": b"hello from zip"}), ) finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert (workspace / "bundle.zip").is_file() assert (workspace / "nested" / "hello.txt").read_text(encoding="utf-8") == "hello from zip" @pytest.mark.asyncio async def test_extract_default_archive_limits_none_preserves_no_resource_limit_behavior( tmp_path: Path, ) -> None: session = _build_session(tmp_path) await session.start() try: await session.extract( "bundle.tar", _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}), ) finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert (workspace / "one.txt").read_text(encoding="utf-8") == "1" assert (workspace / "two.txt").read_text(encoding="utf-8") == "2" def test_sandbox_archive_limits_defaults_enable_sdk_thresholds() -> None: limits = SandboxArchiveLimits() assert limits.max_input_bytes == 1024 * 1024 * 1024 assert limits.max_extracted_bytes == 4 * 1024 * 1024 * 1024 assert limits.max_members == 100_000 @pytest.mark.asyncio async def test_extract_archive_rejects_missing_compression_scheme(tmp_path: Path) -> None: session = _CountingExtractSession(tmp_path / "workspace") with pytest.raises(InvalidCompressionSchemeError) as exc_info: await extract_archive(session, "bundle", io.BytesIO(b"not an archive")) assert exc_info.value.context["path"] == "bundle" assert exc_info.value.context["scheme"] is None @pytest.mark.parametrize( ("kwargs", "message"), [ ({"max_input_bytes": 0}, "archive_limits.max_input_bytes must be at least 1"), ({"max_extracted_bytes": 0}, "archive_limits.max_extracted_bytes must be at least 1"), ({"max_members": 0}, "archive_limits.max_members must be at least 1"), ], ) def test_sandbox_archive_limits_rejects_non_positive_values( kwargs: dict[str, int], message: str, ) -> None: with pytest.raises(ValueError) as exc_info: SandboxArchiveLimits(**kwargs) assert str(exc_info.value) == message @pytest.mark.asyncio async def test_extract_rejects_archive_input_over_limit(tmp_path: Path) -> None: session = _build_session(tmp_path) await session.start() try: with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract( "bundle.zip", _ChunkedBinaryStream([b"123", b"45"]), archive_limits=SandboxArchiveLimits( max_input_bytes=4, max_extracted_bytes=None, max_members=None, ), ) assert exc_info.value.context["reason"] == "archive input size exceeds limit" assert exc_info.value.context["limit"] == 4 assert exc_info.value.context["actual"] == 5 finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert not (workspace / "bundle.zip").exists() @pytest.mark.asyncio async def test_extract_tar_rejects_extracted_bytes_over_limit( tmp_path: Path, ) -> None: session = _build_session(tmp_path) await session.start() try: with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract( "bundle.tar", _tar_bytes(members={"large.txt": b"12345"}), archive_limits=SandboxArchiveLimits( max_input_bytes=None, max_extracted_bytes=4, max_members=None, ), ) assert exc_info.value.context["member"] == "large.txt" assert exc_info.value.context["reason"] == "archive extracted size exceeds limit" assert exc_info.value.context["limit"] == 4 assert exc_info.value.context["actual"] == 5 finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert not (workspace / "large.txt").exists() @pytest.mark.asyncio async def test_extract_zip_rejects_extracted_bytes_over_limit( tmp_path: Path, ) -> None: session = _build_session(tmp_path) await session.start() try: with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract( "bundle.zip", _zip_bytes(members={"large.txt": b"12345"}), archive_limits=SandboxArchiveLimits( max_input_bytes=None, max_extracted_bytes=4, max_members=None, ), ) assert exc_info.value.context["member"] == "large.txt" assert exc_info.value.context["reason"] == "archive extracted size exceeds limit" assert exc_info.value.context["limit"] == 4 assert exc_info.value.context["actual"] == 5 finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert not (workspace / "large.txt").exists() @pytest.mark.asyncio async def test_extract_tar_rejects_member_count_over_limit( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: data = _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}) def fail_getmembers(_self: tarfile.TarFile) -> list[tarfile.TarInfo]: raise AssertionError("tar extraction should not materialize all members") monkeypatch.setattr(tarfile.TarFile, "getmembers", fail_getmembers) session = _build_session(tmp_path) await session.start() try: with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract( "bundle.tar", data, archive_limits=SandboxArchiveLimits( max_input_bytes=None, max_extracted_bytes=None, max_members=1, ), ) assert exc_info.value.context["member"] == "two.txt" assert exc_info.value.context["reason"] == "archive member count exceeds limit" assert exc_info.value.context["limit"] == 1 assert exc_info.value.context["actual"] == 2 finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert not (workspace / "one.txt").exists() assert not (workspace / "two.txt").exists() @pytest.mark.asyncio async def test_extract_zip_rejects_member_count_over_limit( tmp_path: Path, ) -> None: session = _build_session(tmp_path) await session.start() try: with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract( "bundle.zip", _zip_bytes(members={"one.txt": b"1", "two.txt": b"2"}), archive_limits=SandboxArchiveLimits( max_input_bytes=None, max_extracted_bytes=None, max_members=1, ), ) assert exc_info.value.context["member"] == "two.txt" assert exc_info.value.context["reason"] == "archive member count exceeds limit" assert exc_info.value.context["limit"] == 1 assert exc_info.value.context["actual"] == 2 finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert not (workspace / "one.txt").exists() assert not (workspace / "two.txt").exists() @pytest.mark.asyncio async def test_extract_archive_limits_none_disables_only_selected_limits( tmp_path: Path, ) -> None: session = _build_session(tmp_path) await session.start() try: await session.extract( "bundle.tar", _tar_bytes(members={"large.txt": b"12345"}), archive_limits=SandboxArchiveLimits( max_input_bytes=None, max_extracted_bytes=None, max_members=1, ), ) finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert (workspace / "large.txt").read_text(encoding="utf-8") == "12345" @pytest.mark.asyncio async def test_extract_archive_limits_per_call_override_session_default( tmp_path: Path, ) -> None: session = _build_session(tmp_path) session._set_archive_limits( SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1) ) await session.start() try: await session.extract( "bundle.tar", _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}), archive_limits=SandboxArchiveLimits( max_input_bytes=None, max_extracted_bytes=None, max_members=2, ), ) finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert (workspace / "one.txt").read_text(encoding="utf-8") == "1" assert (workspace / "two.txt").read_text(encoding="utf-8") == "2" @pytest.mark.asyncio async def test_extract_uses_session_default_archive_limits( tmp_path: Path, ) -> None: session = _build_session(tmp_path) session._set_archive_limits( SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1) ) await session.start() try: with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract( "bundle.tar", _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}), ) assert exc_info.value.context["member"] == "two.txt" assert exc_info.value.context["reason"] == "archive member count exceeds limit" assert exc_info.value.context["limit"] == 1 assert exc_info.value.context["actual"] == 2 finally: await session.shutdown() @pytest.mark.asyncio async def test_extract_archive_limits_object_with_all_none_overrides_session_default( tmp_path: Path, ) -> None: session = _build_session(tmp_path) session._set_archive_limits( SandboxArchiveLimits(max_input_bytes=None, max_extracted_bytes=None, max_members=1) ) await session.start() try: await session.extract( "bundle.tar", _tar_bytes(members={"one.txt": b"1", "two.txt": b"2"}), archive_limits=SandboxArchiveLimits( max_input_bytes=None, max_extracted_bytes=None, max_members=None, ), ) finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert (workspace / "one.txt").read_text(encoding="utf-8") == "1" assert (workspace / "two.txt").read_text(encoding="utf-8") == "2" @pytest.mark.asyncio async def test_extract_rejects_invalid_per_call_archive_limits( tmp_path: Path, ) -> None: limits = SandboxArchiveLimits(max_input_bytes=1) limits.max_input_bytes = 0 session = _build_session(tmp_path) await session.start() try: with pytest.raises(ValueError) as exc_info: await session.extract( "bundle.tar", _tar_bytes(members={"one.txt": b"1"}), archive_limits=limits, ) assert str(exc_info.value) == "archive_limits.max_input_bytes must be at least 1" finally: await session.shutdown() class _NoSeekableZipStream(io.IOBase): def __init__(self, payload: bytes) -> None: self._buffer = io.BytesIO(payload) def tell(self) -> int: return self._buffer.tell() def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: return self._buffer.seek(offset, whence) def read(self, size: int = -1) -> bytes: return self._buffer.read(size) class _ChunkedBinaryStream(io.IOBase): def __init__(self, chunks: list[bytes]) -> None: self._chunks = list(chunks) self.headers = {"Content-Length": str(sum(len(chunk) for chunk in chunks))} def read(self, size: int = -1) -> bytes: if not self._chunks: return b"" if size < 0: data = b"".join(self._chunks) self._chunks.clear() return data remaining = size out = bytearray() while remaining > 0 and self._chunks: chunk = self._chunks[0] if len(chunk) <= remaining: out.extend(self._chunks.pop(0)) remaining -= len(chunk) continue out.extend(chunk[:remaining]) self._chunks[0] = chunk[remaining:] remaining = 0 return bytes(out) class _SeekableFalseZipStream(io.IOBase): def __init__(self, payload: bytes) -> None: self._buffer = io.BytesIO(payload) def seekable(self) -> bool: return False def read(self, size: int = -1) -> bytes: return self._buffer.read(size) def test_zipfile_compatible_stream_supports_streams_without_seekable() -> None: raw_stream = _NoSeekableZipStream(_zip_bytes(members={"file.txt": b"hello"}).getvalue()) with zipfile_compatible_stream(raw_stream) as compatible: assert compatible.seekable() is True with zipfile.ZipFile(compatible) as archive: assert archive.read("file.txt") == b"hello" def test_zipfile_compatible_stream_buffers_streams_with_seekable_false() -> None: raw_stream = _SeekableFalseZipStream(_zip_bytes(members={"file.txt": b"hello"}).getvalue()) with zipfile_compatible_stream(raw_stream) as compatible: assert compatible.seekable() is True with zipfile.ZipFile(compatible) as archive: assert archive.read("file.txt") == b"hello" @pytest.mark.asyncio async def test_unix_local_write_accepts_chunked_non_seekable_binary_stream(tmp_path: Path) -> None: session = _build_session(tmp_path) await session.start() try: await session.write( Path("streamed.bin"), _ChunkedBinaryStream([b"hello ", b"from ", b"stream"]), ) finally: await session.shutdown() workspace = Path(session.state.manifest.root) assert (workspace / "streamed.bin").read_bytes() == b"hello from stream" @pytest.mark.asyncio async def test_extract_tar_rejects_symlinked_parent_paths(tmp_path: Path) -> None: session = _build_session(tmp_path) await session.start() try: workspace = Path(session.state.manifest.root) outside = tmp_path / "outside" outside.mkdir() os.symlink(outside, workspace / "link", target_is_directory=True) with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract( "bundle.tar", _tar_bytes(members={"link/hello.txt": b"hello from tar"}), ) assert exc_info.value.context["member"] == "link/hello.txt" assert exc_info.value.context["reason"] == "symlink in parent path: link" assert not (outside / "hello.txt").exists() finally: await session.shutdown() @pytest.mark.asyncio async def test_extract_zip_rejects_symlinked_parent_paths(tmp_path: Path) -> None: session = _build_session(tmp_path) await session.start() try: workspace = Path(session.state.manifest.root) outside = tmp_path / "outside" outside.mkdir() os.symlink(outside, workspace / "link", target_is_directory=True) with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.extract( "bundle.zip", _zip_bytes(members={"link/hello.txt": b"hello from zip"}), ) assert exc_info.value.context["member"] == "link/hello.txt" assert exc_info.value.context["reason"] == "symlink in parent path: link" assert not (outside / "hello.txt").exists() finally: await session.shutdown() @pytest.mark.asyncio async def test_unix_local_hydrate_workspace_rejects_external_symlink_targets( tmp_path: Path, ) -> None: session = _build_session(tmp_path) await session.start() try: archive = io.BytesIO() with tarfile.open(fileobj=archive, mode="w") as tar: info = tarfile.TarInfo(name="leak") info.type = tarfile.SYMTYPE info.linkname = "/etc/passwd" tar.addfile(info) archive.seek(0) with pytest.raises(WorkspaceArchiveWriteError) as exc_info: await session.hydrate_workspace(archive) assert exc_info.value.context["member"] == "leak" assert ( exc_info.value.context["reason"] == "absolute symlink target not allowed: /etc/passwd" ) assert not (Path(session.state.manifest.root) / "leak").exists() finally: await session.shutdown() @pytest.mark.asyncio async def test_extract_tar_rejects_windows_drive_member_paths(tmp_path: Path) -> None: await _assert_extract_rejects_member( tmp_path, "bundle.tar", _tar_bytes(members={"C:/tmp/evil.txt": b"evil"}), expected_member="C:/tmp/evil.txt", expected_reason="windows drive path", ) @pytest.mark.asyncio async def test_extract_zip_rejects_windows_drive_member_paths(tmp_path: Path) -> None: await _assert_extract_rejects_member( tmp_path, "bundle.zip", _zip_bytes(members={r"C:\tmp\evil.txt": b"evil"}), expected_member=r"C:\tmp\evil.txt", expected_reason="windows drive path", ) @pytest.mark.asyncio async def test_extract_tar_rejects_windows_separator_member_paths(tmp_path: Path) -> None: await _assert_extract_rejects_member( tmp_path, "bundle.tar", _tar_bytes(members={r"..\evil.txt": b"evil"}), expected_member=r"..\evil.txt", expected_reason="windows path separator", ) @pytest.mark.asyncio async def test_extract_zip_rejects_windows_separator_member_paths(tmp_path: Path) -> None: await _assert_extract_rejects_member( tmp_path, "bundle.zip", _zip_bytes(members={r"\evil.txt": b"evil"}), expected_member=r"\evil.txt", expected_reason="windows path separator", ) @pytest.mark.asyncio async def test_extract_tar_rejects_member_under_non_directory_member(tmp_path: Path) -> None: workspace = await _assert_extract_rejects_member( tmp_path, "bundle.tar", _tar_bytes( members={ "nested/hello.txt": b"hello from tar", "nested": b"not a directory", } ), expected_member="nested/hello.txt", expected_reason="archive path descends through non-directory: nested", ) assert not (workspace / "nested").exists() @pytest.mark.asyncio async def test_extract_zip_rejects_member_under_non_directory_member(tmp_path: Path) -> None: workspace = await _assert_extract_rejects_member( tmp_path, "bundle.zip", _zip_bytes( members={ "nested/hello.txt": b"hello from zip", "nested": b"not a directory", } ), expected_member="nested/hello.txt", expected_reason="archive path descends through non-directory: nested", ) assert not (workspace / "nested").exists() @pytest.mark.asyncio async def test_unix_local_persist_workspace_excludes_resolved_mount_path(tmp_path: Path) -> None: workspace_root = tmp_path / "workspace" actual_mount_path = workspace_root / "actual" actual_mount_path.mkdir(parents=True) (actual_mount_path / "remote.txt").write_text("remote", encoding="utf-8") (workspace_root / "keep.txt").write_text("keep", encoding="utf-8") state = UnixLocalSandboxSessionState( manifest=Manifest( root=str(workspace_root), entries={ "logical": GCSMount( bucket="bucket", mount_path=Path("actual"), mount_strategy=InContainerMountStrategy(pattern=MountpointMountPattern()), ) }, ), snapshot=NoopSnapshot(id="noop"), ) session = UnixLocalSandboxSession.from_state(state) archive = await session.persist_workspace() with tarfile.open(fileobj=archive, mode="r:*") as tar: names = set(tar.getnames()) assert "./keep.txt" in names assert "./actual" not in names assert "./actual/remote.txt" not in names @pytest.mark.asyncio async def test_extract_tar_reuses_directory_listings_during_symlink_checks(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() session = _CountingExtractSession(workspace) await session.extract( "bundle.tar", _tar_bytes( members={ "nested/one.txt": b"one", "nested/two.txt": b"two", } ), ) assert (workspace / "nested" / "one.txt").read_text(encoding="utf-8") == "one" assert (workspace / "nested" / "two.txt").read_text(encoding="utf-8") == "two" assert session.ls_calls == [ workspace, workspace / "nested", ] @pytest.mark.asyncio async def test_unix_local_helpers_reject_paths_outside_workspace_root(tmp_path: Path) -> None: session = _build_session(tmp_path) await session.start() try: with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.ls("../outside") with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.mkdir("../outside", parents=True) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.rm("../outside") with pytest.raises(InvalidManifestPathError, match="must be relative"): await session.extract("/tmp/bundle.tar", _tar_bytes(members={"a.txt": b"a"})) finally: await session.shutdown() @pytest.mark.asyncio async def test_unix_local_helpers_reject_symlink_escape_paths(tmp_path: Path) -> None: session = _build_session(tmp_path) await session.start() try: workspace = Path(session.state.manifest.root) outside = tmp_path / "outside" outside.mkdir() os.symlink(outside, workspace / "link", target_is_directory=True) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.mkdir("link/nested", parents=True) with pytest.raises(InvalidManifestPathError, match="must not escape root"): await session.ls("link") finally: await session.shutdown()