c683f6e739
Docker / build (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
Add bounded document ingestion, controlled web search, recoverable session support, subagent timeouts, and the native sandbox runtime contract. Unify package versioning and add release-focused regression coverage.
778 lines
26 KiB
Python
778 lines
26 KiB
Python
"""Native OS process sandbox for Web conversation workspaces."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import platform
|
|
import selectors
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from deepagents.backends.protocol import ExecuteResponse, SandboxBackendProtocol
|
|
|
|
from .workspace_files import ScopedFilesystemBackend
|
|
|
|
_PINNED_SRT_VERSION = "0.0.73"
|
|
_DEFAULT_TIMEOUT = 300
|
|
_MAX_TIMEOUT = 3600
|
|
_DEFAULT_OUTPUT_LIMIT = 100_000
|
|
_DEFAULT_FILE_SIZE_LIMIT = 100 * 1024 * 1024
|
|
_INIT_ERROR_MARKERS = (
|
|
"could not load settings",
|
|
"failed to initialize",
|
|
"sandbox initialization failed",
|
|
"failed to generate sandbox",
|
|
"sandbox-exec: sandbox_apply",
|
|
)
|
|
|
|
|
|
class NativeSandboxUnavailable(RuntimeError):
|
|
"""Raised when the required native sandbox cannot enforce its policy."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class NativeSandboxInstallation:
|
|
srt: Path
|
|
package_root: Path
|
|
path_env: str
|
|
system_read_paths: tuple[str, ...]
|
|
seccomp_helper: Path | None = None
|
|
bwrap: Path | None = None
|
|
socat: Path | None = None
|
|
|
|
|
|
def _repo_root() -> Path:
|
|
return Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _runtime_root() -> Path:
|
|
configured = os.getenv("EVOSCIENTIST_NATIVE_SANDBOX_ROOT", "").strip()
|
|
return (
|
|
Path(configured).expanduser().resolve()
|
|
if configured
|
|
else (_repo_root() / "runtime" / "native-sandbox").resolve()
|
|
)
|
|
|
|
|
|
def _positive_int(name: str, default: int) -> int:
|
|
raw = os.getenv(name, str(default)).strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError as exc:
|
|
raise NativeSandboxUnavailable(f"{name} must be an integer") from exc
|
|
if value < 1:
|
|
raise NativeSandboxUnavailable(f"{name} must be positive")
|
|
return value
|
|
|
|
|
|
def _tool_path() -> str:
|
|
candidates = (
|
|
[
|
|
"/opt/homebrew/bin",
|
|
"/usr/local/bin",
|
|
"/usr/bin",
|
|
"/bin",
|
|
"/usr/sbin",
|
|
"/sbin",
|
|
]
|
|
if sys.platform == "darwin"
|
|
else [
|
|
"/usr/local/bin",
|
|
"/usr/bin",
|
|
"/bin",
|
|
"/usr/sbin",
|
|
"/sbin",
|
|
"/opt/evoscientist-tools/bin",
|
|
]
|
|
)
|
|
return os.pathsep.join(path for path in candidates if Path(path).is_dir())
|
|
|
|
|
|
def _which_required(command: str, path_env: str) -> Path:
|
|
found = shutil.which(command, path=path_env)
|
|
if not found:
|
|
raise NativeSandboxUnavailable(f"native sandbox requires {command}")
|
|
return Path(found).resolve()
|
|
|
|
|
|
def _node_version(node: Path) -> tuple[int, int, int]:
|
|
try:
|
|
result = subprocess.run(
|
|
[str(node), "--version"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
env={"PATH": str(node.parent)},
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
raise NativeSandboxUnavailable("native sandbox cannot start node") from exc
|
|
value = result.stdout.strip().removeprefix("v")
|
|
try:
|
|
parts = tuple(int(part) for part in value.split(".")[:3])
|
|
except ValueError as exc:
|
|
raise NativeSandboxUnavailable(
|
|
"native sandbox cannot determine node version"
|
|
) from exc
|
|
if len(parts) != 3:
|
|
raise NativeSandboxUnavailable("native sandbox cannot determine node version")
|
|
return parts
|
|
|
|
|
|
def _existing_resolved_paths(candidates: tuple[str, ...]) -> tuple[str, ...]:
|
|
resolved: list[str] = []
|
|
seen: set[str] = set()
|
|
for candidate in candidates:
|
|
try:
|
|
value = str(Path(candidate).resolve(strict=True))
|
|
except OSError:
|
|
continue
|
|
if value not in seen:
|
|
seen.add(value)
|
|
resolved.append(value)
|
|
return tuple(resolved)
|
|
|
|
|
|
def _system_read_paths() -> tuple[str, ...]:
|
|
candidates = (
|
|
(
|
|
"/System",
|
|
"/usr",
|
|
"/bin",
|
|
"/sbin",
|
|
"/opt/homebrew",
|
|
"/usr/local",
|
|
"/private/etc/ssl",
|
|
"/private/var/select/sh",
|
|
"/dev/null",
|
|
"/dev/zero",
|
|
"/dev/urandom",
|
|
)
|
|
if sys.platform == "darwin"
|
|
else (
|
|
"/usr",
|
|
"/bin",
|
|
"/sbin",
|
|
"/lib",
|
|
"/lib64",
|
|
"/opt/evoscientist-tools",
|
|
"/etc/ssl",
|
|
"/etc/ld.so.cache",
|
|
"/dev/null",
|
|
"/dev/zero",
|
|
"/dev/urandom",
|
|
)
|
|
)
|
|
return _existing_resolved_paths(candidates)
|
|
|
|
|
|
def _assert_install_contract() -> NativeSandboxInstallation:
|
|
if sys.platform not in {"darwin", "linux"}:
|
|
raise NativeSandboxUnavailable(
|
|
f"native sandbox does not support {platform.system() or sys.platform}"
|
|
)
|
|
|
|
root = _runtime_root()
|
|
package_root = root / "node_modules" / "@anthropic-ai" / "sandbox-runtime"
|
|
package_json = package_root / "package.json"
|
|
srt = root / "node_modules" / ".bin" / "srt"
|
|
try:
|
|
metadata = json.loads(package_json.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
raise NativeSandboxUnavailable(
|
|
"pinned native sandbox dependency is not installed; run npm ci --omit=dev --prefix runtime/native-sandbox"
|
|
) from exc
|
|
if metadata.get("version") != _PINNED_SRT_VERSION:
|
|
raise NativeSandboxUnavailable(
|
|
f"native sandbox requires @anthropic-ai/sandbox-runtime {_PINNED_SRT_VERSION}"
|
|
)
|
|
if not srt.is_file() or not os.access(srt, os.X_OK):
|
|
raise NativeSandboxUnavailable("pinned srt executable is missing")
|
|
|
|
path_env = _tool_path()
|
|
node = _which_required("node", path_env)
|
|
if _node_version(node) < (20, 11, 0):
|
|
raise NativeSandboxUnavailable("native sandbox requires node >= 20.11.0")
|
|
for tool in ("bash", "rg", "python3", "pandoc"):
|
|
_which_required(tool, path_env)
|
|
|
|
seccomp_helper: Path | None = None
|
|
bwrap: Path | None = None
|
|
socat: Path | None = None
|
|
if sys.platform == "darwin":
|
|
sandbox_exec = Path("/usr/bin/sandbox-exec")
|
|
if not sandbox_exec.is_file() or not os.access(sandbox_exec, os.X_OK):
|
|
raise NativeSandboxUnavailable("native sandbox requires macOS sandbox-exec")
|
|
else:
|
|
bwrap = _which_required("bwrap", path_env)
|
|
socat = _which_required("socat", path_env)
|
|
arch = platform.machine().lower()
|
|
vendor_arch = {
|
|
"x86_64": "x64",
|
|
"amd64": "x64",
|
|
"arm64": "arm64",
|
|
"aarch64": "arm64",
|
|
}.get(arch)
|
|
if vendor_arch is None:
|
|
raise NativeSandboxUnavailable(
|
|
f"native sandbox does not support Linux architecture {arch}"
|
|
)
|
|
candidate = package_root / "vendor" / "seccomp" / vendor_arch / "apply-seccomp"
|
|
try:
|
|
seccomp_helper = candidate.resolve(strict=True)
|
|
seccomp_helper.relative_to(package_root.resolve(strict=True))
|
|
except (OSError, ValueError) as exc:
|
|
raise NativeSandboxUnavailable(
|
|
"native sandbox seccomp helper is invalid"
|
|
) from exc
|
|
if not seccomp_helper.is_file() or not os.access(seccomp_helper, os.X_OK):
|
|
raise NativeSandboxUnavailable(
|
|
"native sandbox seccomp helper is not executable"
|
|
)
|
|
|
|
return NativeSandboxInstallation(
|
|
srt=srt.resolve(),
|
|
package_root=package_root.resolve(),
|
|
path_env=path_env,
|
|
system_read_paths=_system_read_paths(),
|
|
seccomp_helper=seccomp_helper,
|
|
bwrap=bwrap,
|
|
socat=socat,
|
|
)
|
|
|
|
|
|
def _sandbox_settings(
|
|
installation: NativeSandboxInstallation,
|
|
files_dir: Path,
|
|
command_tmp: Path,
|
|
) -> dict[str, Any]:
|
|
allow_read = [str(files_dir), str(command_tmp), *installation.system_read_paths]
|
|
settings: dict[str, Any] = {
|
|
"network": {
|
|
"allowedDomains": [],
|
|
"deniedDomains": [],
|
|
"strictAllowlist": True,
|
|
"allowUnixSockets": [],
|
|
"allowAllUnixSockets": False,
|
|
"allowLocalBinding": False,
|
|
},
|
|
"filesystem": {
|
|
"denyRead": ["/"],
|
|
"allowRead": list(dict.fromkeys(allow_read)),
|
|
"allowWrite": [str(files_dir), str(command_tmp), "/dev/null"],
|
|
# srt adds these shared compatibility paths even when callers do
|
|
# not request them. Explicit deny wins over that built-in allow.
|
|
"denyWrite": [
|
|
str(files_dir / "uploads"),
|
|
"/tmp/claude",
|
|
"/private/tmp/claude",
|
|
"/dev/tty",
|
|
"/dev/dtracehelper",
|
|
"/dev/autofs_nowait",
|
|
],
|
|
},
|
|
"enableWeakerNestedSandbox": os.getenv(
|
|
"EVOSCIENTIST_NATIVE_SANDBOX_WEAKER_NESTED", ""
|
|
).strip().lower()
|
|
in {"1", "true", "yes", "on"},
|
|
"enableWeakerNetworkIsolation": False,
|
|
"allowAppleEvents": False,
|
|
"allowPty": False,
|
|
"ripgrep": {"command": "rg"},
|
|
}
|
|
if sys.platform == "linux":
|
|
if (
|
|
installation.seccomp_helper is None
|
|
or installation.bwrap is None
|
|
or installation.socat is None
|
|
):
|
|
raise NativeSandboxUnavailable(
|
|
"native sandbox Linux helpers are incomplete"
|
|
)
|
|
settings["filesystem"]["allowRead"].append(str(installation.seccomp_helper))
|
|
settings["seccomp"] = {"applyPath": str(installation.seccomp_helper)}
|
|
settings["bwrapPath"] = str(installation.bwrap)
|
|
settings["socatPath"] = str(installation.socat)
|
|
return settings
|
|
|
|
|
|
def _clean_environment(
|
|
installation: NativeSandboxInstallation, command_tmp: Path
|
|
) -> dict[str, str]:
|
|
locale = "en_US.UTF-8" if sys.platform == "darwin" else "C.UTF-8"
|
|
return {
|
|
"PATH": installation.path_env,
|
|
"HOME": str(command_tmp / "home"),
|
|
"TMPDIR": str(command_tmp / "tmp"),
|
|
"WORKSPACE": ".",
|
|
"LANG": locale,
|
|
"LC_ALL": locale,
|
|
}
|
|
|
|
|
|
def _terminate_process_group(process: subprocess.Popen[bytes]) -> None:
|
|
try:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError):
|
|
return
|
|
try:
|
|
process.wait(timeout=0.5)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
|
|
def _kill_remaining_process_group(process: subprocess.Popen[bytes]) -> None:
|
|
"""Remove descendants left behind after the srt parent has exited."""
|
|
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
|
|
def _collect_process(
|
|
process: subprocess.Popen[bytes],
|
|
*,
|
|
timeout: int,
|
|
output_limit: int,
|
|
cancel_event: threading.Event | None = None,
|
|
) -> tuple[bytes, int, bool, bool, bool]:
|
|
selector = selectors.DefaultSelector()
|
|
streams = [
|
|
stream for stream in (process.stdout, process.stderr) if stream is not None
|
|
]
|
|
for stream in streams:
|
|
os.set_blocking(stream.fileno(), False)
|
|
selector.register(stream, selectors.EVENT_READ)
|
|
|
|
chunks: list[bytes] = []
|
|
retained = 0
|
|
truncated = False
|
|
timed_out = False
|
|
cancelled = False
|
|
deadline = time.monotonic() + timeout
|
|
exited_at: float | None = None
|
|
try:
|
|
while selector.get_map():
|
|
now = time.monotonic()
|
|
if not timed_out and now >= deadline:
|
|
timed_out = True
|
|
_terminate_process_group(process)
|
|
if (
|
|
not timed_out
|
|
and not cancelled
|
|
and cancel_event is not None
|
|
and cancel_event.is_set()
|
|
):
|
|
cancelled = True
|
|
_terminate_process_group(process)
|
|
events = selector.select(0.1)
|
|
for key, _ in events:
|
|
try:
|
|
data = os.read(key.fileobj.fileno(), 65_536)
|
|
except BlockingIOError:
|
|
continue
|
|
if not data:
|
|
selector.unregister(key.fileobj)
|
|
continue
|
|
available = max(0, output_limit - retained)
|
|
if available:
|
|
kept = data[:available]
|
|
chunks.append(kept)
|
|
retained += len(kept)
|
|
if len(data) > available:
|
|
truncated = True
|
|
|
|
if process.poll() is not None:
|
|
exited_at = exited_at or time.monotonic()
|
|
# A detached descendant must not keep inherited pipes open forever.
|
|
if not events and time.monotonic() - exited_at > 0.25:
|
|
for key in list(selector.get_map().values()):
|
|
selector.unregister(key.fileobj)
|
|
break
|
|
if process.poll() is None:
|
|
_terminate_process_group(process)
|
|
return_code = process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
_terminate_process_group(process)
|
|
return_code = process.wait(timeout=1)
|
|
finally:
|
|
selector.close()
|
|
for stream in streams:
|
|
stream.close()
|
|
|
|
_kill_remaining_process_group(process)
|
|
|
|
if timed_out:
|
|
return_code = 124
|
|
elif cancelled:
|
|
return_code = 130
|
|
elif return_code < 0:
|
|
return_code = 128 + abs(return_code)
|
|
return b"".join(chunks), return_code, truncated, timed_out, cancelled
|
|
|
|
|
|
class NativeSandboxExecutor:
|
|
"""Execute one shell command with a scope-specific mandatory OS policy."""
|
|
|
|
def __init__(
|
|
self,
|
|
files_dir: str | Path,
|
|
runtime_dir: str | Path,
|
|
*,
|
|
timeout: int = _DEFAULT_TIMEOUT,
|
|
installation: NativeSandboxInstallation | None = None,
|
|
) -> None:
|
|
self.files_dir = Path(files_dir).resolve(strict=True)
|
|
self.runtime_dir = Path(runtime_dir).resolve(strict=True)
|
|
if self.files_dir == self.runtime_dir or self.runtime_dir.is_relative_to(
|
|
self.files_dir
|
|
):
|
|
raise NativeSandboxUnavailable(
|
|
"sandbox control directory must be outside workspace files"
|
|
)
|
|
self.timeout = max(1, min(int(timeout), _MAX_TIMEOUT))
|
|
self._installation = installation
|
|
|
|
def execute(
|
|
self,
|
|
command: str,
|
|
*,
|
|
timeout: int | None = None,
|
|
skip_readiness_check: bool = False,
|
|
cancel_event: threading.Event | None = None,
|
|
) -> ExecuteResponse:
|
|
if not skip_readiness_check:
|
|
assert_native_sandbox_ready()
|
|
installation = self._installation or _assert_install_contract()
|
|
effective_timeout = max(1, min(timeout or self.timeout, _MAX_TIMEOUT))
|
|
output_limit = _positive_int(
|
|
"EVOSCIENTIST_SANDBOX_MAX_OUTPUT_BYTES", _DEFAULT_OUTPUT_LIMIT
|
|
)
|
|
file_size_limit = _positive_int(
|
|
"EVOSCIENTIST_SANDBOX_FILE_SIZE_BYTES", _DEFAULT_FILE_SIZE_LIMIT
|
|
)
|
|
|
|
execution_id = uuid.uuid4().hex
|
|
control_dir = self.runtime_dir / "control" / execution_id
|
|
command_tmp = self.runtime_dir / "tmp" / execution_id
|
|
settings_path = control_dir / "settings.json"
|
|
try:
|
|
control_dir.mkdir(mode=0o700, parents=True)
|
|
(command_tmp / "home").mkdir(mode=0o700, parents=True)
|
|
(command_tmp / "tmp").mkdir(mode=0o700)
|
|
settings = _sandbox_settings(installation, self.files_dir, command_tmp)
|
|
settings_path.write_text(
|
|
json.dumps(settings, ensure_ascii=True, separators=(",", ":")),
|
|
encoding="utf-8",
|
|
)
|
|
settings_path.chmod(0o600)
|
|
|
|
entrypoint = command_tmp / "entrypoint.sh"
|
|
entrypoint.write_text(
|
|
"#!/bin/sh\n"
|
|
"exec /usr/bin/env -i "
|
|
'PATH="$PATH" HOME="$HOME" TMPDIR="$TMPDIR" '
|
|
'WORKSPACE="$WORKSPACE" LANG="$LANG" LC_ALL="$LC_ALL" '
|
|
'/bin/sh -c "$1"\n',
|
|
encoding="ascii",
|
|
)
|
|
entrypoint.chmod(0o700)
|
|
|
|
helper = Path(__file__).with_name("sandbox_exec_helper.py")
|
|
invocation = [
|
|
sys.executable,
|
|
str(helper),
|
|
str(file_size_limit),
|
|
"--",
|
|
str(installation.srt),
|
|
"--settings",
|
|
str(settings_path),
|
|
str(entrypoint),
|
|
command,
|
|
]
|
|
environment = _clean_environment(installation, command_tmp)
|
|
# srt reads this trusted compatibility variable while generating
|
|
# its wrapper. entrypoint.sh removes it before the user command.
|
|
environment["CLAUDE_CODE_TMPDIR"] = str(command_tmp / "tmp")
|
|
try:
|
|
process = subprocess.Popen(
|
|
invocation,
|
|
cwd=self.files_dir,
|
|
env=environment,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
start_new_session=True,
|
|
close_fds=True,
|
|
)
|
|
except OSError as exc:
|
|
raise NativeSandboxUnavailable(
|
|
"native sandbox process could not start"
|
|
) from exc
|
|
|
|
raw, return_code, truncated, timed_out, _cancelled = _collect_process(
|
|
process,
|
|
timeout=effective_timeout,
|
|
output_limit=output_limit,
|
|
cancel_event=cancel_event,
|
|
)
|
|
output = raw.decode("utf-8", errors="replace")
|
|
output = output.replace(str(control_dir), "<sandbox-control>")
|
|
lowered = output.lower()
|
|
if any(marker in lowered for marker in _INIT_ERROR_MARKERS):
|
|
return ExecuteResponse(
|
|
output="Native sandbox failed to initialize.",
|
|
exit_code=125,
|
|
truncated=False,
|
|
)
|
|
if timed_out:
|
|
suffix = f"Command timed out after {effective_timeout} seconds."
|
|
output = f"{output.rstrip()}\n{suffix}" if output else suffix
|
|
return ExecuteResponse(
|
|
output=output,
|
|
exit_code=return_code,
|
|
truncated=truncated,
|
|
)
|
|
finally:
|
|
shutil.rmtree(control_dir, ignore_errors=True)
|
|
shutil.rmtree(command_tmp, ignore_errors=True)
|
|
|
|
|
|
class NativeWorkspaceBackend(ScopedFilesystemBackend, SandboxBackendProtocol):
|
|
"""DeepAgents backend sharing one scope between file tools and native shell."""
|
|
|
|
def __init__(self, root_dir: Path, runtime_dir: Path, *, timeout: int) -> None:
|
|
super().__init__(root_dir)
|
|
self._executor = NativeSandboxExecutor(root_dir, runtime_dir, timeout=timeout)
|
|
self._sandbox_id = f"native-scope-{uuid.uuid4().hex[:8]}"
|
|
|
|
@property
|
|
def id(self) -> str:
|
|
return self._sandbox_id
|
|
|
|
@staticmethod
|
|
def _command_error(command: str) -> ExecuteResponse | None:
|
|
from .backends import validate_command
|
|
|
|
if (
|
|
not isinstance(command, str)
|
|
or not command
|
|
or "\x00" in command
|
|
or len(command) > 100_000
|
|
):
|
|
return ExecuteResponse(
|
|
output="Command blocked: invalid command length.",
|
|
exit_code=1,
|
|
truncated=False,
|
|
)
|
|
error = validate_command(command, dangerous=True)
|
|
if error:
|
|
return ExecuteResponse(output=error, exit_code=1, truncated=False)
|
|
return None
|
|
|
|
def _execute(
|
|
self,
|
|
command: str,
|
|
*,
|
|
timeout: int | None,
|
|
cancel_event: threading.Event | None = None,
|
|
) -> ExecuteResponse:
|
|
error = self._command_error(command)
|
|
if error is not None:
|
|
return error
|
|
try:
|
|
return self._executor.execute(
|
|
command, timeout=timeout, cancel_event=cancel_event
|
|
)
|
|
except (NativeSandboxUnavailable, OSError):
|
|
return ExecuteResponse(
|
|
output="Native sandbox is unavailable; command execution was refused.",
|
|
exit_code=125,
|
|
truncated=False,
|
|
)
|
|
|
|
def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
|
|
return self._execute(command, timeout=timeout)
|
|
|
|
async def aexecute(
|
|
self, command: str, *, timeout: int | None = None
|
|
) -> ExecuteResponse:
|
|
cancel_event = threading.Event()
|
|
task = asyncio.create_task(
|
|
asyncio.to_thread(
|
|
self._execute,
|
|
command,
|
|
timeout=timeout,
|
|
cancel_event=cancel_event,
|
|
)
|
|
)
|
|
try:
|
|
return await asyncio.shield(task)
|
|
except asyncio.CancelledError:
|
|
cancel_event.set()
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(task), timeout=2)
|
|
except (TimeoutError, asyncio.CancelledError):
|
|
pass
|
|
raise
|
|
|
|
|
|
_READY_CONDITION = threading.Condition()
|
|
_READY_STATE = "unchecked"
|
|
_READY_ERROR: str | None = None
|
|
|
|
|
|
def _network_preflight_probe(port: int, unix_path: Path) -> str:
|
|
return (
|
|
"import os,socket;\n"
|
|
"assert 'OPENAI_API_KEY' not in os.environ\n"
|
|
f"t=socket.socket(); tcp=t.connect_ex(('127.0.0.1',{port})); t.close()\n"
|
|
"try:\n"
|
|
f" u=socket.socket(socket.AF_UNIX); unix=u.connect_ex({str(unix_path)!r}); u.close()\n"
|
|
"except OSError:\n"
|
|
" unix=1\n"
|
|
"assert tcp != 0 and unix != 0\n"
|
|
)
|
|
|
|
|
|
def _run_preflight(installation: NativeSandboxInstallation) -> None:
|
|
# AF_UNIX paths are limited to roughly 100 bytes on both target platforms;
|
|
# a deployment workspace path can already exceed that before the filename.
|
|
with tempfile.TemporaryDirectory(prefix="evosci-srt-") as temporary:
|
|
root = Path(temporary)
|
|
files_dir = root / "files"
|
|
runtime_dir = root / "runtime"
|
|
files_dir.mkdir(mode=0o700)
|
|
runtime_dir.mkdir(mode=0o700)
|
|
(files_dir / "probe.txt").write_text("allowed", encoding="utf-8")
|
|
uploads_dir = files_dir / "uploads"
|
|
uploads_dir.mkdir(mode=0o700)
|
|
upload_source = uploads_dir / "source.txt"
|
|
upload_source.write_text("immutable", encoding="utf-8")
|
|
outside = root / "outside-secret.txt"
|
|
outside.write_text("secret", encoding="utf-8")
|
|
control_secret = runtime_dir / "control-secret.txt"
|
|
control_secret.write_text("control", encoding="utf-8")
|
|
|
|
tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
unix_server: socket.socket | None = None
|
|
unix_path = files_dir / "blocked.sock"
|
|
try:
|
|
tcp.bind(("127.0.0.1", 0))
|
|
tcp.listen(1)
|
|
port = int(tcp.getsockname()[1])
|
|
if hasattr(socket, "AF_UNIX"):
|
|
unix_server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
unix_server.bind(str(unix_path))
|
|
unix_server.listen(1)
|
|
|
|
python_probe = _network_preflight_probe(port, unix_path)
|
|
command = " && ".join(
|
|
(
|
|
'test "$(cat probe.txt)" = allowed',
|
|
'test "$(cat uploads/source.txt)" = immutable',
|
|
"! sh -c 'printf changed > uploads/source.txt' 2>/dev/null",
|
|
"! rm uploads/source.txt 2>/dev/null",
|
|
"printf written > written.txt",
|
|
'printf temporary > "$TMPDIR/probe.tmp"',
|
|
"pandoc --version >/dev/null",
|
|
f"python3 -c {shlex.quote(python_probe)}",
|
|
f"! cat {shlex.quote(str(outside))} >/dev/null 2>&1",
|
|
f"! cat {shlex.quote(str(control_secret))} >/dev/null 2>&1",
|
|
f"! sh -c {shlex.quote('printf escaped > ' + str(outside))} 2>/dev/null",
|
|
)
|
|
)
|
|
result = NativeSandboxExecutor(
|
|
files_dir,
|
|
runtime_dir,
|
|
timeout=20,
|
|
installation=installation,
|
|
).execute(command, skip_readiness_check=True)
|
|
finally:
|
|
tcp.close()
|
|
if unix_server is not None:
|
|
unix_server.close()
|
|
try:
|
|
unix_path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
if result.exit_code != 0:
|
|
detail = result.output.replace(str(root), "<preflight>").strip()[:500]
|
|
raise NativeSandboxUnavailable(
|
|
f"native sandbox preflight failed (exit {result.exit_code})"
|
|
+ (f": {detail}" if detail else "")
|
|
)
|
|
if (files_dir / "written.txt").read_text(encoding="utf-8") != "written":
|
|
raise NativeSandboxUnavailable(
|
|
"native sandbox preflight could not write workspace"
|
|
)
|
|
if outside.read_text(encoding="utf-8") != "secret":
|
|
raise NativeSandboxUnavailable("native sandbox preflight escaped workspace")
|
|
if upload_source.read_text(encoding="utf-8") != "immutable":
|
|
raise NativeSandboxUnavailable("native sandbox preflight modified uploads")
|
|
|
|
|
|
def ensure_native_sandbox_ready() -> None:
|
|
"""Run the real isolation preflight once and cache the process-level result."""
|
|
|
|
global _READY_ERROR, _READY_STATE
|
|
with _READY_CONDITION:
|
|
while _READY_STATE == "checking":
|
|
_READY_CONDITION.wait()
|
|
if _READY_STATE == "ready":
|
|
return
|
|
if _READY_STATE == "failed":
|
|
raise NativeSandboxUnavailable(
|
|
_READY_ERROR or "native sandbox preflight previously failed"
|
|
)
|
|
_READY_STATE = "checking"
|
|
|
|
try:
|
|
installation = _assert_install_contract()
|
|
_run_preflight(installation)
|
|
except Exception as exc:
|
|
message = str(exc) or "native sandbox preflight failed"
|
|
with _READY_CONDITION:
|
|
_READY_ERROR = message
|
|
_READY_STATE = "failed"
|
|
_READY_CONDITION.notify_all()
|
|
if isinstance(exc, NativeSandboxUnavailable):
|
|
raise
|
|
raise NativeSandboxUnavailable(message) from exc
|
|
else:
|
|
with _READY_CONDITION:
|
|
_READY_ERROR = None
|
|
_READY_STATE = "ready"
|
|
_READY_CONDITION.notify_all()
|
|
|
|
|
|
def assert_native_sandbox_ready() -> None:
|
|
ensure_native_sandbox_ready()
|
|
|
|
|
|
def _reset_native_sandbox_readiness_for_tests() -> None:
|
|
global _READY_ERROR, _READY_STATE
|
|
with _READY_CONDITION:
|
|
_READY_ERROR = None
|
|
_READY_STATE = "unchecked"
|
|
_READY_CONDITION.notify_all()
|