Skip to content
Closed
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
7 changes: 7 additions & 0 deletions python/packages/core/agent_framework/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@ class IntegrationInvalidRequestException(IntegrationException):
pass


class CodeActFidesCoverageError(IntegrationInvalidRequestException):
"""Required FIDES policy coverage is unavailable for nested CodeAct host tools.

This is an opt-in refusal, not evidence that CodeAct provides FIDES mediation.
"""
Comment on lines +144 to +148


class IntegrationInvalidResponseException(IntegrationException):
"""An invalid or unexpected response was received from an external integration."""

Expand Down
28 changes: 28 additions & 0 deletions python/packages/hyperlight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,34 @@ 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).

### Opt-in fail-closed host-tool protection

Applications that **explicitly require** FIDES coverage can set `require_fides=True`
on `HyperlightCodeActProvider` or `HyperlightExecuteCodeTool`. CodeAct cannot currently
mediate nested host-tool calls through FIDES, so this option **refuses any
nonempty managed host-tool registry**, regardless of FIDES metadata. Both initial
registration and dynamic additions fail with `CodeActFidesCoverageError` before
any host-tool body can run. The requirement carries into run-scoped snapshots.
Code execution without registered host tools remains available.

```python
from agent_framework.exceptions import CodeActFidesCoverageError
from agent_framework_hyperlight import HyperlightCodeActProvider

# Strict CodeAct may execute Python, but cannot call nested host functions.
provider = HyperlightCodeActProvider(require_fides=True)

# A nonempty `tools=` argument or later provider.add_tools(...) raises
# CodeActFidesCoverageError, preventing an unsupported security boundary.
```

Pass the option from **trusted host configuration** alongside your FIDES setup.
There is no automatic detection of middleware, and the option does not enforce
FIDES on filesystem/network access or intermediate program values. If nested
host tools are needed, register security-sensitive functions as direct agent
tools under their ordinary FIDES middleware instead. With the default
`require_fides=False`, existing behavior and diagnostic warnings are unchanged.

## Installation

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
_ToolDescriptionFormat, # pyright: ignore[reportPrivateUsage]
normalize_tools,
)
from agent_framework.exceptions import CodeActFidesCoverageError

from ._instructions import build_codeact_instructions, build_execute_code_description
from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountHostPath, FileMountInput
Expand Down Expand Up @@ -112,6 +113,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
require_fides: bool = False

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


Expand Down Expand Up @@ -413,6 +416,16 @@ def _load_sandbox_class() -> type[Any]:
return Sandbox


def _ensure_fides_coverage(*, require_fides: bool, tools: Sequence[FunctionTool]) -> None:
"""Reject unsupported nested host tools before any guest callback runs."""
if require_fides and tools:
raise CodeActFidesCoverageError(
"CodeAct host tools cannot execute with require_fides=True: "
"per-host-tool FIDES middleware enforcement is unavailable. "
"Remove the CodeAct host tools or disable CodeAct host-tool access."
)


def _collect_tools(*tool_groups: Any) -> list[FunctionTool]:
tools_by_name: dict[str, FunctionTool] = {}

