diff --git a/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs index 9ac8470d..3aa909b5 100644 --- a/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs @@ -36,7 +36,6 @@ public void GetShellEnvironmentDescription_ReturnsNonEmpty() { [Fact] public async Task ExecuteCommand_NonAdminUser_ReturnsError() { - // Use a userId that is different from the actual AdminId var toolContext = new ToolContext { ChatId = 1, UserId = long.MaxValue - 1 }; var result = await _service.ExecuteCommand("echo test", toolContext); Assert.Contains("Error", result); @@ -51,7 +50,6 @@ public async Task ExecuteCommand_NullToolContext_ReturnsError() { [Fact] public async Task ExecuteCommand_EmptyCommand_ReturnsError() { - // Even with admin user, empty command should fail var toolContext = new ToolContext { ChatId = 1, UserId = Env.AdminId }; var result = await _service.ExecuteCommand("", toolContext); Assert.Contains("Error", result); @@ -71,7 +69,6 @@ public async Task ExecuteCommand_InvalidWorkingDirectory_ReturnsError() { public async Task ExecuteCommand_AdminUser_ExecutesSuccessfully() { var toolContext = new ToolContext { ChatId = 1, UserId = Env.AdminId }; - // Use platform-appropriate command string command; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { command = "Write-Output 'hello test'"; @@ -89,7 +86,6 @@ public async Task ExecuteCommand_AdminUser_ExecutesSuccessfully() { public async Task ExecuteCommand_TimeoutClamped() { var toolContext = new ToolContext { ChatId = 1, UserId = Env.AdminId }; - // Very short timeout should be clamped to 1000ms minimum string command; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { command = "Write-Output 'quick'; Start-Sleep -Seconds 2"; diff --git a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs index 19f326bc..9f8faf9b 100644 --- a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs @@ -197,5 +197,6 @@ public async Task ExecuteCommand( return $"Error executing command: {ex.Message}"; } } + } } diff --git a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs index e39aefe9..9039850a 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs @@ -17,8 +17,16 @@ public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() { try { var chatId = 12345L; + Directory.CreateDirectory(AppContext.BaseDirectory); + Directory.CreateDirectory(Path.Combine(Env.WorkDir, "Photos")); + Directory.CreateDirectory(Path.Combine(Env.WorkDir, "Audios")); + Directory.CreateDirectory(Path.Combine(Env.WorkDir, "Videos")); + Directory.CreateDirectory(Path.Combine(Env.WorkDir, "Files")); + Directory.CreateDirectory(Path.Combine(Env.WorkDir, "logs")); + Directory.CreateDirectory(Path.Combine(Env.WorkDir, "temp")); var ini = BuildIni(chatId); + Assert.Contains(ReadPath(AppContext.BaseDirectory), ini); Assert.Contains(ReadPath(Path.Combine(Env.WorkDir, "Photos", chatId.ToString())), ini); Assert.Contains(ReadPath(Path.Combine(Env.WorkDir, "Audios", chatId.ToString())), ini); Assert.Contains(ReadPath(Path.Combine(Env.WorkDir, "Videos", chatId.ToString())), ini); @@ -28,8 +36,13 @@ public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() { Assert.Contains(ClosedPath(Path.Combine(Env.WorkDir, "Audios")), ini); Assert.Contains(ClosedPath(Path.Combine(Env.WorkDir, "Videos")), ini); Assert.Contains(ClosedPath(Path.Combine(Env.WorkDir, "Files")), ini); + Assert.Contains(ClosedPath(Path.Combine(Env.WorkDir, "logs")), ini); + Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "temp"))}\\*", ini); - Assert.DoesNotContain("Index_Data", ini, StringComparison.OrdinalIgnoreCase); + var indexDataDir = Path.Combine(Env.WorkDir, "Index_Data"); + if (Directory.Exists(indexDataDir)) { + Assert.Contains($"ClosedFilePath={Normalize(indexDataDir)}", ini, StringComparison.OrdinalIgnoreCase); + } Assert.DoesNotContain("GroupFiles", ini, StringComparison.OrdinalIgnoreCase); } finally { Env.SandboxieGroupFilesRoot = originalGroupFilesRoot; @@ -72,6 +85,41 @@ public void BuildPortableBoxIni_WhenDenyHostFileSystemDisabled_DoesNotCloseDrive } } + [Fact] + public void EnsureBoxesDirectory_CreatesMissingDirectory() { + var testDir = Path.Combine(Path.GetTempPath(), "TGSB_SandboxieBoxes_" + Guid.NewGuid().ToString("N")); + try { + Assert.False(Directory.Exists(testDir)); + + SandboxieToolHostService.EnsureBoxesDirectory(testDir); + + Assert.True(Directory.Exists(testDir)); + } finally { + if (Directory.Exists(testDir)) { + Directory.Delete(testDir, recursive: true); + } + } + } + + [Fact] + public void GetDefaultWorkDirClosedPaths_DoesNotCloseAllowedAppDirectory() { + var appDir = Path.Combine(Env.WorkDir, "app"); + var otherDir = Path.Combine(Env.WorkDir, "scratch"); + Directory.CreateDirectory(appDir); + Directory.CreateDirectory(otherDir); + + try { + var closedPaths = SandboxieToolHostService.GetDefaultWorkDirClosedPaths(new[] { appDir }).ToList(); + + Assert.DoesNotContain(Normalize(appDir), closedPaths, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Normalize(otherDir), closedPaths, StringComparer.OrdinalIgnoreCase); + } finally { + if (Directory.Exists(otherDir)) { + Directory.Delete(otherDir, recursive: true); + } + } + } + private static string BuildIni(long chatId) { var instance = new SandboxieInstance( chatId, diff --git a/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs b/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs index 864c9f88..cb4ad55b 100644 --- a/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs +++ b/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs @@ -115,6 +115,7 @@ public async Task EnsureToolHostAsync(long chatId, Cancellati await _lock.WaitAsync(cancellationToken); try { var instance = BuildInstance(chatId); + EnsureBoxesDirectory(instance.BoxesDirectory); if (Env.SandboxieAutoRegisterImportBox) { EnsureImportBoxDirective(instance.BoxesDirectory); } @@ -181,8 +182,16 @@ private static SandboxieInstance BuildInstance(long chatId) { Path.Combine(boxesDir, boxName)); } + internal static void EnsureBoxesDirectory(string boxesDirectory) { + if (string.IsNullOrWhiteSpace(boxesDirectory)) { + throw new InvalidOperationException("Sandboxie box import directory is not configured."); + } + + Directory.CreateDirectory(boxesDirectory); + } + private void EnsurePortableBoxDefinition(SandboxieInstance instance) { - Directory.CreateDirectory(instance.BoxesDirectory); + EnsureBoxesDirectory(instance.BoxesDirectory); var content = BuildPortableBoxIni(instance); if (File.Exists(instance.BoxIniPath)) { var existing = File.ReadAllText(instance.BoxIniPath, Encoding.Unicode); @@ -212,6 +221,11 @@ internal static string BuildPortableBoxIni(SandboxieInstance instance) { }; var defaultReadPaths = GetDefaultToolHostReadPaths(instance.ChatId).ToList(); + var globalReadPaths = Env.SandboxieGlobalReadPaths + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(NormalizeSandboxiePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in defaultReadPaths .Concat(Env.SandboxieGlobalReadPaths) .Where(p => !string.IsNullOrWhiteSpace(p)) @@ -227,6 +241,10 @@ internal static string BuildPortableBoxIni(SandboxieInstance instance) { lines.Add($"ClosedFilePath={NormalizeSandboxiePath(path)}{(Directory.Exists(path) ? "\\*" : string.Empty)}"); } + foreach (var path in GetDefaultWorkDirClosedPaths(defaultReadPaths, globalReadPaths)) { + lines.Add($"ClosedFilePath={path}\\*"); + } + lines.Add(string.Empty); return string.Join(Environment.NewLine, lines); } @@ -297,11 +315,35 @@ internal static IEnumerable GetDefaultClosedPaths() { yield return Path.Combine(Env.WorkDir, "Config.json"); yield return Path.Combine(Env.WorkDir, "Data.sqlite"); - yield return Path.Combine(Env.WorkDir, "Logs"); + yield return Path.Combine(Env.WorkDir, "logs"); yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh"); yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); } + internal static IEnumerable GetDefaultWorkDirClosedPaths( + IEnumerable allowedReadPaths, + ISet? extraAllowedPaths = null) { + var comparer = StringComparer.OrdinalIgnoreCase; + var normalizedWorkDir = NormalizeSandboxiePath(Env.WorkDir); + var allowedRoots = allowedReadPaths + .Concat(extraAllowedPaths ?? Enumerable.Empty()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeSandboxiePath) + .Where(path => path.StartsWith(normalizedWorkDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + .Distinct(comparer) + .ToList(); + + foreach (var childDir in Directory.Exists(Env.WorkDir) + ? Directory.EnumerateDirectories(Env.WorkDir).Select(NormalizeSandboxiePath).Distinct(comparer) + : Array.Empty()) { + if (allowedRoots.Any(allowed => IsSameOrSubPath(childDir, allowed, comparer))) { + continue; + } + + yield return childDir; + } + } + internal static IEnumerable GetChatResourceParentPaths() { if (!string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot)) { yield return Env.SandboxieGroupFilesRoot; @@ -326,6 +368,11 @@ private static IEnumerable GetHostDriveRoots() { private static string NormalizeSandboxiePath(string path) { return Path.GetFullPath(path.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } + + private static bool IsSameOrSubPath(string path, string root, StringComparer comparer) { + return comparer.Equals(path, root) || + path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } } public sealed record SandboxieInstance(long ChatId, string BoxName, string BoxesDirectory, string BoxIniPath, string BoxRootPath);