From 0615af898b1863ba0a082015f6c26939e264afff Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 6 Oct 2026 10:13:11 +0100 Subject: [PATCH 1/2] Validate CrewAI task identifiers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CrewAI/CrewAIEnterpriseClientTests.cs | 75 ++++++++++++++++++- .../CrewAI/Client/CrewAIEnterpriseClient.cs | 10 +++ .../crew_ai/crew_ai_enterprise_client.py | 4 + .../test_crew_ai_enterprise_client.py | 70 +++++++++++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 python/tests/unit/core_plugins/test_crew_ai_enterprise_client.py diff --git a/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseClientTests.cs b/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseClientTests.cs index f49fa4ddce0d..e323af7dfd85 100644 --- a/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseClientTests.cs +++ b/dotnet/src/Plugins/Plugins.AI.UnitTests/CrewAI/CrewAIEnterpriseClientTests.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.Plugins.AI.CrewAI; using Moq; using Moq.Protected; @@ -18,6 +19,7 @@ namespace SemanticKernel.Plugins.AI.UnitTests.CrewAI; public sealed partial class CrewAIEnterpriseClientTests { private readonly Mock _httpMessageHandlerMock; + private readonly Mock>> _authTokenProviderMock; private readonly CrewAIEnterpriseClient _client; /// @@ -26,10 +28,12 @@ public sealed partial class CrewAIEnterpriseClientTests public CrewAIEnterpriseClientTests() { this._httpMessageHandlerMock = new Mock(); + this._authTokenProviderMock = new Mock>>(); + this._authTokenProviderMock.Setup(provider => provider()).ReturnsAsync("token"); using var httpClientFactory = new MockHttpClientFactory(this._httpMessageHandlerMock); this._client = new CrewAIEnterpriseClient( endpoint: new Uri("http://example.com"), - authTokenProvider: () => Task.FromResult("token"), + authTokenProvider: this._authTokenProviderMock.Object, httpClientFactory); } @@ -148,4 +152,73 @@ public async Task GetStatusAsyncReturnsCrewAIStatusResponseAsync(CrewAIKickoffSt Assert.NotNull(result.LastStep); Assert.Equal("value1", result.LastStep["step1"].ToString()); } + + /// + /// Verifies that valid task identifiers are preserved in the status request path. + /// + [Theory] + [InlineData("12345")] + [InlineData("AZaz09_-")] + [InlineData("640f9a3a-2734-4ba8-8b8a-2f796f419d03")] + public async Task GetStatusAsyncPreservesValidTaskIdAsync(string taskId) + { + using var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"state\":\"SUCCESS\",\"result\":\"The Result\"}") + }; + + this._httpMessageHandlerMock.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(request => + request.RequestUri!.AbsolutePath == $"/status/{taskId}"), + ItExpr.IsAny()) + .ReturnsAsync(responseMessage); + + var result = await this._client.GetStatusAsync(taskId); + + Assert.Equal(CrewAIKickoffState.Success, result.State); + Assert.Equal("The Result", result.Result); + this._httpMessageHandlerMock.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.Is(request => + request.RequestUri!.AbsoluteUri == $"http://example.com/status/{taskId}"), + ItExpr.IsAny()); + } + + /// + /// Verifies that invalid task identifiers are rejected before requesting credentials or sending HTTP requests. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("task.id")] + [InlineData("task/id")] + [InlineData(@"task\id")] + [InlineData("task%id")] + [InlineData("task?id")] + [InlineData("task#id")] + [InlineData("task id")] + [InlineData("task\n")] + [InlineData("task\r\n")] + [InlineData("task\t")] + [InlineData("task\u0000")] + [InlineData("t\u00E1sk")] + [InlineData("\uFF11\uFF12\uFF13")] + public async Task GetStatusAsyncRejectsInvalidTaskIdBeforeAuthenticationAsync(string? taskId) + { + var exception = await Assert.ThrowsAsync( + () => this._client.GetStatusAsync(taskId!)); + + var argumentException = Assert.IsAssignableFrom(exception.InnerException); + Assert.Equal("taskId", argumentException.ParamName); + this._authTokenProviderMock.Verify(provider => provider(), Times.Never()); + this._httpMessageHandlerMock.Protected().Verify( + "SendAsync", + Times.Never(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } } diff --git a/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIEnterpriseClient.cs b/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIEnterpriseClient.cs index be2822d3e85e..376f8d1fabbc 100644 --- a/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIEnterpriseClient.cs +++ b/dotnet/src/Plugins/Plugins.AI/CrewAI/Client/CrewAIEnterpriseClient.cs @@ -3,6 +3,7 @@ using System; using System.Net.Http; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.SemanticKernel.Http; @@ -125,6 +126,15 @@ public async Task GetStatusAsync(string taskId, Cancellati { try { + Verify.NotNullOrWhiteSpace(taskId, nameof(taskId)); + + if (!Regex.IsMatch(taskId, @"\A[A-Za-z0-9_-]+\z")) + { + throw new ArgumentException( + "Task ID must contain only ASCII letters, digits, underscores, or hyphens.", + nameof(taskId)); + } + using var client = await this.CreateHttpClientAsync().ConfigureAwait(false); using var requestMessage = HttpRequest.CreateGetRequest($"/status/{taskId}"); using var response = await client.SendWithSuccessCheckAsync(requestMessage, cancellationToken) diff --git a/python/semantic_kernel/core_plugins/crew_ai/crew_ai_enterprise_client.py b/python/semantic_kernel/core_plugins/crew_ai/crew_ai_enterprise_client.py index f52efa77e17d..f01ebe010d66 100644 --- a/python/semantic_kernel/core_plugins/crew_ai/crew_ai_enterprise_client.py +++ b/python/semantic_kernel/core_plugins/crew_ai/crew_ai_enterprise_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import re from typing import Any import aiohttp @@ -89,6 +90,9 @@ async def get_status(self, task_id: str) -> CrewAIStatusResponse: Returns: CrewAIStatusResponse: The status response of the task. """ + if not task_id or re.fullmatch(r"[A-Za-z0-9_-]+", task_id) is None: + raise ValueError("Task ID must contain only ASCII letters, digits, underscores, or hyphens.") + async with ( self.session.get(f"{self.endpoint}/status/{task_id}", headers=self.request_header) as response, # type: ignore ): diff --git a/python/tests/unit/core_plugins/test_crew_ai_enterprise_client.py b/python/tests/unit/core_plugins/test_crew_ai_enterprise_client.py new file mode 100644 index 000000000000..7e0676895b52 --- /dev/null +++ b/python/tests/unit/core_plugins/test_crew_ai_enterprise_client.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import MagicMock + +import aiohttp +import pytest +from yarl import URL + +from semantic_kernel.core_plugins.crew_ai.crew_ai_enterprise_client import CrewAIEnterpriseClient +from semantic_kernel.core_plugins.crew_ai.crew_ai_models import CrewAIEnterpriseKickoffState + + +@pytest.fixture +def crew_ai_client(): + client = CrewAIEnterpriseClient(endpoint="https://test.com", auth_token="FakeToken") + client.session = MagicMock(spec=aiohttp.ClientSession) + return client + + +@pytest.mark.parametrize( + "task_id", + ["12345", "AZaz09_-", "640f9a3a-2734-4ba8-8b8a-2f796f419d03"], +) +async def test_get_status_preserves_valid_task_id(crew_ai_client, task_id): + response = MagicMock(spec=aiohttp.ClientResponse) + response.text.return_value = '{"state":"SUCCESS","result":"The Result"}' + crew_ai_client.session.get.return_value.__aenter__.return_value = response + + result = await crew_ai_client.get_status(task_id) + + assert result.state == CrewAIEnterpriseKickoffState.Success + assert result.result == "The Result" + crew_ai_client.session.get.assert_called_once_with( + f"https://test.com/status/{task_id}", + headers=crew_ai_client.request_header, + ) + request_url = crew_ai_client.session.get.call_args.args[0] + assert URL(request_url).raw_path == f"/status/{task_id}" + response.raise_for_status.assert_called_once() + + +@pytest.mark.parametrize( + "task_id", + [ + None, + "", + " ", + "task.id", + "task/id", + r"task\id", + "task%id", + "task?id", + "task#id", + "task id", + "task\n", + "task\r\n", + "task\t", + "task\x00", + "t\u00e1sk", + "\uff11\uff12\uff13", + ], +) +async def test_get_status_rejects_invalid_task_id_without_request(crew_ai_client, task_id): + with pytest.raises( + ValueError, + match="Task ID must contain only ASCII letters, digits, underscores, or hyphens", + ): + await crew_ai_client.get_status(task_id) + + crew_ai_client.session.get.assert_not_called() From 359be6ac041477d7e2bb309f958d28f2ad2a0ddd Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Tue, 6 Oct 2026 10:35:27 +0100 Subject: [PATCH 2/2] Handle invalid CrewAI task identifier types Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../core_plugins/crew_ai/crew_ai_enterprise_client.py | 2 +- .../tests/unit/core_plugins/test_crew_ai_enterprise_client.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/python/semantic_kernel/core_plugins/crew_ai/crew_ai_enterprise_client.py b/python/semantic_kernel/core_plugins/crew_ai/crew_ai_enterprise_client.py index f01ebe010d66..2fcf8f819e70 100644 --- a/python/semantic_kernel/core_plugins/crew_ai/crew_ai_enterprise_client.py +++ b/python/semantic_kernel/core_plugins/crew_ai/crew_ai_enterprise_client.py @@ -90,7 +90,7 @@ async def get_status(self, task_id: str) -> CrewAIStatusResponse: Returns: CrewAIStatusResponse: The status response of the task. """ - if not task_id or re.fullmatch(r"[A-Za-z0-9_-]+", task_id) is None: + if not isinstance(task_id, str) or re.fullmatch(r"[A-Za-z0-9_-]+", task_id) is None: raise ValueError("Task ID must contain only ASCII letters, digits, underscores, or hyphens.") async with ( diff --git a/python/tests/unit/core_plugins/test_crew_ai_enterprise_client.py b/python/tests/unit/core_plugins/test_crew_ai_enterprise_client.py index 7e0676895b52..451a1c57588b 100644 --- a/python/tests/unit/core_plugins/test_crew_ai_enterprise_client.py +++ b/python/tests/unit/core_plugins/test_crew_ai_enterprise_client.py @@ -43,6 +43,8 @@ async def test_get_status_preserves_valid_task_id(crew_ai_client, task_id): "task_id", [ None, + 123, + [], "", " ", "task.id",