Expand Down Expand Up @@ -1257,10 +1270,15 @@ def _build_execution_contents(
return outputs


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

def _callback(**kwargs: Any) -> Any:
if require_fides:
raise CodeActFidesCoverageError(
"CodeAct nested host-tool execution refused: required FIDES enforcement is unavailable."
)

async def _invoke() -> Any:
return await sandbox_tool.invoke(arguments=kwargs, skip_parsing=True)

Expand Down Expand Up @@ -1408,6 +1426,7 @@ def execute(self, *, config: _RunConfig, code: str) -> list[Content]:
objects never escape the worker; this method returns only sendable plain Python data.
Entries whose output cannot be cleaned safely are evicted before another invocation.
"""
_ensure_fides_coverage(require_fides=config.require_fides, tools=config.tools)
cache_key = config.cache_key()
entry = self._get_or_create_entry(config)
try:
Expand Down Expand Up @@ -1459,6 +1478,7 @@ def close(self) -> None:
del entries

def _create_entry(self, config: _RunConfig) -> _SandboxEntry:
_ensure_fides_coverage(require_fides=config.require_fides, tools=config.tools)
input_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None
output_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None

Expand Down Expand Up @@ -1487,7 +1507,9 @@ 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, require_fides=config.require_fides)
)

for allowed_domain in config.allowed_domains:
for target in _allowed_domain_registration_targets(
Expand Down Expand Up @@ -1535,6 +1557,8 @@ class HyperlightExecuteCodeTool(FunctionTool):
"""Execute Python code inside a Hyperlight sandbox.

Keyword Args:
require_fides: Opt-in, trusted host requirement for nested FIDES coverage. Unsupported
host-tool registration and execution are refused; code without host tools may run.
tool_description_format: Parameter documentation in ``.description``: ``"compact"`` (default)
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.
Expand All @@ -1557,7 +1581,10 @@ def __init__(
module: str | None = DEFAULT_HYPERLIGHT_MODULE,
module_path: str | None = None,
_registry: SandboxRuntime | None = None,
require_fides: bool = False,
) -> None:
if not isinstance(require_fides, bool):
raise TypeError("require_fides 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 @@ -1570,6 +1597,7 @@ def __init__(
input_model=EXECUTE_CODE_INPUT_SCHEMA,
)
self._state_lock = threading.RLock()
self._require_fides = require_fides
self._tool_description_format: _NormalizedToolDescriptionFormat = normalized_description_format
self._registry = _registry or _SandboxRegistry()
self._default_approval_mode: ApprovalMode = approval_mode or "never_require"
Expand Down Expand Up @@ -1600,6 +1628,7 @@ def description(self) -> str:
return str(self.__dict__.get("description", EXECUTE_CODE_TOOL_DESCRIPTION))

with state_lock:
_ensure_fides_coverage(require_fides=self._require_fides, tools=self._managed_tools)
allowed_domains = sorted(self._allowed_domains.values(), key=lambda value: value.target)
return build_execute_code_description(
tools=self._managed_tools,
Expand All @@ -1614,6 +1643,11 @@ def description(self) -> str:
def description(self, value: str) -> None:
self.__dict__["description"] = value

@property
def require_fides(self) -> bool:
"""Whether nested host-tool calls require unavailable FIDES enforcement."""
return self._require_fides

def add_tools(
self,
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]],
Expand All @@ -1622,6 +1656,7 @@ def add_tools(
with self._state_lock:
new_tools = _collect_tools(tools)
combined_tools = _collect_tools(self._managed_tools, new_tools)
_ensure_fides_coverage(require_fides=self._require_fides, tools=combined_tools)
self._managed_tools = combined_tools
for tool_obj in new_tools:
if tool_obj.additional_properties and not _FIDES_TOOL_PROPERTY_KEYS.isdisjoint(
Expand Down Expand Up @@ -1735,6 +1770,8 @@ def build_instructions(self, *, tools_visible_to_model: bool) -> str:

def create_run_tool(self) -> HyperlightExecuteCodeTool:
"""Create a run-scoped snapshot of this execute_code surface."""
with self._state_lock:
_ensure_fides_coverage(require_fides=self._require_fides, tools=self._managed_tools)
file_mounts = self.get_file_mounts()
allowed_domains = self.get_allowed_domains()

Expand All @@ -1752,6 +1789,7 @@ def create_run_tool(self) -> HyperlightExecuteCodeTool:
module=self._module,
module_path=self._module_path,
_registry=self._registry,
require_fides=self._require_fides,
)

def build_serializable_state(self) -> dict[str, Any]:
Expand All @@ -1762,6 +1800,7 @@ def build_serializable_state(self) -> dict[str, Any]:
"module": config.module,
"module_path": config.module_path,
"approval_mode": config.approval_mode,
"require_fides": config.require_fides,
"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 @@ -1803,6 +1842,7 @@ def _refresh_approval_mode(self) -> None:
def _build_run_config(self) -> _RunConfig:
with self._state_lock:
managed_tools = tuple(self._managed_tools)
_ensure_fides_coverage(require_fides=self._require_fides, tools=managed_tools)
workspace_root = self._workspace_root
stored_mounts = tuple(self._file_mounts.values())
allowed_domains = tuple(sorted(self._allowed_domains.values(), key=lambda value: value.target))
Expand Down Expand Up @@ -1830,6 +1870,7 @@ def _build_run_config(self) -> _RunConfig:
module_path=self._module_path,
approval_mode=approval_mode,
tools=managed_tools,
require_fides=self._require_fides,
workspace_root=workspace_root,
workspace_signature=workspace_signature,
file_mounts=normalized_mounts,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ def __init__(
module: str | None = "python_guest.path",
module_path: str | None = None,
_registry: SandboxRuntime | None = None,
require_fides: bool = False,
) -> None:
super().__init__(source_id)
self._execute_code_tool = HyperlightExecuteCodeTool(
Expand All @@ -66,6 +67,7 @@ def __init__(
module=module,
module_path=module_path,
_registry=_registry,
require_fides=require_fides,
)

def add_tools(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Copyright (c) Microsoft. All rights reserved.

"""Regression tests for opt-in fail-closed FIDES handling in Hyperlight CodeAct."""

from __future__ import annotations

import asyncio
from dataclasses import replace
from typing import Any

import pytest
from agent_framework import Content, tool
from agent_framework.exceptions import CodeActFidesCoverageError

from agent_framework_hyperlight import HyperlightCodeActProvider, HyperlightExecuteCodeTool
from agent_framework_hyperlight import _execute_code_tool as execute_module

host_calls: list[str] = []


@tool
def sensitive_tool(value: str) -> str:
"""Example sensitive host function."""
host_calls.append(value)
return value


class SpyRuntime:
def __init__(self) -> None:
self.invocations = 0

def execute(self, *, config: Any, code: str) -> list[Content]:
self.invocations += 1
return [Content.from_text("No host-tool invocation")]


@pytest.fixture(autouse=True)
def reset_side_effects() -> None:
host_calls.clear()


@pytest.mark.parametrize("entry_point", [HyperlightExecuteCodeTool, HyperlightCodeActProvider])
def test_strict_rejects_registered_host_tool(entry_point: type) -> None:
spy = SpyRuntime()
with pytest.raises(CodeActFidesCoverageError, match="require_fides=True"):
entry_point(tools=[sensitive_tool], require_fides=True, _registry=spy)
assert spy.invocations == 0
assert not host_calls


@pytest.mark.parametrize("entry_point", [HyperlightExecuteCodeTool, HyperlightCodeActProvider])
def test_strict_rejects_dynamic_tools_without_mutation(entry_point: type) -> None:
codeact = entry_point(require_fides=True, _registry=SpyRuntime())
with pytest.raises(CodeActFidesCoverageError):
codeact.add_tools(sensitive_tool)
assert codeact.get_tools() == []
assert not host_calls


@pytest.mark.parametrize("invalid", [None, 1, "true", [], object()])
def test_strict_option_requires_bool(invalid: object) -> None:
with pytest.raises(TypeError, match="must be a bool"):
HyperlightExecuteCodeTool(require_fides=invalid, _registry=SpyRuntime()) # type: ignore[arg-type]


async def test_strict_no_host_tools_can_execute_and_snapshot() -> None:
spy = SpyRuntime()
provider = HyperlightCodeActProvider(require_fides=True, _registry=spy)
execute_code = provider._execute_code_tool
assert execute_code.require_fides
with pytest.raises(AttributeError):
execute_code.require_fides = False # type: ignore[misc]
assert execute_code.build_serializable_state()["require_fides"] is True
assert execute_code.build_instructions(tools_visible_to_model=False)
run_tool = execute_code.create_run_tool()
assert run_tool.require_fides
assert run_tool._build_run_config().require_fides
assert run_tool.build_serializable_state()["require_fides"] is True
assert run_tool.get_tools() == []
contents = await run_tool._run_code(code="print('safe')")
assert contents[0].type == "text"
assert spy.invocations == 1
assert not host_calls


def test_legacy_default_preserves_host_tool_registration() -> None:
codeact = HyperlightExecuteCodeTool(tools=sensitive_tool, _registry=SpyRuntime())
assert codeact.require_fides is False
assert codeact.get_tools() == [sensitive_tool]
assert codeact._build_run_config().tools == (sensitive_tool,)
assert codeact.build_serializable_state()["require_fides"] is False


async def test_strict_rechecks_run_config_before_runtime() -> None:
spy = SpyRuntime()
codeact = HyperlightExecuteCodeTool(require_fides=True, _registry=spy)
codeact._managed_tools.append(sensitive_tool)
with pytest.raises(CodeActFidesCoverageError):
await codeact._run_code(code="call_tool('sensitive_tool', value='secret')")
with pytest.raises(CodeActFidesCoverageError):
codeact.create_run_tool()
with pytest.raises(CodeActFidesCoverageError):
codeact.build_serializable_state()
with pytest.raises(CodeActFidesCoverageError):
codeact.build_instructions(tools_visible_to_model=True)
assert spy.invocations == 0
assert not host_calls


def test_strict_registry_rechecks_forged_run_config_before_any_setup(monkeypatch: pytest.MonkeyPatch) -> None:
codeact = HyperlightExecuteCodeTool(require_fides=True, _registry=SpyRuntime())
config = replace(codeact._build_run_config(), tools=(sensitive_tool,))
runtime = execute_module._SandboxRegistry()
try:

def unexpected_entry(*args: Any, **kwargs: Any) -> None:
pytest.fail("Sandbox should not be allocated on denied requests")

monkeypatch.setattr(runtime, "_get_or_create_entry", unexpected_entry)
with pytest.raises(CodeActFidesCoverageError):
runtime.execute(config=config, code="call_tool('sensitive_tool', value='secret')")
with pytest.raises(CodeActFidesCoverageError):
runtime._create_entry(config)
finally:
runtime.close()
assert not host_calls


def test_strict_callback_never_invokes_host_tool() -> None:
callback = execute_module._make_sandbox_callback(sensitive_tool, require_fides=True)
with pytest.raises(CodeActFidesCoverageError):
callback(value="private-value")
assert not host_calls


async def test_concurrent_strict_requests_fail_before_runtime() -> None:
spy = SpyRuntime()
codeact = HyperlightExecuteCodeTool(require_fides=True, _registry=spy)
codeact._managed_tools.append(sensitive_tool)
results = await asyncio.gather(
*(codeact._run_code(code="call_tool('sensitive_tool', value='secret')") for _ in range(25)),
return_exceptions=True,
)
assert all(isinstance(item, CodeActFidesCoverageError) for item in results)
assert spy.invocations == 0
assert not host_calls
Loading
Loading