8376f56ab4
Adds native sandbox execution runtime, dynamic review middleware, and workspace file handling, with supporting stream events, prompt, and scope registry changes plus architecture docs.
744 lines
25 KiB
Python
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()
|