diff --git a/docs/ar/guides/flows/conversational-flows.mdx b/docs/ar/guides/flows/conversational-flows.mdx index 9bd408049..4a3807c04 100644 --- a/docs/ar/guides/flows/conversational-flows.mdx +++ b/docs/ar/guides/flows/conversational-flows.mdx @@ -354,7 +354,7 @@ Routes: ### أذونات المسارات -استخدم `@listen(..., required_permissions=[...])` لحماية المسارات بعد أن يختار الموجّه مساراً صالحاً. يتحقق المفوّض الافتراضي من مجموعة `permissions` في `self.state`؛ وتُعاد الجولات المرفوضة إلى `permission_denied`. +استخدم `@listen(..., required_permissions=[...])` لحماية المسارات بعد أن يختار الموجّه مسارًا صالحًا. يتحقق المفوّض الافتراضي من مجموعة `permissions` في `self.state`؛ وتُعاد الجولات المرفوضة إلى `permission_denied`. ```python from crewai import Flow @@ -402,7 +402,7 @@ class SupportFlow(Flow[AppState]): return reply ``` -استخدم `RouterConfig.route_permissions` عندما تحتاج إلى تكوين الأذونات بعيداً عن المعالجات. للتفويض المخصص، تجاوز `can_access_route(required_permissions)` بدلاً من تجاوز `route_turn`. +استخدم `RouterConfig.route_permissions` عندما تحتاج إلى تكوين الأذونات بعيدًا عن المعالجات. للتفويض المخصص، تجاوز `can_access_route(required_permissions)` بدلًا من تجاوز `route_turn`. ### المسارات المدمجة diff --git a/lib/crewai/src/crewai/experimental/conversational_mixin.py b/lib/crewai/src/crewai/experimental/conversational_mixin.py index 5c7e0c01a..7e1747f54 100644 --- a/lib/crewai/src/crewai/experimental/conversational_mixin.py +++ b/lib/crewai/src/crewai/experimental/conversational_mixin.py @@ -659,7 +659,10 @@ class _ConversationalMixin: ) -> str | None: router_llm = self._default_router_llm(router_config) if router_llm is None: - return router_config.default_intent + return self._route_or_permission_denied( + router_config.default_intent, + router_config, + ) try: llm = self._coerce_llm(router_llm) @@ -670,19 +673,36 @@ class _ConversationalMixin: ) intent = self._extract_router_intent(response, router_config.intent_field) except Exception: - return router_config.fallback_intent or router_config.default_intent + return self._route_or_permission_denied( + router_config.fallback_intent or router_config.default_intent, + router_config, + ) if intent is None: - return router_config.fallback_intent or router_config.default_intent + return self._route_or_permission_denied( + router_config.fallback_intent or router_config.default_intent, + router_config, + ) valid_labels = self._effective_routes(router_config) if valid_labels and intent not in valid_labels: - return router_config.fallback_intent or router_config.default_intent + return self._route_or_permission_denied( + router_config.fallback_intent or router_config.default_intent, + router_config, + ) - if not self._can_access_router_intent(intent, router_config): + return self._route_or_permission_denied(intent, router_config) + + def _route_or_permission_denied( + self, + route: str | None, + router_config: RouterConfig, + ) -> str | None: + if route is None: + return None + if not self._can_access_router_intent(route, router_config): return router_config.permission_denied_route - - return intent + return route def _can_access_router_intent( self, diff --git a/lib/crewai/src/crewai/flow/dsl/_listen.py b/lib/crewai/src/crewai/flow/dsl/_listen.py index 473179bfe..75a679b87 100644 --- a/lib/crewai/src/crewai/flow/dsl/_listen.py +++ b/lib/crewai/src/crewai/flow/dsl/_listen.py @@ -64,6 +64,11 @@ def listen( ) if not permissions: raise ValueError("required_permissions must not be empty") + if any( + not isinstance(permission, str) or not permission.strip() + for permission in permissions + ): + raise ValueError("required_permissions must contain non-empty strings") wrapper.__route_permissions__ = permissions return wrapper diff --git a/lib/crewai/src/crewai/flow/flow_wrappers.py b/lib/crewai/src/crewai/flow/flow_wrappers.py index 42cfb2ded..05bbf8d06 100644 --- a/lib/crewai/src/crewai/flow/flow_wrappers.py +++ b/lib/crewai/src/crewai/flow/flow_wrappers.py @@ -171,6 +171,7 @@ class ListenMethod(FlowMethod[P, R]): __trigger_methods__: list[FlowMethodName] | None = None __condition_type__: FlowConditionType | None = None __trigger_condition__: FlowCondition | None = None + __route_permissions__: tuple[str, ...] | None = None class RouterMethod(FlowMethod[P, R]): diff --git a/lib/crewai/tests/test_flow_conversation.py b/lib/crewai/tests/test_flow_conversation.py index 05b31d7cb..c78b82c56 100644 --- a/lib/crewai/tests/test_flow_conversation.py +++ b/lib/crewai/tests/test_flow_conversation.py @@ -891,6 +891,7 @@ class TestConversationalFlow: return "denied" flow = PermissionFlow() + flow._state = PermissionState() result = flow.handle_turn("research CrewAI") assert result == "denied" @@ -929,6 +930,7 @@ class TestConversationalFlow: return "denied" flow = PermissionFlow() + flow._state = PermissionState() result = flow.handle_turn("research CrewAI") assert result == "researched" @@ -970,10 +972,65 @@ class TestConversationalFlow: assert result == "admin report" assert flow.state.last_intent == "admin" - def test_router_infers_permissions_from_listener_metadata(self) -> None: - class PermissionState(ConversationState): - permissions: set[str] = Field(default_factory=set) + def test_router_permissions_gate_protected_default_intent(self) -> None: + @ConversationConfig( + router=RouterConfig( + routes=["admin"], + route_permissions={"admin": "admin"}, + default_intent="admin", + ), + ) + class PermissionFlow(ConversationalFlow): + @listen("admin") + def run_admin(self) -> str: + self.append_assistant_message("admin report") + return "admin report" + @listen("permission_denied") + def deny(self) -> str: + self.append_assistant_message("denied") + return "denied" + + flow = PermissionFlow() + result = flow.handle_turn("show audit") + + assert result == "denied" + assert flow.state.last_intent == "permission_denied" + + def test_router_permissions_gate_protected_fallback_intent(self) -> None: + class Route(BaseModel): + intent: str + + router_llm = MagicMock() + router_llm.call.return_value = Route(intent="unknown") + + @ConversationConfig( + router=RouterConfig( + response_format=Route, + llm=router_llm, + routes=["admin"], + route_permissions={"admin": "admin"}, + fallback_intent="admin", + ), + ) + class PermissionFlow(ConversationalFlow): + @listen("admin") + def run_admin(self) -> str: + self.append_assistant_message("admin report") + return "admin report" + + @listen("permission_denied") + def deny(self) -> str: + self.append_assistant_message("denied") + return "denied" + + flow = PermissionFlow() + result = flow.handle_turn("something unexpected") + + assert result == "denied" + assert flow.state.last_intent == "permission_denied" + + def test_router_infers_permissions_from_listener_metadata(self) -> None: router_llm = MagicMock() @ConversationConfig( @@ -981,7 +1038,7 @@ class TestConversationalFlow: llm=router_llm, ), ) - class PermissionFlow(Flow[PermissionState]): + class PermissionFlow(Flow[ConversationState]): conversational = True @listen("research", required_permissions=["web_search"]) @@ -1007,6 +1064,14 @@ class TestConversationalFlow: assert result == "denied" assert flow.state.last_intent == "permission_denied" + def test_listener_required_permissions_reject_empty_values(self) -> None: + try: + listen("research", required_permissions=["web_search", ""])(lambda: None) + except ValueError as exc: + assert "non-empty strings" in str(exc) + else: + raise AssertionError("empty permission names should be rejected") + def test_conversational_flow_auto_defaults_to_conversation_state(self) -> None: """``class C(Flow): conversational = True`` resolves state to ConversationState.