From 0182bfa2994ed87d24daf36fc58e4edac3edc8ac Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:07:19 +0300 Subject: [PATCH 01/10] feat: add pre-tool policy provider adapter --- lib/crewai/src/crewai/hooks/__init__.py | 108 ++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/lib/crewai/src/crewai/hooks/__init__.py b/lib/crewai/src/crewai/hooks/__init__.py index 5b9fbf7bd6..d8a0cfdd90 100644 --- a/lib/crewai/src/crewai/hooks/__init__.py +++ b/lib/crewai/src/crewai/hooks/__init__.py @@ -1,5 +1,10 @@ from __future__ import annotations +from copy import deepcopy +from dataclasses import dataclass, field +import logging +from typing import Any, Mapping, Protocol, runtime_checkable + from crewai.hooks.decorators import ( after_llm_call, after_tool_call, @@ -30,6 +35,105 @@ unregister_after_tool_call_hook, unregister_before_tool_call_hook, ) +from crewai.hooks.types import BeforeToolCallHookType + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, slots=True) +class PolicyRequest: + """Detached context for one provider decision before a tool call.""" + + tool_name: str + tool_alias: str + tool_input: Mapping[str, Any] + agent_id: str | None = None + agent_role: str | None = None + task_description: str | None = None + crew_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class PolicyDecision: + """Provider verdict for one proposed tool call.""" + + allow: bool + reason: str | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class PolicyProvider(Protocol): + """Contract for pluggable pre-tool-call policy providers.""" + + name: str + + def evaluate(self, request: PolicyRequest) -> PolicyDecision: + """Evaluate whether the requested tool call may proceed.""" + ... + + +def enable_policy_provider( + provider: PolicyProvider, + *, + fail_closed: bool = True, +) -> BeforeToolCallHookType: + """Register a provider through CrewAI's existing before-tool-call hooks. + + Provider failures and invalid results block execution by default. Set + ``fail_closed=False`` only when availability is intentionally preferred. + The returned hook can be removed with ``unregister_before_tool_call_hook``. + """ + + def _hook(context: ToolCallHookContext) -> bool | None: + try: + request = _build_policy_request(context) + decision = provider.evaluate(request) + if not isinstance(decision, PolicyDecision): + raise TypeError("PolicyProvider.evaluate() must return PolicyDecision") + if type(decision.allow) is not bool: + raise TypeError("PolicyDecision.allow must be a bool") + except Exception: + logger.exception("PolicyProvider evaluation failed") + return False if fail_closed else None + + return None if decision.allow else False + + register_before_tool_call_hook(_hook) + return _hook + + +def _build_policy_request(context: ToolCallHookContext) -> PolicyRequest: + """Snapshot the mutable tool-call context before provider evaluation.""" + + original_tool_name = _optional_text(getattr(context, "tool", None), "name") + return PolicyRequest( + tool_name=original_tool_name or context.tool_name, + tool_alias=context.tool_name, + tool_input=deepcopy(context.tool_input), + agent_id=_optional_identifier(context.agent, "id"), + agent_role=_optional_text(context.agent, "role"), + task_description=_optional_text(context.task, "description"), + crew_id=_optional_identifier(context.crew, "id"), + ) + + +def _optional_text(source: Any, attribute: str) -> str | None: + if source is None: + return None + value = getattr(source, attribute, None) + return value if isinstance(value, str) and value else None + + +def _optional_identifier(source: Any, attribute: str) -> str | None: + if source is None: + return None + value = getattr(source, attribute, None) + if value is None: + return None + rendered = str(value) + return rendered if rendered else None def clear_all_global_hooks() -> dict[str, tuple[int, int]]: @@ -75,6 +179,9 @@ def clear_all_global_hooks() -> dict[str, tuple[int, int]]: __all__ = [ "LLMCallHookContext", + "PolicyDecision", + "PolicyProvider", + "PolicyRequest", "ToolCallHookContext", "after_llm_call", "after_tool_call", @@ -87,6 +194,7 @@ def clear_all_global_hooks() -> dict[str, tuple[int, int]]: "clear_all_tool_call_hooks", "clear_before_llm_call_hooks", "clear_before_tool_call_hooks", + "enable_policy_provider", "get_after_llm_call_hooks", "get_after_tool_call_hooks", "get_before_llm_call_hooks", From 680b79737f6b7a8b8cf781f32ee2970d8cd299e3 Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:08:43 +0300 Subject: [PATCH 02/10] test: cover pre-tool policy provider adapter --- lib/crewai/tests/hooks/test_decorators.py | 113 ++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/lib/crewai/tests/hooks/test_decorators.py b/lib/crewai/tests/hooks/test_decorators.py index 1d60cf21da..a0637879c2 100644 --- a/lib/crewai/tests/hooks/test_decorators.py +++ b/lib/crewai/tests/hooks/test_decorators.py @@ -5,10 +5,13 @@ from unittest.mock import Mock from crewai.hooks import ( + PolicyDecision, + PolicyRequest, after_llm_call, after_tool_call, before_llm_call, before_tool_call, + enable_policy_provider, get_after_llm_call_hooks, get_after_tool_call_hooks, get_before_llm_call_hooks, @@ -348,3 +351,113 @@ def manual_hook(context): hooks = get_before_tool_call_hooks() assert len(hooks) == 2 + + +class RecordingPolicyProvider: + """Small provider fixture that records the latest detached request.""" + + name = "recording" + + def __init__( + self, + decision: PolicyDecision | None = None, + error: Exception | None = None, + ) -> None: + self.decision = decision if decision is not None else PolicyDecision(allow=True) + self.error = error + self.request: PolicyRequest | None = None + + def evaluate(self, request: PolicyRequest) -> PolicyDecision: + self.request = request + if self.error is not None: + raise self.error + return self.decision + + +class TestPolicyProviderAdapter: + """Test the provider adapter built on the existing before-tool hook registry.""" + + @staticmethod + def context() -> ToolCallHookContext: + tool = Mock() + tool.name = "Send Email" + agent = Mock() + agent.id = "agent-123" + agent.role = "Support Agent" + task = Mock() + task.description = "Reply to the customer" + crew = Mock() + crew.id = "crew-456" + return ToolCallHookContext( + tool_name="send_email", + tool_input={"recipient": "qa@example.com", "body": "Hello"}, + tool=tool, + agent=agent, + task=task, + crew=crew, + ) + + def test_registers_provider_and_allows(self): + provider = RecordingPolicyProvider() + hook = enable_policy_provider(provider) + + assert get_before_tool_call_hooks() == [hook] + assert hook(self.context()) is None + assert provider.request is not None + assert provider.request.tool_name == "Send Email" + assert provider.request.tool_alias == "send_email" + assert provider.request.agent_id == "agent-123" + assert provider.request.agent_role == "Support Agent" + assert provider.request.task_description == "Reply to the customer" + assert provider.request.crew_id == "crew-456" + + def test_deny_maps_to_false(self): + provider = RecordingPolicyProvider( + PolicyDecision(allow=False, reason="policy") + ) + + assert enable_policy_provider(provider)(self.context()) is False + + def test_provider_receives_detached_tool_input(self): + context = self.context() + provider = RecordingPolicyProvider() + + assert enable_policy_provider(provider)(context) is None + assert provider.request is not None + provider.request.tool_input["recipient"] = "other@example.com" + + assert context.tool_input["recipient"] == "qa@example.com" + + def test_provider_failure_is_fail_closed_by_default(self): + provider = RecordingPolicyProvider(error=RuntimeError("unavailable")) + + assert enable_policy_provider(provider)(self.context()) is False + + def test_provider_failure_can_fail_open(self): + provider = RecordingPolicyProvider(error=RuntimeError("unavailable")) + + assert ( + enable_policy_provider(provider, fail_closed=False)(self.context()) + is None + ) + + @pytest.mark.parametrize("fail_closed, expected", [(True, False), (False, None)]) + def test_invalid_provider_result_follows_failure_policy( + self, + fail_closed: bool, + expected: bool | None, + ): + provider = RecordingPolicyProvider() + provider.decision = object() # type: ignore[assignment] + + assert ( + enable_policy_provider(provider, fail_closed=fail_closed)(self.context()) + is expected + ) + + def test_non_boolean_allow_is_rejected(self): + decision = PolicyDecision(allow=True) + object.__setattr__(decision, "allow", 1) + provider = RecordingPolicyProvider(decision) + + assert enable_policy_provider(provider)(self.context()) is False From e33d4ca0f6a7e7620c830b44ac90df9892ef84cc Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:09:34 +0300 Subject: [PATCH 03/10] refactor: keep policy provider with tool hooks --- lib/crewai/src/crewai/hooks/tool_hooks.py | 103 +++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/lib/crewai/src/crewai/hooks/tool_hooks.py b/lib/crewai/src/crewai/hooks/tool_hooks.py index a860c01d45..10e8c576fa 100644 --- a/lib/crewai/src/crewai/hooks/tool_hooks.py +++ b/lib/crewai/src/crewai/hooks/tool_hooks.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from copy import deepcopy +from dataclasses import dataclass, field +import logging +from typing import TYPE_CHECKING, Any, Mapping, Protocol, runtime_checkable from crewai_core.printer import PRINTER @@ -13,6 +16,9 @@ ) +logger = logging.getLogger(__name__) + + if TYPE_CHECKING: from crewai.agent import Agent from crewai.agents.agent_builder.base_agent import BaseAgent @@ -316,3 +322,98 @@ def clear_all_tool_call_hooks() -> tuple[int, int]: before_count = clear_before_tool_call_hooks() after_count = clear_after_tool_call_hooks() return (before_count, after_count) + + +@dataclass(frozen=True, slots=True) +class PolicyRequest: + """Detached context for one provider decision before a tool call.""" + + tool_name: str + tool_alias: str + tool_input: Mapping[str, Any] + agent_id: str | None = None + agent_role: str | None = None + task_description: str | None = None + crew_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class PolicyDecision: + """Provider verdict for one proposed tool call.""" + + allow: bool + reason: str | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class PolicyProvider(Protocol): + """Contract for pluggable pre-tool-call policy providers.""" + + name: str + + def evaluate(self, request: PolicyRequest) -> PolicyDecision: + """Evaluate whether the requested tool call may proceed.""" + ... + + +def enable_policy_provider( + provider: PolicyProvider, + *, + fail_closed: bool = True, +) -> BeforeToolCallHookType: + """Register a provider through the existing before-tool-call registry. + + Provider failures and invalid results block execution by default. Set + ``fail_closed=False`` only when availability is intentionally preferred. + The returned hook can be removed with ``unregister_before_tool_call_hook``. + """ + + def _hook(context: ToolCallHookContext) -> bool | None: + try: + request = _build_policy_request(context) + decision = provider.evaluate(request) + if not isinstance(decision, PolicyDecision): + raise TypeError("PolicyProvider.evaluate() must return PolicyDecision") + if type(decision.allow) is not bool: + raise TypeError("PolicyDecision.allow must be a bool") + except Exception: + logger.exception("PolicyProvider evaluation failed") + return False if fail_closed else None + + return None if decision.allow else False + + register_before_tool_call_hook(_hook) + return _hook + + +def _build_policy_request(context: ToolCallHookContext) -> PolicyRequest: + """Snapshot the mutable tool-call context before provider evaluation.""" + + original_tool_name = _optional_text(getattr(context, "tool", None), "name") + return PolicyRequest( + tool_name=original_tool_name or context.tool_name, + tool_alias=context.tool_name, + tool_input=deepcopy(context.tool_input), + agent_id=_optional_identifier(context.agent, "id"), + agent_role=_optional_text(context.agent, "role"), + task_description=_optional_text(context.task, "description"), + crew_id=_optional_identifier(context.crew, "id"), + ) + + +def _optional_text(source: Any, attribute: str) -> str | None: + if source is None: + return None + value = getattr(source, attribute, None) + return value if isinstance(value, str) and value else None + + +def _optional_identifier(source: Any, attribute: str) -> str | None: + if source is None: + return None + value = getattr(source, attribute, None) + if value is None: + return None + rendered = str(value) + return rendered if rendered else None From ae09db9b2836373c9a9c7a38f61713d0827bd69b Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:09:50 +0300 Subject: [PATCH 04/10] refactor: export policy provider from tool hooks --- lib/crewai/src/crewai/hooks/__init__.py | 108 +----------------------- 1 file changed, 4 insertions(+), 104 deletions(-) diff --git a/lib/crewai/src/crewai/hooks/__init__.py b/lib/crewai/src/crewai/hooks/__init__.py index d8a0cfdd90..9eddb8efc1 100644 --- a/lib/crewai/src/crewai/hooks/__init__.py +++ b/lib/crewai/src/crewai/hooks/__init__.py @@ -1,10 +1,5 @@ from __future__ import annotations -from copy import deepcopy -from dataclasses import dataclass, field -import logging -from typing import Any, Mapping, Protocol, runtime_checkable - from crewai.hooks.decorators import ( after_llm_call, after_tool_call, @@ -24,10 +19,14 @@ unregister_before_llm_call_hook, ) from crewai.hooks.tool_hooks import ( + PolicyDecision, + PolicyProvider, + PolicyRequest, ToolCallHookContext, clear_after_tool_call_hooks, clear_all_tool_call_hooks, clear_before_tool_call_hooks, + enable_policy_provider, get_after_tool_call_hooks, get_before_tool_call_hooks, register_after_tool_call_hook, @@ -35,105 +34,6 @@ unregister_after_tool_call_hook, unregister_before_tool_call_hook, ) -from crewai.hooks.types import BeforeToolCallHookType - - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True, slots=True) -class PolicyRequest: - """Detached context for one provider decision before a tool call.""" - - tool_name: str - tool_alias: str - tool_input: Mapping[str, Any] - agent_id: str | None = None - agent_role: str | None = None - task_description: str | None = None - crew_id: str | None = None - - -@dataclass(frozen=True, slots=True) -class PolicyDecision: - """Provider verdict for one proposed tool call.""" - - allow: bool - reason: str | None = None - metadata: Mapping[str, Any] = field(default_factory=dict) - - -@runtime_checkable -class PolicyProvider(Protocol): - """Contract for pluggable pre-tool-call policy providers.""" - - name: str - - def evaluate(self, request: PolicyRequest) -> PolicyDecision: - """Evaluate whether the requested tool call may proceed.""" - ... - - -def enable_policy_provider( - provider: PolicyProvider, - *, - fail_closed: bool = True, -) -> BeforeToolCallHookType: - """Register a provider through CrewAI's existing before-tool-call hooks. - - Provider failures and invalid results block execution by default. Set - ``fail_closed=False`` only when availability is intentionally preferred. - The returned hook can be removed with ``unregister_before_tool_call_hook``. - """ - - def _hook(context: ToolCallHookContext) -> bool | None: - try: - request = _build_policy_request(context) - decision = provider.evaluate(request) - if not isinstance(decision, PolicyDecision): - raise TypeError("PolicyProvider.evaluate() must return PolicyDecision") - if type(decision.allow) is not bool: - raise TypeError("PolicyDecision.allow must be a bool") - except Exception: - logger.exception("PolicyProvider evaluation failed") - return False if fail_closed else None - - return None if decision.allow else False - - register_before_tool_call_hook(_hook) - return _hook - - -def _build_policy_request(context: ToolCallHookContext) -> PolicyRequest: - """Snapshot the mutable tool-call context before provider evaluation.""" - - original_tool_name = _optional_text(getattr(context, "tool", None), "name") - return PolicyRequest( - tool_name=original_tool_name or context.tool_name, - tool_alias=context.tool_name, - tool_input=deepcopy(context.tool_input), - agent_id=_optional_identifier(context.agent, "id"), - agent_role=_optional_text(context.agent, "role"), - task_description=_optional_text(context.task, "description"), - crew_id=_optional_identifier(context.crew, "id"), - ) - - -def _optional_text(source: Any, attribute: str) -> str | None: - if source is None: - return None - value = getattr(source, attribute, None) - return value if isinstance(value, str) and value else None - - -def _optional_identifier(source: Any, attribute: str) -> str | None: - if source is None: - return None - value = getattr(source, attribute, None) - if value is None: - return None - rendered = str(value) - return rendered if rendered else None def clear_all_global_hooks() -> dict[str, tuple[int, int]]: From 7ff8c00f4bc9bed0a4c8488c065da934ceee0d1e Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:12:50 +0300 Subject: [PATCH 05/10] fix: satisfy hook adapter lint rules --- lib/crewai/src/crewai/hooks/tool_hooks.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/crewai/src/crewai/hooks/tool_hooks.py b/lib/crewai/src/crewai/hooks/tool_hooks.py index 10e8c576fa..169441c62f 100644 --- a/lib/crewai/src/crewai/hooks/tool_hooks.py +++ b/lib/crewai/src/crewai/hooks/tool_hooks.py @@ -1,9 +1,10 @@ from __future__ import annotations +import logging +from collections.abc import Mapping from copy import deepcopy from dataclasses import dataclass, field -import logging -from typing import TYPE_CHECKING, Any, Mapping, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from crewai_core.printer import PRINTER @@ -375,7 +376,7 @@ def _hook(context: ToolCallHookContext) -> bool | None: decision = provider.evaluate(request) if not isinstance(decision, PolicyDecision): raise TypeError("PolicyProvider.evaluate() must return PolicyDecision") - if type(decision.allow) is not bool: + if not isinstance(decision.allow, bool): raise TypeError("PolicyDecision.allow must be a bool") except Exception: logger.exception("PolicyProvider evaluation failed") From 13245a742d3bfca0d1eb0f7c82a90a8752dea95c Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:35:16 +0300 Subject: [PATCH 06/10] refactor: align minimal GuardrailProvider API --- lib/crewai/src/crewai/hooks/tool_hooks.py | 39 ++++++++++------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/lib/crewai/src/crewai/hooks/tool_hooks.py b/lib/crewai/src/crewai/hooks/tool_hooks.py index 169441c62f..9126675cdb 100644 --- a/lib/crewai/src/crewai/hooks/tool_hooks.py +++ b/lib/crewai/src/crewai/hooks/tool_hooks.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from copy import deepcopy from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol from crewai_core.printer import PRINTER @@ -326,7 +326,7 @@ def clear_all_tool_call_hooks() -> tuple[int, int]: @dataclass(frozen=True, slots=True) -class PolicyRequest: +class GuardrailRequest: """Detached context for one provider decision before a tool call.""" tool_name: str @@ -334,12 +334,10 @@ class PolicyRequest: tool_input: Mapping[str, Any] agent_id: str | None = None agent_role: str | None = None - task_description: str | None = None - crew_id: str | None = None @dataclass(frozen=True, slots=True) -class PolicyDecision: +class GuardrailDecision: """Provider verdict for one proposed tool call.""" allow: bool @@ -347,19 +345,16 @@ class PolicyDecision: metadata: Mapping[str, Any] = field(default_factory=dict) -@runtime_checkable -class PolicyProvider(Protocol): - """Contract for pluggable pre-tool-call policy providers.""" +class GuardrailProvider(Protocol): + """Contract for pluggable pre-tool-call guardrail providers.""" - name: str - - def evaluate(self, request: PolicyRequest) -> PolicyDecision: + def evaluate(self, request: GuardrailRequest) -> GuardrailDecision: """Evaluate whether the requested tool call may proceed.""" ... -def enable_policy_provider( - provider: PolicyProvider, +def enable_guardrail( + provider: GuardrailProvider, *, fail_closed: bool = True, ) -> BeforeToolCallHookType: @@ -372,14 +367,16 @@ def enable_policy_provider( def _hook(context: ToolCallHookContext) -> bool | None: try: - request = _build_policy_request(context) + request = _build_guardrail_request(context) decision = provider.evaluate(request) - if not isinstance(decision, PolicyDecision): - raise TypeError("PolicyProvider.evaluate() must return PolicyDecision") + if not isinstance(decision, GuardrailDecision): + raise TypeError( + "GuardrailProvider.evaluate() must return GuardrailDecision" + ) if not isinstance(decision.allow, bool): - raise TypeError("PolicyDecision.allow must be a bool") + raise TypeError("GuardrailDecision.allow must be a bool") except Exception: - logger.exception("PolicyProvider evaluation failed") + logger.exception("GuardrailProvider evaluation failed") return False if fail_closed else None return None if decision.allow else False @@ -388,18 +385,16 @@ def _hook(context: ToolCallHookContext) -> bool | None: return _hook -def _build_policy_request(context: ToolCallHookContext) -> PolicyRequest: +def _build_guardrail_request(context: ToolCallHookContext) -> GuardrailRequest: """Snapshot the mutable tool-call context before provider evaluation.""" original_tool_name = _optional_text(getattr(context, "tool", None), "name") - return PolicyRequest( + return GuardrailRequest( tool_name=original_tool_name or context.tool_name, tool_alias=context.tool_name, tool_input=deepcopy(context.tool_input), agent_id=_optional_identifier(context.agent, "id"), agent_role=_optional_text(context.agent, "role"), - task_description=_optional_text(context.task, "description"), - crew_id=_optional_identifier(context.crew, "id"), ) From 70667e337ba13498decd8e9e3971803e37d0f147 Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:35:49 +0300 Subject: [PATCH 07/10] refactor: export minimal GuardrailProvider API --- lib/crewai/src/crewai/hooks/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/crewai/src/crewai/hooks/__init__.py b/lib/crewai/src/crewai/hooks/__init__.py index 9eddb8efc1..483d429889 100644 --- a/lib/crewai/src/crewai/hooks/__init__.py +++ b/lib/crewai/src/crewai/hooks/__init__.py @@ -19,14 +19,14 @@ unregister_before_llm_call_hook, ) from crewai.hooks.tool_hooks import ( - PolicyDecision, - PolicyProvider, - PolicyRequest, + GuardrailDecision, + GuardrailProvider, + GuardrailRequest, ToolCallHookContext, clear_after_tool_call_hooks, clear_all_tool_call_hooks, clear_before_tool_call_hooks, - enable_policy_provider, + enable_guardrail, get_after_tool_call_hooks, get_before_tool_call_hooks, register_after_tool_call_hook, @@ -78,10 +78,10 @@ def clear_all_global_hooks() -> dict[str, tuple[int, int]]: __all__ = [ + "GuardrailDecision", + "GuardrailProvider", + "GuardrailRequest", "LLMCallHookContext", - "PolicyDecision", - "PolicyProvider", - "PolicyRequest", "ToolCallHookContext", "after_llm_call", "after_tool_call", @@ -94,7 +94,7 @@ def clear_all_global_hooks() -> dict[str, tuple[int, int]]: "clear_all_tool_call_hooks", "clear_before_llm_call_hooks", "clear_before_tool_call_hooks", - "enable_policy_provider", + "enable_guardrail", "get_after_llm_call_hooks", "get_after_tool_call_hooks", "get_before_llm_call_hooks", From 3cd6ecb791658928a928912b4a4b6d7eaedd3ecb Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:36:43 +0300 Subject: [PATCH 08/10] test: align minimal GuardrailProvider contract --- lib/crewai/tests/hooks/test_decorators.py | 73 +++++++++-------------- 1 file changed, 28 insertions(+), 45 deletions(-) diff --git a/lib/crewai/tests/hooks/test_decorators.py b/lib/crewai/tests/hooks/test_decorators.py index a0637879c2..c3adbd2248 100644 --- a/lib/crewai/tests/hooks/test_decorators.py +++ b/lib/crewai/tests/hooks/test_decorators.py @@ -5,13 +5,13 @@ from unittest.mock import Mock from crewai.hooks import ( - PolicyDecision, - PolicyRequest, + GuardrailDecision, + GuardrailRequest, after_llm_call, after_tool_call, before_llm_call, before_tool_call, - enable_policy_provider, + enable_guardrail, get_after_llm_call_hooks, get_after_tool_call_hooks, get_before_llm_call_hooks, @@ -187,7 +187,6 @@ def test_before_tool_call_tool_filter_sanitizes_names(self): """Tool filter should auto-sanitize names so users can pass BaseTool.name directly.""" execution_log = [] - # User passes the human-readable tool name (e.g. BaseTool.name) @before_tool_call(tools=["Delete File", "Execute Code"]) def filtered_hook(context): execution_log.append(context.tool_name) @@ -197,7 +196,6 @@ def filtered_hook(context): assert len(hooks) == 1 mock_tool = Mock() - # Context uses the sanitized name (as set by the executor) context = ToolCallHookContext( tool_name="delete_file", tool_input={}, @@ -206,7 +204,6 @@ def filtered_hook(context): hooks[0](context) assert execution_log == ["delete_file"] - # Non-matching tool still filtered out context2 = ToolCallHookContext( tool_name="read_file", tool_input={}, @@ -280,7 +277,7 @@ def filtered_hook(context): ) result2 = hooks[0](context2) - assert result2 is None # Hook didn't run, returns None + assert result2 is None class TestDecoratorAttributes: @@ -353,28 +350,28 @@ def manual_hook(context): assert len(hooks) == 2 -class RecordingPolicyProvider: +class RecordingGuardrailProvider: """Small provider fixture that records the latest detached request.""" - name = "recording" - def __init__( self, - decision: PolicyDecision | None = None, + decision: GuardrailDecision | None = None, error: Exception | None = None, ) -> None: - self.decision = decision if decision is not None else PolicyDecision(allow=True) + self.decision = ( + decision if decision is not None else GuardrailDecision(allow=True) + ) self.error = error - self.request: PolicyRequest | None = None + self.request: GuardrailRequest | None = None - def evaluate(self, request: PolicyRequest) -> PolicyDecision: + def evaluate(self, request: GuardrailRequest) -> GuardrailDecision: self.request = request if self.error is not None: raise self.error return self.decision -class TestPolicyProviderAdapter: +class TestGuardrailProviderAdapter: """Test the provider adapter built on the existing before-tool hook registry.""" @staticmethod @@ -384,22 +381,16 @@ def context() -> ToolCallHookContext: agent = Mock() agent.id = "agent-123" agent.role = "Support Agent" - task = Mock() - task.description = "Reply to the customer" - crew = Mock() - crew.id = "crew-456" return ToolCallHookContext( tool_name="send_email", tool_input={"recipient": "qa@example.com", "body": "Hello"}, tool=tool, agent=agent, - task=task, - crew=crew, ) def test_registers_provider_and_allows(self): - provider = RecordingPolicyProvider() - hook = enable_policy_provider(provider) + provider = RecordingGuardrailProvider() + hook = enable_guardrail(provider) assert get_before_tool_call_hooks() == [hook] assert hook(self.context()) is None @@ -408,38 +399,33 @@ def test_registers_provider_and_allows(self): assert provider.request.tool_alias == "send_email" assert provider.request.agent_id == "agent-123" assert provider.request.agent_role == "Support Agent" - assert provider.request.task_description == "Reply to the customer" - assert provider.request.crew_id == "crew-456" def test_deny_maps_to_false(self): - provider = RecordingPolicyProvider( - PolicyDecision(allow=False, reason="policy") + provider = RecordingGuardrailProvider( + GuardrailDecision(allow=False, reason="policy") ) - assert enable_policy_provider(provider)(self.context()) is False + assert enable_guardrail(provider)(self.context()) is False def test_provider_receives_detached_tool_input(self): context = self.context() - provider = RecordingPolicyProvider() + provider = RecordingGuardrailProvider() - assert enable_policy_provider(provider)(context) is None + assert enable_guardrail(provider)(context) is None assert provider.request is not None provider.request.tool_input["recipient"] = "other@example.com" assert context.tool_input["recipient"] == "qa@example.com" def test_provider_failure_is_fail_closed_by_default(self): - provider = RecordingPolicyProvider(error=RuntimeError("unavailable")) + provider = RecordingGuardrailProvider(error=RuntimeError("unavailable")) - assert enable_policy_provider(provider)(self.context()) is False + assert enable_guardrail(provider)(self.context()) is False def test_provider_failure_can_fail_open(self): - provider = RecordingPolicyProvider(error=RuntimeError("unavailable")) + provider = RecordingGuardrailProvider(error=RuntimeError("unavailable")) - assert ( - enable_policy_provider(provider, fail_closed=False)(self.context()) - is None - ) + assert enable_guardrail(provider, fail_closed=False)(self.context()) is None @pytest.mark.parametrize("fail_closed, expected", [(True, False), (False, None)]) def test_invalid_provider_result_follows_failure_policy( @@ -447,17 +433,14 @@ def test_invalid_provider_result_follows_failure_policy( fail_closed: bool, expected: bool | None, ): - provider = RecordingPolicyProvider() + provider = RecordingGuardrailProvider() provider.decision = object() # type: ignore[assignment] - assert ( - enable_policy_provider(provider, fail_closed=fail_closed)(self.context()) - is expected - ) + assert enable_guardrail(provider, fail_closed=fail_closed)(self.context()) is expected def test_non_boolean_allow_is_rejected(self): - decision = PolicyDecision(allow=True) + decision = GuardrailDecision(allow=True) object.__setattr__(decision, "allow", 1) - provider = RecordingPolicyProvider(decision) + provider = RecordingGuardrailProvider(decision) - assert enable_policy_provider(provider)(self.context()) is False + assert enable_guardrail(provider)(self.context()) is False From 20d48fbeef9abdb14ed19968d9f1f4652a9a16f2 Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:42:02 +0300 Subject: [PATCH 09/10] test: preserve existing comments and verify deep detachment --- lib/crewai/tests/hooks/test_decorators.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/crewai/tests/hooks/test_decorators.py b/lib/crewai/tests/hooks/test_decorators.py index c3adbd2248..111ee1ef90 100644 --- a/lib/crewai/tests/hooks/test_decorators.py +++ b/lib/crewai/tests/hooks/test_decorators.py @@ -187,6 +187,7 @@ def test_before_tool_call_tool_filter_sanitizes_names(self): """Tool filter should auto-sanitize names so users can pass BaseTool.name directly.""" execution_log = [] + # User passes the human-readable tool name (e.g. BaseTool.name) @before_tool_call(tools=["Delete File", "Execute Code"]) def filtered_hook(context): execution_log.append(context.tool_name) @@ -196,6 +197,7 @@ def filtered_hook(context): assert len(hooks) == 1 mock_tool = Mock() + # Context uses the sanitized name (as set by the executor) context = ToolCallHookContext( tool_name="delete_file", tool_input={}, @@ -204,6 +206,7 @@ def filtered_hook(context): hooks[0](context) assert execution_log == ["delete_file"] + # Non-matching tool still filtered out context2 = ToolCallHookContext( tool_name="read_file", tool_input={}, @@ -277,7 +280,7 @@ def filtered_hook(context): ) result2 = hooks[0](context2) - assert result2 is None + assert result2 is None # Hook didn't run, returns None class TestDecoratorAttributes: @@ -383,7 +386,11 @@ def context() -> ToolCallHookContext: agent.role = "Support Agent" return ToolCallHookContext( tool_name="send_email", - tool_input={"recipient": "qa@example.com", "body": "Hello"}, + tool_input={ + "recipient": "qa@example.com", + "body": "Hello", + "headers": {"x-trace": "original"}, + }, tool=tool, agent=agent, ) @@ -407,15 +414,17 @@ def test_deny_maps_to_false(self): assert enable_guardrail(provider)(self.context()) is False - def test_provider_receives_detached_tool_input(self): + def test_provider_receives_deeply_detached_tool_input(self): context = self.context() provider = RecordingGuardrailProvider() assert enable_guardrail(provider)(context) is None assert provider.request is not None provider.request.tool_input["recipient"] = "other@example.com" + provider.request.tool_input["headers"]["x-trace"] = "changed" assert context.tool_input["recipient"] == "qa@example.com" + assert context.tool_input["headers"]["x-trace"] == "original" def test_provider_failure_is_fail_closed_by_default(self): provider = RecordingGuardrailProvider(error=RuntimeError("unavailable")) From 31ebabad79c54e88a750c73f92838b859e6f7b15 Mon Sep 17 00:00:00 2001 From: Aleksey Safonov <55020240+safal207@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:50:38 +0300 Subject: [PATCH 10/10] test: prove guardrail hook can be unregistered --- .../tests/hooks/test_guardrail_provider.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 lib/crewai/tests/hooks/test_guardrail_provider.py diff --git a/lib/crewai/tests/hooks/test_guardrail_provider.py b/lib/crewai/tests/hooks/test_guardrail_provider.py new file mode 100644 index 0000000000..b7736e3a5d --- /dev/null +++ b/lib/crewai/tests/hooks/test_guardrail_provider.py @@ -0,0 +1,44 @@ +"""Focused contract tests for the GuardrailProvider adapter.""" + +from __future__ import annotations + +import pytest + +from crewai.hooks import ( + GuardrailDecision, + GuardrailRequest, + clear_before_tool_call_hooks, + enable_guardrail, + get_before_tool_call_hooks, + register_before_tool_call_hook, + unregister_before_tool_call_hook, +) + + +class AllowProvider: + """Minimal provider fixture that always allows the tool call.""" + + def evaluate(self, request: GuardrailRequest) -> GuardrailDecision: + return GuardrailDecision(allow=True) + + +@pytest.fixture(autouse=True) +def restore_before_tool_hooks(): + """Keep the global hook registry unchanged outside this test module.""" + + original_hooks = get_before_tool_call_hooks() + clear_before_tool_call_hooks() + yield + clear_before_tool_call_hooks() + for hook in original_hooks: + register_before_tool_call_hook(hook) + + +def test_enable_guardrail_returns_unregisterable_hook(): + """The adapter hook can be removed through the existing registry API.""" + + hook = enable_guardrail(AllowProvider()) + + assert get_before_tool_call_hooks() == [hook] + assert unregister_before_tool_call_hook(hook) is True + assert get_before_tool_call_hooks() == []