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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.