Files
EvoScientist-Multi/EvoScientist/native_sandbox.py
T
m4 8376f56ab4 feat: native sandbox execution, dynamic review middleware, and workspace files
Adds native sandbox execution runtime, dynamic review middleware, and
workspace file handling, with supporting stream events, prompt, and scope
registry changes plus architecture docs.
2026-08-19 20:00:09 +08:00

744 lines
25 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 _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 tuple(path for path in candidates if Path(path).exists())
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": [
"/tmp/claude",
"/private/tmp/claude",
"/dev/tty",
"/dev/dtracehelper",
"/dev/autofs_nowait",
],
},
"enableWeakerNestedSandbox": False,
"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 _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")
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 = (
"import os,socket,sys;"
"assert 'OPENAI_API_KEY' not in os.environ;"
f"t=socket.socket(); tcp=t.connect_ex(('127.0.0.1',{port})); t.close();"
f"u=socket.socket(socket.AF_UNIX); unix=u.connect_ex({str(unix_path)!r}); u.close();"
"sys.exit(0 if tcp != 0 and unix != 0 else 9)"
)
command = " && ".join(
(
'test "$(cat probe.txt)" = allowed',
"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")
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()