diff --git a/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs b/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs index 334156a4f116..6b5024bc4a7e 100644 --- a/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs +++ b/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs @@ -58,6 +58,11 @@ async Task TokenProvider(CancellationToken cancellationToken) sessionId: Guid.NewGuid().ToString(), endpoint: new Uri(endpoint)); +// Uncomment the following lines to enable file upload operations (disabled by default for security) +// settings.EnableDangerousFileUploads = true; +// settings.AllowedUploadDirectories = new[] { @"C:\allowed\upload\directory" }; +// settings.AllowedDownloadDirectories = new[] { @"C:\allowed\download\directory" }; + Console.WriteLine("=== Code Interpreter With Azure Container Apps Plugin Demo ===\n"); Console.WriteLine("Start your conversation with the assistant. Type enter or an empty message to quit."); diff --git a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs index 4ad77e054e68..10898b5eed75 100644 --- a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -43,7 +44,11 @@ public SessionsPythonPluginTests() this._settings = new(sessionId: Guid.NewGuid().ToString(), endpoint: new Uri(_spConfiguration.Endpoint)) { CodeExecutionType = SessionsPythonSettings.CodeExecutionTypeSetting.Synchronous, - CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline + CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline, + // Enable file operations for integration tests + EnableDangerousFileUploads = true, + AllowedUploadDirectories = new[] { Path.GetFullPath("TestData") }, + AllowedDownloadDirectories = new[] { Path.GetFullPath("TestData") } }; this._httpClientFactory = new HttpClientFactory(); diff --git a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs index 6ff22d990c92..7f8272031c9a 100644 --- a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs +++ b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs @@ -113,6 +113,7 @@ public async Task ExecuteCodeAsync( /// The metadata of the uploaded file. /// /// + /// Thrown when file operations are disabled or the path is not allowed. [KernelFunction, Description("Uploads a file to the `/mnt/data` directory of the current session.")] public async Task UploadFileAsync( [Description("The name of the remote file, relative to `/mnt/data`.")] string remoteFileName, @@ -122,11 +123,13 @@ public async Task UploadFileAsync( Verify.NotNullOrWhiteSpace(remoteFileName, nameof(remoteFileName)); Verify.NotNullOrWhiteSpace(localFilePath, nameof(localFilePath)); - this._logger.LogInformation("Uploading file: {LocalFilePath} to {RemoteFileName}", localFilePath, remoteFileName); + var validatedLocalPath = this.ValidateLocalPathForUpload(localFilePath); + + this._logger.LogInformation("Uploading file: {LocalFilePath} to {RemoteFileName}", validatedLocalPath, remoteFileName); using var httpClient = this._httpClientFactory.CreateClient(); - using var fileContent = new ByteArrayContent(File.ReadAllBytes(localFilePath)); + using var fileContent = new ByteArrayContent(File.ReadAllBytes(validatedLocalPath)); using var multipartFormDataContent = new MultipartFormDataContent() { @@ -147,15 +150,21 @@ public async Task UploadFileAsync( /// The path to save the downloaded file to. If not provided won't save it in the disk. /// The cancellation token. /// The data of the downloaded file as byte array. - [KernelFunction, Description("Downloads a file from the `/mnt/data` directory of the current session.")] + /// Thrown when file operations are disabled or the path is not allowed. public async Task DownloadFileAsync( - [Description("The name of the remote file to download, relative to `/mnt/data`.")] string remoteFileName, - [Description("The path to save the downloaded file to. If not provided won't save it in the disk.")] string? localFilePath = null, + string remoteFileName, + string? localFilePath = null, CancellationToken cancellationToken = default) { Verify.NotNullOrWhiteSpace(remoteFileName, nameof(remoteFileName)); - this._logger.LogTrace("Downloading file: {RemoteFileName} to {LocalFileName}", remoteFileName, localFilePath); + string? validatedLocalPath = null; + if (!string.IsNullOrWhiteSpace(localFilePath)) + { + validatedLocalPath = this.ValidateLocalPathForDownload(localFilePath); + } + + this._logger.LogTrace("Downloading file: {RemoteFileName} to {LocalFileName}", remoteFileName, validatedLocalPath); using var httpClient = this._httpClientFactory.CreateClient(); @@ -163,11 +172,11 @@ public async Task DownloadFileAsync( var fileContent = await response.Content.ReadAsByteArrayAndTranslateExceptionAsync(cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(localFilePath)) + if (!string.IsNullOrWhiteSpace(validatedLocalPath)) { try { - File.WriteAllBytes(localFilePath, fileContent); + File.WriteAllBytes(validatedLocalPath, fileContent); } catch (Exception ex) { @@ -279,6 +288,90 @@ private async Task SendAsync(HttpClient httpClient, HttpMet return await httpClient.SendWithSuccessCheckAsync(request, cancellationToken).ConfigureAwait(false); } + /// + /// Validates that the local file path is within allowed upload directories. + /// + /// The local file path to validate. + /// The canonicalized path if valid. + /// Thrown when file operations are disabled or the path is not allowed. + private string ValidateLocalPathForUpload(string localFilePath) + { + if (!this._settings.EnableDangerousFileUploads) + { + throw new InvalidOperationException( + "File upload is disabled. Set 'EnableDangerousFileUploads' to true and configure 'AllowedUploadDirectories' to enable."); + } + + if (this._settings.AllowedUploadDirectories is null || !this._settings.AllowedUploadDirectories.Any()) + { + throw new InvalidOperationException( + "File upload requires 'AllowedUploadDirectories' to be configured."); + } + + var canonicalPath = Path.GetFullPath(localFilePath); + + foreach (var allowedDir in this._settings.AllowedUploadDirectories) + { + var canonicalAllowedDir = Path.GetFullPath(allowedDir); + // Ensure we match the directory correctly by appending separator + var separator = Path.DirectorySeparatorChar.ToString(); + var allowedDirWithSeparator = canonicalAllowedDir.EndsWith(separator, StringComparison.OrdinalIgnoreCase) + ? canonicalAllowedDir + : canonicalAllowedDir + separator; + + if (canonicalPath.StartsWith(allowedDirWithSeparator, StringComparison.OrdinalIgnoreCase)) + { + return canonicalPath; + } + } + + throw new InvalidOperationException( + $"Access denied: '{localFilePath}' is not within allowed upload directories."); + } + + /// + /// Validates that the local file path is within allowed download directories. + /// + /// The local file path to validate. + /// The canonicalized path if valid. + /// Thrown when the path is not allowed. + private string ValidateLocalPathForDownload(string localFilePath) + { + // If no restrictions configured, allow all paths (permissive by default for downloads) + if (this._settings.AllowedDownloadDirectories is null || !this._settings.AllowedDownloadDirectories.Any()) + { + return Path.GetFullPath(localFilePath); + } + + // Get the directory of the target file path + var targetDirectory = Path.GetDirectoryName(localFilePath); + if (string.IsNullOrEmpty(targetDirectory)) + { + targetDirectory = "."; + } + + var canonicalTargetDir = Path.GetFullPath(targetDirectory); + var canonicalFilePath = Path.GetFullPath(localFilePath); + + foreach (var allowedDir in this._settings.AllowedDownloadDirectories) + { + var canonicalAllowedDir = Path.GetFullPath(allowedDir); + // Ensure we match the directory correctly by appending separator + var separator = Path.DirectorySeparatorChar.ToString(); + var allowedDirWithSeparator = canonicalAllowedDir.EndsWith(separator, StringComparison.OrdinalIgnoreCase) + ? canonicalAllowedDir + : canonicalAllowedDir + separator; + + if (canonicalTargetDir.StartsWith(allowedDirWithSeparator, StringComparison.OrdinalIgnoreCase)) + { + return canonicalFilePath; + } + } + + throw new InvalidOperationException( + $"Access denied: '{localFilePath}' is not within allowed download directories."); + } + #if NET [GeneratedRegex(@"^(\s|`)*(?i:python)?\s*", RegexOptions.ExplicitCapture)] private static partial Regex RemoveLeadingWhitespaceBackticksPython(); diff --git a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs index e21e51ac46da..f2a6076bd449 100644 --- a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs +++ b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs @@ -29,6 +29,30 @@ public class SessionsPythonSettings /// public IEnumerable? AllowedDomains { get; set; } + /// + /// Gets or sets a value indicating whether dangerous file upload operations are enabled. + /// Default is false. Must be set to true along with configuring + /// to enable file uploads. + /// + [JsonIgnore] + public bool EnableDangerousFileUploads { get; set; } + + /// + /// Gets or sets the list of allowed local directories for file uploads. + /// When is true, only files within these directories can be uploaded. + /// If null or empty, file uploads are denied. + /// + [JsonIgnore] + public IEnumerable? AllowedUploadDirectories { get; set; } + + /// + /// Gets or sets the list of allowed local directories for file downloads. + /// If configured, files can only be downloaded to these directories. + /// If null or empty, all paths are allowed (permissive by default). + /// + [JsonIgnore] + public IEnumerable? AllowedDownloadDirectories { get; set; } + /// /// The session identifier. /// diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs index 46b8f23ba168..e39891d90f07 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs @@ -35,6 +35,8 @@ public sealed class SessionsPythonPluginTests : IDisposable CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline }; + private readonly SessionsPythonSettings _settingsWithFileOperationsEnabled; + private readonly IHttpClientFactory _httpClientFactory; public SessionsPythonPluginTests() @@ -46,6 +48,18 @@ public SessionsPythonPluginTests() httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(this._httpClient); this._httpClientFactory = httpClientFactoryMock.Object; + + // Initialize settings with file operations enabled for tests that need them + this._settingsWithFileOperationsEnabled = new( + sessionId: Guid.NewGuid().ToString(), + endpoint: new Uri("http://localhost:8888")) + { + CodeExecutionType = SessionsPythonSettings.CodeExecutionTypeSetting.Synchronous, + CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline, + EnableDangerousFileUploads = true, + AllowedUploadDirectories = new[] { Path.GetDirectoryName(Path.GetFullPath(FileTestDataFilePath))! }, + AllowedDownloadDirectories = new[] { Path.GetDirectoryName(Path.GetFullPath(FileTestDataFilePath))! } + }; } [Fact] @@ -64,6 +78,24 @@ public void ItCanBeImported() Assert.NotNull(KernelPluginFactory.CreateFromObject(plugin)); } + [Fact] + public void ItExposesExpectedKernelFunctions() + { + // Arrange + var plugin = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory); + + // Act + var kernelPlugin = KernelPluginFactory.CreateFromObject(plugin); + + // Assert - Only ExecuteCode, UploadFile, and ListFiles should be exposed + // DownloadFile should NOT be exposed as a KernelFunction (matching Python behavior) + Assert.Equal(3, kernelPlugin.FunctionCount); + Assert.Contains(kernelPlugin, f => f.Name == "ExecuteCode"); + Assert.Contains(kernelPlugin, f => f.Name == "UploadFile"); + Assert.Contains(kernelPlugin, f => f.Name == "ListFiles"); + Assert.DoesNotContain(kernelPlugin, f => f.Name == "DownloadFile"); + } + [Fact] public async Task ItShouldExecuteCodeAsync() { @@ -106,7 +138,7 @@ Task tokenProviderAsync(CancellationToken _) Content = new StringContent(""), }; - var plugin = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory, tokenProviderAsync); + var plugin = new SessionsPythonPlugin(this._settingsWithFileOperationsEnabled, this._httpClientFactory, tokenProviderAsync); // Act try @@ -155,9 +187,9 @@ public async Task ItShouldUseSameSessionIdAcrossMultipleCallsAsync() }); var expectedSessionId = Guid.NewGuid().ToString(); - this._defaultSettings.SessionId = expectedSessionId; + this._settingsWithFileOperationsEnabled.SessionId = expectedSessionId; - var plugin = new SessionsPythonPlugin(this._defaultSettings, httpClientFactoryMock.Object); + var plugin = new SessionsPythonPlugin(this._settingsWithFileOperationsEnabled, httpClientFactoryMock.Object); // Act await plugin.ExecuteCodeAsync("print('hello world')"); @@ -229,7 +261,7 @@ public async Task ItShouldUploadFileAsync() Content = new StringContent(responseContent), }; - var plugin = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory); + var plugin = new SessionsPythonPlugin(this._settingsWithFileOperationsEnabled, this._httpClientFactory); // Act var result = await plugin.UploadFileAsync("test-file.txt", FileTestDataFilePath); @@ -278,6 +310,7 @@ public async Task ItShouldDownloadFileSavingInDiskAsync() Content = new ByteArrayContent(responseContent), }; + // Downloads are permissive by default - no need for special settings var plugin = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory); // Act @@ -341,7 +374,7 @@ public async Task ItShouldAddHeadersAsync() Content = new StringContent(responseContent), }; - var plugin = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory, (_) => Task.FromResult("test-auth-token")); + var plugin = new SessionsPythonPlugin(this._settingsWithFileOperationsEnabled, this._httpClientFactory, (_) => Task.FromResult("test-auth-token")); // Act var result = await plugin.UploadFileAsync("test-file.txt", FileTestDataFilePath); @@ -358,6 +391,116 @@ public async Task ItShouldAddHeadersAsync() Assert.Single(authorizationHeaderValues, value => value == "Bearer test-auth-token"); } + [Fact] + public async Task ItShouldDenyUploadWhenFileOperationsDisabledAsync() + { + // Arrange - default settings have EnableDangerousFileUploads = false + var plugin = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => plugin.UploadFileAsync("test.txt", FileTestDataFilePath)); + + Assert.Contains("EnableDangerousFileUploads", exception.Message); + } + + [Fact] + public async Task ItShouldDenyUploadWhenAllowedDirectoriesNotConfiguredAsync() + { + // Arrange - EnableDangerousFileUploads is true but AllowedUploadDirectories is null + var settings = new SessionsPythonSettings( + sessionId: Guid.NewGuid().ToString(), + endpoint: new Uri("http://localhost:8888")) + { + EnableDangerousFileUploads = true, + AllowedUploadDirectories = null + }; + + var plugin = new SessionsPythonPlugin(settings, this._httpClientFactory); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => plugin.UploadFileAsync("test.txt", FileTestDataFilePath)); + + Assert.Contains("AllowedUploadDirectories", exception.Message); + } + + [Fact] + public async Task ItShouldDenyUploadOutsideAllowedDirectoriesAsync() + { + // Arrange + var settings = new SessionsPythonSettings( + sessionId: Guid.NewGuid().ToString(), + endpoint: new Uri("http://localhost:8888")) + { + EnableDangerousFileUploads = true, + AllowedUploadDirectories = new[] { "/some/allowed/directory" } + }; + + var plugin = new SessionsPythonPlugin(settings, this._httpClientFactory); + + // Act & Assert - FileTestDataFilePath is not in /some/allowed/directory + var exception = await Assert.ThrowsAsync( + () => plugin.UploadFileAsync("test.txt", FileTestDataFilePath)); + + Assert.Contains("not within allowed upload directories", exception.Message); + } + + [Fact] + public async Task ItShouldDenyDownloadOutsideAllowedDirectoriesAsync() + { + // Arrange - AllowedDownloadDirectories is configured, so path validation applies + var settings = new SessionsPythonSettings( + sessionId: Guid.NewGuid().ToString(), + endpoint: new Uri("http://localhost:8888")) + { + AllowedDownloadDirectories = new[] { "/some/allowed/directory" } + }; + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(new byte[] { 1, 2, 3 }), + }; + + var plugin = new SessionsPythonPlugin(settings, this._httpClientFactory); + var downloadPath = Path.Combine(Path.GetTempPath(), "test_download.txt"); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => plugin.DownloadFileAsync("test.txt", downloadPath)); + + Assert.Contains("not within allowed download directories", exception.Message); + } + + [Fact] + public async Task ItShouldDenyUploadWithPathTraversalAsync() + { + // Arrange + var settings = new SessionsPythonSettings( + sessionId: Guid.NewGuid().ToString(), + endpoint: new Uri("http://localhost:8888")) + { + EnableDangerousFileUploads = true, + AllowedUploadDirectories = new[] { Path.GetDirectoryName(Path.GetFullPath(FileTestDataFilePath))! } + }; + + var plugin = new SessionsPythonPlugin(settings, this._httpClientFactory); + + // Attempt path traversal + var traversalPath = Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(FileTestDataFilePath))!, + "..", + "..", + "etc", + "passwd"); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => plugin.UploadFileAsync("test.txt", traversalPath)); + + Assert.Contains("not within allowed upload directories", exception.Message); + } + public void Dispose() { this._httpClient.Dispose(); diff --git a/python/semantic_kernel/core_plugins/sessions_python_tool/sessions_python_plugin.py b/python/semantic_kernel/core_plugins/sessions_python_tool/sessions_python_plugin.py index cf9edc4e0be1..28d3e9aee3b2 100644 --- a/python/semantic_kernel/core_plugins/sessions_python_tool/sessions_python_plugin.py +++ b/python/semantic_kernel/core_plugins/sessions_python_tool/sessions_python_plugin.py @@ -38,6 +38,8 @@ class SessionsPythonTool(KernelBaseModel): settings: SessionsPythonSettings auth_callback: Callable[..., Any | Awaitable[Any]] http_client: AsyncClient + enable_dangerous_file_uploads: bool = False + """Flag to enable file upload operations. Must be True along with allowed_upload_directories to enable uploads.""" allowed_upload_directories: set[str] | None = None """Allowed local directories for file uploads. If None, upload_file is disabled (deny-by-default).""" allowed_download_directories: set[str] | None = None @@ -52,6 +54,7 @@ def __init__( env_file_path: str | None = None, token_endpoint: str | None = None, credential: TokenCredential | None = None, + enable_dangerous_file_uploads: bool = False, allowed_upload_directories: set[str] | list[str] | None = None, allowed_download_directories: set[str] | list[str] | None = None, **kwargs, @@ -66,12 +69,15 @@ def __init__( env_file_path: Path to .env file. token_endpoint: Token endpoint for authentication. credential: Azure credential for authentication. + enable_dangerous_file_uploads: Flag to enable file upload operations. + Must be True along with allowed_upload_directories to enable file uploads. + Default is False (file uploads disabled). allowed_upload_directories: Set or list of allowed directories for file uploads. If None, upload_file will be disabled (deny-by-default). Empty set/list means no directories are allowed (all uploads denied). allowed_download_directories: Set or list of allowed directories for file downloads. If None, all paths are allowed (permissive-by-default). - Empty set/list means no directories are allowed (all local downloads denied). + If configured, downloads are restricted to these directories. kwargs: Additional keyword arguments. """ try: @@ -104,6 +110,7 @@ def __init__( settings=settings, auth_callback=auth_callback, http_client=http_client, + enable_dangerous_file_uploads=enable_dangerous_file_uploads, allowed_upload_directories=upload_dirs, allowed_download_directories=download_dirs, **kwargs, @@ -186,13 +193,17 @@ def _validate_local_path_for_upload(self, local_file_path: str) -> str: str: The canonicalized absolute path. Raises: - FunctionExecutionException: If the path is not within allowed directories. + FunctionExecutionException: If file operations are disabled or path is not within allowed directories. """ - if self.allowed_upload_directories is None: + if not self.enable_dangerous_file_uploads: raise FunctionExecutionException( - "File upload is disabled. Configure 'allowed_upload_directories' to enable." + "File upload is disabled. Set 'enable_dangerous_file_uploads=True' " + "and configure 'allowed_upload_directories' to enable." ) + if self.allowed_upload_directories is None: + raise FunctionExecutionException("File upload requires 'allowed_upload_directories' to be configured.") + canonical_path = os.path.realpath(local_file_path) for allowed_dir in self.allowed_upload_directories: @@ -416,13 +427,12 @@ async def download_file( remote_file_name: The name of the file to download, relative to `/mnt/data`. local_file_path: The path to save the downloaded file to. Should include the extension. If not provided, the file is returned as a BytesIO object. - If allowed_download_directories is configured, must be within those directories. Returns: BytesIO | None: The file content as BytesIO if no local_file_path provided, otherwise None. Raises: - FunctionExecutionException: If local_file_path is not in allowed directories (when configured). + FunctionExecutionException: If local_file_path is not in allowed directories. """ auth_token = await self._ensure_auth_token() self.http_client.headers.update({ diff --git a/python/tests/unit/core_plugins/test_sessions_python_plugin.py b/python/tests/unit/core_plugins/test_sessions_python_plugin.py index a6382846b18f..576fc6ff874c 100644 --- a/python/tests/unit/core_plugins/test_sessions_python_plugin.py +++ b/python/tests/unit/core_plugins/test_sessions_python_plugin.py @@ -232,6 +232,7 @@ async def async_return(result): plugin = SessionsPythonTool( auth_callback=lambda: "sample_token", env_file_path="test.env", + enable_dangerous_file_uploads=True, allowed_upload_directories={str(tmp_path)}, ) @@ -295,6 +296,7 @@ async def async_return(result): plugin = SessionsPythonTool( auth_callback=lambda: "sample_token", env_file_path="test.env", + enable_dangerous_file_uploads=True, allowed_upload_directories={str(tmp_path)}, ) @@ -326,6 +328,7 @@ async def async_raise_http_error(*args, **kwargs): plugin = SessionsPythonTool( auth_callback=lambda: "sample_token", env_file_path="test.env", + enable_dangerous_file_uploads=True, allowed_upload_directories={str(tmp_path)}, ) @@ -400,6 +403,7 @@ async def async_return(result): plugin = SessionsPythonTool( auth_callback=lambda: "sample_token", + enable_dangerous_file_uploads=True, allowed_upload_directories={str(tmp_path)}, ) @@ -532,6 +536,8 @@ async def mock_auth_callback(): plugin = SessionsPythonTool( auth_callback=mock_auth_callback, env_file_path="test.env", + enable_dangerous_file_uploads=True, + allowed_download_directories={str(tmp_path)}, ) await plugin.download_file(remote_file_name="remote_test.txt", local_file_path=str(local_file)) @@ -667,13 +673,24 @@ def test_full_path(filename, expected_full_path): async def test_upload_file_denied_when_no_allowed_directories(aca_python_sessions_unit_test_env): - """Test that upload_file raises an exception when allowed_upload_directories is not configured.""" + """Test that upload_file raises an exception when enable_dangerous_file_uploads is False.""" plugin = SessionsPythonTool(auth_callback=lambda: "sample_token") with pytest.raises(FunctionExecutionException, match="File upload is disabled"): await plugin.upload_file(local_file_path="/some/path/file.txt") +async def test_upload_file_denied_when_flag_true_but_no_directories(aca_python_sessions_unit_test_env): + """Test that upload_file raises an exception when flag is True but directories not configured.""" + plugin = SessionsPythonTool( + auth_callback=lambda: "sample_token", + enable_dangerous_file_uploads=True, + ) + + with pytest.raises(FunctionExecutionException, match="allowed_upload_directories"): + await plugin.upload_file(local_file_path="/some/path/file.txt") + + async def test_upload_file_denied_outside_allowed_directories(aca_python_sessions_unit_test_env, tmp_path): """Test that upload_file raises an exception when path is outside allowed directories.""" allowed_dir = tmp_path / "allowed" @@ -681,6 +698,7 @@ async def test_upload_file_denied_outside_allowed_directories(aca_python_session plugin = SessionsPythonTool( auth_callback=lambda: "sample_token", + enable_dangerous_file_uploads=True, allowed_upload_directories={str(allowed_dir)}, ) @@ -695,6 +713,7 @@ async def test_upload_file_path_traversal_blocked(aca_python_sessions_unit_test_ plugin = SessionsPythonTool( auth_callback=lambda: "sample_token", + enable_dangerous_file_uploads=True, allowed_upload_directories={str(allowed_dir)}, ) @@ -755,6 +774,7 @@ async def async_return(result): plugin = SessionsPythonTool( auth_callback=lambda: "sample_token", + enable_dangerous_file_uploads=True, allowed_upload_directories={str(allowed_dir)}, ) @@ -870,6 +890,7 @@ async def test_empty_set_denies_all_uploads(aca_python_sessions_unit_test_env, t plugin = SessionsPythonTool( auth_callback=lambda: "sample_token", + enable_dangerous_file_uploads=True, allowed_upload_directories=set(), # Empty set - deny all )