Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions python/packages/hyperlight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,25 @@ Hyperlight-backed CodeAct integrations for Microsoft Agent Framework.
> Sandbox isolation does not supply FIDES enforcement.
> See the [FIDES guide](../../samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md).

## Error diagnostics

Host tool callbacks expose a generic `RuntimeError("Host tool execution failed.")`
by default, before diagnostics reach generated code. Raw sandbox failure details
are omitted from error contents. Successful outputs and known-safe output-limit
or cleanup errors retain their existing behavior.

Both `HyperlightCodeActProvider` and `HyperlightExecuteCodeTool` accept the
host-side option `include_detailed_errors=True` for trusted channels. It preserves
original callback exceptions and sandbox error details, which may contain
sensitive data. It is not a parameter of `execute_code` and is independent of the
outer chat client's error policy and sensitive-data logging.

The policy is captured in each run snapshot and serialized state. Sandbox cache
keys include it, so detailed-error callbacks are not reused for redacted runs.
Initialization, snapshot restore, and execution failures follow the same policy.
Failed sandbox entries are retired; cancellation and system-exit signals still
propagate without becoming ordinary execution errors.

## Installation

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ class _RunConfig:
max_output_files: int = DEFAULT_MAX_OUTPUT_FILES
max_output_file_bytes: int = DEFAULT_MAX_OUTPUT_FILE_BYTES
max_output_total_bytes: int = DEFAULT_MAX_OUTPUT_TOTAL_BYTES
include_detailed_errors: bool = False

@property
def mounted_paths(self) -> tuple[str, ...]:
Expand All @@ -135,6 +136,7 @@ def cache_key(self) -> tuple[Any, ...]:
self.max_output_files,
self.max_output_file_bytes,
self.max_output_total_bytes,
self.include_detailed_errors,
)


Expand Down Expand Up @@ -173,7 +175,8 @@ class _SandboxWorker:
are dropped on the wrong thread and PyO3 panics. To prevent this, every
exception raised inside a worker closure is caught on the worker, the
traceback is dropped while still on the worker thread, and a sanitized
copy (preserving message and exception type) is re-raised on the caller.
copy is re-raised on the caller. Ordinary failures expose their original
type and message only when detailed errors are enabled.
"""

__slots__ = ("_executor", "_initialized", "_reusable", "_sandbox", "_snapshot")
Expand All @@ -186,28 +189,36 @@ def __init__(self, *, name: str = "hl-sandbox") -> None:
self._initialized = False
self._reusable = True

def _run_on_worker(self, fn: Callable[[], _T]) -> _T:
def _run_on_worker(self, fn: Callable[[], _T], *, include_detailed_errors: bool = False) -> _T:
"""Run ``fn`` on the worker thread; sanitize any exception's traceback there.

If ``fn`` raises, the exception's ``__traceback__`` is dropped on the worker
thread (so any PyO3 unsendable locals captured in frame locals are released
on the owner thread) and a fresh exception of the same type is raised on
the caller's thread carrying only the original message.
on the owner thread). Ordinary failures are replaced with a generic
exception unless detailed errors are enabled. Cancellation and other
system signals retain their type.
"""

def _wrapped() -> tuple[bool, Any]:
try:
return True, fn()
except BaseException as exc:
exc_type = type(exc)
self._reusable = False
exc_type: type[BaseException]
exc_args: tuple[str, ...]
# Capture args (usually (message,)) so the re-raised exception keeps the
# original shape for types whose constructor doesn't accept a single str.
# Coerce each arg to ``str`` on the worker thread: if a caller-supplied
# callback (or an underlying SDK) constructed the exception with a PyO3
# unsendable object in args, forwarding it as-is would re-introduce the
# same cross-thread Drop hazard the traceback nulling avoids. Strings
# are always sendable. Fall back to the str() form if args is empty.
exc_args: tuple[str, ...] = tuple(str(a) for a in exc.args) if exc.args else (str(exc),)
if isinstance(exc, Exception) and not include_detailed_errors:
exc_type = RuntimeError
exc_args = ("Code execution failed.",)
else:
exc_type = type(exc)
exc_args = tuple(str(a) for a in exc.args) if exc.args else (str(exc),)
# Drop the traceback on the worker thread so frame locals (which
# may include PyO3 unsendable objects) are released here, not on
# the caller thread that will receive the wrapped exception.
Expand All @@ -224,11 +235,12 @@ def _wrapped() -> tuple[bool, Any]:
# If the exception type's constructor rejects the captured args (rare), fall
# back to a RuntimeError carrying the string form so we never lose the signal.
try:
raise exc_type(*exc_args)
error = exc_type(*exc_args)
except TypeError:
raise RuntimeError(f"{exc_type.__name__}: {exc_args}") from None
raise error

def initialize(self, build_fn: Callable[[], tuple[Any, Any]]) -> None:
def initialize(self, build_fn: Callable[[], tuple[Any, Any]], *, include_detailed_errors: bool = False) -> None:
"""Build and install the sandbox+snapshot on the worker thread.

