Python: Fix Hyperlight workspace staging (#6856)
* Fix Hyperlight workspace link staging Reject symlinks, Windows junctions, and reparse points during Hyperlight input staging, and harden output collection/cleanup against the same link types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Hyperlight staging review Anchor workspace enumeration to the resolved root and avoid following links while classifying output cleanup entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improve Hyperlight path resolve errors Handle RuntimeError from path resolution alongside OSError when validating Hyperlight sandbox paths and report the source-root validation context in the error message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Mark Hyperlight real sandbox tests as integration Ensure Windows unit CI excludes real Hyperlight sandbox tests by applying the integration marker consistently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up Hyperlight integration sandboxes Close real sandbox fixtures and provider-owned registries in Hyperlight integration tests so they do not rely on process teardown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
62f0024707
commit
09ea690062
@@ -360,6 +360,72 @@ def _resolve_workspace_root(value: str | Path | None) -> Path | None:
|
||||
return resolved_path
|
||||
|
||||
|
||||
def _is_link_or_reparse_point(path: Path, path_stat: os.stat_result | None = None) -> bool:
|
||||
"""Return True for links or Windows reparse points without following targets."""
|
||||
if path_stat is None:
|
||||
try:
|
||||
path_stat = path.lstat()
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
if stat.S_ISLNK(path_stat.st_mode):
|
||||
return True
|
||||
|
||||
is_junction = getattr(path, "is_junction", None)
|
||||
if callable(is_junction):
|
||||
try:
|
||||
if bool(is_junction()):
|
||||
return True
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
||||
file_attributes = getattr(path_stat, "st_file_attributes", 0)
|
||||
return bool(reparse_attribute and file_attributes & reparse_attribute)
|
||||
|
||||
|
||||
def _is_relative_to_or_same(*, path: Path, root: Path) -> bool:
|
||||
return path == root or path.is_relative_to(root)
|
||||
|
||||
|
||||
def _resolve_contained_path(*, path: Path, root: Path) -> Path:
|
||||
try:
|
||||
resolved_path = path.resolve(strict=True)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise ValueError(
|
||||
"Could not resolve Hyperlight sandbox input path while validating it stays under the configured "
|
||||
f"source root: {path}. Source root: {root}. Ensure the path exists, is accessible, and does not "
|
||||
f"contain symlink loops. Original error: {exc}"
|
||||
) from exc
|
||||
|
||||
if not _is_relative_to_or_same(path=resolved_path, root=root):
|
||||
raise ValueError(f"Refusing to stage Hyperlight sandbox input path outside the configured source root: {path}")
|
||||
|
||||
return resolved_path
|
||||
|
||||
|
||||
def _inspect_stageable_input_path(*, path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
path_stat = path.lstat()
|
||||
except OSError as exc:
|
||||
raise ValueError(f"Could not inspect Hyperlight sandbox input path: {path}") from exc
|
||||
|
||||
if _is_link_or_reparse_point(path, path_stat):
|
||||
raise ValueError(f"Refusing to stage linked or reparse-point path for Hyperlight sandbox input: {path}")
|
||||
|
||||
_resolve_contained_path(path=path, root=root)
|
||||
return path_stat
|
||||
|
||||
|
||||
def _is_resolved_under_root(*, path: Path, root: Path) -> bool:
|
||||
try:
|
||||
resolved_path = path.resolve(strict=True)
|
||||
resolved_root = root.resolve(strict=True)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
return _is_relative_to_or_same(path=resolved_path, root=resolved_root)
|
||||
|
||||
|
||||
def _is_file_mount_pair(value: Any) -> TypeGuard[FileMount | tuple[FileMountHostPath, str]]:
|
||||
if not isinstance(value, tuple):
|
||||
return False
|
||||
@@ -491,14 +557,15 @@ def _display_mount_path(mount_path: str) -> str:
|
||||
return f"/input/{mount_path}"
|
||||
|
||||
|
||||
def _iter_real_entries(root: Path) -> Iterator[Path]:
|
||||
def _iter_real_entries(root: Path, *, reject_links: bool = False) -> Iterator[Path]:
|
||||
"""Walk ``root`` recursively, yielding directories and regular files only.
|
||||
|
||||
``Path.rglob`` follows directory symlinks by default, which combined with
|
||||
``Path.rglob`` follows directory links by default, which combined with
|
||||
``Path.is_file()`` / ``shutil.copy2`` (all follow symlinks) would expose
|
||||
paths outside the configured input tree if the source tree is
|
||||
attacker-controlled. This walker mirrors the safe behaviour by checking
|
||||
``is_symlink()`` at every directory level and never descending through one.
|
||||
attacker-controlled. This walker mirrors the safe behaviour by rejecting or
|
||||
skipping symlinks, Windows junctions, and other reparse points at every
|
||||
directory level and never descending through one.
|
||||
|
||||
Non-regular files (sockets, FIFOs, devices) are also filtered out so the
|
||||
signature mirrors exactly what ``_copy_path`` actually stages.
|
||||
@@ -508,20 +575,29 @@ def _iter_real_entries(root: Path) -> Iterator[Path]:
|
||||
current = stack.pop()
|
||||
try:
|
||||
children = list(current.iterdir())
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
if reject_links:
|
||||
raise ValueError(f"Could not inspect Hyperlight sandbox input directory: {current}") from exc
|
||||
continue
|
||||
for child in children:
|
||||
try:
|
||||
if child.is_symlink():
|
||||
child_stat = child.lstat()
|
||||
if _is_link_or_reparse_point(child, child_stat):
|
||||
if reject_links:
|
||||
raise ValueError(
|
||||
f"Refusing to stage linked or reparse-point path for Hyperlight sandbox input: {child}"
|
||||
)
|
||||
continue
|
||||
if child.is_dir():
|
||||
if stat.S_ISDIR(child_stat.st_mode):
|
||||
stack.append(child)
|
||||
yield child
|
||||
elif child.is_file():
|
||||
elif stat.S_ISREG(child_stat.st_mode):
|
||||
yield child
|
||||
# Non-regular files (sockets/FIFOs/devices) are skipped to
|
||||
# match ``_copy_path``'s staging behaviour.
|
||||
except OSError:
|
||||
except OSError as exc:
|
||||
if reject_links:
|
||||
raise ValueError(f"Could not inspect Hyperlight sandbox input path: {child}") from exc
|
||||
continue
|
||||
|
||||
|
||||
@@ -534,58 +610,60 @@ def _path_tree_signature(path: Path) -> tuple[tuple[str, int, int], ...]:
|
||||
resolve roots up front) and acts as defense in depth for any direct caller
|
||||
that builds a ``_RunConfig`` without going through the constructor.
|
||||
|
||||
Symlinks encountered inside the walked tree are skipped, and ``lstat()`` is
|
||||
used so size/mtime are read from the entry itself, never through a
|
||||
target. The result mirrors what ``_copy_path`` actually stages.
|
||||
Links encountered inside the walked tree are rejected, and ``lstat()`` is
|
||||
used so size/mtime are read from the entry itself, never through a target.
|
||||
The result mirrors what ``_copy_path`` actually stages.
|
||||
"""
|
||||
if path.is_symlink():
|
||||
try:
|
||||
path = path.resolve(strict=True)
|
||||
except OSError:
|
||||
except (OSError, RuntimeError):
|
||||
return ()
|
||||
if path.is_file():
|
||||
stat = path.lstat()
|
||||
return ((path.name, int(stat.st_size), int(stat.st_mtime_ns)),)
|
||||
path_stat = path.lstat()
|
||||
return ((path.name, int(path_stat.st_size), int(path_stat.st_mtime_ns)),)
|
||||
|
||||
entries: list[tuple[str, int, int]] = []
|
||||
for candidate in sorted(_iter_real_entries(path), key=lambda value: value.as_posix()):
|
||||
resolved_path = _resolve_existing_path(path)
|
||||
for candidate in sorted(_iter_real_entries(resolved_path, reject_links=True), key=lambda value: value.as_posix()):
|
||||
try:
|
||||
stat = candidate.lstat()
|
||||
candidate_stat = candidate.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
relative_path = candidate.relative_to(path).as_posix()
|
||||
size = int(stat.st_size) if candidate.is_file() else 0
|
||||
entries.append((relative_path, size, int(stat.st_mtime_ns)))
|
||||
relative_path = candidate.relative_to(resolved_path).as_posix()
|
||||
size = int(candidate_stat.st_size) if stat.S_ISREG(candidate_stat.st_mode) else 0
|
||||
entries.append((relative_path, size, int(candidate_stat.st_mtime_ns)))
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _copy_path(source: Path, destination: Path) -> None:
|
||||
"""Stage ``source`` into ``destination`` without following symlinks.
|
||||
def _copy_path(source: Path, destination: Path, *, source_root: Path) -> None:
|
||||
"""Stage ``source`` into ``destination`` without following links.
|
||||
|
||||
Symlinks (file or directory) found in the source tree are skipped entirely
|
||||
so a sandbox input tree can only contain real entries that physically live
|
||||
under the configured ``workspace_root`` or a ``file_mounts`` host path.
|
||||
``Path.is_dir()``, ``Path.is_file()`` and ``shutil.copy2`` all follow
|
||||
symlinks by default, which is unsafe for symlinks planted in the source
|
||||
tree at rest.
|
||||
Symlinks, Windows junctions, and other reparse-point entries found in the
|
||||
source tree are rejected so a sandbox input tree can only contain real
|
||||
entries that physically live under the configured ``workspace_root`` or a
|
||||
``file_mounts`` host path. ``Path.is_dir()``, ``Path.is_file()`` and
|
||||
``shutil.copy2`` all follow links by default, which is unsafe for links
|
||||
planted in the source tree at rest.
|
||||
|
||||
This helper does not attempt to make the copy atomic with respect to
|
||||
concurrent mutation of the source tree. Callers that need protection from
|
||||
an adversary modifying the workspace mid-stage should pass in an
|
||||
immutable / snapshotted directory.
|
||||
"""
|
||||
# Detect symlinks before doing anything else - ``is_symlink()`` does not
|
||||
# follow the link, unlike ``is_dir()`` / ``is_file()``.
|
||||
if source.is_symlink():
|
||||
return
|
||||
source_stat = _inspect_stageable_input_path(path=source, root=source_root)
|
||||
|
||||
if source.is_dir():
|
||||
if stat.S_ISDIR(source_stat.st_mode):
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for child in sorted(source.iterdir(), key=lambda value: value.name):
|
||||
_copy_path(child, destination / child.name)
|
||||
try:
|
||||
children = sorted(source.iterdir(), key=lambda value: value.name)
|
||||
except OSError as exc:
|
||||
raise ValueError(f"Could not inspect Hyperlight sandbox input directory: {source}") from exc
|
||||
for child in children:
|
||||
_copy_path(child, destination / child.name, source_root=source_root)
|
||||
return
|
||||
|
||||
if not source.is_file():
|
||||
if not stat.S_ISREG(source_stat.st_mode):
|
||||
# Non-regular files (sockets, FIFOs, devices) are intentionally skipped.
|
||||
return
|
||||
|
||||
@@ -595,30 +673,34 @@ def _copy_path(source: Path, destination: Path) -> None:
|
||||
|
||||
def _populate_input_dir(*, config: _RunConfig, input_root: Path) -> None:
|
||||
if config.workspace_root is not None:
|
||||
for child in sorted(config.workspace_root.iterdir(), key=lambda value: value.name):
|
||||
_copy_path(child, input_root / child.name)
|
||||
workspace_root = _resolve_existing_path(config.workspace_root)
|
||||
for child in sorted(workspace_root.iterdir(), key=lambda value: value.name):
|
||||
_copy_path(child, input_root / child.name, source_root=workspace_root)
|
||||
|
||||
for mount in config.file_mounts:
|
||||
_copy_path(mount.host_path, input_root / mount.mount_path)
|
||||
mount_root = _resolve_existing_path(mount.host_path)
|
||||
_copy_path(mount.host_path, input_root / mount.mount_path, source_root=mount_root)
|
||||
|
||||
|
||||
def _read_output_file_bytes(file_path: Path) -> bytes:
|
||||
"""Read ``file_path`` without following a symlink, even under a TOCTOU swap.
|
||||
"""Read ``file_path`` without following a link, even under a TOCTOU swap.
|
||||
|
||||
``Path.read_bytes`` follows symlinks, so a sandbox payload that replaces an
|
||||
output file with ``/output/leak.txt -> /host/secret`` between validation and
|
||||
read could still exfiltrate a host file. Two layers defend against this:
|
||||
``Path.read_bytes`` follows links, so a sandbox payload that replaces an
|
||||
output file with ``/output/leak.txt -> /host/secret`` or a Windows reparse
|
||||
point between validation and read could still exfiltrate a host file. Two
|
||||
layers defend against this:
|
||||
|
||||
* ``os.O_NOFOLLOW`` makes the kernel reject a final-component symlink with
|
||||
``ELOOP``. The flag is absent on some platforms (notably Windows), where
|
||||
it degrades to ``0``, so it cannot be the only defense.
|
||||
* The file is ``lstat``-ed before opening and ``fstat``-ed after; if the
|
||||
``(st_dev, st_ino)`` identity changed, or the pre-open entry is a symlink,
|
||||
the read is refused. This closes the swap window on every platform.
|
||||
``(st_dev, st_ino)`` identity changed, or the pre-open entry is a link or
|
||||
reparse point, the read is refused. This closes the swap window on every
|
||||
platform.
|
||||
"""
|
||||
pre_stat = file_path.lstat()
|
||||
if stat.S_ISLNK(pre_stat.st_mode):
|
||||
raise OSError(f"refusing to read symlinked output file: {file_path}")
|
||||
if _is_link_or_reparse_point(file_path, pre_stat):
|
||||
raise OSError(f"refusing to read linked or reparse-point output file: {file_path}")
|
||||
|
||||
fd = os.open(file_path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
try:
|
||||
@@ -664,8 +746,9 @@ def _is_safe_output_file(*, root: Path, host_path: Path) -> bool:
|
||||
"""Return True only if ``host_path`` is a real regular file safely under ``root``.
|
||||
|
||||
The ``/output`` directory is sandbox-controlled, so a payload can plant a
|
||||
final-component symlink (``/output/leak.txt -> /host/secret``) or an
|
||||
intermediate directory symlink to escape ``root`` and read host files.
|
||||
final-component symlink (``/output/leak.txt -> /host/secret``), a Windows
|
||||
junction/reparse point, or an intermediate directory link to escape ``root``
|
||||
and read host files.
|
||||
``Path.is_file`` follows symlinks, so this validator instead walks each path
|
||||
component from ``root`` to ``host_path`` with ``lstat`` and rejects the path
|
||||
if any component is a symlink, requiring the final entry to be a regular
|
||||
@@ -683,6 +766,9 @@ def _is_safe_output_file(*, root: Path, host_path: Path) -> bool:
|
||||
if not relative.parts or any(part in {"..", "."} for part in relative.parts):
|
||||
return False
|
||||
|
||||
if not _is_resolved_under_root(path=host_path, root=root):
|
||||
return False
|
||||
|
||||
*parent_parts, final_part = relative.parts
|
||||
current = root
|
||||
for part in parent_parts:
|
||||
@@ -691,7 +777,7 @@ def _is_safe_output_file(*, root: Path, host_path: Path) -> bool:
|
||||
parent_stat = current.lstat()
|
||||
except OSError:
|
||||
return False
|
||||
if stat.S_ISLNK(parent_stat.st_mode):
|
||||
if _is_link_or_reparse_point(current, parent_stat):
|
||||
return False
|
||||
|
||||
current = current / final_part
|
||||
@@ -699,7 +785,7 @@ def _is_safe_output_file(*, root: Path, host_path: Path) -> bool:
|
||||
final_stat = current.lstat()
|
||||
except OSError:
|
||||
return False
|
||||
if stat.S_ISLNK(final_stat.st_mode):
|
||||
if _is_link_or_reparse_point(current, final_stat):
|
||||
return False
|
||||
return stat.S_ISREG(final_stat.st_mode)
|
||||
|
||||
@@ -863,11 +949,20 @@ def _clear_directory(output_dir: TemporaryDirectory[str] | None) -> None:
|
||||
root = Path(output_dir.name)
|
||||
for child in root.iterdir():
|
||||
try:
|
||||
if child.is_symlink() or child.is_file():
|
||||
child_stat = child.lstat()
|
||||
if _is_link_or_reparse_point(child, child_stat):
|
||||
if stat.S_ISDIR(child_stat.st_mode):
|
||||
child.rmdir()
|
||||
continue
|
||||
try:
|
||||
child.unlink()
|
||||
except OSError:
|
||||
child.rmdir()
|
||||
elif stat.S_ISREG(child_stat.st_mode):
|
||||
child.unlink()
|
||||
elif child.is_dir():
|
||||
elif stat.S_ISDIR(child_stat.st_mode):
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
except (FileNotFoundError, PermissionError):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -70,17 +72,22 @@ def _hyperlight_integration_runtime_skip_reason() -> str | None:
|
||||
if (reason := _hyperlight_integration_static_skip_reason()) is not None:
|
||||
return reason
|
||||
|
||||
sandbox: Any | None = None
|
||||
try:
|
||||
sandbox_cls = execute_code_module._load_sandbox_class()
|
||||
sandbox = sandbox_cls(
|
||||
sandbox_instance = sandbox_cls(
|
||||
backend=execute_code_module.DEFAULT_HYPERLIGHT_BACKEND,
|
||||
module=execute_code_module.DEFAULT_HYPERLIGHT_MODULE,
|
||||
)
|
||||
sandbox.run("None")
|
||||
sandbox = sandbox_instance
|
||||
sandbox_instance.run("None")
|
||||
except RuntimeError as exc:
|
||||
message = str(exc)
|
||||
if "no hypervisor was found for sandbox" in message.lower():
|
||||
return "Hyperlight integration tests require a runner with a working Hyperlight hypervisor."
|
||||
finally:
|
||||
if sandbox is not None:
|
||||
_close_sandbox(sandbox)
|
||||
|
||||
return None
|
||||
|
||||
@@ -144,6 +151,23 @@ def span_exporter(monkeypatch) -> Generator[InMemorySpanExporter]:
|
||||
exporter.clear()
|
||||
|
||||
|
||||
def _close_sandbox(sandbox: Any) -> None:
|
||||
close_hook = getattr(sandbox, "close", None) or getattr(sandbox, "shutdown", None)
|
||||
if callable(close_hook):
|
||||
with contextlib.suppress(Exception):
|
||||
close_hook()
|
||||
|
||||
|
||||
def _close_execute_code_registry(execute_code: HyperlightExecuteCodeTool) -> None:
|
||||
close_hook = getattr(execute_code._registry, "close", None)
|
||||
if callable(close_hook):
|
||||
close_hook()
|
||||
|
||||
|
||||
def _close_provider_registry(provider: HyperlightCodeActProvider) -> None:
|
||||
_close_execute_code_registry(provider._execute_code_tool)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def shared_sandbox():
|
||||
"""Long-lived sandbox with snapshot/restore for read-mostly tests.
|
||||
@@ -161,7 +185,10 @@ def shared_sandbox():
|
||||
)
|
||||
sandbox.run("None")
|
||||
snapshot = sandbox.snapshot()
|
||||
yield sandbox, snapshot
|
||||
try:
|
||||
yield sandbox, snapshot
|
||||
finally:
|
||||
_close_sandbox(sandbox)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -188,7 +215,10 @@ def fresh_sandbox():
|
||||
module=execute_code_module.DEFAULT_HYPERLIGHT_MODULE,
|
||||
temp_output=True,
|
||||
)
|
||||
yield sandbox
|
||||
try:
|
||||
yield sandbox
|
||||
finally:
|
||||
_close_sandbox(sandbox)
|
||||
|
||||
|
||||
@tool(approval_mode="never_require")
|
||||
@@ -552,7 +582,53 @@ def _symlinks_supported(tmp: Path) -> bool:
|
||||
test_target.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def test_populate_input_dir_skips_symlink_to_file_outside_workspace(tmp_path: Path) -> None:
|
||||
def _create_junction_or_skip(*, link: Path, target: Path) -> None:
|
||||
if sys.platform != "win32":
|
||||
pytest.skip("Windows directory junctions are only available on Windows")
|
||||
|
||||
result = subprocess.run(
|
||||
[os.environ.get("COMSPEC", "cmd"), "/c", "mklink", "/J", str(link), str(target)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"Could not create Windows directory junction: {result.stderr or result.stdout}")
|
||||
|
||||
if not execute_code_module._is_link_or_reparse_point(link):
|
||||
link.rmdir()
|
||||
pytest.skip("Created junction was not reported as a reparse point")
|
||||
|
||||
|
||||
def test_resolve_contained_path_reports_runtime_resolve_failures(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
candidate = workspace / "candidate.txt"
|
||||
candidate.write_text("content", encoding="utf-8")
|
||||
original_resolve = type(candidate).resolve
|
||||
|
||||
def fail_candidate_resolve(self: Path, strict: bool = False) -> Path:
|
||||
if self == candidate:
|
||||
raise RuntimeError("symlink loop from candidate")
|
||||
return original_resolve(self, strict=strict)
|
||||
|
||||
monkeypatch.setattr(type(candidate), "resolve", fail_candidate_resolve)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
execute_code_module._resolve_contained_path(path=candidate, root=workspace)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "Could not resolve Hyperlight sandbox input path" in message
|
||||
assert "validating it stays under the configured source root" in message
|
||||
assert str(candidate) in message
|
||||
assert str(workspace) in message
|
||||
assert "symlink loop from candidate" in message
|
||||
|
||||
|
||||
def test_populate_input_dir_rejects_symlink_to_file_outside_workspace(tmp_path: Path) -> None:
|
||||
if not _symlinks_supported(tmp_path):
|
||||
pytest.skip("Symlinks not supported on this platform/environment")
|
||||
workspace = tmp_path / "workspace"
|
||||
@@ -565,16 +641,14 @@ def test_populate_input_dir_skips_symlink_to_file_outside_workspace(tmp_path: Pa
|
||||
input_root = tmp_path / "input"
|
||||
input_root.mkdir()
|
||||
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(workspace_root=workspace),
|
||||
input_root=input_root,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Refusing to stage linked or reparse-point path"):
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(workspace_root=workspace),
|
||||
input_root=input_root,
|
||||
)
|
||||
|
||||
# Real file copied; symlink and its target are absent.
|
||||
assert (input_root / "real.txt").read_text(encoding="utf-8") == "real-content"
|
||||
assert not (input_root / "link.txt").exists()
|
||||
assert not (input_root / "link.txt").is_symlink()
|
||||
# Sanity: no outside-content anywhere in the input tree.
|
||||
leaked = [
|
||||
path
|
||||
for path in input_root.rglob("*")
|
||||
@@ -583,7 +657,7 @@ def test_populate_input_dir_skips_symlink_to_file_outside_workspace(tmp_path: Pa
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_populate_input_dir_skips_symlinked_directory_outside_workspace(tmp_path: Path) -> None:
|
||||
def test_populate_input_dir_rejects_symlinked_directory_outside_workspace(tmp_path: Path) -> None:
|
||||
if not _symlinks_supported(tmp_path):
|
||||
pytest.skip("Symlinks not supported on this platform/environment")
|
||||
workspace = tmp_path / "workspace"
|
||||
@@ -596,12 +670,12 @@ def test_populate_input_dir_skips_symlinked_directory_outside_workspace(tmp_path
|
||||
input_root = tmp_path / "input"
|
||||
input_root.mkdir()
|
||||
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(workspace_root=workspace),
|
||||
input_root=input_root,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Refusing to stage linked or reparse-point path"):
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(workspace_root=workspace),
|
||||
input_root=input_root,
|
||||
)
|
||||
|
||||
# Neither the symlink itself nor anything under the symlinked target leaks.
|
||||
assert not (input_root / "linked_dir").exists()
|
||||
leaked = [
|
||||
path for path in input_root.rglob("*") if path.is_file() and path.read_text(encoding="utf-8") == "deep-content"
|
||||
@@ -609,8 +683,8 @@ def test_populate_input_dir_skips_symlinked_directory_outside_workspace(tmp_path
|
||||
assert leaked == []
|
||||
|
||||
|
||||
def test_populate_input_dir_skips_nested_symlinks(tmp_path: Path) -> None:
|
||||
"""A symlink several levels deep inside a real subdir must also be skipped."""
|
||||
def test_populate_input_dir_rejects_nested_symlinks(tmp_path: Path) -> None:
|
||||
"""A symlink several levels deep inside a real subdir must also be rejected."""
|
||||
if not _symlinks_supported(tmp_path):
|
||||
pytest.skip("Symlinks not supported on this platform/environment")
|
||||
workspace = tmp_path / "workspace"
|
||||
@@ -623,17 +697,95 @@ def test_populate_input_dir_skips_nested_symlinks(tmp_path: Path) -> None:
|
||||
input_root = tmp_path / "input"
|
||||
input_root.mkdir()
|
||||
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(workspace_root=workspace),
|
||||
input_root=input_root,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Refusing to stage linked or reparse-point path"):
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(workspace_root=workspace),
|
||||
input_root=input_root,
|
||||
)
|
||||
|
||||
assert (input_root / "real_sub" / "ok.txt").read_text(encoding="utf-8") == "ok"
|
||||
assert not any(
|
||||
path.is_file() and path.read_text(encoding="utf-8") == "outside-content" for path in input_root.rglob("*")
|
||||
)
|
||||
assert not (input_root / "real_sub" / "link.txt").exists()
|
||||
|
||||
|
||||
def test_path_tree_signature_does_not_follow_symlinks(tmp_path: Path) -> None:
|
||||
"""The cache-key signature must reflect only real files (mirrors the staged tree)."""
|
||||
def test_populate_input_dir_rejects_workspace_junction_outside_workspace(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "deep.txt").write_text("deep-content", encoding="utf-8")
|
||||
_create_junction_or_skip(link=workspace / "linked_dir", target=outside_dir)
|
||||
|
||||
input_root = tmp_path / "input"
|
||||
input_root.mkdir()
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to stage linked or reparse-point path"):
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(workspace_root=workspace),
|
||||
input_root=input_root,
|
||||
)
|
||||
|
||||
assert not (input_root / "linked_dir").exists()
|
||||
assert not any(
|
||||
path.is_file() and path.read_text(encoding="utf-8") == "deep-content" for path in input_root.rglob("*")
|
||||
)
|
||||
|
||||
|
||||
def test_populate_input_dir_rejects_nested_workspace_junction(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
(workspace / "real_sub").mkdir(parents=True)
|
||||
(workspace / "real_sub" / "ok.txt").write_text("ok", encoding="utf-8")
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "deep.txt").write_text("deep-content", encoding="utf-8")
|
||||
_create_junction_or_skip(link=workspace / "real_sub" / "linked_dir", target=outside_dir)
|
||||
|
||||
input_root = tmp_path / "input"
|
||||
input_root.mkdir()
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to stage linked or reparse-point path"):
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(workspace_root=workspace),
|
||||
input_root=input_root,
|
||||
)
|
||||
|
||||
assert not any(
|
||||
path.is_file() and path.read_text(encoding="utf-8") == "deep-content" for path in input_root.rglob("*")
|
||||
)
|
||||
assert not (input_root / "real_sub" / "linked_dir").exists()
|
||||
|
||||
|
||||
def test_populate_input_dir_rejects_file_mount_junction(tmp_path: Path) -> None:
|
||||
mount_root = tmp_path / "mount"
|
||||
mount_root.mkdir()
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "deep.txt").write_text("deep-content", encoding="utf-8")
|
||||
_create_junction_or_skip(link=mount_root / "linked_dir", target=outside_dir)
|
||||
|
||||
input_root = tmp_path / "input"
|
||||
input_root.mkdir()
|
||||
mount = execute_code_module._NormalizedFileMount(
|
||||
host_path=mount_root,
|
||||
mount_path="mounted",
|
||||
path_signature=(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Refusing to stage linked or reparse-point path"):
|
||||
execute_code_module._populate_input_dir(
|
||||
config=_build_run_config(file_mounts=(mount,)),
|
||||
input_root=input_root,
|
||||
)
|
||||
|
||||
assert not (input_root / "mounted" / "linked_dir").exists()
|
||||
assert not any(
|
||||
path.is_file() and path.read_text(encoding="utf-8") == "deep-content" for path in input_root.rglob("*")
|
||||
)
|
||||
|
||||
|
||||
def test_path_tree_signature_rejects_symlinks(tmp_path: Path) -> None:
|
||||
"""The cache-key signature must mirror staging and reject linked entries."""
|
||||
if not _symlinks_supported(tmp_path):
|
||||
pytest.skip("Symlinks not supported on this platform/environment")
|
||||
workspace = tmp_path / "workspace"
|
||||
@@ -644,11 +796,8 @@ def test_path_tree_signature_does_not_follow_symlinks(tmp_path: Path) -> None:
|
||||
outside.write_text("outside-content", encoding="utf-8")
|
||||
(workspace / "link.txt").symlink_to(outside)
|
||||
|
||||
signature = execute_code_module._path_tree_signature(workspace)
|
||||
|
||||
names = [entry[0] for entry in signature]
|
||||
assert "real.txt" in names
|
||||
assert "link.txt" not in names
|
||||
with pytest.raises(ValueError, match="Refusing to stage linked or reparse-point path"):
|
||||
execute_code_module._path_tree_signature(workspace)
|
||||
|
||||
|
||||
def test_path_tree_signature_walks_through_symlinked_root(tmp_path: Path) -> None:
|
||||
@@ -738,6 +887,20 @@ def test_collect_output_relative_paths_skips_symlinked_directory(tmp_path: Path)
|
||||
assert relative_paths == set()
|
||||
|
||||
|
||||
def test_collect_output_relative_paths_skips_junctioned_directory(tmp_path: Path) -> None:
|
||||
"""A junctioned directory in /output must not be descended into."""
|
||||
output_root = tmp_path / "output"
|
||||
output_root.mkdir()
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "deep.txt").write_text("deep-secret", encoding="utf-8")
|
||||
_create_junction_or_skip(link=output_root / "linked_dir", target=outside_dir)
|
||||
|
||||
relative_paths = execute_code_module._collect_output_relative_paths(sandbox=object(), root=output_root)
|
||||
|
||||
assert relative_paths == set()
|
||||
|
||||
|
||||
def test_parse_output_files_skips_symlink_to_host_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""End-to-end: a /output symlink to a host file is never returned as Content."""
|
||||
if not _symlinks_supported(tmp_path):
|
||||
@@ -786,6 +949,43 @@ def test_parse_output_files_rejects_intermediate_dir_symlink_from_listing(
|
||||
assert all(item.additional_properties.get("path") != "/output/sub/leak.txt" for item in contents)
|
||||
|
||||
|
||||
def test_parse_output_files_rejects_intermediate_dir_junction_from_listing(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A backend-listed path traversing an intermediate dir junction must be rejected."""
|
||||
monkeypatch.setattr(execute_code_module, "OUTPUT_FILE_RETRY_ATTEMPTS", 1)
|
||||
output_root = tmp_path / "output"
|
||||
output_root.mkdir()
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "leak.txt").write_text("HOST_SECRET", encoding="utf-8")
|
||||
_create_junction_or_skip(link=output_root / "sub", target=outside_dir)
|
||||
|
||||
contents = execute_code_module._parse_output_files(
|
||||
sandbox=_SandboxWithListing(["output/sub/leak.txt"]),
|
||||
output_dir=cast("TemporaryDirectory[str]", _OutputDirShim(output_root)),
|
||||
expect_output_files=False,
|
||||
)
|
||||
|
||||
assert all(b"HOST_SECRET" not in _decode_content_bytes(item) for item in contents if item.type == "data")
|
||||
assert all(item.additional_properties.get("path") != "/output/sub/leak.txt" for item in contents)
|
||||
|
||||
|
||||
def test_clear_directory_removes_junction_without_deleting_target(tmp_path: Path) -> None:
|
||||
output_root = tmp_path / "output"
|
||||
output_root.mkdir()
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
outside_file = outside_dir / "keep.txt"
|
||||
outside_file.write_text("do-not-delete", encoding="utf-8")
|
||||
_create_junction_or_skip(link=output_root / "linked_dir", target=outside_dir)
|
||||
|
||||
execute_code_module._clear_directory(cast("TemporaryDirectory[str]", _OutputDirShim(output_root)))
|
||||
|
||||
assert outside_file.read_text(encoding="utf-8") == "do-not-delete"
|
||||
assert not (output_root / "linked_dir").exists()
|
||||
|
||||
|
||||
def test_is_safe_output_file_rejects_parent_traversal(tmp_path: Path) -> None:
|
||||
"""A lexical ``..`` component must be rejected even without any symlink."""
|
||||
output_root = tmp_path / "output"
|
||||
@@ -799,6 +999,26 @@ def test_is_safe_output_file_rejects_parent_traversal(tmp_path: Path) -> None:
|
||||
assert execute_code_module._is_safe_output_file(root=output_root, host_path=secret) is False
|
||||
|
||||
|
||||
def test_is_safe_output_file_rejects_runtime_resolve_failures(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
output_root = tmp_path / "output"
|
||||
output_root.mkdir()
|
||||
report = output_root / "report.txt"
|
||||
report.write_text("artifact", encoding="utf-8")
|
||||
original_resolve = type(report).resolve
|
||||
|
||||
def fail_report_resolve(self: Path, strict: bool = False) -> Path:
|
||||
if self == report:
|
||||
raise RuntimeError("symlink loop from output")
|
||||
return original_resolve(self, strict=strict)
|
||||
|
||||
monkeypatch.setattr(type(report), "resolve", fail_report_resolve)
|
||||
|
||||
assert execute_code_module._is_safe_output_file(root=output_root, host_path=report) is False
|
||||
|
||||
|
||||
def test_parse_output_files_collects_real_output_file(tmp_path: Path) -> None:
|
||||
"""Regression: a genuine /output file is still collected and returned."""
|
||||
output_root = tmp_path / "output"
|
||||
@@ -1041,14 +1261,18 @@ async def test_agent_runs_hyperlight_codeact_end_to_end_with_fake_sandbox(monkey
|
||||
provider = HyperlightCodeActProvider(tools=[compute])
|
||||
agent = Agent(client=client, context_providers=[provider])
|
||||
|
||||
response = await agent.run("Use the sandbox to add 20 and 22.")
|
||||
try:
|
||||
response = await agent.run("Use the sandbox to add 20 and 22.")
|
||||
|
||||
assert response.text == "The sandbox returned 42."
|
||||
assert client.call_count == 2
|
||||
assert response.text == "The sandbox returned 42."
|
||||
assert client.call_count == 2
|
||||
finally:
|
||||
_close_provider_registry(provider)
|
||||
assert len(_FakeSandbox.instances) == 1
|
||||
assert "compute" in _FakeSandbox.instances[0].registered_tools
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_agent_runs_hyperlight_codeact_end_to_end_with_real_sandbox() -> None:
|
||||
_skip_if_hyperlight_integration_runtime_disabled()
|
||||
@@ -1057,12 +1281,16 @@ async def test_agent_runs_hyperlight_codeact_end_to_end_with_real_sandbox() -> N
|
||||
provider = HyperlightCodeActProvider(tools=[compute])
|
||||
agent = Agent(client=client, context_providers=[provider])
|
||||
|
||||
response = await agent.run("Use the sandbox to add 20 and 22.")
|
||||
try:
|
||||
response = await agent.run("Use the sandbox to add 20 and 22.")
|
||||
|
||||
assert response.text == "The sandbox returned 42."
|
||||
assert client.call_count == 2
|
||||
assert response.text == "The sandbox returned 42."
|
||||
assert client.call_count == 2
|
||||
finally:
|
||||
_close_provider_registry(provider)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_provider_run_tool_writes_files_with_real_sandbox(tmp_path: Path) -> None:
|
||||
_skip_if_hyperlight_integration_runtime_disabled()
|
||||
@@ -1078,41 +1306,44 @@ async def test_provider_run_tool_writes_files_with_real_sandbox(tmp_path: Path)
|
||||
run_tool = context.tools[0][1][0]
|
||||
assert isinstance(run_tool, HyperlightExecuteCodeTool)
|
||||
|
||||
result = await run_tool.invoke(
|
||||
arguments={
|
||||
"code": (
|
||||
'payload = "hello from sandbox"\n'
|
||||
"output_path = None\n"
|
||||
'for candidate in ("/output/result.txt",):\n'
|
||||
" try:\n"
|
||||
' with open(candidate, "w", encoding="utf-8") as f:\n'
|
||||
" f.write(payload)\n"
|
||||
" except OSError:\n"
|
||||
" continue\n"
|
||||
" output_path = candidate\n"
|
||||
" break\n"
|
||||
'assert output_path is not None, "output path unavailable"\n'
|
||||
'print("validated")\n'
|
||||
)
|
||||
}
|
||||
)
|
||||
try:
|
||||
result = await run_tool.invoke(
|
||||
arguments={
|
||||
"code": (
|
||||
'payload = "hello from sandbox"\n'
|
||||
"output_path = None\n"
|
||||
'for candidate in ("/output/result.txt",):\n'
|
||||
" try:\n"
|
||||
' with open(candidate, "w", encoding="utf-8") as f:\n'
|
||||
" f.write(payload)\n"
|
||||
" except OSError:\n"
|
||||
" continue\n"
|
||||
" output_path = candidate\n"
|
||||
" break\n"
|
||||
'assert output_path is not None, "output path unavailable"\n'
|
||||
'print("validated")\n'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
outputs = result
|
||||
error_outputs = [
|
||||
f"{item.message}: {item.error_details}"
|
||||
for item in outputs
|
||||
if item.type == "error" and item.error_details is not None
|
||||
]
|
||||
assert not error_outputs, error_outputs
|
||||
outputs = result
|
||||
error_outputs = [
|
||||
f"{item.message}: {item.error_details}"
|
||||
for item in outputs
|
||||
if item.type == "error" and item.error_details is not None
|
||||
]
|
||||
assert not error_outputs, error_outputs
|
||||
|
||||
text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None)
|
||||
if text_output is not None:
|
||||
assert text_output.text == "validated\n"
|
||||
text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None)
|
||||
if text_output is not None:
|
||||
assert text_output.text == "validated\n"
|
||||
|
||||
file_output = next((item for item in outputs if item.type == "data"), None)
|
||||
if file_output is not None:
|
||||
assert file_output.uri is not None and file_output.uri.startswith("data:")
|
||||
assert file_output.additional_properties["path"] in {"/output/result.txt", "/output/output/result.txt"}
|
||||
file_output = next((item for item in outputs if item.type == "data"), None)
|
||||
if file_output is not None:
|
||||
assert file_output.uri is not None and file_output.uri.startswith("data:")
|
||||
assert file_output.additional_properties["path"] in {"/output/result.txt", "/output/output/result.txt"}
|
||||
finally:
|
||||
_close_execute_code_registry(run_tool)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -1131,46 +1362,49 @@ async def test_provider_run_tool_pings_bing_with_real_sandbox() -> None:
|
||||
run_tool = context.tools[0][1][0]
|
||||
assert isinstance(run_tool, HyperlightExecuteCodeTool)
|
||||
|
||||
result = await run_tool.invoke(
|
||||
arguments={
|
||||
"code": (
|
||||
"import _socket\n\n"
|
||||
'addresses = _socket.getaddrinfo("bing.com", 80, _socket.AF_INET, _socket.SOCK_STREAM)\n'
|
||||
'assert addresses, "bing.com did not resolve"\n'
|
||||
"last_error = None\n"
|
||||
"for family, socktype, proto, _, sockaddr in addresses:\n"
|
||||
" connection = None\n"
|
||||
" try:\n"
|
||||
" connection = _socket.socket(family, socktype, proto)\n"
|
||||
" connection.settimeout(10)\n"
|
||||
" connection.connect(sockaddr)\n"
|
||||
' print("pinged bing.com")\n'
|
||||
" break\n"
|
||||
" except OSError as exc:\n"
|
||||
" last_error = exc\n"
|
||||
" finally:\n"
|
||||
" if connection is not None:\n"
|
||||
" try:\n"
|
||||
" connection.close()\n"
|
||||
" except OSError:\n"
|
||||
" pass\n"
|
||||
"else:\n"
|
||||
' raise last_error or RuntimeError("unable to reach bing.com")\n'
|
||||
)
|
||||
}
|
||||
)
|
||||
try:
|
||||
result = await run_tool.invoke(
|
||||
arguments={
|
||||
"code": (
|
||||
"import _socket\n\n"
|
||||
'addresses = _socket.getaddrinfo("bing.com", 80, _socket.AF_INET, _socket.SOCK_STREAM)\n'
|
||||
'assert addresses, "bing.com did not resolve"\n'
|
||||
"last_error = None\n"
|
||||
"for family, socktype, proto, _, sockaddr in addresses:\n"
|
||||
" connection = None\n"
|
||||
" try:\n"
|
||||
" connection = _socket.socket(family, socktype, proto)\n"
|
||||
" connection.settimeout(10)\n"
|
||||
" connection.connect(sockaddr)\n"
|
||||
' print("pinged bing.com")\n'
|
||||
" break\n"
|
||||
" except OSError as exc:\n"
|
||||
" last_error = exc\n"
|
||||
" finally:\n"
|
||||
" if connection is not None:\n"
|
||||
" try:\n"
|
||||
" connection.close()\n"
|
||||
" except OSError:\n"
|
||||
" pass\n"
|
||||
"else:\n"
|
||||
' raise last_error or RuntimeError("unable to reach bing.com")\n'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
outputs = result
|
||||
error_outputs = [
|
||||
f"{item.message}: {item.error_details}"
|
||||
for item in outputs
|
||||
if item.type == "error" and item.error_details is not None
|
||||
]
|
||||
assert not error_outputs, error_outputs
|
||||
outputs = result
|
||||
error_outputs = [
|
||||
f"{item.message}: {item.error_details}"
|
||||
for item in outputs
|
||||
if item.type == "error" and item.error_details is not None
|
||||
]
|
||||
assert not error_outputs, error_outputs
|
||||
|
||||
text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None)
|
||||
if text_output is not None:
|
||||
assert text_output.text == "pinged bing.com\n"
|
||||
text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None)
|
||||
if text_output is not None:
|
||||
assert text_output.text == "pinged bing.com\n"
|
||||
finally:
|
||||
_close_execute_code_registry(run_tool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1178,6 +1412,7 @@ async def test_provider_run_tool_pings_bing_with_real_sandbox() -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_sandbox_runs_simple_code(restored_sandbox) -> None:
|
||||
result = restored_sandbox.run('print("hello")')
|
||||
@@ -1185,6 +1420,7 @@ async def test_sandbox_runs_simple_code(restored_sandbox) -> None:
|
||||
assert "hello" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_sandbox_stdout_and_stderr_captured(restored_sandbox) -> None:
|
||||
result = restored_sandbox.run('import sys\nprint("out")\nprint("err", file=sys.stderr)')
|
||||
@@ -1193,6 +1429,7 @@ async def test_sandbox_stdout_and_stderr_captured(restored_sandbox) -> None:
|
||||
assert "err" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_sandbox_code_failure_returns_nonzero_exit(restored_sandbox) -> None:
|
||||
result = restored_sandbox.run("raise ValueError('boom')")
|
||||
@@ -1200,6 +1437,7 @@ async def test_sandbox_code_failure_returns_nonzero_exit(restored_sandbox) -> No
|
||||
assert "boom" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32" and sys.version_info < (3, 11),
|
||||
@@ -1226,6 +1464,7 @@ async def test_sandbox_snapshot_restore_keeps_sandbox_functional(restored_sandbo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_sandbox_with_tool_registration_and_execution(fresh_sandbox) -> None:
|
||||
"""Verify that a sync host tool round-trips via call_tool in the real sandbox."""
|
||||
@@ -1242,6 +1481,7 @@ async def test_sandbox_with_tool_registration_and_execution(fresh_sandbox) -> No
|
||||
assert "42" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_sandbox_async_callback_round_trips_with_real_sandbox(fresh_sandbox) -> None:
|
||||
"""Confirm that _make_sandbox_callback (sync wrapper) works with real FFI."""
|
||||
@@ -1261,6 +1501,7 @@ async def test_sandbox_async_callback_round_trips_with_real_sandbox(fresh_sandbo
|
||||
assert "42" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_output_dir_cleared_between_invocations() -> None:
|
||||
"""Verify stale output files don't leak across invocations (comment 23)."""
|
||||
@@ -1274,27 +1515,32 @@ async def test_output_dir_cleared_between_invocations() -> None:
|
||||
run_tool = context.tools[0][1][0]
|
||||
assert isinstance(run_tool, HyperlightExecuteCodeTool)
|
||||
|
||||
# First invocation: write a file
|
||||
result1 = await run_tool.invoke(
|
||||
arguments={"code": ('with open("/output/stale.txt", "w") as f:\n f.write("first")\nprint("wrote")\n')}
|
||||
)
|
||||
assert result1[0].type == "text" or result1[0].type == "data"
|
||||
outputs1 = result1
|
||||
assert any(
|
||||
item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") for item in outputs1
|
||||
), "First invocation should produce stale.txt"
|
||||
try:
|
||||
# First invocation: write a file
|
||||
result1 = await run_tool.invoke(
|
||||
arguments={"code": ('with open("/output/stale.txt", "w") as f:\n f.write("first")\nprint("wrote")\n')}
|
||||
)
|
||||
assert result1[0].type == "text" or result1[0].type == "data"
|
||||
outputs1 = result1
|
||||
assert any(
|
||||
item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "")
|
||||
for item in outputs1
|
||||
), "First invocation should produce stale.txt"
|
||||
|
||||
# Second invocation: no file writes
|
||||
result2 = await run_tool.invoke(arguments={"code": 'print("clean")\n'})
|
||||
outputs2 = result2
|
||||
stale_files = [
|
||||
item
|
||||
for item in outputs2
|
||||
if item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "")
|
||||
]
|
||||
assert not stale_files, "Stale output file leaked into second invocation"
|
||||
# Second invocation: no file writes
|
||||
result2 = await run_tool.invoke(arguments={"code": 'print("clean")\n'})
|
||||
outputs2 = result2
|
||||
stale_files = [
|
||||
item
|
||||
for item in outputs2
|
||||
if item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "")
|
||||
]
|
||||
assert not stale_files, "Stale output file leaked into second invocation"
|
||||
finally:
|
||||
_close_execute_code_registry(run_tool)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@skip_if_hyperlight_integration_tests_disabled
|
||||
async def test_run_code_does_not_block_event_loop() -> None:
|
||||
"""Verify _run_code uses asyncio.to_thread so the event loop stays responsive (comment 26)."""
|
||||
@@ -1330,12 +1576,16 @@ async def test_run_code_does_not_block_event_loop() -> None:
|
||||
concurrent_ran = True
|
||||
release.set()
|
||||
|
||||
code_task = asyncio.create_task(run_tool.invoke(arguments={"code": 'print("done")\n'}))
|
||||
await _concurrent_task()
|
||||
result = await code_task
|
||||
try:
|
||||
code_task = asyncio.create_task(run_tool.invoke(arguments={"code": 'print("done")\n'}))
|
||||
await _concurrent_task()
|
||||
result = await code_task
|
||||
|
||||
assert concurrent_ran, "Event loop was blocked during sandbox execution"
|
||||
assert result[0].type == "text"
|
||||
assert concurrent_ran, "Event loop was blocked during sandbox execution"
|
||||
assert result[0].type == "text"
|
||||
finally:
|
||||
release.set()
|
||||
_close_execute_code_registry(run_tool)
|
||||
|
||||
|
||||
class _ThreadAffinityFakeSandbox(_FakeSandbox):
|
||||
|
||||
Reference in New Issue
Block a user