From 9a859663a20df9e9690c2c7c28da27902bcd4182 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Mon, 1 Jun 2026 22:55:19 +0800 Subject: [PATCH 1/4] Fix sandbox tool access to workdir secrets --- .../Service/Tools/BashToolServiceTests.cs | 33 +++++++-- .../Service/Tools/FileToolServiceTests.cs | 18 +++++ .../Service/Tools/BashToolService.cs | 69 +++++++++++++++++++ .../Service/Tools/FileToolService.cs | 68 ++++++++++++++++++ .../AI/LLM/SandboxieToolHostServiceTests.cs | 10 +-- .../AI/LLM/SandboxieToolHostService.cs | 2 +- 6 files changed, 191 insertions(+), 9 deletions(-) diff --git a/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs index 9ac8470d..84148791 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"; @@ -101,5 +97,34 @@ public async Task ExecuteCommand_TimeoutClamped() { workingDirectory: Path.GetTempPath(), timeoutMs: 100); Assert.Contains("Command timed out after 1000ms.", result); } + + [Fact] + public void TryGetSandboxCommandRestrictionError_RejectsWorkDirRootForSandboxedChat() { + var toolContext = new ToolContext { ChatId = 123, IsSandboxed = true }; + + var blocked = BashToolService.TryGetSandboxCommandRestrictionError(toolContext, Env.WorkDir, out var error); + + Assert.True(blocked); + Assert.Contains("not in the allowed path list", error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TryGetSandboxCommandRestrictionError_AllowsBaseDirectoryForSandboxedChat() { + var toolContext = new ToolContext { ChatId = 123, IsSandboxed = true }; + + var blocked = BashToolService.TryGetSandboxCommandRestrictionError(toolContext, AppContext.BaseDirectory, out var error); + + Assert.False(blocked); + Assert.True(string.IsNullOrWhiteSpace(error)); + } + + [Fact] + public void IsPathInSandboxCommandAllowList_AllowsOnlyCurrentChatRoots() { + Assert.True(BashToolService.IsPathInSandboxCommandAllowList(Path.Combine(Env.WorkDir, "Photos", "123"), 123)); + Assert.True(BashToolService.IsPathInSandboxCommandAllowList(Path.Combine(Env.WorkDir, "Audios", "123"), 123)); + Assert.False(BashToolService.IsPathInSandboxCommandAllowList(Path.Combine(Env.WorkDir, "Photos", "999"), 123)); + Assert.False(BashToolService.IsPathInSandboxCommandAllowList(Path.Combine(Env.WorkDir, "logs"), 123)); + Assert.False(BashToolService.IsPathInSandboxCommandAllowList(Env.WorkDir, 123)); + } } } diff --git a/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs index 40c269bc..e5e17104 100644 --- a/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs @@ -170,5 +170,23 @@ public async Task ListFiles_NonAdmin_ReturnsError() { var result = await _service.ListFiles(_nonAdminContext); Assert.Contains("Error", result); } + + [Fact] + public async Task ListFiles_SandboxedChat_CannotListWorkDirRoot() { + var sandboxedContext = new ToolContext { ChatId = 123, UserId = 0, IsSandboxed = true }; + + var result = await _service.ListFiles(sandboxedContext); + + Assert.Contains("not allowed", result, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void IsPathInSandboxAllowList_AllowsOnlyCurrentChatRootsAndAppDirectory() { + Assert.True(FileToolService.IsPathInSandboxAllowList(AppContext.BaseDirectory, 123)); + Assert.True(FileToolService.IsPathInSandboxAllowList(Path.Combine(Env.WorkDir, "Files", "123"), 123)); + Assert.False(FileToolService.IsPathInSandboxAllowList(Path.Combine(Env.WorkDir, "Files", "999"), 123)); + Assert.False(FileToolService.IsPathInSandboxAllowList(Path.Combine(Env.WorkDir, "logs"), 123)); + Assert.False(FileToolService.IsPathInSandboxAllowList(Env.WorkDir, 123)); + } } } diff --git a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs index 19f326bc..7caa553e 100644 --- a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs @@ -96,6 +96,10 @@ public async Task ExecuteCommand( return "Error: Command cannot be empty."; } + if (toolContext.IsSandboxed && TryGetSandboxCommandRestrictionError(toolContext, workingDirectory, out var restrictionError)) { + return restrictionError; + } + // Limit timeout to reasonable bounds timeoutMs = Math.Clamp(timeoutMs, 1000, 300000); // 1s to 5min @@ -197,5 +201,70 @@ public async Task ExecuteCommand( return $"Error executing command: {ex.Message}"; } } + + internal static bool TryGetSandboxCommandRestrictionError(ToolContext toolContext, string? workingDirectory, out string error) { + error = string.Empty; + if (toolContext == null || !toolContext.IsSandboxed) { + return false; + } + + if (toolContext.ChatId == 0) { + error = "Error: Sandboxed command execution requires a valid chat context."; + return true; + } + + var workDir = ResolveFullPath(workingDirectory ?? AppContext.BaseDirectory); + if (!IsPathInSandboxCommandAllowList(workDir, toolContext.ChatId)) { + error = $"Error: Sandboxed command working directory '{workDir}' is not in the allowed path list."; + return true; + } + + return false; + } + + internal static bool IsPathInSandboxCommandAllowList(string path, long chatId) { + var normalizedPath = ResolveFullPath(path); + foreach (var allowedRoot in GetSandboxCommandAllowedRoots(chatId)) { + if (IsPathUnderRoot(normalizedPath, allowedRoot)) { + return true; + } + } + + return false; + } + + internal static IEnumerable GetSandboxCommandAllowedRoots(long chatId) { + yield return AppContext.BaseDirectory; + yield return Path.Combine(Env.WorkDir, "Photos", chatId.ToString()); + yield return Path.Combine(Env.WorkDir, "Audios", chatId.ToString()); + yield return Path.Combine(Env.WorkDir, "Videos", chatId.ToString()); + yield return Path.Combine(Env.WorkDir, "Files", chatId.ToString()); + if (!string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot)) { + yield return Path.Combine(Env.SandboxieGroupFilesRoot, chatId.ToString()); + } + } + + private static string ResolveFullPath(string path) { + return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + private static bool IsPathUnderRoot(string path, string root) { + var comparer = GetPathComparer(); + var normalizedPath = ResolveFullPath(path); + var normalizedRoot = ResolveFullPath(root); + if (comparer.Equals(normalizedPath, normalizedRoot)) { + return true; + } + + return normalizedPath.StartsWith( + normalizedRoot + Path.DirectorySeparatorChar, + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + } + + private static StringComparer GetPathComparer() { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + } } } diff --git a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs index a22dd959..091338b3 100644 --- a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs @@ -45,6 +45,9 @@ public async Task ReadFile( try { path = ResolvePath(path); + if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { + return restrictionError; + } if (!File.Exists(path)) { return $"Error: File not found: {path}"; @@ -95,6 +98,9 @@ public async Task WriteFile( try { path = ResolvePath(path); + if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { + return restrictionError; + } var directory = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { @@ -123,6 +129,9 @@ public async Task EditFile( try { path = ResolvePath(path); + if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { + return restrictionError; + } if (!File.Exists(path)) { return $"Error: File not found: {path}"; @@ -172,6 +181,9 @@ public async Task SearchText( try { path = ResolvePath(path ?? Env.WorkDir); + if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { + return restrictionError; + } if (!Directory.Exists(path)) { return $"Error: Directory not found: {path}"; @@ -243,6 +255,9 @@ public async Task ListFiles( try { path = ResolvePath(path ?? Env.WorkDir); + if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { + return restrictionError; + } if (!Directory.Exists(path)) { return $"Error: Directory not found: {path}"; @@ -277,6 +292,47 @@ private static bool IsFileToolAllowed(ToolContext toolContext) { return toolContext != null && ( toolContext.UserId == Env.AdminId || toolContext.IsSandboxed ); } + internal static bool TryValidateSandboxPath(string path, ToolContext toolContext, out string error) { + error = string.Empty; + if (toolContext == null || !toolContext.IsSandboxed) { + return false; + } + + if (toolContext.ChatId == 0) { + error = "Error: Sandboxed file operations require a valid chat context."; + return true; + } + + if (IsPathInSandboxAllowList(path, toolContext.ChatId)) { + return false; + } + + error = $"Error: Sandboxed file access to '{path}' is not allowed."; + return true; + } + + internal static bool IsPathInSandboxAllowList(string path, long chatId) { + var normalizedPath = NormalizePath(path); + foreach (var allowedRoot in GetSandboxAllowedRoots(chatId)) { + if (IsPathUnderRoot(normalizedPath, allowedRoot)) { + return true; + } + } + + return false; + } + + internal static IEnumerable GetSandboxAllowedRoots(long chatId) { + yield return AppContext.BaseDirectory; + yield return Path.Combine(Env.WorkDir, "Photos", chatId.ToString()); + yield return Path.Combine(Env.WorkDir, "Audios", chatId.ToString()); + yield return Path.Combine(Env.WorkDir, "Videos", chatId.ToString()); + yield return Path.Combine(Env.WorkDir, "Files", chatId.ToString()); + if (!string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot)) { + yield return Path.Combine(Env.SandboxieGroupFilesRoot, chatId.ToString()); + } + } + private static string ResolvePath(string path) { if (string.IsNullOrWhiteSpace(path)) { return Env.WorkDir; @@ -287,6 +343,18 @@ private static string ResolvePath(string path) { return Path.GetFullPath(path); } + private static bool IsPathUnderRoot(string path, string root) { + var normalizedPath = NormalizePath(path); + var normalizedRoot = NormalizePath(root); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return normalizedPath.Equals(normalizedRoot, comparison) || + normalizedPath.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, comparison); + } + + private static string NormalizePath(string path) { + return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + private static int CountOccurrences(string text, string pattern) { int count = 0; int index = 0; diff --git a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs index e39aefe9..9af86dde 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs @@ -17,6 +17,7 @@ public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() { try { var chatId = 12345L; + Directory.CreateDirectory(Path.Combine(Env.WorkDir, "logs")); var ini = BuildIni(chatId); Assert.Contains(ReadPath(Path.Combine(Env.WorkDir, "Photos", chatId.ToString())), ini); @@ -24,10 +25,11 @@ public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() { Assert.Contains(ReadPath(Path.Combine(Env.WorkDir, "Videos", chatId.ToString())), ini); Assert.Contains(ReadPath(Path.Combine(Env.WorkDir, "Files", chatId.ToString())), ini); - Assert.Contains(ClosedPath(Path.Combine(Env.WorkDir, "Photos")), ini); - 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($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Photos"))}", ini); + Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Audios"))}", ini); + Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Videos"))}", ini); + Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Files"))}", ini); + Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "logs"))}", ini); Assert.DoesNotContain("Index_Data", ini, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("GroupFiles", ini, StringComparison.OrdinalIgnoreCase); diff --git a/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs b/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs index 864c9f88..e2874a6d 100644 --- a/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs +++ b/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs @@ -297,7 +297,7 @@ 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"); } From cd6938567de0143f54d56054f16a0439f25e5950 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Mon, 1 Jun 2026 23:14:20 +0800 Subject: [PATCH 2/4] Tighten Sandboxie box setup and log isolation --- .../Service/Tools/BashToolServiceTests.cs | 29 -------- .../Service/Tools/FileToolServiceTests.cs | 18 ----- .../Service/Tools/BashToolService.cs | 68 ------------------- .../Service/Tools/FileToolService.cs | 68 ------------------- .../AI/LLM/SandboxieToolHostServiceTests.cs | 16 +++++ .../AI/LLM/SandboxieToolHostService.cs | 11 ++- 6 files changed, 26 insertions(+), 184 deletions(-) diff --git a/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs index 84148791..3aa909b5 100644 --- a/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs @@ -97,34 +97,5 @@ public async Task ExecuteCommand_TimeoutClamped() { workingDirectory: Path.GetTempPath(), timeoutMs: 100); Assert.Contains("Command timed out after 1000ms.", result); } - - [Fact] - public void TryGetSandboxCommandRestrictionError_RejectsWorkDirRootForSandboxedChat() { - var toolContext = new ToolContext { ChatId = 123, IsSandboxed = true }; - - var blocked = BashToolService.TryGetSandboxCommandRestrictionError(toolContext, Env.WorkDir, out var error); - - Assert.True(blocked); - Assert.Contains("not in the allowed path list", error, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void TryGetSandboxCommandRestrictionError_AllowsBaseDirectoryForSandboxedChat() { - var toolContext = new ToolContext { ChatId = 123, IsSandboxed = true }; - - var blocked = BashToolService.TryGetSandboxCommandRestrictionError(toolContext, AppContext.BaseDirectory, out var error); - - Assert.False(blocked); - Assert.True(string.IsNullOrWhiteSpace(error)); - } - - [Fact] - public void IsPathInSandboxCommandAllowList_AllowsOnlyCurrentChatRoots() { - Assert.True(BashToolService.IsPathInSandboxCommandAllowList(Path.Combine(Env.WorkDir, "Photos", "123"), 123)); - Assert.True(BashToolService.IsPathInSandboxCommandAllowList(Path.Combine(Env.WorkDir, "Audios", "123"), 123)); - Assert.False(BashToolService.IsPathInSandboxCommandAllowList(Path.Combine(Env.WorkDir, "Photos", "999"), 123)); - Assert.False(BashToolService.IsPathInSandboxCommandAllowList(Path.Combine(Env.WorkDir, "logs"), 123)); - Assert.False(BashToolService.IsPathInSandboxCommandAllowList(Env.WorkDir, 123)); - } } } diff --git a/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs index e5e17104..40c269bc 100644 --- a/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs @@ -170,23 +170,5 @@ public async Task ListFiles_NonAdmin_ReturnsError() { var result = await _service.ListFiles(_nonAdminContext); Assert.Contains("Error", result); } - - [Fact] - public async Task ListFiles_SandboxedChat_CannotListWorkDirRoot() { - var sandboxedContext = new ToolContext { ChatId = 123, UserId = 0, IsSandboxed = true }; - - var result = await _service.ListFiles(sandboxedContext); - - Assert.Contains("not allowed", result, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void IsPathInSandboxAllowList_AllowsOnlyCurrentChatRootsAndAppDirectory() { - Assert.True(FileToolService.IsPathInSandboxAllowList(AppContext.BaseDirectory, 123)); - Assert.True(FileToolService.IsPathInSandboxAllowList(Path.Combine(Env.WorkDir, "Files", "123"), 123)); - Assert.False(FileToolService.IsPathInSandboxAllowList(Path.Combine(Env.WorkDir, "Files", "999"), 123)); - Assert.False(FileToolService.IsPathInSandboxAllowList(Path.Combine(Env.WorkDir, "logs"), 123)); - Assert.False(FileToolService.IsPathInSandboxAllowList(Env.WorkDir, 123)); - } } } diff --git a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs index 7caa553e..9f8faf9b 100644 --- a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs @@ -96,10 +96,6 @@ public async Task ExecuteCommand( return "Error: Command cannot be empty."; } - if (toolContext.IsSandboxed && TryGetSandboxCommandRestrictionError(toolContext, workingDirectory, out var restrictionError)) { - return restrictionError; - } - // Limit timeout to reasonable bounds timeoutMs = Math.Clamp(timeoutMs, 1000, 300000); // 1s to 5min @@ -202,69 +198,5 @@ public async Task ExecuteCommand( } } - internal static bool TryGetSandboxCommandRestrictionError(ToolContext toolContext, string? workingDirectory, out string error) { - error = string.Empty; - if (toolContext == null || !toolContext.IsSandboxed) { - return false; - } - - if (toolContext.ChatId == 0) { - error = "Error: Sandboxed command execution requires a valid chat context."; - return true; - } - - var workDir = ResolveFullPath(workingDirectory ?? AppContext.BaseDirectory); - if (!IsPathInSandboxCommandAllowList(workDir, toolContext.ChatId)) { - error = $"Error: Sandboxed command working directory '{workDir}' is not in the allowed path list."; - return true; - } - - return false; - } - - internal static bool IsPathInSandboxCommandAllowList(string path, long chatId) { - var normalizedPath = ResolveFullPath(path); - foreach (var allowedRoot in GetSandboxCommandAllowedRoots(chatId)) { - if (IsPathUnderRoot(normalizedPath, allowedRoot)) { - return true; - } - } - - return false; - } - - internal static IEnumerable GetSandboxCommandAllowedRoots(long chatId) { - yield return AppContext.BaseDirectory; - yield return Path.Combine(Env.WorkDir, "Photos", chatId.ToString()); - yield return Path.Combine(Env.WorkDir, "Audios", chatId.ToString()); - yield return Path.Combine(Env.WorkDir, "Videos", chatId.ToString()); - yield return Path.Combine(Env.WorkDir, "Files", chatId.ToString()); - if (!string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot)) { - yield return Path.Combine(Env.SandboxieGroupFilesRoot, chatId.ToString()); - } - } - - private static string ResolveFullPath(string path) { - return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - } - - private static bool IsPathUnderRoot(string path, string root) { - var comparer = GetPathComparer(); - var normalizedPath = ResolveFullPath(path); - var normalizedRoot = ResolveFullPath(root); - if (comparer.Equals(normalizedPath, normalizedRoot)) { - return true; - } - - return normalizedPath.StartsWith( - normalizedRoot + Path.DirectorySeparatorChar, - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - } - - private static StringComparer GetPathComparer() { - return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - } } } diff --git a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs index 091338b3..a22dd959 100644 --- a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs @@ -45,9 +45,6 @@ public async Task ReadFile( try { path = ResolvePath(path); - if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { - return restrictionError; - } if (!File.Exists(path)) { return $"Error: File not found: {path}"; @@ -98,9 +95,6 @@ public async Task WriteFile( try { path = ResolvePath(path); - if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { - return restrictionError; - } var directory = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { @@ -129,9 +123,6 @@ public async Task EditFile( try { path = ResolvePath(path); - if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { - return restrictionError; - } if (!File.Exists(path)) { return $"Error: File not found: {path}"; @@ -181,9 +172,6 @@ public async Task SearchText( try { path = ResolvePath(path ?? Env.WorkDir); - if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { - return restrictionError; - } if (!Directory.Exists(path)) { return $"Error: Directory not found: {path}"; @@ -255,9 +243,6 @@ public async Task ListFiles( try { path = ResolvePath(path ?? Env.WorkDir); - if (TryValidateSandboxPath(path, toolContext, out var restrictionError)) { - return restrictionError; - } if (!Directory.Exists(path)) { return $"Error: Directory not found: {path}"; @@ -292,47 +277,6 @@ private static bool IsFileToolAllowed(ToolContext toolContext) { return toolContext != null && ( toolContext.UserId == Env.AdminId || toolContext.IsSandboxed ); } - internal static bool TryValidateSandboxPath(string path, ToolContext toolContext, out string error) { - error = string.Empty; - if (toolContext == null || !toolContext.IsSandboxed) { - return false; - } - - if (toolContext.ChatId == 0) { - error = "Error: Sandboxed file operations require a valid chat context."; - return true; - } - - if (IsPathInSandboxAllowList(path, toolContext.ChatId)) { - return false; - } - - error = $"Error: Sandboxed file access to '{path}' is not allowed."; - return true; - } - - internal static bool IsPathInSandboxAllowList(string path, long chatId) { - var normalizedPath = NormalizePath(path); - foreach (var allowedRoot in GetSandboxAllowedRoots(chatId)) { - if (IsPathUnderRoot(normalizedPath, allowedRoot)) { - return true; - } - } - - return false; - } - - internal static IEnumerable GetSandboxAllowedRoots(long chatId) { - yield return AppContext.BaseDirectory; - yield return Path.Combine(Env.WorkDir, "Photos", chatId.ToString()); - yield return Path.Combine(Env.WorkDir, "Audios", chatId.ToString()); - yield return Path.Combine(Env.WorkDir, "Videos", chatId.ToString()); - yield return Path.Combine(Env.WorkDir, "Files", chatId.ToString()); - if (!string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot)) { - yield return Path.Combine(Env.SandboxieGroupFilesRoot, chatId.ToString()); - } - } - private static string ResolvePath(string path) { if (string.IsNullOrWhiteSpace(path)) { return Env.WorkDir; @@ -343,18 +287,6 @@ private static string ResolvePath(string path) { return Path.GetFullPath(path); } - private static bool IsPathUnderRoot(string path, string root) { - var normalizedPath = NormalizePath(path); - var normalizedRoot = NormalizePath(root); - var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - return normalizedPath.Equals(normalizedRoot, comparison) || - normalizedPath.StartsWith(normalizedRoot + Path.DirectorySeparatorChar, comparison); - } - - private static string NormalizePath(string path) { - return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - } - private static int CountOccurrences(string text, string pattern) { int count = 0; int index = 0; diff --git a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs index 9af86dde..2544d85f 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs @@ -74,6 +74,22 @@ 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); + } + } + } + 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 e2874a6d..13e608e6 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); From e4ed4b8cf46ef6ac7bfa21db65115ab197bd8db7 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Mon, 1 Jun 2026 23:31:24 +0800 Subject: [PATCH 3/4] Whitelist app and chat paths in Sandboxie --- .../AI/LLM/SandboxieToolHostServiceTests.cs | 32 +++++++++++++++- .../AI/LLM/SandboxieToolHostService.cs | 38 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs index 2544d85f..250814d6 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs @@ -17,9 +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); @@ -30,8 +37,12 @@ public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() { Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Videos"))}", ini); Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Files"))}", ini); Assert.Contains($"ClosedFilePath={Normalize(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; @@ -90,6 +101,25 @@ public void EnsureBoxesDirectory_CreatesMissingDirectory() { } } + [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 13e608e6..cb4ad55b 100644 --- a/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs +++ b/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs @@ -221,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)) @@ -236,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); } @@ -311,6 +320,30 @@ internal static IEnumerable GetDefaultClosedPaths() { 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; @@ -335,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); From 82c7e22b4aa526421967a66b381a4e889de438e8 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Mon, 1 Jun 2026 23:33:17 +0800 Subject: [PATCH 4/4] Fix Sandboxie path assertions --- .../Service/AI/LLM/SandboxieToolHostServiceTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs index 250814d6..9039850a 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs @@ -32,11 +32,11 @@ public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() { Assert.Contains(ReadPath(Path.Combine(Env.WorkDir, "Videos", chatId.ToString())), ini); Assert.Contains(ReadPath(Path.Combine(Env.WorkDir, "Files", chatId.ToString())), ini); - Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Photos"))}", ini); - Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Audios"))}", ini); - Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Videos"))}", ini); - Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "Files"))}", ini); - Assert.Contains($"ClosedFilePath={Normalize(Path.Combine(Env.WorkDir, "logs"))}", ini); + Assert.Contains(ClosedPath(Path.Combine(Env.WorkDir, "Photos")), ini); + 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); var indexDataDir = Path.Combine(Env.WorkDir, "Index_Data");