langchain-hyperlight 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- langchain_hyperlight/__init__.py +33 -0
- langchain_hyperlight/py.typed +0 -0
- langchain_hyperlight/tool.py +545 -0
- langchain_hyperlight-0.1.0.dist-info/METADATA +304 -0
- langchain_hyperlight-0.1.0.dist-info/RECORD +7 -0
- langchain_hyperlight-0.1.0.dist-info/WHEEL +4 -0
- langchain_hyperlight-0.1.0.dist-info/licenses/LICENSE +15 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""LangChain integration for Microsoft Hyperlight micro VMs.
|
|
2
|
+
|
|
3
|
+
Exposes a LangChain tool that executes untrusted code inside a hardware-isolated
|
|
4
|
+
Hyperlight micro virtual machine (KVM / MSHV / Hyper-V).
|
|
5
|
+
|
|
6
|
+
.. warning::
|
|
7
|
+
This package is **vibe-coded (AI-generated)** and experimental. It has only
|
|
8
|
+
been tested on **Linux (x86_64)** and is **not tested on Windows or macOS**.
|
|
9
|
+
Use at your own risk.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from langchain_hyperlight import HyperlightSandboxTool
|
|
13
|
+
>>> tool = HyperlightSandboxTool(host_tools={"add": lambda a=0, b=0: a + b})
|
|
14
|
+
>>> tool.invoke({"code": "print(call_tool('add', a=3, b=4))"})
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .tool import (
|
|
18
|
+
AllowedDomain,
|
|
19
|
+
FileMount,
|
|
20
|
+
HyperlightSandboxTool,
|
|
21
|
+
SandboxCodeInput,
|
|
22
|
+
create_hyperlight_tool,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AllowedDomain",
|
|
27
|
+
"FileMount",
|
|
28
|
+
"HyperlightSandboxTool",
|
|
29
|
+
"SandboxCodeInput",
|
|
30
|
+
"create_hyperlight_tool",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""LangChain tool that executes untrusted code inside a Hyperlight micro VM.
|
|
2
|
+
|
|
3
|
+
This module bridges LangChain's tool interface to the Hyperlight Sandbox
|
|
4
|
+
Python SDK (https://github.com/hyperlight-dev/hyperlight-sandbox). The
|
|
5
|
+
resulting tool can be handed to any LangChain agent so that an LLM can run
|
|
6
|
+
arbitrary, untrusted code in a hardware-isolated micro virtual machine
|
|
7
|
+
(KVM / MSHV / Hyper-V) instead of on the host.
|
|
8
|
+
|
|
9
|
+
The API intentionally mirrors Microsoft's canonical ``agent-framework-hyperlight``
|
|
10
|
+
package (https://github.com/microsoft/agent-framework) so the concepts transfer
|
|
11
|
+
directly, but this tool targets LangChain's ``BaseTool`` interface.
|
|
12
|
+
|
|
13
|
+
Thread-safety note
|
|
14
|
+
------------------
|
|
15
|
+
The Hyperlight ``WasmSandbox`` is declared ``unsendable`` in PyO3: it may only be
|
|
16
|
+
accessed *and dropped* from the OS thread that created it. Touching it from any
|
|
17
|
+
other thread triggers an uncatchable Rust panic. To honor that invariant, all
|
|
18
|
+
sandbox access is routed through a single-threaded worker (see
|
|
19
|
+
:class:`_SandboxWorker`), so the tool is safe to call from arbitrary threads and
|
|
20
|
+
event loops.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import shutil
|
|
27
|
+
import tempfile
|
|
28
|
+
from collections.abc import Callable, Sequence
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
30
|
+
from pathlib import Path, PurePosixPath
|
|
31
|
+
from typing import Any, NamedTuple
|
|
32
|
+
from urllib.parse import urlparse
|
|
33
|
+
|
|
34
|
+
from hyperlight_sandbox import ExecutionResult, Sandbox
|
|
35
|
+
from langchain_core.tools import BaseTool
|
|
36
|
+
from pydantic import BaseModel, Field, PrivateAttr, field_validator
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AllowedDomain",
|
|
40
|
+
"FileMount",
|
|
41
|
+
"HyperlightSandboxTool",
|
|
42
|
+
"SandboxCodeInput",
|
|
43
|
+
"create_hyperlight_tool",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SandboxCodeInput(BaseModel):
|
|
48
|
+
"""Arguments accepted by :class:`HyperlightSandboxTool`."""
|
|
49
|
+
|
|
50
|
+
code: str = Field(
|
|
51
|
+
description=(
|
|
52
|
+
"The full source code to execute inside the Hyperlight micro VM. "
|
|
53
|
+
"The sandbox runs Python by default."
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FileMount(NamedTuple):
|
|
59
|
+
"""Map a host file or directory into the sandbox ``/input`` tree."""
|
|
60
|
+
|
|
61
|
+
host_path: str | Path
|
|
62
|
+
mount_path: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AllowedDomain(NamedTuple):
|
|
66
|
+
"""Allow outbound requests to one target, optionally restricted to HTTP methods."""
|
|
67
|
+
|
|
68
|
+
target: str
|
|
69
|
+
methods: tuple[str, ...] | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
DEFAULT_DESCRIPTION = """\
|
|
73
|
+
Execute untrusted code inside a hardware-isolated Hyperlight micro virtual machine (micro VM).
|
|
74
|
+
|
|
75
|
+
The code runs in a fresh, OS-free sandbox with no access to the host filesystem or network
|
|
76
|
+
unless explicitly granted. Use this tool to safely run code that may be untrusted, to perform
|
|
77
|
+
computations, or to process data in isolation.
|
|
78
|
+
|
|
79
|
+
The sandbox runs Python by default. Inside the sandbox the following built-in functions are
|
|
80
|
+
available:
|
|
81
|
+
- call_tool(name, **kwargs): invoke a host-registered tool by name with keyword arguments.
|
|
82
|
+
- http_get(url) / http_post(url, body=...): make HTTP requests to allow-listed domains only.
|
|
83
|
+
- read_file(path) / write_file(path, data): read from the /input directory and write to the
|
|
84
|
+
/output directory (capability-based file access).
|
|
85
|
+
|
|
86
|
+
Provide the full source code to execute. The tool returns the program's stdout, stderr, and
|
|
87
|
+
exit code. A non-zero exit code or any stderr output indicates an error.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------- #
|
|
92
|
+
# Input normalization (mirrors microsoft/agent-framework hyperlight package)
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
|
|
95
|
+
def _normalize_domain(target: str) -> str:
|
|
96
|
+
"""Normalize a domain to a lowercase host (no scheme, no trailing slash).
|
|
97
|
+
|
|
98
|
+
Accepts an optional ``scheme://`` prefix and strips it, returning just the
|
|
99
|
+
netloc/path in lowercase. Raises ``ValueError`` for empty input.
|
|
100
|
+
"""
|
|
101
|
+
candidate = target.strip()
|
|
102
|
+
if not candidate:
|
|
103
|
+
raise ValueError("Allowed domain entries must not be empty.")
|
|
104
|
+
parsed = urlparse(candidate if "://" in candidate else f"//{candidate}")
|
|
105
|
+
normalized = (parsed.netloc or parsed.path).strip().rstrip("/")
|
|
106
|
+
if not normalized:
|
|
107
|
+
raise ValueError(f"Could not normalize allowed domain entry: {target!r}.")
|
|
108
|
+
return normalized.lower()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _normalize_methods(methods: str | Sequence[str] | None) -> tuple[str, ...] | None:
|
|
112
|
+
"""Normalize HTTP method(s) to a sorted tuple of uppercase strings.
|
|
113
|
+
|
|
114
|
+
Accepts a single string or a sequence of strings. Returns ``None`` for
|
|
115
|
+
``None`` input (meaning "all methods allowed"). Raises ``ValueError`` if
|
|
116
|
+
the result is empty.
|
|
117
|
+
"""
|
|
118
|
+
if methods is None:
|
|
119
|
+
return None
|
|
120
|
+
if isinstance(methods, str):
|
|
121
|
+
methods = [methods]
|
|
122
|
+
normalized = {m.strip().upper() for m in methods if m.strip()}
|
|
123
|
+
if not normalized:
|
|
124
|
+
raise ValueError("Allowed domain methods must not be empty when provided.")
|
|
125
|
+
return tuple(sorted(normalized))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _normalize_allowed_domain(value: Any) -> AllowedDomain:
|
|
129
|
+
"""Coerce a flexible allowed-domain input into a normalized :class:`AllowedDomain`.
|
|
130
|
+
|
|
131
|
+
Accepts a domain string, a ``(target, methods)`` tuple, or an
|
|
132
|
+
:class:`AllowedDomain`. Raises ``ValueError`` for any other shape.
|
|
133
|
+
"""
|
|
134
|
+
if isinstance(value, str):
|
|
135
|
+
return AllowedDomain(target=_normalize_domain(value), methods=None)
|
|
136
|
+
if isinstance(value, AllowedDomain):
|
|
137
|
+
return AllowedDomain(
|
|
138
|
+
target=_normalize_domain(value.target),
|
|
139
|
+
methods=_normalize_methods(value.methods),
|
|
140
|
+
)
|
|
141
|
+
if isinstance(value, tuple) and len(value) == 2:
|
|
142
|
+
target, methods = value
|
|
143
|
+
return AllowedDomain(
|
|
144
|
+
target=_normalize_domain(target),
|
|
145
|
+
methods=_normalize_methods(methods),
|
|
146
|
+
)
|
|
147
|
+
raise ValueError(f"Invalid allowed domain entry: {value!r}.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalize_mount_path(mount_path: str) -> str:
|
|
151
|
+
"""Normalize a sandbox mount path to a relative POSIX path under ``/input``.
|
|
152
|
+
|
|
153
|
+
Strips a leading ``/input`` component, rejects ``..`` traversal, and returns
|
|
154
|
+
a ``/``-joined relative path. Raises ``ValueError`` for empty or escaping
|
|
155
|
+
paths.
|
|
156
|
+
"""
|
|
157
|
+
raw = mount_path.strip().replace("\\", "/")
|
|
158
|
+
if not raw:
|
|
159
|
+
raise ValueError("mount_path must not be empty.")
|
|
160
|
+
parts = [p for p in PurePosixPath(raw).parts if p not in {"", "/", "."}]
|
|
161
|
+
if parts and parts[0] == "input":
|
|
162
|
+
parts = parts[1:]
|
|
163
|
+
if any(p == ".." for p in parts):
|
|
164
|
+
raise ValueError("mount_path must stay within /input.")
|
|
165
|
+
if not parts:
|
|
166
|
+
raise ValueError("mount_path must point to a concrete path under /input.")
|
|
167
|
+
return "/".join(parts)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _normalize_file_mount(value: Any) -> FileMount:
|
|
171
|
+
"""Coerce a flexible file-mount input into a normalized :class:`FileMount`.
|
|
172
|
+
|
|
173
|
+
Accepts a path string (same path on host and in sandbox), a
|
|
174
|
+
``(host_path, mount_path)`` tuple, or a :class:`FileMount`. Resolves the
|
|
175
|
+
host path and validates that it exists.
|
|
176
|
+
"""
|
|
177
|
+
if isinstance(value, str):
|
|
178
|
+
host_path, mount_path = value, value
|
|
179
|
+
elif isinstance(value, FileMount):
|
|
180
|
+
host_path, mount_path = value.host_path, value.mount_path
|
|
181
|
+
elif isinstance(value, tuple) and len(value) == 2:
|
|
182
|
+
host_path, mount_path = value
|
|
183
|
+
else:
|
|
184
|
+
raise ValueError(f"Invalid file mount entry: {value!r}.")
|
|
185
|
+
host = Path(host_path).expanduser().resolve()
|
|
186
|
+
if not host.exists():
|
|
187
|
+
raise ValueError(f"File mount host path does not exist: {host}")
|
|
188
|
+
return FileMount(host_path=host, mount_path=_normalize_mount_path(mount_path))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------------------------------------------------------------- #
|
|
192
|
+
# Thread-confined sandbox worker
|
|
193
|
+
# --------------------------------------------------------------------------- #
|
|
194
|
+
|
|
195
|
+
class _SandboxWorker:
|
|
196
|
+
"""Single-threaded owner of the Hyperlight sandbox.
|
|
197
|
+
|
|
198
|
+
The Hyperlight ``WasmSandbox`` (and its snapshots) are ``unsendable`` in
|
|
199
|
+
PyO3: they can only be accessed and dropped from the OS thread that created
|
|
200
|
+
them. This actor routes every operation through a dedicated single-thread
|
|
201
|
+
executor so the sandbox is always touched on its owner thread, and the
|
|
202
|
+
unsendable objects never escape to caller threads.
|
|
203
|
+
|
|
204
|
+
Exceptions raised inside worker closures have their traceback dropped on the
|
|
205
|
+
worker thread (so any PyO3 unsendable locals are released there) and a
|
|
206
|
+
sanitized copy is re-raised on the caller.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
__slots__ = ("_executor", "_sandbox", "_snapshot", "_initialized")
|
|
210
|
+
|
|
211
|
+
def __init__(self) -> None:
|
|
212
|
+
"""Create the worker with a single-thread executor and no sandbox yet."""
|
|
213
|
+
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="hyperlight")
|
|
214
|
+
self._sandbox: Sandbox | None = None
|
|
215
|
+
self._snapshot: Any = None
|
|
216
|
+
self._initialized = False
|
|
217
|
+
|
|
218
|
+
def _submit(self, fn: Callable[[], Any]) -> Any:
|
|
219
|
+
"""Run ``fn`` on the owner thread and return its result.
|
|
220
|
+
|
|
221
|
+
Exceptions raised inside ``fn`` have their traceback dropped on the
|
|
222
|
+
worker thread (so any PyO3 unsendable locals are released there) and a
|
|
223
|
+
sanitized copy is re-raised on the caller.
|
|
224
|
+
"""
|
|
225
|
+
def _wrapped() -> tuple[bool, Any]:
|
|
226
|
+
"""Run ``fn`` and sanitize any exception's traceback on the worker thread."""
|
|
227
|
+
try:
|
|
228
|
+
return True, fn()
|
|
229
|
+
except BaseException as exc: # noqa: BLE001 - sanitize and re-raise
|
|
230
|
+
exc_type = type(exc)
|
|
231
|
+
exc_args = tuple(str(a) for a in exc.args) if exc.args else (str(exc),)
|
|
232
|
+
exc.__traceback__ = None
|
|
233
|
+
del exc
|
|
234
|
+
return False, (exc_type, exc_args)
|
|
235
|
+
|
|
236
|
+
ok, payload = self._executor.submit(_wrapped).result()
|
|
237
|
+
if ok:
|
|
238
|
+
return payload
|
|
239
|
+
exc_type, exc_args = payload
|
|
240
|
+
try:
|
|
241
|
+
raise exc_type(*exc_args)
|
|
242
|
+
except TypeError:
|
|
243
|
+
raise RuntimeError(f"{exc_type.__name__}: {exc_args}") from None
|
|
244
|
+
|
|
245
|
+
def initialize(self, build_fn: Callable[[], Sandbox]) -> None:
|
|
246
|
+
"""Build and install the sandbox on the owner thread.
|
|
247
|
+
|
|
248
|
+
``build_fn`` is invoked with no arguments on the worker thread and must
|
|
249
|
+
return the :class:`Sandbox` to own.
|
|
250
|
+
"""
|
|
251
|
+
def _init() -> None:
|
|
252
|
+
"""Build the sandbox and mark the worker initialized (runs on owner thread)."""
|
|
253
|
+
self._sandbox = build_fn()
|
|
254
|
+
self._initialized = True
|
|
255
|
+
|
|
256
|
+
self._submit(_init)
|
|
257
|
+
|
|
258
|
+
def run(self, code: str) -> ExecutionResult:
|
|
259
|
+
"""Run ``code`` in the sandbox on the owner thread and return the result."""
|
|
260
|
+
return self._submit(lambda: self._sandbox.run(code)) # type: ignore[union-attr]
|
|
261
|
+
|
|
262
|
+
def snapshot(self) -> None:
|
|
263
|
+
"""Capture the current state, stored worker-locally for a later restore."""
|
|
264
|
+
self._submit(lambda: setattr(self, "_snapshot", self._sandbox.snapshot())) # type: ignore[union-attr]
|
|
265
|
+
|
|
266
|
+
def restore(self) -> None:
|
|
267
|
+
"""Restore the most recently captured snapshot on the owner thread."""
|
|
268
|
+
self._submit(lambda: self._sandbox.restore(self._snapshot)) # type: ignore[union-attr]
|
|
269
|
+
|
|
270
|
+
def get_output_files(self) -> list[str]:
|
|
271
|
+
"""List output filenames written by the guest, on the owner thread."""
|
|
272
|
+
return self._submit(lambda: list(self._sandbox.get_output_files())) # type: ignore[union-attr]
|
|
273
|
+
|
|
274
|
+
def output_path(self) -> str | None:
|
|
275
|
+
"""Return the host output directory path, on the owner thread."""
|
|
276
|
+
return self._submit(lambda: self._sandbox.output_path()) # type: ignore[union-attr]
|
|
277
|
+
|
|
278
|
+
def _dispose_on_worker(self) -> None:
|
|
279
|
+
"""Drop the unsendable sandbox/snapshot on the owner thread."""
|
|
280
|
+
sandbox = self._sandbox
|
|
281
|
+
self._sandbox = None
|
|
282
|
+
self._snapshot = None
|
|
283
|
+
close = getattr(sandbox, "close", None) or getattr(sandbox, "shutdown", None)
|
|
284
|
+
if callable(close):
|
|
285
|
+
try:
|
|
286
|
+
close()
|
|
287
|
+
except Exception: # noqa: BLE001 - best-effort teardown
|
|
288
|
+
pass
|
|
289
|
+
del sandbox
|
|
290
|
+
|
|
291
|
+
def dispose(self) -> None:
|
|
292
|
+
"""Release the sandbox on its owner thread (blocking; for explicit close)."""
|
|
293
|
+
if self._initialized:
|
|
294
|
+
try:
|
|
295
|
+
self._executor.submit(self._dispose_on_worker).result()
|
|
296
|
+
except RuntimeError:
|
|
297
|
+
pass
|
|
298
|
+
finally:
|
|
299
|
+
self._initialized = False
|
|
300
|
+
self._executor.shutdown(wait=False, cancel_futures=False)
|
|
301
|
+
|
|
302
|
+
def __del__(self) -> None:
|
|
303
|
+
"""Route the unsendable sandbox drop to its owner thread, non-blocking.
|
|
304
|
+
|
|
305
|
+
Without this, when the worker is garbage-collected on an arbitrary thread
|
|
306
|
+
(e.g. the main thread at script exit), the PyO3 ``WasmSandbox`` would be
|
|
307
|
+
dropped on the wrong thread and raise ``RuntimeError: ... unsendable ...``.
|
|
308
|
+
"""
|
|
309
|
+
if getattr(self, "_initialized", False):
|
|
310
|
+
executor = getattr(self, "_executor", None)
|
|
311
|
+
if executor is not None:
|
|
312
|
+
try:
|
|
313
|
+
executor.submit(self._dispose_on_worker)
|
|
314
|
+
except Exception: # noqa: BLE001 - interpreter may be shutting down
|
|
315
|
+
pass
|
|
316
|
+
try:
|
|
317
|
+
executor.shutdown(wait=False, cancel_futures=False)
|
|
318
|
+
except Exception: # noqa: BLE001
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# --------------------------------------------------------------------------- #
|
|
323
|
+
# The LangChain tool
|
|
324
|
+
# --------------------------------------------------------------------------- #
|
|
325
|
+
|
|
326
|
+
class HyperlightSandboxTool(BaseTool):
|
|
327
|
+
"""A LangChain tool that runs code in a Hyperlight micro VM.
|
|
328
|
+
|
|
329
|
+
Parameters mirror the :class:`hyperlight_sandbox.Sandbox` constructor plus
|
|
330
|
+
LangChain-friendly conveniences. The underlying sandbox is created lazily on
|
|
331
|
+
the first invocation and reused for subsequent calls (cold-start latency is
|
|
332
|
+
paid once).
|
|
333
|
+
|
|
334
|
+
Examples:
|
|
335
|
+
>>> tool = HyperlightSandboxTool(host_tools={"add": lambda a=0, b=0: a + b})
|
|
336
|
+
>>> tool.invoke({"code": "print(call_tool('add', a=3, b=4))"})
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
name: str = "execute_code"
|
|
340
|
+
description: str = DEFAULT_DESCRIPTION
|
|
341
|
+
args_schema: type[BaseModel] = SandboxCodeInput
|
|
342
|
+
|
|
343
|
+
# --- Sandbox configuration (forwarded to hyperlight_sandbox.Sandbox) ---
|
|
344
|
+
backend: str = "wasm"
|
|
345
|
+
module: str = "python_guest.path"
|
|
346
|
+
module_path: str | None = None
|
|
347
|
+
input_dir: str | None = None
|
|
348
|
+
output_dir: str | None = None
|
|
349
|
+
temp_output: bool = False
|
|
350
|
+
heap_size: str | None = None
|
|
351
|
+
stack_size: str | None = None
|
|
352
|
+
|
|
353
|
+
# --- Host capabilities exposed to the guest ---
|
|
354
|
+
host_tools: dict[str, Callable[..., Any]] = Field(default_factory=dict, exclude=True)
|
|
355
|
+
allowed_domains: dict[str, tuple[str, ...] | None] = Field(default_factory=dict)
|
|
356
|
+
file_mounts: dict[str, str] = Field(default_factory=dict)
|
|
357
|
+
|
|
358
|
+
_worker: _SandboxWorker | None = PrivateAttr(default=None)
|
|
359
|
+
_staged_input_dir: Any = PrivateAttr(default=None)
|
|
360
|
+
|
|
361
|
+
@field_validator("allowed_domains", mode="before")
|
|
362
|
+
@classmethod
|
|
363
|
+
def _coerce_allowed_domains(cls, value: Any) -> dict[str, tuple[str, ...] | None]:
|
|
364
|
+
"""Pydantic ``before`` validator: normalize flexible allowed-domain input.
|
|
365
|
+
|
|
366
|
+
Accepts a dict, a single entry, or a sequence of entries (string, tuple,
|
|
367
|
+
or :class:`AllowedDomain`) and returns a normalized ``{target: methods}``
|
|
368
|
+
dict.
|
|
369
|
+
"""
|
|
370
|
+
if value is None:
|
|
371
|
+
return {}
|
|
372
|
+
if isinstance(value, dict):
|
|
373
|
+
return {
|
|
374
|
+
_normalize_domain(k): _normalize_methods(v)
|
|
375
|
+
for k, v in value.items()
|
|
376
|
+
}
|
|
377
|
+
if isinstance(value, (str, AllowedDomain, tuple)):
|
|
378
|
+
value = [value]
|
|
379
|
+
result: dict[str, tuple[str, ...] | None] = {}
|
|
380
|
+
for entry in value:
|
|
381
|
+
domain = _normalize_allowed_domain(entry)
|
|
382
|
+
result[domain.target] = domain.methods
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
@field_validator("file_mounts", mode="before")
|
|
386
|
+
@classmethod
|
|
387
|
+
def _coerce_file_mounts(cls, value: Any) -> dict[str, str]:
|
|
388
|
+
"""Pydantic ``before`` validator: normalize flexible file-mount input.
|
|
389
|
+
|
|
390
|
+
Accepts a dict, a single entry, or a sequence of entries (string, tuple,
|
|
391
|
+
or :class:`FileMount`) and returns a normalized ``{mount_path: host_path}``
|
|
392
|
+
dict.
|
|
393
|
+
"""
|
|
394
|
+
if value is None:
|
|
395
|
+
return {}
|
|
396
|
+
if isinstance(value, dict):
|
|
397
|
+
return {_normalize_mount_path(k): str(Path(v).expanduser().resolve()) for k, v in value.items()}
|
|
398
|
+
if isinstance(value, (str, FileMount, tuple)):
|
|
399
|
+
value = [value]
|
|
400
|
+
result: dict[str, str] = {}
|
|
401
|
+
for entry in value:
|
|
402
|
+
mount = _normalize_file_mount(entry)
|
|
403
|
+
result[mount.mount_path] = str(mount.host_path)
|
|
404
|
+
return result
|
|
405
|
+
|
|
406
|
+
# -- worker / sandbox lifecycle -----------------------------------------
|
|
407
|
+
|
|
408
|
+
def _get_worker(self) -> _SandboxWorker:
|
|
409
|
+
"""Return the (lazily created) thread-confined worker.
|
|
410
|
+
|
|
411
|
+
On first call, creates the worker and builds the sandbox on its owner
|
|
412
|
+
thread. Subsequent calls return the same worker.
|
|
413
|
+
"""
|
|
414
|
+
if self._worker is None:
|
|
415
|
+
self._worker = _SandboxWorker()
|
|
416
|
+
self._worker.initialize(self._build_sandbox)
|
|
417
|
+
return self._worker
|
|
418
|
+
|
|
419
|
+
def _build_sandbox(self) -> Sandbox:
|
|
420
|
+
"""Build and configure the sandbox. Runs on the worker thread."""
|
|
421
|
+
input_dir = self.input_dir
|
|
422
|
+
if self.file_mounts:
|
|
423
|
+
input_dir = self._stage_file_mounts()
|
|
424
|
+
|
|
425
|
+
sandbox = Sandbox(
|
|
426
|
+
backend=self.backend,
|
|
427
|
+
module=self.module,
|
|
428
|
+
module_path=self.module_path,
|
|
429
|
+
input_dir=input_dir,
|
|
430
|
+
output_dir=self.output_dir,
|
|
431
|
+
temp_output=self.temp_output,
|
|
432
|
+
heap_size=self.heap_size,
|
|
433
|
+
stack_size=self.stack_size,
|
|
434
|
+
)
|
|
435
|
+
for name, fn in self.host_tools.items():
|
|
436
|
+
sandbox.register_tool(name, fn)
|
|
437
|
+
for target, methods in self.allowed_domains.items():
|
|
438
|
+
sandbox.allow_domain(target, list(methods) if methods is not None else None)
|
|
439
|
+
return sandbox
|
|
440
|
+
|
|
441
|
+
def _stage_file_mounts(self) -> str:
|
|
442
|
+
"""Copy mounted host paths into a managed temporary ``/input`` tree."""
|
|
443
|
+
staged = tempfile.mkdtemp(prefix="hyperlight-input-")
|
|
444
|
+
self._staged_input_dir = staged
|
|
445
|
+
for mount_path, host_path in self.file_mounts.items():
|
|
446
|
+
src = Path(host_path)
|
|
447
|
+
dst = Path(staged) / mount_path
|
|
448
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
449
|
+
if src.is_dir():
|
|
450
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
451
|
+
else:
|
|
452
|
+
shutil.copy2(src, dst)
|
|
453
|
+
return staged
|
|
454
|
+
|
|
455
|
+
# -- BaseTool interface --------------------------------------------------
|
|
456
|
+
|
|
457
|
+
def _run(self, code: str) -> str:
|
|
458
|
+
"""Execute ``code`` in the micro VM and return a formatted result."""
|
|
459
|
+
result = self._get_worker().run(code)
|
|
460
|
+
return self._format_result(result)
|
|
461
|
+
|
|
462
|
+
async def _arun(self, code: str) -> str:
|
|
463
|
+
"""Async variant; the native sandbox call is blocking, so run in a thread."""
|
|
464
|
+
return await asyncio.to_thread(self._run, code)
|
|
465
|
+
|
|
466
|
+
@staticmethod
|
|
467
|
+
def _format_result(result: ExecutionResult) -> str:
|
|
468
|
+
"""Render an :class:`ExecutionResult` as a stable, human-readable string."""
|
|
469
|
+
parts: list[str] = []
|
|
470
|
+
if result.stdout:
|
|
471
|
+
parts.append(f"[stdout]\n{result.stdout.rstrip()}")
|
|
472
|
+
if result.stderr:
|
|
473
|
+
parts.append(f"[stderr]\n{result.stderr.rstrip()}")
|
|
474
|
+
parts.append(f"[exit_code] {result.exit_code}")
|
|
475
|
+
return "\n".join(parts)
|
|
476
|
+
|
|
477
|
+
# -- Convenience helpers -------------------------------------------------
|
|
478
|
+
|
|
479
|
+
def snapshot(self) -> None:
|
|
480
|
+
"""Capture the current sandbox state for a later :meth:`restore`.
|
|
481
|
+
|
|
482
|
+
The snapshot is held worker-locally (it is a PyO3 ``unsendable`` object),
|
|
483
|
+
so it is never exposed to the caller.
|
|
484
|
+
"""
|
|
485
|
+
self._get_worker().snapshot()
|
|
486
|
+
|
|
487
|
+
def restore(self) -> None:
|
|
488
|
+
"""Restore the most recently captured snapshot."""
|
|
489
|
+
self._get_worker().restore()
|
|
490
|
+
|
|
491
|
+
def get_output_files(self) -> list[str]:
|
|
492
|
+
"""List filenames written by the guest to the output directory."""
|
|
493
|
+
return self._get_worker().get_output_files()
|
|
494
|
+
|
|
495
|
+
def output_path(self) -> str | None:
|
|
496
|
+
"""Return the host path of the sandbox output directory (if configured)."""
|
|
497
|
+
return self._get_worker().output_path()
|
|
498
|
+
|
|
499
|
+
def close(self) -> None:
|
|
500
|
+
"""Release the sandbox on its owner thread and clean up staged files."""
|
|
501
|
+
if self._worker is not None:
|
|
502
|
+
self._worker.dispose()
|
|
503
|
+
self._worker = None
|
|
504
|
+
if self._staged_input_dir is not None:
|
|
505
|
+
shutil.rmtree(self._staged_input_dir, ignore_errors=True)
|
|
506
|
+
self._staged_input_dir = None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def create_hyperlight_tool(
|
|
510
|
+
*,
|
|
511
|
+
name: str = "execute_code",
|
|
512
|
+
description: str | None = None,
|
|
513
|
+
host_tools: dict[str, Callable[..., Any]] | None = None,
|
|
514
|
+
allowed_domains: Any = None,
|
|
515
|
+
file_mounts: Any = None,
|
|
516
|
+
**sandbox_kwargs: Any,
|
|
517
|
+
) -> HyperlightSandboxTool:
|
|
518
|
+
"""Create a :class:`HyperlightSandboxTool` from configuration.
|
|
519
|
+
|
|
520
|
+
The sandbox is always created and owned by the tool on a dedicated thread,
|
|
521
|
+
which is required because the Hyperlight ``WasmSandbox`` is ``unsendable``
|
|
522
|
+
(it may only be touched from the thread that created it).
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
name: Tool name exposed to the agent.
|
|
526
|
+
description: Optional override for the tool description.
|
|
527
|
+
host_tools: Mapping of tool name -> host callable, registered with the sandbox.
|
|
528
|
+
allowed_domains: A domain string, ``(target, methods)`` tuple, ``AllowedDomain``,
|
|
529
|
+
or a sequence of any of these.
|
|
530
|
+
file_mounts: A path string, ``(host_path, mount_path)`` tuple, ``FileMount``,
|
|
531
|
+
or a sequence of any of these.
|
|
532
|
+
**sandbox_kwargs: Forwarded to :class:`hyperlight_sandbox.Sandbox`
|
|
533
|
+
(e.g. ``backend``, ``module``, ``input_dir``, ``output_dir``).
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
A configured :class:`HyperlightSandboxTool`.
|
|
537
|
+
"""
|
|
538
|
+
return HyperlightSandboxTool(
|
|
539
|
+
name=name,
|
|
540
|
+
description=description or DEFAULT_DESCRIPTION,
|
|
541
|
+
host_tools=host_tools or {},
|
|
542
|
+
allowed_domains=allowed_domains,
|
|
543
|
+
file_mounts=file_mounts,
|
|
544
|
+
**sandbox_kwargs,
|
|
545
|
+
)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-hyperlight
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LangChain tool for executing untrusted code in Microsoft Hyperlight micro VMs (experimental, AI-generated)
|
|
5
|
+
Project-URL: Homepage, https://github.com/wildaces215/langchain-hyperlight
|
|
6
|
+
Project-URL: Repository, https://github.com/wildaces215/langchain-hyperlight
|
|
7
|
+
Project-URL: Documentation, https://github.com/wildaces215/langchain-hyperlight#readme
|
|
8
|
+
Author: theph03*nix215
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: code-execution,hyperlight,langchain,micro-vm,sandbox,wasm
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: hyperlight-sandbox[python-guest,wasm]>=0.5.0
|
|
25
|
+
Requires-Dist: langchain-core>=0.3.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Provides-Extra: js
|
|
30
|
+
Requires-Dist: hyperlight-sandbox[hyperlight-js,javascript-guest]>=0.5.0; extra == 'js'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
> ⚠️ **VIBE-CODED — AI-GENERATED, NOT PRODUCTION-READY**
|
|
34
|
+
>
|
|
35
|
+
> This project was written by an AI ("vibe coded"). It is **experimental** and has had
|
|
36
|
+
> **no security review, fuzzing, or adversarial testing**. It has only been smoke-tested
|
|
37
|
+
> on **Linux (x86_64)**. **Use at your own risk** — do not rely on it for anything
|
|
38
|
+
> security-sensitive or mission-critical. See [Limitations](#limitations).
|
|
39
|
+
|
|
40
|
+
# langchain-hyperlight
|
|
41
|
+
|
|
42
|
+
A [LangChain](https://www.langchain.com/) tool that executes untrusted code inside a
|
|
43
|
+
[Microsoft Hyperlight](https://github.com/hyperlight-dev/hyperlight) **micro virtual machine**.
|
|
44
|
+
|
|
45
|
+
Hyperlight is a lightweight Virtual Machine Manager (VMM) designed to be embedded within
|
|
46
|
+
applications. It runs untrusted code in hardware-isolated micro VMs (KVM, MSHV, or Hyper-V)
|
|
47
|
+
with very low latency and minimal overhead. This package exposes that capability to LangChain
|
|
48
|
+
agents as a standard tool, so an LLM can safely run arbitrary code without touching the host.
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
- **Hardware isolation** — code runs in a micro VM, not on the host.
|
|
53
|
+
- **Host tool dispatch** — register host callables that guest code invokes by name with
|
|
54
|
+
schema-validated arguments (`call_tool(...)`).
|
|
55
|
+
- **Capability-based file access** — read-only `/input`, writable `/output`, strict path isolation.
|
|
56
|
+
- **Network allow-listing** — network is off by default; opt in per-domain and per-HTTP-verb.
|
|
57
|
+
- **Snapshot / restore** — capture and rewind sandbox state.
|
|
58
|
+
- **Lazy sandbox creation** — constructing the tool is cheap; the micro VM boots on first use.
|
|
59
|
+
|
|
60
|
+
## Limitations
|
|
61
|
+
|
|
62
|
+
This is an early-stage, AI-generated integration. Be aware of the following before adopting it.
|
|
63
|
+
|
|
64
|
+
### Platform
|
|
65
|
+
|
|
66
|
+
- **x86_64 only.** Hyperlight currently targets x86_64; there are no `aarch64` (ARM) wheels.
|
|
67
|
+
Raspberry Pi, Apple Silicon, and AWS Graviton are unsupported.
|
|
68
|
+
- **glibc 2.34+.** The Rust backend ships `manylinux_2_34_x86_64` wheels, so it needs a recent
|
|
69
|
+
glibc. Works on Ubuntu 22.04+, Debian 12+, Fedora 36+, RHEL 9+. Does **not** work on
|
|
70
|
+
Ubuntu 20.04, Debian 11, RHEL 8, or musl-based distros (Alpine, Void) without building the
|
|
71
|
+
Rust backend from source.
|
|
72
|
+
- **Python 3.10–3.14.**
|
|
73
|
+
- **A hypervisor is required at runtime:** KVM (`/dev/kvm`) or MSHV on Linux.
|
|
74
|
+
- **Tested on Linux only.** This package has only been tested on **Linux (x86_64)**. It is
|
|
75
|
+
**not tested on Windows or macOS** — use on those platforms at your own risk.
|
|
76
|
+
|
|
77
|
+
### Security model
|
|
78
|
+
|
|
79
|
+
- The micro VM isolates the *guest code* you run, but any **host tools you register via
|
|
80
|
+
`host_tools` run with full host privileges** inside the sandbox's `call_tool(...)`. Only
|
|
81
|
+
register callables you trust, and treat their inputs as untrusted.
|
|
82
|
+
- Network is off by default and gated by `allowed_domains`, but an allow-listed domain is
|
|
83
|
+
reachable by any code running in the sandbox.
|
|
84
|
+
- This package has **not** been security-reviewed. Do not treat it as a hardened sandbox
|
|
85
|
+
boundary without your own audit.
|
|
86
|
+
|
|
87
|
+
### Maturity
|
|
88
|
+
|
|
89
|
+
- **Alpha / vibe-coded.** No fuzzing, no adversarial testing, no cross-platform CI matrix.
|
|
90
|
+
- The thread-confinement worker (required because the `WasmSandbox` is `unsendable` in PyO3)
|
|
91
|
+
is correct for the tested paths but has not been stress-tested under heavy concurrency.
|
|
92
|
+
- `host_tools` accepts plain Python callables only — it does not yet wrap LangChain
|
|
93
|
+
`BaseTool` instances directly.
|
|
94
|
+
|
|
95
|
+
## Installation
|
|
96
|
+
|
|
97
|
+
> **Platform support:** this package is **tested on Linux (x86_64) only**. It is
|
|
98
|
+
> **not tested on Windows or macOS** — install and use on those platforms at your own risk.
|
|
99
|
+
|
|
100
|
+
```shell
|
|
101
|
+
pip install langchain-hyperlight
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This pulls in `langchain-core` and `hyperlight-sandbox[wasm,python_guest]`.
|
|
105
|
+
|
|
106
|
+
> **Prerequisite:** a working hypervisor is required at *runtime* (not at install time):
|
|
107
|
+
>
|
|
108
|
+
> - **Linux:** KVM (`/dev/kvm`) or MSHV (`/dev/mshv`)
|
|
109
|
+
|
|
110
|
+
## Quick start
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from langchain_hyperlight import HyperlightSandboxTool
|
|
114
|
+
|
|
115
|
+
tool = HyperlightSandboxTool(
|
|
116
|
+
host_tools={
|
|
117
|
+
"add": lambda a=0, b=0: a + b,
|
|
118
|
+
"greet": lambda name="world": f"Hello, {name}!",
|
|
119
|
+
},
|
|
120
|
+
allowed_domains={"https://httpbin.org": ["GET"]},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
result = tool.invoke({
|
|
124
|
+
"code": """
|
|
125
|
+
total = call_tool('add', a=3, b=4)
|
|
126
|
+
greeting = call_tool('greet', name='James')
|
|
127
|
+
print(f"3 + 4 = {total}")
|
|
128
|
+
print(greeting)
|
|
129
|
+
""",
|
|
130
|
+
})
|
|
131
|
+
print(result)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Using it inside an agent
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from langchain_core.tools import create_agent # or your agent of choice
|
|
138
|
+
|
|
139
|
+
agent = create_agent(model, tools=[tool])
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The tool is a standard `langchain_core.tools.BaseTool`, so it works with any LangChain agent
|
|
143
|
+
runtime (LangGraph, `create_agent`, `AgentExecutor`, etc.).
|
|
144
|
+
|
|
145
|
+
## Relationship to Microsoft's Agent Framework
|
|
146
|
+
|
|
147
|
+
Microsoft ships an official Hyperlight integration for *its own* Agent Framework:
|
|
148
|
+
[`agent-framework-hyperlight`](https://github.com/microsoft/agent-framework/tree/main/python/packages/hyperlight)
|
|
149
|
+
(`HyperlightExecuteCodeTool` / `HyperlightCodeActProvider`). This package is the **LangChain**
|
|
150
|
+
equivalent: it targets `langchain_core.tools.BaseTool` and mirrors the same concepts — the
|
|
151
|
+
`execute_code` tool name, `file_mounts`, `allowed_domains`, and host-tool dispatch via
|
|
152
|
+
`call_tool(...)` — so the mental model transfers directly.
|
|
153
|
+
|
|
154
|
+
### Thread safety
|
|
155
|
+
|
|
156
|
+
The Hyperlight `WasmSandbox` is `unsendable` in PyO3: it may only be accessed and dropped from
|
|
157
|
+
the OS thread that created it, or it panics. This tool routes every sandbox operation through a
|
|
158
|
+
dedicated single-threaded worker, so it is safe to call from arbitrary threads and event loops
|
|
159
|
+
(including LangChain's async `ainvoke`).
|
|
160
|
+
|
|
161
|
+
## Guest environment
|
|
162
|
+
|
|
163
|
+
By default the sandbox runs **Python**. Inside the guest, these built-ins are available:
|
|
164
|
+
|
|
165
|
+
| Function | Purpose |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `call_tool(name, **kwargs)` | Invoke a host-registered tool by name |
|
|
168
|
+
| `http_get(url)` / `http_post(url, body=...)` | HTTP to allow-listed domains only |
|
|
169
|
+
| `read_file(path)` / `write_file(path, data)` | Capability-based file I/O (`/input`, `/output`) |
|
|
170
|
+
|
|
171
|
+
## Configuration
|
|
172
|
+
|
|
173
|
+
`HyperlightSandboxTool` forwards its constructor arguments to
|
|
174
|
+
[`hyperlight_sandbox.Sandbox`](https://github.com/hyperlight-dev/hyperlight-sandbox):
|
|
175
|
+
|
|
176
|
+
| Argument | Default | Description |
|
|
177
|
+
| --- | --- | --- |
|
|
178
|
+
| `backend` | `"wasm"` | `"wasm"` (Python/JS guest) or `"hyperlight-js"` |
|
|
179
|
+
| `module` | `"python_guest.path"` | Packaged guest module reference |
|
|
180
|
+
| `module_path` | `None` | Explicit path to a `.aot`/`.wasm` guest |
|
|
181
|
+
| `input_dir` / `output_dir` | `None` | Host directories mounted into the guest |
|
|
182
|
+
| `temp_output` | `False` | Use a temporary output directory |
|
|
183
|
+
| `heap_size` / `stack_size` | `None` | Guest memory limits (e.g. `"25Mi"`) |
|
|
184
|
+
| `host_tools` | `{}` | `{name: callable}` exposed to the guest |
|
|
185
|
+
| `allowed_domains` | `{}` | Network allow-list (see below) |
|
|
186
|
+
| `file_mounts` | `{}` | Host paths staged into the guest `/input` tree (see below) |
|
|
187
|
+
|
|
188
|
+
`allowed_domains` accepts a domain string, a `(target, methods)` tuple, an `AllowedDomain`, or a
|
|
189
|
+
sequence of any of these:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from langchain_hyperlight import AllowedDomain
|
|
193
|
+
|
|
194
|
+
tool = HyperlightSandboxTool(
|
|
195
|
+
allowed_domains=[
|
|
196
|
+
"api.github.com", # all methods
|
|
197
|
+
("internal.example.com", "GET"), # GET only
|
|
198
|
+
AllowedDomain("https://httpbin.org", ("GET", "POST")),
|
|
199
|
+
],
|
|
200
|
+
)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`file_mounts` accepts a path string (same path on host and in the sandbox), a
|
|
204
|
+
`(host_path, mount_path)` tuple, a `FileMount`, or a sequence of any of these. Mounted files are
|
|
205
|
+
staged into a managed temporary `/input` tree and are readable in the guest via `read_file(...)`:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from langchain_hyperlight import FileMount
|
|
209
|
+
|
|
210
|
+
tool = HyperlightSandboxTool(
|
|
211
|
+
file_mounts=[
|
|
212
|
+
"/host/data", # -> /input/data
|
|
213
|
+
("/host/models", "models"), # -> /input/models
|
|
214
|
+
FileMount("/host/config", "config"), # -> /input/config
|
|
215
|
+
],
|
|
216
|
+
)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The `create_hyperlight_tool()` factory is a thin convenience over the same constructor:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
from langchain_hyperlight import create_hyperlight_tool
|
|
223
|
+
|
|
224
|
+
tool = create_hyperlight_tool(
|
|
225
|
+
host_tools={"add": lambda a=0, b=0: a + b},
|
|
226
|
+
allowed_domains=["api.github.com"],
|
|
227
|
+
)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
> **Note:** the tool always creates and owns its sandbox on a dedicated thread. Do not
|
|
231
|
+
> construct a `hyperlight_sandbox.Sandbox` yourself and try to share it across threads — the
|
|
232
|
+
> underlying `WasmSandbox` is `unsendable` and will panic if touched from a different thread
|
|
233
|
+
> than the one that created it. The tool manages this confinement for you.
|
|
234
|
+
|
|
235
|
+
## Running on Bluefin / Fedora Silverblue (immutable)
|
|
236
|
+
|
|
237
|
+
Bluefin is an immutable Fedora (Silverblue) image. The package itself installs normally into a
|
|
238
|
+
virtual environment, but the **KVM hypervisor** must be available on the host:
|
|
239
|
+
|
|
240
|
+
1. **Verify virtualization is enabled** in firmware (AMD-V / Intel VT-x):
|
|
241
|
+
|
|
242
|
+
```shell
|
|
243
|
+
grep -E 'vmx|svm' /proc/cpuinfo
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
2. **Ensure the KVM device exists** (the `kvm_amd`/`kvm_intel` module is loaded):
|
|
247
|
+
|
|
248
|
+
```shell
|
|
249
|
+
ls -l /dev/kvm
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
If it is missing, the module is not loaded. On Bluefin this is usually a firmware/BIOS
|
|
253
|
+
setting (enable SVM/VT-x) rather than a package issue, since the kernel ships KVM.
|
|
254
|
+
|
|
255
|
+
3. **Add your user to the `kvm` group** so you can open `/dev/kvm` without root:
|
|
256
|
+
|
|
257
|
+
```shell
|
|
258
|
+
sudo usermod -aG kvm $USER
|
|
259
|
+
# log out and back in, then verify:
|
|
260
|
+
groups
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
4. **Install the package in a venv** (never layer Python packages system-wide on an immutable
|
|
264
|
+
image — use `uv`, `pipx`, or a `distrobox`/`toolbox` container):
|
|
265
|
+
|
|
266
|
+
```shell
|
|
267
|
+
uv venv .venv
|
|
268
|
+
uv pip install --python .venv/bin/python langchain-hyperlight
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
For a fully isolated dev environment, `distrobox` is the idiomatic Bluefin approach:
|
|
272
|
+
|
|
273
|
+
```shell
|
|
274
|
+
distrobox create --name hyperlight-dev --image fedora:latest
|
|
275
|
+
distrobox enter hyperlight-dev
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Development
|
|
279
|
+
|
|
280
|
+
```shell
|
|
281
|
+
uv venv .venv
|
|
282
|
+
uv pip install --python .venv/bin/python -e ".[dev]"
|
|
283
|
+
.venv/bin/pytest
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Tests that require a hypervisor are skipped automatically when `/dev/kvm` (or `/dev/mshv`) is
|
|
287
|
+
unavailable.
|
|
288
|
+
|
|
289
|
+
## References
|
|
290
|
+
|
|
291
|
+
- [Hyperlight project site](https://hyperlight.org/) — official docs and getting-started guide
|
|
292
|
+
- [hyperlight-dev/hyperlight](https://github.com/hyperlight-dev/hyperlight) — the VMM itself
|
|
293
|
+
- [hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox) — the
|
|
294
|
+
multi-backend sandbox framework this tool wraps
|
|
295
|
+
- [hyperlight-dev/hyperlight-wasm](https://github.com/hyperlight-dev/hyperlight-wasm) — the Wasm
|
|
296
|
+
component backend
|
|
297
|
+
- [Microsoft Agent Framework Hyperlight integration](https://github.com/microsoft/agent-framework/tree/main/python/packages/hyperlight) —
|
|
298
|
+
the canonical `agent-framework-hyperlight` package this tool mirrors
|
|
299
|
+
- [Microsoft Learn: Hyperlight integration](https://learn.microsoft.com/en-us/agent-framework/integrations/hyperlight)
|
|
300
|
+
- [`hyperlight-sandbox` on PyPI](https://pypi.org/project/hyperlight-sandbox/)
|
|
301
|
+
|
|
302
|
+
## License
|
|
303
|
+
|
|
304
|
+
Apache-2.0. Hyperlight is a [CNCF](https://cncf.io/) sandbox project.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
langchain_hyperlight/__init__.py,sha256=j_0iVrivN_JYBU7t8kTCsiPVBjmfvJEQDuzsB6fBX-g,919
|
|
2
|
+
langchain_hyperlight/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
langchain_hyperlight/tool.py,sha256=02CTVY58JnLP5WCANn7KFAbo2oHm1m4SB2Woc0zw1o8,22219
|
|
4
|
+
langchain_hyperlight-0.1.0.dist-info/METADATA,sha256=ymj4o-PbHI1Q8hDlWMx9mXndpX2KYhBI9HJCBQfB8m0,12525
|
|
5
|
+
langchain_hyperlight-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
langchain_hyperlight-0.1.0.dist-info/licenses/LICENSE,sha256=fs2M4dMLiqJiMvXHyHjMpTvCc1R85S8WePlVFUdG5k8,709
|
|
7
|
+
langchain_hyperlight-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
you may not use this file except in compliance with the License.
|
|
7
|
+
You may obtain a copy of the License at
|
|
8
|
+
|
|
9
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
|
|
11
|
+
Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
See the License for the specific language governing permissions and
|
|
15
|
+
limitations under the License.
|