diff --git a/README.md b/README.md index 55f0186c..ec1694a9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Python SDK for **AI Agent Assembly** — a governance-native runtime for AI agen ## Why use it -- **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, and MCP servers — drop in, no SDK rewrites required. +- **Framework adapters** for LangChain, LangGraph, CrewAI, OpenAI Agents, Pydantic AI, Google ADK, Haystack, and MCP servers — drop in, no SDK rewrites required. - **Pre-execution policy enforcement** via the `FrameworkAdapter` ABC — block disallowed tool calls before they hit the LLM. - **Audit trail** — every tool call, prompt, and policy decision is emitted to the gateway with full agent lineage (parent / root / team). - **Native PyO3 fast path** (optional) — drop into a Rust runtime client when you need sub-millisecond policy checks. @@ -37,6 +37,7 @@ are expected to work but are not continuously tested. | Pydantic AI | `pydantic_ai` | `>=0.1.0` | `>=0.3.0` | | CrewAI | `crewai` | `>=0.1.0` | `1.14.x` | | Google ADK | `google.adk` | `>=1.0.0,<2.0` | `1.x` line | +| Haystack | `haystack` | `>=2.0.0,<3.0` | `2.30.x` | | MCP | `mcp` | `>=1.0.0` | `1.27.x` | | OpenAI Agents | `agents` | `>=0.1.0` | `0.17.x` | diff --git a/agent_assembly/adapters/haystack/__init__.py b/agent_assembly/adapters/haystack/__init__.py new file mode 100644 index 00000000..04ff38a8 --- /dev/null +++ b/agent_assembly/adapters/haystack/__init__.py @@ -0,0 +1,12 @@ +"""Haystack adapter package. + +Governs deepset Haystack 2.x agents by intercepting tool execution at +``haystack.tools.Tool.invoke`` — the single chokepoint through which both +direct ``Tool.invoke()`` calls and the ``Agent``/``ToolInvoker`` tool-call +path flow. See ``patch.py`` for the hook-point rationale. +""" + +from agent_assembly.adapters.haystack.adapter import HaystackAdapter +from agent_assembly.adapters.haystack.patch import HaystackPatch + +__all__ = ["HaystackAdapter", "HaystackPatch"] diff --git a/agent_assembly/adapters/haystack/adapter.py b/agent_assembly/adapters/haystack/adapter.py new file mode 100644 index 00000000..cd9a63e7 --- /dev/null +++ b/agent_assembly/adapters/haystack/adapter.py @@ -0,0 +1,31 @@ +"""Haystack framework adapter.""" + +from __future__ import annotations + +from agent_assembly.adapters.base import FrameworkAdapter, GovernanceInterceptor +from agent_assembly.adapters.haystack.patch import HaystackPatch + + +class HaystackAdapter(FrameworkAdapter): + """Adapter for deepset Haystack 2.x governance hook installation.""" + + def __init__(self) -> None: + self._patch: HaystackPatch | None = None + + def get_framework_name(self) -> str: + return "haystack" + + def get_supported_versions(self) -> list[str]: + # Haystack 2.x is the line that exposes the agentic ``Agent`` / ``ToolInvoker`` + # tool-call path and the ``haystack.tools.Tool.invoke`` hook point this adapter + # patches. The 1.x line predates that API and is out of scope. + return [">=2.0.0,<3.0"] + + def register_hooks(self, interceptor: GovernanceInterceptor) -> None: + self._patch = HaystackPatch(interceptor) + self._patch.apply() + + def unregister_hooks(self) -> None: + if self._patch is not None: + self._patch.revert() + self._patch = None diff --git a/agent_assembly/adapters/haystack/patch.py b/agent_assembly/adapters/haystack/patch.py new file mode 100644 index 00000000..2bf181e8 --- /dev/null +++ b/agent_assembly/adapters/haystack/patch.py @@ -0,0 +1,265 @@ +"""Haystack runtime monkey-patch. + +Haystack (deepset; ``pip install haystack-ai``, imported as ``haystack``) executes +every tool through ``haystack.tools.Tool.invoke(**kwargs)``. The agentic +``haystack.components.agents.Agent`` dispatches its tool calls via +``haystack.components.tools.ToolInvoker``, which itself ends up calling +``tool_to_invoke.invoke(**final_args)`` (see ``ToolInvoker._make_context_bound_invoke`` +in Haystack 2.x). ``Tool.invoke`` is therefore the *single* execution chokepoint that +governs both the bare ``Tool.invoke()`` path and the full Agent tool-call loop — +patching it intercepts every tool execution, which is why governance is wired here and +not at the higher-level ``Agent`` / ``ToolInvoker`` layer. + +The interceptor contract mirrors the other tool-call adapters (CrewAI, Pydantic AI): +a ``check_tool_start`` pre-execution gate that returns ``allow`` / ``deny`` / +``pending``, an optional ``wait_for_tool_approval`` for the pending flow, and a +post-execution ``record_result`` / ``on_tool_end`` audit hook. Under the fail-closed +``enforce`` posture an unknown or malformed verdict denies (AAASM-3107). +""" + +from __future__ import annotations + +import importlib as importlib +from collections.abc import Mapping +from dataclasses import dataclass +from functools import wraps +from typing import Any, Literal, cast + +_TOOL_PATCHED_FLAG = "_agent_assembly_haystack_tool_patched" +_ORIGINAL_TOOL_INVOKE = "_agent_assembly_original_haystack_tool_invoke" +_DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS = 300 + + +@dataclass(slots=True) +class HaystackPatch: + """Applies Haystack runtime monkey-patching hooks on ``Tool.invoke``.""" + + callback_handler: Any + + def apply(self) -> bool: + """Patch ``Tool.invoke`` and return whether Haystack is available.""" + tool_cls = _load_haystack_tool_class() + if tool_cls is None: + return False + _apply_tool_invoke_patch(tool_cls, self.callback_handler) + return True + + def revert(self) -> None: + """Revert the ``Tool.invoke`` monkey patch when present.""" + tool_cls = _load_haystack_tool_class() + if tool_cls is not None: + _revert_tool_invoke_patch(tool_cls) + return None + + +def _load_haystack_tool_class() -> type[Any] | None: + try: + module = importlib.import_module("haystack.tools") + except ImportError: + return None + + tool_cls = getattr(module, "Tool", None) + if isinstance(tool_cls, type): + return tool_cls + return None + + +def _format_blocked_message(reason: str | None) -> str: + reason_text = reason or "No reason provided." + return f"[BLOCKED by governance policy] {reason_text}. Please choose a different approach to accomplish this task." + + +def _format_approval_rejected_message(reason: str | None) -> str: + reason_text = reason or "No reason provided." + return f"[APPROVAL REJECTED] Action was reviewed and denied: {reason_text}" + + +_UNKNOWN_DECISION_REASON = "Unrecognized governance decision; denied under enforce." + + +def _interceptor_enforces(callback_handler: Any) -> bool: + """Return whether the wired interceptor is in fail-closed ``enforce`` posture. + + The governance interceptor carries ``_enforce`` set from + ``enforcement_mode == "enforce"`` (AAASM-3106). A bare ``GatewayClient`` has no + such attribute and defaults to fail-open. Compared strictly against ``True`` so a + stub interceptor whose ``__getattr__`` synthesizes truthy values for missing + attributes is not mistaken for the enforce posture; the real flag is always a + ``bool``. + """ + target = getattr(callback_handler, "_interceptor", callback_handler) + return getattr(target, "_enforce", False) is True + + +def _unknown_decision(enforce: bool) -> tuple[Literal["allow", "deny", "pending"], str | None]: + """Map an unrecognized / malformed verdict, failing closed under ``enforce`` (AAASM-3107).""" + if enforce: + return "deny", _UNKNOWN_DECISION_REASON + return "allow", None + + +_KNOWN_STATUSES: frozenset[str] = frozenset({"allow", "deny", "pending"}) + + +def _coerce_known_status(value: str) -> Literal["allow", "deny", "pending"] | None: + if value in _KNOWN_STATUSES: + return cast("Literal['allow', 'deny', 'pending']", value) + return None + + +def _normalize_decision( + decision: object, + *, + enforce: bool = False, +) -> tuple[Literal["allow", "deny", "pending"], str | None]: + if isinstance(decision, str): + status = _coerce_known_status(decision.strip().lower()) + if status is not None: + return status, None + return _unknown_decision(enforce) + + if isinstance(decision, Mapping): + reason_value = decision.get("reason") + reason = str(reason_value) if reason_value is not None else None + status = _coerce_known_status(str(decision.get("status", "")).strip().lower()) + if status is not None: + return status, reason + unknown_status, unknown_reason = _unknown_decision(enforce) + return unknown_status, reason if reason is not None else unknown_reason + + return _unknown_decision(enforce) + + +def _invoke_tool_check( + callback_handler: Any, + *, + tool_name: str, + tool_args: dict[str, Any], +) -> object: + method = getattr(callback_handler, "check_tool_start", None) + if callable(method): + return method( + serialized={"name": tool_name}, + input_str=str(tool_args), + tool_name=tool_name, + args=tool_args, + agent_id=None, + ) + return {"status": "allow"} + + +def _wait_for_tool_approval( + callback_handler: Any, + *, + tool_name: str, + timeout_seconds: int, + tool_args: dict[str, Any], +) -> object: + method = getattr(callback_handler, "wait_for_tool_approval", None) + if callable(method): + return method( + tool_name=tool_name, + timeout_seconds=timeout_seconds, + args=tool_args, + agent_id=None, + ) + return {"status": "deny", "reason": "Approval handler is unavailable."} + + +def _get_pending_tool_approval_timeout_seconds(callback_handler: Any) -> int: + provider = getattr(callback_handler, "get_pending_tool_approval_timeout_seconds", None) + if callable(provider): + configured = provider() + else: + configured = getattr(callback_handler, "pending_tool_approval_timeout_seconds", None) + + if isinstance(configured, str): + stripped = configured.strip() + if stripped.isdigit(): + parsed = int(stripped) + if parsed > 0: + return parsed + return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS + + if isinstance(configured, bool): + return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS + + if isinstance(configured, int) and configured > 0: + return configured + + return _DEFAULT_PENDING_APPROVAL_TIMEOUT_SECONDS + + +def _record_tool_result( + callback_handler: Any, + *, + tool_name: str, + result: object, +) -> None: + record_method = getattr(callback_handler, "record_result", None) + if callable(record_method): + record_method(tool_name=tool_name, result=result) + return None + + tool_end_method = getattr(callback_handler, "on_tool_end", None) + if callable(tool_end_method): + tool_end_method(output=result, tool_name=tool_name) + return None + + +def _apply_tool_invoke_patch(tool_cls: type[Any], callback_handler: Any) -> None: + if getattr(tool_cls, _TOOL_PATCHED_FLAG, False): + return None + + original_invoke = tool_cls.invoke + enforce = _interceptor_enforces(callback_handler) + + @wraps(original_invoke) + def patched_invoke(self: Any, **kwargs: Any) -> Any: + tool_name = str(getattr(self, "name", self.__class__.__name__)) + tool_args = dict(kwargs) + decision = _invoke_tool_check( + callback_handler, + tool_name=tool_name, + tool_args=tool_args, + ) + status, reason = _normalize_decision(decision, enforce=enforce) + is_pending_flow = False + if status == "pending": + is_pending_flow = True + timeout_seconds = _get_pending_tool_approval_timeout_seconds(callback_handler) + final_decision = _wait_for_tool_approval( + callback_handler, + tool_name=tool_name, + timeout_seconds=timeout_seconds, + tool_args=tool_args, + ) + status, reason = _normalize_decision(final_decision, enforce=enforce) + + if status == "deny": + if is_pending_flow: + return _format_approval_rejected_message(reason) + return _format_blocked_message(reason) + + result = original_invoke(self, **kwargs) + _record_tool_result(callback_handler, tool_name=tool_name, result=result) + return result + + setattr(tool_cls, _ORIGINAL_TOOL_INVOKE, original_invoke) + tool_cls.invoke = patched_invoke + setattr(tool_cls, _TOOL_PATCHED_FLAG, True) + + +def _revert_tool_invoke_patch(tool_cls: type[Any]) -> None: + if not getattr(tool_cls, _TOOL_PATCHED_FLAG, False): + return None + + original_invoke = getattr(tool_cls, _ORIGINAL_TOOL_INVOKE, None) + if callable(original_invoke): + tool_cls.invoke = original_invoke + + if hasattr(tool_cls, _ORIGINAL_TOOL_INVOKE): + delattr(tool_cls, _ORIGINAL_TOOL_INVOKE) + if hasattr(tool_cls, _TOOL_PATCHED_FLAG): + delattr(tool_cls, _TOOL_PATCHED_FLAG) + return None diff --git a/agent_assembly/adapters/registry.py b/agent_assembly/adapters/registry.py index 6dd3d297..ef299a68 100644 --- a/agent_assembly/adapters/registry.py +++ b/agent_assembly/adapters/registry.py @@ -8,6 +8,7 @@ from agent_assembly.adapters.base import FrameworkAdapter from agent_assembly.adapters.crewai.adapter import CrewAIAdapter from agent_assembly.adapters.google_adk.adapter import GoogleADKAdapter +from agent_assembly.adapters.haystack.adapter import HaystackAdapter from agent_assembly.adapters.langchain.adapter import LangChainAdapter from agent_assembly.adapters.langgraph.adapter import LangGraphAdapter from agent_assembly.adapters.mcp.adapter import MCPAdapter @@ -24,6 +25,7 @@ "pydantic_ai": 3, "openai": 4, "google_adk": 5, + "haystack": 6, "mcp": 99, } @@ -66,6 +68,7 @@ def __init__(self) -> None: PydanticAIAdapter(), OpenAIAgentsAdapter(), GoogleADKAdapter(), + HaystackAdapter(), MCPAdapter(), ] for adapter in builtin_adapters: diff --git a/docs/compatibility/frameworks.md b/docs/compatibility/frameworks.md index e9a7ad25..1acd3939 100644 --- a/docs/compatibility/frameworks.md +++ b/docs/compatibility/frameworks.md @@ -48,6 +48,7 @@ to work but are not continuously tested. | Pydantic AI | `pydantic_ai` | `agent_assembly.adapters.pydantic_ai` | `>=0.1.0` (both the `<0.3` `Tool._run` and `>=0.3` `AbstractToolset.call_tool` hook points) | `>=0.3.0` — see [note below](#pydantic-ai-version-range) | | CrewAI | `crewai` | `agent_assembly.adapters.crewai` | `>=0.1.0` | `1.14.x` | | Google ADK | `google.adk` | `agent_assembly.adapters.google_adk` | `>=1.0.0,<2.0` | `1.x` line | +| Haystack | `haystack` | `agent_assembly.adapters.haystack` | `>=2.0.0,<3.0` (the Haystack 2.x `Tool.invoke` hook point, used by both `Tool.invoke()` and the `Agent`/`ToolInvoker` tool-call loop) | `2.30.x` | | MCP | `mcp` | `agent_assembly.adapters.mcp` | `>=1.0.0` | `1.27.x` | | OpenAI Agents | `agents` | `agent_assembly.adapters.openai_agents` | `>=0.1.0` | `0.17.x` | diff --git a/test/unit/adapters/haystack/__init__.py b/test/unit/adapters/haystack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/adapters/haystack/test_patch.py b/test/unit/adapters/haystack/test_patch.py new file mode 100644 index 00000000..7e626bfb --- /dev/null +++ b/test/unit/adapters/haystack/test_patch.py @@ -0,0 +1,359 @@ +"""Unit tests for the Haystack adapter patch. + +These prove the adapter performs *real* governance on Haystack's tool-execution +hook (``haystack.tools.Tool.invoke``), not a no-op: + +- a ``deny`` verdict blocks the underlying tool function from running and returns a + governance string; +- an ``allow`` verdict runs the tool and records the result; +- the negative-control test (``test_real_haystack_tool_is_genuinely_governed``) + drives a *real* ``haystack.tools.Tool`` and asserts the wrapped function never + executed when denied — it would fail if the patch were a pass-through. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters.haystack import patch as haystack_patch + + +class _RecordingInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + +def _install_fake_haystack_module(monkeypatch: pytest.MonkeyPatch) -> type[Any]: + class FakeTool: + name = "fake_tool" + + def invoke(self, **kwargs: Any) -> dict[str, object]: + return {"kwargs": kwargs} + + fake_tools_module = SimpleNamespace(Tool=FakeTool) + + def fake_import_module(module_name: str) -> object: + if module_name == "haystack.tools": + return fake_tools_module + raise ImportError(module_name) + + monkeypatch.setattr(haystack_patch.importlib, "import_module", fake_import_module) + return FakeTool + + +def test_apply_patches_invoke_and_is_idempotent(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_haystack_module(monkeypatch) + + patcher = haystack_patch.HaystackPatch(_RecordingInterceptor()) + assert patcher.apply() is True + first_ref = FakeTool.invoke + assert getattr(FakeTool, haystack_patch._TOOL_PATCHED_FLAG, False) is True + + assert patcher.apply() is True + assert FakeTool.invoke is first_ref + + +def test_revert_restores_invoke(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_haystack_module(monkeypatch) + original_invoke = FakeTool.invoke + + patcher = haystack_patch.HaystackPatch(_RecordingInterceptor()) + assert patcher.apply() is True + assert FakeTool.invoke is not original_invoke + + patcher.revert() + assert FakeTool.invoke is original_invoke + assert getattr(FakeTool, haystack_patch._TOOL_PATCHED_FLAG, False) is False + + +def test_apply_false_without_haystack(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_import_error(module_name: str) -> object: + raise ImportError(module_name) + + monkeypatch.setattr(haystack_patch.importlib, "import_module", raise_import_error) + assert haystack_patch._load_haystack_tool_class() is None + assert haystack_patch.HaystackPatch(_RecordingInterceptor()).apply() is False + + monkeypatch.setattr( + haystack_patch.importlib, + "import_module", + lambda name: SimpleNamespace(Tool=object()) if name == "haystack.tools" else None, + ) + assert haystack_patch._load_haystack_tool_class() is None + + +def test_blocked_tool_returns_policy_string_and_skips_function(monkeypatch: pytest.MonkeyPatch) -> None: + ran: list[bool] = [] + + class FakeTool: + name = "danger_tool" + + def invoke(self, **kwargs: Any) -> dict[str, object]: + ran.append(True) + return {"kwargs": kwargs} + + monkeypatch.setattr( + haystack_patch.importlib, + "import_module", + lambda name: ( + SimpleNamespace(Tool=FakeTool) if name == "haystack.tools" else (_ for _ in ()).throw(ImportError(name)) + ), + ) + + class BlockInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "blocked for safety"} + + patcher = haystack_patch.HaystackPatch(BlockInterceptor()) + assert patcher.apply() is True + + result = FakeTool().invoke(param="value") + + assert isinstance(result, str) + assert "[BLOCKED by governance policy]" in result + assert "blocked for safety" in result + assert ran == [] # the real tool function must NOT have executed + + +def test_allowed_tool_runs_and_records_result(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_haystack_module(monkeypatch) + observed: list[object] = [] + + class AllowInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + def on_tool_end(self, *, output: object, **kwargs: object) -> None: + del kwargs + observed.append(output) + + patcher = haystack_patch.HaystackPatch(AllowInterceptor()) + assert patcher.apply() is True + + result = FakeTool().invoke(param="value") + + assert result == {"kwargs": {"param": "value"}} + assert observed == [result] + + +def test_pending_tool_waits_and_allows_when_approved(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_haystack_module(monkeypatch) + wait_calls: list[dict[str, object]] = [] + + class PendingThenApproveInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "needs approval"} + + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + wait_calls.append(dict(kwargs)) + return {"status": "allow"} + + patcher = haystack_patch.HaystackPatch(PendingThenApproveInterceptor()) + assert patcher.apply() is True + + result = FakeTool().invoke(param="value") + + assert result == {"kwargs": {"param": "value"}} + assert len(wait_calls) == 1 + assert wait_calls[0]["timeout_seconds"] == 300 + + +def test_pending_timeout_returns_denied_string(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_haystack_module(monkeypatch) + + class PendingTimeoutInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "pending", "reason": "requires manual approval"} + + def wait_for_tool_approval(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "approval timeout"} + + patcher = haystack_patch.HaystackPatch(PendingTimeoutInterceptor()) + assert patcher.apply() is True + + result = FakeTool().invoke(param="value") + + assert isinstance(result, str) + assert result.startswith("[APPROVAL REJECTED]") + assert "approval timeout" in result + + +# --- AAASM-3107: unknown/None/malformed verdicts must fail closed under enforce --- + + +@pytest.mark.parametrize("decision", [None, "maybe", 12345, {"status": "garbage"}, {}]) +def test_normalize_decision_denies_unknown_under_enforce(decision: object) -> None: + status, reason = haystack_patch._normalize_decision(decision, enforce=True) + assert status == "deny" + assert reason + + +@pytest.mark.parametrize("decision", [None, "maybe", 12345, {"status": "garbage"}, {}]) +def test_normalize_decision_allows_unknown_when_not_enforcing(decision: object) -> None: + assert haystack_patch._normalize_decision(decision, enforce=False) == ("allow", None) + + +def test_normalize_decision_known_verdicts_unchanged_under_enforce() -> None: + assert haystack_patch._normalize_decision("allow", enforce=True) == ("allow", None) + assert haystack_patch._normalize_decision("deny", enforce=True) == ("deny", None) + assert haystack_patch._normalize_decision("pending", enforce=True) == ("pending", None) + assert haystack_patch._normalize_decision({"status": "deny", "reason": "x"}, enforce=True) == ("deny", "x") + + +def test_interceptor_enforces_reads_enforce_flag() -> None: + assert haystack_patch._interceptor_enforces(SimpleNamespace(_enforce=True)) is True + assert haystack_patch._interceptor_enforces(SimpleNamespace()) is False + assert haystack_patch._interceptor_enforces(SimpleNamespace(_interceptor=SimpleNamespace(_enforce=True))) is True + + +def test_unknown_verdict_blocks_tool_under_enforce(monkeypatch: pytest.MonkeyPatch) -> None: + FakeTool = _install_fake_haystack_module(monkeypatch) + + class EnforcingUnknownInterceptor: + _enforce = True + + def check_tool_start(self, **kwargs: object) -> object: + del kwargs + return None + + patcher = haystack_patch.HaystackPatch(EnforcingUnknownInterceptor()) + assert patcher.apply() is True + + result = FakeTool().invoke(param="value") + assert isinstance(result, str) + assert "[BLOCKED by governance policy]" in result + + +def test_timeout_helper_branches() -> None: + assert ( + haystack_patch._get_pending_tool_approval_timeout_seconds( + SimpleNamespace(get_pending_tool_approval_timeout_seconds=lambda: "42") + ) + == 42 + ) + assert ( + haystack_patch._get_pending_tool_approval_timeout_seconds( + SimpleNamespace(pending_tool_approval_timeout_seconds=0) + ) + == 300 + ) + assert ( + haystack_patch._get_pending_tool_approval_timeout_seconds( + SimpleNamespace(pending_tool_approval_timeout_seconds=True) + ) + == 300 + ) + assert ( + haystack_patch._get_pending_tool_approval_timeout_seconds( + SimpleNamespace(pending_tool_approval_timeout_seconds=37) + ) + == 37 + ) + + +def test_no_handler_interceptor_defaults_to_allow() -> None: + class NoHandlers: + pass + + assert haystack_patch._invoke_tool_check(NoHandlers(), tool_name="x", tool_args={}) == {"status": "allow"} + assert haystack_patch._wait_for_tool_approval(NoHandlers(), tool_name="x", timeout_seconds=1, tool_args={}) == { + "status": "deny", + "reason": "Approval handler is unavailable.", + } + + +# --- Negative control: genuine governance over a REAL haystack.tools.Tool --- + + +def test_real_haystack_tool_is_genuinely_governed() -> None: + """Drive a real ``haystack.tools.Tool`` to prove deny blocks the actual function. + + This is the no-op guard (AAASM-3528 lesson): if the patch were a pass-through the + wrapped ``function`` would still run on deny and ``side_effects`` would be + populated, failing this test. + """ + haystack_tools = pytest.importorskip("haystack.tools") + Tool = haystack_tools.Tool + + side_effects: list[tuple[int, int]] = [] + + def add(a: int, b: int) -> int: + side_effects.append((a, b)) + return a + b + + schema = { + "type": "object", + "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, + "required": ["a", "b"], + } + + class DenyEverythingInterceptor: + _enforce = True + + def __init__(self) -> None: + self.checked: list[str] = [] + + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + self.checked.append(str(kwargs.get("tool_name"))) + return {"status": "deny", "reason": "policy: arithmetic tools are blocked"} + + interceptor = DenyEverythingInterceptor() + patcher = haystack_patch.HaystackPatch(interceptor) + assert patcher.apply() is True + try: + tool = Tool(name="adder", description="adds two ints", parameters=schema, function=add) + result = tool.invoke(a=2, b=3) + + # Governance actually fired and blocked the real tool function. + assert interceptor.checked == ["adder"] + assert isinstance(result, str) + assert "[BLOCKED by governance policy]" in result + assert "arithmetic tools are blocked" in result + assert side_effects == [] # the real function must never have run + finally: + patcher.revert() + + # After revert, the same tool runs normally (proves the patch was the only gate). + tool_after = Tool(name="adder", description="adds two ints", parameters=schema, function=add) + assert tool_after.invoke(a=2, b=3) == 5 + assert side_effects == [(2, 3)] + + +def test_real_haystack_tool_allow_runs_and_records() -> None: + """A real ``haystack.tools.Tool`` runs and its result is recorded on allow.""" + haystack_tools = pytest.importorskip("haystack.tools") + Tool = haystack_tools.Tool + + recorded: list[object] = [] + + def greet(name: str) -> str: + return f"hello {name}" + + schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} + + class AllowAndRecordInterceptor: + def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "allow"} + + def record_result(self, **kwargs: object) -> None: + recorded.append(kwargs["result"]) + + patcher = haystack_patch.HaystackPatch(AllowAndRecordInterceptor()) + assert patcher.apply() is True + try: + tool = Tool(name="greeter", description="greets", parameters=schema, function=greet) + result = tool.invoke(name="world") + assert result == "hello world" + assert recorded == ["hello world"] + finally: + patcher.revert()