diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index a908b5bce5d..d47504c6b50 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -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. + """ + + class IntegrationInvalidResponseException(IntegrationException): """An invalid or unexpected response was received from an external integration.""" diff --git a/python/packages/hyperlight/README.md b/python/packages/hyperlight/README.md index 3c700c4b32e..c73ce5ac942 100644 --- a/python/packages/hyperlight/README.md +++ b/python/packages/hyperlight/README.md @@ -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 diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index 45fe83f6b1c..c58326ee19c 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -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 @@ -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, ...]: @@ -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, ) @@ -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] = {} @@ -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) @@ -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: @@ -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 @@ -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( @@ -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. @@ -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) @@ -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" @@ -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, @@ -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]], @@ -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( @@ -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() @@ -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]: @@ -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, @@ -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)) @@ -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, diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_provider.py b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py index 119e7fbcdc4..17977b0b04c 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_provider.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py @@ -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( @@ -66,6 +67,7 @@ def __init__( module=module, module_path=module_path, _registry=_registry, + require_fides=require_fides, ) def add_tools( diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_fides_fail_closed.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_fides_fail_closed.py new file mode 100644 index 00000000000..886142ec0c8 --- /dev/null +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_fides_fail_closed.py @@ -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 diff --git a/python/packages/monty/README.md b/python/packages/monty/README.md index ad63ce90acf..d3f3af9b95d 100644 --- a/python/packages/monty/README.md +++ b/python/packages/monty/README.md @@ -22,6 +22,34 @@ Monty-backed CodeAct integrations for Microsoft Agent Framework. > enforce authorization and destination restrictions inside host functions. > 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 `MontyCodeActProvider` or `MontyExecuteCodeTool`. 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_monty import MontyCodeActProvider + +# Strict CodeAct may execute Python, but cannot call nested host functions. +provider = MontyCodeActProvider(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 diff --git a/python/packages/monty/agent_framework_monty/_execute_code_tool.py b/python/packages/monty/agent_framework_monty/_execute_code_tool.py index 6a143f33bb0..aa902abebcb 100644 --- a/python/packages/monty/agent_framework_monty/_execute_code_tool.py +++ b/python/packages/monty/agent_framework_monty/_execute_code_tool.py @@ -28,6 +28,7 @@ _ToolDescriptionFormat, # pyright: ignore[reportPrivateUsage] normalize_tools, ) +from agent_framework.exceptions import CodeActFidesCoverageError from ._instructions import build_codeact_instructions, build_execute_code_description from ._monty_bridge import InlineCodeBridge, generate_type_stubs @@ -212,6 +213,12 @@ class MontyExecuteCodeTool(FunctionTool): names use compact; rich schemas fall back to full JSON Schema. Mappings are copied and retain entries for tools registered later. + ``require_fides=True`` requires FIDES-compatible enforcement for all nested + host-tool calls. Because this integration cannot mediate those calls through + FIDES, it refuses nonempty host-tool surfaces instead of merely warning. + Generated code without registered host tools remains available. This option + must be wired by the trusted application alongside its FIDES configuration. + All mutators (``add_tools``, ``add_file_mounts`` etc.) must be called from the same task/thread that owns the tool. Monty itself runs on the event loop, so no internal locking is needed. @@ -226,6 +233,7 @@ def __init__( file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, resource_limits: dict[str, Any] | None = None, tool_description_format: _ToolDescriptionFormat = "compact", + require_fides: bool = False, ) -> None: super().__init__( name=EXECUTE_CODE_TOOL_NAME, @@ -234,6 +242,9 @@ def __init__( func=self._run_code, input_model=EXECUTE_CODE_INPUT_SCHEMA, ) + if not isinstance(require_fides, bool): + raise TypeError("require_fides must be a bool.") + self._require_fides = require_fides self._default_approval_mode: ApprovalMode = approval_mode or "never_require" self._tool_description_format: _NormalizedToolDescriptionFormat = _normalize_tool_description_format( tool_description_format @@ -257,6 +268,7 @@ def description(self) -> str: # During FunctionTool.__init__, ``_managed_tools`` is not yet set. if not hasattr(self, "_managed_tools"): return str(self.__dict__.get("description", EXECUTE_CODE_TOOL_DESCRIPTION)) + self._ensure_fides_coverage(self._managed_tools) return build_execute_code_description( tools=self._managed_tools, mounts=self._effective_mounts(), @@ -267,13 +279,28 @@ 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 _ensure_fides_coverage(self, tools: Sequence[FunctionTool]) -> None: + if self._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 add_tools( self, tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]], ) -> None: """Add Monty-side tools to this execute_code surface.""" new_tools = _collect_tools(tools) - self._managed_tools = _collect_tools(self._managed_tools, new_tools) + combined_tools = _collect_tools(self._managed_tools, new_tools) + self._ensure_fides_coverage(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( tool_obj.additional_properties @@ -345,6 +372,7 @@ def resource_limits(self) -> dict[str, Any] | None: def build_instructions(self, *, tools_visible_to_model: bool) -> str: """Build the current CodeAct instructions for this execute_code surface.""" + self._ensure_fides_coverage(self._managed_tools) return build_codeact_instructions( tools=list(self._managed_tools), tools_visible_to_model=tools_visible_to_model, @@ -354,6 +382,7 @@ def build_instructions(self, *, tools_visible_to_model: bool) -> str: def create_run_tool(self) -> MontyExecuteCodeTool: """Create a run-scoped snapshot of this execute_code surface.""" + self._ensure_fides_coverage(self._managed_tools) return MontyExecuteCodeTool( tools=self.get_tools(), approval_mode=self._default_approval_mode, @@ -361,10 +390,12 @@ def create_run_tool(self) -> MontyExecuteCodeTool: file_mounts=list(self._file_mounts.values()) or None, resource_limits=self._resource_limits, tool_description_format=self._tool_description_format, + require_fides=self._require_fides, ) def build_serializable_state(self) -> dict[str, Any]: """Return a JSON-serializable snapshot of the effective run state.""" + self._ensure_fides_coverage(self._managed_tools) approval_mode = _resolve_execute_code_approval_mode( base_approval_mode=self._default_approval_mode, tools=self._managed_tools, @@ -373,6 +404,7 @@ def build_serializable_state(self) -> dict[str, Any]: return { "runtime": "monty", "approval_mode": approval_mode, + "require_fides": self._require_fides, "tool_names": [tool_obj.name for tool_obj in self._managed_tools], "tool_description_format": ( dict(self._tool_description_format) @@ -404,6 +436,7 @@ def _refresh_approval_mode(self) -> None: ) def _build_tool_map(self, tools: Sequence[FunctionTool]) -> dict[str, Callable[..., Any]]: + self._ensure_fides_coverage(tools) return {tool_obj.name: _make_tool_callback(tool_obj) for tool_obj in tools} def _build_type_stub_map(self, tools: Sequence[FunctionTool]) -> dict[str, Callable[..., Any]]: @@ -438,6 +471,7 @@ def _effective_mounts(self) -> list[FileMount]: async def _run_code(self, *, code: str) -> list[Content]: tools = list(self._managed_tools) + self._ensure_fides_coverage(tools) mounts = self._effective_mounts() tool_map = self._build_tool_map(tools) diff --git a/python/packages/monty/agent_framework_monty/_provider.py b/python/packages/monty/agent_framework_monty/_provider.py index 81192a038ee..70fc97008e9 100644 --- a/python/packages/monty/agent_framework_monty/_provider.py +++ b/python/packages/monty/agent_framework_monty/_provider.py @@ -45,6 +45,7 @@ def __init__( file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, resource_limits: dict[str, Any] | None = None, tool_description_format: _ToolDescriptionFormat = "compact", + require_fides: bool = False, ) -> None: super().__init__(source_id) self._execute_code_tool = MontyExecuteCodeTool( @@ -54,6 +55,7 @@ def __init__( file_mounts=file_mounts, resource_limits=resource_limits, tool_description_format=tool_description_format, + require_fides=require_fides, ) def add_tools( diff --git a/python/packages/monty/tests/monty/test_fides_fail_closed.py b/python/packages/monty/tests/monty/test_fides_fail_closed.py new file mode 100644 index 00000000000..968719d30b7 --- /dev/null +++ b/python/packages/monty/tests/monty/test_fides_fail_closed.py @@ -0,0 +1,112 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Regression tests for opt-in fail-closed FIDES handling in Monty CodeAct.""" + +from __future__ import annotations + +import asyncio + +import pytest +from agent_framework import tool +from agent_framework.exceptions import CodeActFidesCoverageError + +from agent_framework_monty import MontyCodeActProvider, MontyExecuteCodeTool + + +@tool +def sensitive_tool(value: str) -> str: + """Example sensitive host function.""" + sensitive_tool_calls.append(value) + return value + + +sensitive_tool_calls: list[str] = [] + + +@pytest.fixture(autouse=True) +def reset_side_effects() -> None: + sensitive_tool_calls.clear() + + +@pytest.mark.parametrize("entry_point", [MontyExecuteCodeTool, MontyCodeActProvider]) +def test_strict_rejects_initial_host_tool_surface(entry_point: type) -> None: + with pytest.raises(CodeActFidesCoverageError, match="require_fides=True") as exc: + entry_point(tools=[sensitive_tool], require_fides=True) + assert "private-value" not in str(exc.value) + assert not sensitive_tool_calls + + +@pytest.mark.parametrize("entry_point", [MontyExecuteCodeTool, MontyCodeActProvider]) +def test_strict_rejects_dynamic_registration_without_mutation(entry_point: type) -> None: + codeact = entry_point(require_fides=True) + assert codeact.get_tools() == [] + with pytest.raises(CodeActFidesCoverageError): + codeact.add_tools(sensitive_tool) + assert codeact.get_tools() == [] + assert not sensitive_tool_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"): + MontyExecuteCodeTool(require_fides=invalid) # type: ignore[arg-type] + + +def test_strict_empty_surface_snapshots_and_instruction_generation() -> None: + provider = MontyCodeActProvider(require_fides=True) + execute_code = provider._execute_code_tool + assert execute_code.require_fides is True + 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 is True + assert run_tool.get_tools() == [] + assert run_tool.build_serializable_state()["require_fides"] is True + + +def test_legacy_default_accepts_unannotated_tool_and_snapshot() -> None: + codeact = MontyExecuteCodeTool(tools=sensitive_tool) + assert not codeact.require_fides + assert codeact.get_tools() == [sensitive_tool] + assert not codeact.create_run_tool().require_fides + assert codeact.build_serializable_state()["require_fides"] is False + + +async def test_strict_dispatch_guard_blocks_tampered_registry_before_bridge() -> None: + codeact = MontyExecuteCodeTool(require_fides=True) + # Defense in depth against accidental internal registry mutation after validation. + codeact._managed_tools.append(sensitive_tool) + with pytest.raises(CodeActFidesCoverageError): + await codeact._run_code(code="await sensitive_tool(value='private-value')") + with pytest.raises(CodeActFidesCoverageError): + codeact._build_tool_map([sensitive_tool]) + 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 not sensitive_tool_calls + + +async def test_concurrent_refusals_have_no_side_effects() -> None: + codeact = MontyExecuteCodeTool(require_fides=True) + codeact._managed_tools.append(sensitive_tool) + results = await asyncio.gather( + *(codeact._run_code(code="await sensitive_tool(value='private-value')") for _ in range(25)), + return_exceptions=True, + ) + assert all(isinstance(item, CodeActFidesCoverageError) for item in results) + assert not sensitive_tool_calls + + +@pytest.mark.asyncio +async def test_strict_mode_executes_code_without_host_tools() -> None: + """Strict FIDES coverage must permit code with no host-tool surface.""" + codeact = MontyExecuteCodeTool(require_fides=True) + + result = await codeact._run_code(code="print(2 + 3)") + + assert any(item.type == "text" and item.text.strip() == "5" for item in result)