"""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), "") 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), "").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()