``build_fn`` is invoked with no arguments on the worker thread. It must
Expand All @@ -244,7 +256,7 @@ def _init_on_worker() -> None:
# Locals fall out of scope on the worker thread; the worker-local
# attributes hold the only strong refs from now on.

self._run_on_worker(_init_on_worker)
self._run_on_worker(_init_on_worker, include_detailed_errors=include_detailed_errors)

def execute(
self,
Expand All @@ -255,6 +267,7 @@ def execute(
max_output_files: int,
max_output_file_bytes: int,
max_output_total_bytes: int,
include_detailed_errors: bool = False,
) -> list[Content]:
"""Restore + run + build sendable contents — all on the worker thread.

Expand Down Expand Up @@ -290,6 +303,7 @@ def _on_worker() -> list[Content]:
max_output_files=max_output_files,
max_output_file_bytes=max_output_file_bytes,
max_output_total_bytes=max_output_total_bytes,
include_detailed_errors=include_detailed_errors,
Comment thread
eavanvalkenburg marked this conversation as resolved.
Resolved
)
if output_dir is not None and not any(item.type == "data" for item in contents):
# Isolate any file that becomes visible after the finite discovery window
Expand All @@ -313,7 +327,7 @@ def _on_worker() -> list[Content]:
if contents is not None and not any(item.type == "error" for item in contents):
contents.append(_output_cleanup_error_content(exc))

return self._run_on_worker(_on_worker)
return self._run_on_worker(_on_worker, include_detailed_errors=include_detailed_errors)

def is_reusable(self) -> bool:
return self._reusable
Expand Down Expand Up @@ -1209,6 +1223,7 @@ def _build_execution_contents(
max_output_files: int,
max_output_file_bytes: int,
max_output_total_bytes: int,
include_detailed_errors: bool = False,
) -> list[Content]:
success = bool(getattr(result, "success", False))
stdout = str(getattr(result, "stdout", "") or "").replace("\r\n", "\n") or None
Expand Down Expand Up @@ -1246,7 +1261,7 @@ def _build_execution_contents(
outputs.append(Content.from_text("Code executed successfully without output."))
return outputs

error_details = stderr or "Unknown sandbox error"
error_details = (stderr or "Unknown sandbox error") if include_detailed_errors else None
outputs.append(
Content.from_error(
message="Execution error",
Expand All @@ -1257,7 +1272,7 @@ def _build_execution_contents(
return outputs


def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]:
def _make_sandbox_callback(tool_obj: FunctionTool, *, include_detailed_errors: bool = False) -> Callable[..., Any]:
sandbox_tool = copy(tool_obj)

def _callback(**kwargs: Any) -> Any:
Expand All @@ -1282,6 +1297,8 @@ def _run() -> None:
worker.start()
worker.join()
if error_box:
if not include_detailed_errors and isinstance(error_box[0], Exception):
raise RuntimeError("Host tool execution failed.") from None
raise error_box[0]
# Return the raw value. The Hyperlight FFI marshals primitives (dict, list,
# str, int, float, bool, None) natively into the guest, and falls back to
Expand Down Expand Up @@ -1418,6 +1435,7 @@ def execute(self, *, config: _RunConfig, code: str) -> list[Content]:
max_output_files=config.max_output_files,
max_output_file_bytes=config.max_output_file_bytes,
max_output_total_bytes=config.max_output_total_bytes,
include_detailed_errors=config.include_detailed_errors,
)
finally:
if not entry.worker.is_reusable():
Expand Down Expand Up @@ -1487,7 +1505,10 @@ def _create_sandbox() -> Any:

def _configure_sandbox(*, sandbox: Any, expand_missing_scheme: bool) -> None:
for tool_obj in config.tools:
sandbox.register_tool(tool_obj.name, _make_sandbox_callback(tool_obj))
sandbox.register_tool(
tool_obj.name,
_make_sandbox_callback(tool_obj, include_detailed_errors=config.include_detailed_errors),
)

for allowed_domain in config.allowed_domains:
for target in _allowed_domain_registration_targets(
Expand Down Expand Up @@ -1518,7 +1539,7 @@ def _build_sandbox() -> tuple[Any, Any]:

worker = _SandboxWorker()
try:
worker.initialize(_build_sandbox)
worker.initialize(_build_sandbox, include_detailed_errors=config.include_detailed_errors)
except BaseException:
worker.dispose()
raise
Expand Down Expand Up @@ -1573,13 +1594,16 @@ class HyperlightExecuteCodeTool(FunctionTool):
or ``"json"``, globally or mapped by exact, case-sensitive tool name. Missing names use
compact format. Schemas that cannot be represented faithfully in compact form use JSON Schema.
Mappings are copied, including entries for tools registered later.
include_detailed_errors: Expose host exception and execution diagnostics only for a trusted
channel. Defaults to ``False`` and is independent of outer-client error/logging settings.
"""

def __init__(
self,
*,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
tool_description_format: _ToolDescriptionFormat = "compact",
include_detailed_errors: bool = False,
approval_mode: ApprovalMode | None = None,
workspace_root: str | Path | None = None,
file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
Expand All @@ -1593,6 +1617,8 @@ def __init__(
_registry: SandboxRuntime | None = None,
_fides_warned_metadata: list[tuple[FunctionTool, dict[str, str]]] | None = None,
) -> None:
if not isinstance(include_detailed_errors, bool):
raise TypeError("include_detailed_errors must be a bool.")
normalized_description_format = _normalize_tool_description_format(tool_description_format)
max_output_files = _validate_positive_integer(name="max_output_files", value=max_output_files)
max_output_file_bytes = _validate_positive_integer(name="max_output_file_bytes", value=max_output_file_bytes)
Expand All @@ -1608,6 +1634,7 @@ def __init__(
self._tool_description_format: _NormalizedToolDescriptionFormat = normalized_description_format
self._registry = _registry or _SandboxRegistry()
self._default_approval_mode: ApprovalMode = approval_mode or "never_require"
self._include_detailed_errors = include_detailed_errors
self._workspace_root = _resolve_workspace_root(workspace_root)
self._backend: str = backend
self._module: str | None = module
Expand Down Expand Up @@ -1785,6 +1812,7 @@ def create_run_tool(self) -> HyperlightExecuteCodeTool:
tools=tools,
tool_description_format=self._tool_description_format,
approval_mode=self._default_approval_mode,
include_detailed_errors=self._include_detailed_errors,
workspace_root=self._workspace_root,
file_mounts=file_mounts or None,
allowed_domains=allowed_domains or None,
Expand All @@ -1805,6 +1833,7 @@ def build_serializable_state(self) -> dict[str, Any]:
"module": config.module,
"module_path": config.module_path,
"approval_mode": config.approval_mode,
"include_detailed_errors": config.include_detailed_errors,
"max_output_files": config.max_output_files,
"max_output_file_bytes": config.max_output_file_bytes,
"max_output_total_bytes": config.max_output_total_bytes,
Expand Down Expand Up @@ -1852,6 +1881,7 @@ def _build_run_config(self) -> _RunConfig:
max_output_files = self._max_output_files
max_output_file_bytes = self._max_output_file_bytes
max_output_total_bytes = self._max_output_total_bytes
include_detailed_errors = self._include_detailed_errors
approval_mode = _resolve_execute_code_approval_mode(
base_approval_mode=self._default_approval_mode,
tools=managed_tools,
Expand Down Expand Up @@ -1880,8 +1910,15 @@ def _build_run_config(self) -> _RunConfig:
max_output_files=max_output_files,
max_output_file_bytes=max_output_file_bytes,
max_output_total_bytes=max_output_total_bytes,
include_detailed_errors=include_detailed_errors,
)

async def _run_code(self, *, code: str) -> list[Content]:
config = self._build_run_config()
return await asyncio.to_thread(self._registry.execute, config=config, code=code)
include_detailed_errors = self._include_detailed_errors
try:
config = self._build_run_config()
return await asyncio.to_thread(self._registry.execute, config=config, code=code)
except Exception:
if include_detailed_errors:
raise
raise RuntimeError("Code execution failed.") from None
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ class HyperlightCodeActProvider(ContextProvider):
``"compact"`` (default) or ``"json"``, globally or mapped by exact, case-sensitive tool name.
Missing names use compact format; rich schemas fall back to full JSON Schema.
Mappings are copied, including entries for tools registered later.
include_detailed_errors: Expose host exception and execution diagnostics only for a trusted
channel. Defaults to ``False`` and is independent of outer-client error/logging settings.
"""

DEFAULT_SOURCE_ID = "hyperlight_codeact"
Expand All @@ -39,6 +41,7 @@ def __init__(
*,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
tool_description_format: _ToolDescriptionFormat = "compact",
include_detailed_errors: bool = False,
approval_mode: ApprovalMode | None = None,
workspace_root: str | Path | None = None,
file_mounts: FileMountInput | Sequence[FileMountInput] | None = None,
Expand All @@ -55,6 +58,7 @@ def __init__(
self._execute_code_tool = HyperlightExecuteCodeTool(
tools=tools,
tool_description_format=tool_description_format,
include_detailed_errors=include_detailed_errors,
approval_mode=approval_mode,
workspace_root=workspace_root,
file_mounts=file_mounts,
Expand Down
84 changes: 84 additions & 0 deletions python/packages/hyperlight/tests/hyperlight/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Copyright (c) Microsoft. All rights reserved.

from __future__ import annotations

import asyncio
import threading
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any

import pytest
from agent_framework import FunctionTool


@pytest.fixture
def host_error_detail() -> str:
return "synthetic backend diagnostic"


@pytest.fixture
def failing_host_tool(request: pytest.FixtureRequest, host_error_detail: str) -> FunctionTool:
def fail() -> str:
raise ValueError(host_error_detail)

async def fail_async() -> str:
await asyncio.sleep(0)
raise ValueError(host_error_detail)

return FunctionTool(
name="failing_host_tool",
description="Raise a synthetic diagnostic.",
func=fail_async if request.param else fail,
)


@pytest.fixture
def cancelled_host_tool() -> FunctionTool:
async def cancel() -> str:
raise asyncio.CancelledError

return FunctionTool(name="cancelled_host_tool", func=cancel)


@pytest.fixture
def lifecycle_sandbox_factory(host_error_detail: str) -> Callable[..., type[Any]]:
def factory(failure_stage: str, error_type: type[BaseException] = ValueError) -> type[Any]:
class LifecycleSandbox:
events: list[tuple[str, int]] = []

def __init__(self, **kwargs: Any) -> None:
self._owner_thread = threading.get_ident()
self._record("build")

def _record(self, stage: str) -> None:
self.events.append((stage, threading.get_ident()))
if stage == failure_stage:
raise error_type(host_error_detail)

def register_tool(self, name: str, callback: Any) -> None:
self._record("register_tool")

def allow_domain(self, target: str, methods: Any = None) -> None:
self._record("allow_domain")

def run(self, code: str) -> Any:
self._record("warmup" if code == "None" else "run")
return SimpleNamespace(success=True, stdout="ok\n", stderr="")

def snapshot(self) -> object:
self._record("snapshot")
return object()

def restore(self, snapshot: object) -> None:
self._record("restore")

def close(self) -> None:
self._record("close")

def __del__(self) -> None:
self.events.append(("drop", threading.get_ident()))

return LifecycleSandbox

return factory
Loading
Loading