From be4f850717f11bb97e7d5550efa6d8597712e8ae Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Wed, 27 May 2026 10:29:32 +0800 Subject: [PATCH] Add Sandboxie-backed LLM tool sandbox --- README.md | 21 ++ TelegramSearchBot.Common/Env.cs | 41 +++ .../Model/AI/LlmAgentContracts.cs | 22 ++ TelegramSearchBot.Common/Model/ToolContext.cs | 12 + .../Service/AI/LLM/McpToolHelper.cs | 9 +- .../Service/Tools/BashToolService.cs | 10 +- .../Service/Tools/FileToolService.cs | 24 +- TelegramSearchBot.LLMAgent/LLMAgentProgram.cs | 42 ++- .../Service/SandboxToolConsumer.cs | 166 +++++++++ .../AI/LLM/SandboxieToolHostServiceTests.cs | 90 +++++ .../AppBootstrap/GeneralBootstrap.cs | 33 ++ .../AppBootstrap/SandboxToolHostBootstrap.cs | 9 + TelegramSearchBot/Properties/AssemblyInfo.cs | 3 + .../AI/LLM/SandboxieToolHostService.cs | 332 ++++++++++++++++++ 14 files changed, 791 insertions(+), 23 deletions(-) create mode 100644 TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs create mode 100644 TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs create mode 100644 TelegramSearchBot/AppBootstrap/SandboxToolHostBootstrap.cs create mode 100644 TelegramSearchBot/Properties/AssemblyInfo.cs create mode 100644 TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs diff --git a/README.md b/README.md index 4fe038cf..cd7301fa 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,17 @@ "AgentQueueBacklogWarningThreshold": 20, "AgentProcessMemoryLimitMb": 256, "MaxToolCycles": 25, + "EnableLlmSandboxie": false, + "SandboxieStartExe": "C:\\Program Files\\Sandboxie-Plus\\Start.exe", + "SandboxieIniPath": "C:\\Windows\\Sandboxie.ini", + "SandboxieAutoRegisterImportBox": true, + "SandboxieDenyHostFileSystem": false, + "SandboxieBoxImportDirectory": "", + "SandboxieBoxPrefix": "TGSB_G_", + "SandboxieGroupFilesRoot": "", + "SandboxieGlobalReadPaths": [], + "SandboxieGlobalClosedPaths": [], + "SandboxieToolTimeoutSeconds": 120, "OLTPAuth": "", "OLTPAuthUrl": "", "OLTPName": "", @@ -114,6 +125,16 @@ - `AgentQueueBacklogWarningThreshold`: Agent 任务队列告警阈值(默认20) - `AgentProcessMemoryLimitMb`: Agent 进程工作集上限(默认256MB) - `MaxToolCycles`: LLM工具调用最大迭代次数(默认25),防止无限循环 + - `EnableLlmSandboxie`: 是否启用 Sandboxie Plus LLM 工具沙箱(默认false)。启用后 `ReadFile`/`WriteFile`/`EditFile`/`SearchText`/`ListFiles`/`ExecuteCommand` 会通过每群一个 Sandboxie portable box 的 ToolHost 执行。 + - `SandboxieStartExe`: Sandboxie Plus `Start.exe` 路径。 + - `SandboxieIniPath`: Sandboxie 主配置路径。仅在 `SandboxieAutoRegisterImportBox=true` 时用于自动加入 `ImportBox=\\*`。 + - `SandboxieAutoRegisterImportBox`: 是否由程序自动把 portable box 目录注册到 Sandboxie 主配置(默认true)。如希望自行在 Sandboxie Plus 中添加便携容器目录,可设为 false。 + - `SandboxieDenyHostFileSystem`: 是否默认关闭宿主机盘符根目录访问(默认false)。保持 false 时更适合运行 bash/npm/python 等工具链;写入仍由 Sandboxie 虚拟化,敏感项目数据仍会通过 `ClosedFilePath` 阻断。需要极严格白名单模式时可设为 true。 + - `SandboxieBoxImportDirectory`: portable box ini 目录;为空时默认 `%LOCALAPPDATA%/TelegramSearchBot/Sandboxie/Boxes`。每个群聊的 box ini 和虚拟文件根都生成在这里。 + - `SandboxieGroupFilesRoot`: 可选的额外每群文件根目录;为空时不开放。配置后,每个群只读开放 `/`。 + - 程序默认会关闭聊天资源父目录 `Photos`、`Audios`、`Videos`、`Files`,再仅为当前群的既有聊天媒体/文件目录生成只读授权:`Photos/`、`Audios/`、`Videos/`、`Files/`。其他群的资源目录默认不可读。Lucene `Index_Data` 不开放给 ToolHost;搜索仍由主进程侧服务完成。 + - `SandboxieGlobalReadPaths` / `SandboxieGlobalClosedPaths`: 额外全局只读开放/禁止访问路径。 + - `SandboxieToolTimeoutSeconds`: 沙箱工具调用等待超时(默认120秒)。 启用 `EnableLLMAgentProcess=true` 后,主进程会负责任务排队、Telegram 发消息和流式转发;独立 Agent 进程负责执行 LLM 循环、本地工具和故障恢复。主进程会在 Agent 心跳超时、任务超时或配置切换时执行恢复、重试、死信投递和优雅停机。 diff --git a/TelegramSearchBot.Common/Env.cs b/TelegramSearchBot.Common/Env.cs index 3964c34c..29b17205 100644 --- a/TelegramSearchBot.Common/Env.cs +++ b/TelegramSearchBot.Common/Env.cs @@ -60,6 +60,25 @@ static Env() { AgentMaxRecoveryAttempts = config.AgentMaxRecoveryAttempts; AgentQueueBacklogWarningThreshold = config.AgentQueueBacklogWarningThreshold; AgentProcessMemoryLimitMb = config.AgentProcessMemoryLimitMb; + EnableLlmSandboxie = config.EnableLlmSandboxie; + SandboxieStartExe = string.IsNullOrWhiteSpace(config.SandboxieStartExe) + ? @"C:\Program Files\Sandboxie-Plus\Start.exe" + : config.SandboxieStartExe.Trim(); + SandboxieIniPath = string.IsNullOrWhiteSpace(config.SandboxieIniPath) + ? @"C:\Windows\Sandboxie.ini" + : config.SandboxieIniPath.Trim(); + SandboxieAutoRegisterImportBox = config.SandboxieAutoRegisterImportBox; + SandboxieDenyHostFileSystem = config.SandboxieDenyHostFileSystem; + SandboxieBoxImportDirectory = string.IsNullOrWhiteSpace(config.SandboxieBoxImportDirectory) + ? Path.Combine(WorkDir, "Sandboxie", "Boxes") + : config.SandboxieBoxImportDirectory; + SandboxieBoxPrefix = string.IsNullOrWhiteSpace(config.SandboxieBoxPrefix) ? "TGSB_G_" : config.SandboxieBoxPrefix; + SandboxieGroupFilesRoot = string.IsNullOrWhiteSpace(config.SandboxieGroupFilesRoot) + ? string.Empty + : config.SandboxieGroupFilesRoot.Trim(); + SandboxieGlobalReadPaths = config.SandboxieGlobalReadPaths ?? new List(); + SandboxieGlobalClosedPaths = config.SandboxieGlobalClosedPaths ?? new List(); + SandboxieToolTimeoutSeconds = Math.Clamp(config.SandboxieToolTimeoutSeconds, 5, 3600); } public static BotApiEndpointSettings ResolveBotApiEndpoint(Config config) { @@ -137,6 +156,17 @@ private static string NormalizeBaseUrl(string? baseUrl, string fallback) { public static int AgentMaxRecoveryAttempts { get; set; } = 2; public static int AgentQueueBacklogWarningThreshold { get; set; } = 20; public static int AgentProcessMemoryLimitMb { get; set; } = 256; + public static bool EnableLlmSandboxie { get; set; } = false; + public static string SandboxieStartExe { get; set; } = @"C:\Program Files\Sandboxie-Plus\Start.exe"; + public static string SandboxieIniPath { get; set; } = @"C:\Windows\Sandboxie.ini"; + public static bool SandboxieAutoRegisterImportBox { get; set; } = true; + public static bool SandboxieDenyHostFileSystem { get; set; } = false; + public static string SandboxieBoxImportDirectory { get; set; } = null!; + public static string SandboxieBoxPrefix { get; set; } = "TGSB_G_"; + public static string SandboxieGroupFilesRoot { get; set; } = null!; + public static List SandboxieGlobalReadPaths { get; set; } = new List(); + public static List SandboxieGlobalClosedPaths { get; set; } = new List(); + public static int SandboxieToolTimeoutSeconds { get; set; } = 120; public static Dictionary Configuration { get; set; } = new Dictionary(); @@ -205,6 +235,17 @@ public class Config { public int AgentMaxRecoveryAttempts { get; set; } = 2; public int AgentQueueBacklogWarningThreshold { get; set; } = 20; public int AgentProcessMemoryLimitMb { get; set; } = 256; + public bool EnableLlmSandboxie { get; set; } = false; + public string SandboxieStartExe { get; set; } = @"C:\Program Files\Sandboxie-Plus\Start.exe"; + public string SandboxieIniPath { get; set; } = @"C:\Windows\Sandboxie.ini"; + public bool SandboxieAutoRegisterImportBox { get; set; } = true; + public bool SandboxieDenyHostFileSystem { get; set; } = false; + public string SandboxieBoxImportDirectory { get; set; } = string.Empty; + public string SandboxieBoxPrefix { get; set; } = "TGSB_G_"; + public string SandboxieGroupFilesRoot { get; set; } = string.Empty; + public List SandboxieGlobalReadPaths { get; set; } = new List(); + public List SandboxieGlobalClosedPaths { get; set; } = new List(); + public int SandboxieToolTimeoutSeconds { get; set; } = 120; } public sealed record BotApiEndpointSettings(string BaseUrl, bool IsLocalApi, string ExternalLocalBotApiBaseUrl); diff --git a/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs b/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs index 1660360f..da67a6e4 100644 --- a/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs +++ b/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs @@ -115,6 +115,25 @@ public sealed class TelegramAgentToolResult { public DateTime CompletedAtUtc { get; set; } = DateTime.UtcNow; } + public sealed class SandboxToolTask { + public string RequestId { get; set; } = Guid.NewGuid().ToString("N"); + public string ToolName { get; set; } = string.Empty; + public Dictionary Arguments { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public long ChatId { get; set; } + public long UserId { get; set; } + public long MessageId { get; set; } + public string BoxName { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + } + + public sealed class SandboxToolResult { + public string RequestId { get; set; } = string.Empty; + public bool Success { get; set; } + public string Result { get; set; } = string.Empty; + public string ErrorMessage { get; set; } = string.Empty; + public DateTime CompletedAtUtc { get; set; } = DateTime.UtcNow; + } + public sealed class AgentSessionInfo { public long ChatId { get; set; } public int ProcessId { get; set; } @@ -217,5 +236,8 @@ public static class LlmAgentRedisKeys { public static string AgentControl(long chatId) => $"AGENT_CONTROL:{chatId}"; public static string TelegramResult(string requestId) => $"TELEGRAM_RESULT:{requestId}"; public static string SubAgentResult(string requestId) => $"SUBAGENT_RESULT:{requestId}"; + public static string SandboxToolQueue(long chatId) => $"SANDBOX_TOOL_TASKS:{chatId}"; + public static string SandboxToolHeartbeat(long chatId) => $"SANDBOX_TOOL_HEARTBEAT:{chatId}"; + public static string SandboxToolResult(string requestId) => $"SANDBOX_TOOL_RESULT:{requestId}"; } } diff --git a/TelegramSearchBot.Common/Model/ToolContext.cs b/TelegramSearchBot.Common/Model/ToolContext.cs index f1e115ce..126de733 100644 --- a/TelegramSearchBot.Common/Model/ToolContext.cs +++ b/TelegramSearchBot.Common/Model/ToolContext.cs @@ -6,5 +6,17 @@ public class ToolContext { /// The original user message ID, used as default reply target for tool actions (e.g. sending photos). /// public long MessageId { get; set; } + + /// + /// True when the tool is being executed inside an OS-level sandboxed tool host. + /// Sandboxed tool hosts are allowed to expose file/process tools to non-admin chats + /// because host file access is constrained by the sandbox policy. + /// + public bool IsSandboxed { get; set; } + + /// + /// Optional sandbox box name used for diagnostics and routing. + /// + public string SandboxBoxName { get; set; } = string.Empty; } } diff --git a/TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs b/TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs index 341246a7..ab4c18e5 100644 --- a/TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs +++ b/TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs @@ -1309,11 +1309,12 @@ await redis.GetDatabase().StringSetAsync( /// /// Registers proxy tools that are executed remotely via IPC (e.g., agent calling main process tools). /// Unlike RegisterExternalTools, this preserves original tool names without any prefix. - /// Tools already registered locally (in ToolRegistry) are skipped. + /// Tools already registered locally (in ToolRegistry) are skipped unless allowLocalOverride is true. /// public static void RegisterProxyTools( List toolDefinitions, - Func, Task> executor) { + Func, Task> executor, + bool allowLocalOverride = false) { _proxyToolExecutor = executor; ProxyToolRegistry.Clear(); @@ -1322,8 +1323,8 @@ public static void RegisterProxyTools( int skippedLocal = 0; foreach (var tool in toolDefinitions) { - // Skip tools already registered locally - if (ToolRegistry.ContainsKey(tool.Name)) { + // Skip tools already registered locally unless explicitly overriding them (used by OS-sandboxed tool hosts). + if (!allowLocalOverride && ToolRegistry.ContainsKey(tool.Name)) { skippedLocal++; continue; } diff --git a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs index 1d799037..19f326bc 100644 --- a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs @@ -17,7 +17,7 @@ namespace TelegramSearchBot.Service.Tools { /// Built-in tool for executing shell commands. /// On Windows, uses PowerShell (preferring pwsh over powershell). /// On Linux/macOS, uses /bin/bash. - /// Restricted to admin users for security. + /// Restricted to admin users or OS-sandboxed tool hosts for security. /// [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Transient)] public class BashToolService : IService, IBashToolService { @@ -80,16 +80,16 @@ internal static string FindExecutableOnPath(string fileName) { [BuiltInTool("Execute a shell command and return the output. " + "On Windows, commands are executed using PowerShell (pwsh if available, otherwise powershell). " + "On Linux/macOS, commands are executed using bash. " + - "Only available to admin users.")] + "Only available to admin users or OS-sandboxed tool hosts.")] public async Task ExecuteCommand( [BuiltInParameter("The shell command to execute")] string command, ToolContext toolContext, [BuiltInParameter("Working directory for command execution. Defaults to the bot's work directory.", IsRequired = false)] string workingDirectory = null, [BuiltInParameter("Timeout in milliseconds. Defaults to 30000 (30 seconds).", IsRequired = false)] int timeoutMs = 30000) { - // Security check: only allow admin users - if (toolContext == null || toolContext.UserId != Env.AdminId) { - return "Error: Command execution is only available to admin users."; + // Security check: only allow admin users or OS-sandboxed tool hosts. + if (toolContext == null || ( toolContext.UserId != Env.AdminId && !toolContext.IsSandboxed )) { + return "Error: Command execution is only available to admin users or sandboxed tool hosts."; } if (string.IsNullOrWhiteSpace(command)) { diff --git a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs index 8479e9ff..a22dd959 100644 --- a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs @@ -39,8 +39,8 @@ public async Task ReadFile( [BuiltInParameter("Starting line number (1-based). If omitted, reads from the beginning.", IsRequired = false)] int? startLine = null, [BuiltInParameter("Ending line number (1-based, inclusive). If omitted, reads to the end.", IsRequired = false)] int? endLine = null) { - if (toolContext == null || toolContext.UserId != Env.AdminId) { - return "Error: File operations are only available to admin users."; + if (!IsFileToolAllowed(toolContext)) { + return "Error: File operations are only available to admin users or sandboxed tool hosts."; } try { @@ -89,8 +89,8 @@ public async Task WriteFile( [BuiltInParameter("Content to write to the file")] string content, ToolContext toolContext) { - if (toolContext == null || toolContext.UserId != Env.AdminId) { - return "Error: File operations are only available to admin users."; + if (!IsFileToolAllowed(toolContext)) { + return "Error: File operations are only available to admin users or sandboxed tool hosts."; } try { @@ -117,8 +117,8 @@ public async Task EditFile( [BuiltInParameter("The new text to replace the old text with")] string newText, ToolContext toolContext) { - if (toolContext == null || toolContext.UserId != Env.AdminId) { - return "Error: File operations are only available to admin users."; + if (!IsFileToolAllowed(toolContext)) { + return "Error: File operations are only available to admin users or sandboxed tool hosts."; } try { @@ -166,8 +166,8 @@ public async Task SearchText( [BuiltInParameter("File glob pattern to filter files (e.g., '*.cs', '*.json'). Defaults to all files.", IsRequired = false)] string fileGlob = null, [BuiltInParameter("Whether to ignore case. Defaults to true.", IsRequired = false)] bool ignoreCase = true) { - if (toolContext == null || toolContext.UserId != Env.AdminId) { - return "Error: File operations are only available to admin users."; + if (!IsFileToolAllowed(toolContext)) { + return "Error: File operations are only available to admin users or sandboxed tool hosts."; } try { @@ -237,8 +237,8 @@ public async Task ListFiles( [BuiltInParameter("Directory path to list. Defaults to bot work directory.", IsRequired = false)] string path = null, [BuiltInParameter("Glob pattern to filter files (e.g., '*.cs'). If omitted, lists all.", IsRequired = false)] string pattern = null) { - if (toolContext == null || toolContext.UserId != Env.AdminId) { - return "Error: File operations are only available to admin users."; + if (!IsFileToolAllowed(toolContext)) { + return "Error: File operations are only available to admin users or sandboxed tool hosts."; } try { @@ -273,6 +273,10 @@ public async Task ListFiles( } } + private static bool IsFileToolAllowed(ToolContext toolContext) { + return toolContext != null && ( toolContext.UserId == Env.AdminId || toolContext.IsSandboxed ); + } + private static string ResolvePath(string path) { if (string.IsNullOrWhiteSpace(path)) { return Env.WorkDir; diff --git a/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs b/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs index ea29afd4..57530092 100644 --- a/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs +++ b/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs @@ -16,10 +16,15 @@ namespace TelegramSearchBot.LLMAgent { public static class LLMAgentProgram { public static async Task RunAsync(string[] args) { var effectiveArgs = NormalizeArgs(args); + if (effectiveArgs.Length > 0 && effectiveArgs[0].Equals("SandboxToolHost", StringComparison.OrdinalIgnoreCase)) { + await RunSandboxToolHostAsync(effectiveArgs.Skip(1).ToArray()); + return; + } + if (effectiveArgs.Length != 2 || !long.TryParse(effectiveArgs[0], out var chatId) || !int.TryParse(effectiveArgs[1], out var port)) { - Console.Error.WriteLine("Usage: LLMAgent "); + Console.Error.WriteLine("Usage: LLMAgent | SandboxToolHost "); Environment.ExitCode = 1; return; } @@ -31,14 +36,42 @@ public static async Task RunAsync(string[] args) { services, logger); var loop = services.GetRequiredService(); - using var shutdownCts = new CancellationTokenSource(); + using var shutdownCts = CreateShutdownTokenSource(); + + await loop.RunAsync(chatId, port, shutdownCts.Token); + } + + private static async Task RunSandboxToolHostAsync(string[] args) { + if (args.Length != 5 || + !long.TryParse(args[0], out var chatId) || + !int.TryParse(args[1], out var port) || + !int.TryParse(args[3], out var parentProcessId) || + !long.TryParse(args[4], out var parentStartTicksUtc)) { + Console.Error.WriteLine("Usage: SandboxToolHost "); + Environment.ExitCode = 1; + return; + } + + var boxName = args[2]; + using var services = BuildServices(port); + var logger = services.GetRequiredService().CreateLogger("SandboxToolHost"); + McpToolHelper.EnsureInitialized( + typeof(FileToolService).Assembly, + services, logger); + + using var shutdownCts = CreateShutdownTokenSource(); + var consumer = services.GetRequiredService(); + await consumer.RunAsync(chatId, boxName, parentProcessId, parentStartTicksUtc, shutdownCts.Token); + } + + private static CancellationTokenSource CreateShutdownTokenSource() { + var shutdownCts = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; shutdownCts.Cancel(); }; AppDomain.CurrentDomain.ProcessExit += (_, _) => shutdownCts.Cancel(); - - await loop.RunAsync(chatId, port, shutdownCts.Token); + return shutdownCts; } private static string[] NormalizeArgs(string[] args) { @@ -82,6 +115,7 @@ private static ServiceProvider BuildServices(int port) { services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services.BuildServiceProvider(); } } diff --git a/TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs b/TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs new file mode 100644 index 00000000..a320ecf6 --- /dev/null +++ b/TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs @@ -0,0 +1,166 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using StackExchange.Redis; +using TelegramSearchBot.Common; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Service.AI.LLM; + +namespace TelegramSearchBot.LLMAgent.Service { + /// + /// Runs inside a Sandboxie box and executes dangerous local tools on behalf of the main process. + /// File/process isolation is provided by Sandboxie; this consumer only handles IPC and ToolContext wiring. + /// + public sealed class SandboxToolConsumer { + private readonly IConnectionMultiplexer _redis; + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public SandboxToolConsumer(IConnectionMultiplexer redis, IServiceScopeFactory scopeFactory, ILogger logger) { + _redis = redis; + _scopeFactory = scopeFactory; + _logger = logger; + } + + public async Task RunAsync(long chatId, string boxName, int parentProcessId, long parentStartTicksUtc, CancellationToken cancellationToken) { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + void OnConnectionFailed(object? sender, ConnectionFailedEventArgs args) { + _logger.LogWarning( + "Garnet connection failed; sandbox ToolHost will exit. Endpoint={Endpoint}, FailureType={FailureType}", + args.EndPoint, + args.FailureType); + linkedCts.Cancel(); + } + + _redis.ConnectionFailed += OnConnectionFailed; + var watchdogTask = RunParentWatchdogAsync(parentProcessId, parentStartTicksUtc, linkedCts); + var heartbeatTask = RunHeartbeatAsync(chatId, boxName, linkedCts.Token); + var db = _redis.GetDatabase(); + var queueKey = LlmAgentRedisKeys.SandboxToolQueue(chatId); + _logger.LogInformation( + "Sandbox tool consumer started. ChatId={ChatId}, Box={BoxName}, Queue={Queue}, ParentPid={ParentPid}", + chatId, + boxName, + queueKey, + parentProcessId); + + try { + while (!linkedCts.Token.IsCancellationRequested) { + string? payload = null; + try { + var result = await db.ExecuteAsync("BRPOP", queueKey, 5); + if (result.IsNull) { + continue; + } + + var parts = ( RedisResult[] ) result!; + if (parts.Length == 2) { + payload = parts[1].ToString(); + } + if (string.IsNullOrWhiteSpace(payload)) { + continue; + } + + var task = JsonConvert.DeserializeObject(payload); + if (task == null) { + continue; + } + + var response = await ExecuteAsync(task, boxName, linkedCts.Token); + await db.StringSetAsync( + LlmAgentRedisKeys.SandboxToolResult(task.RequestId), + JsonConvert.SerializeObject(response), + TimeSpan.FromSeconds(Math.Max(Env.SandboxieToolTimeoutSeconds * 2, 60))); + } catch (OperationCanceledException) { + break; + } catch (RedisConnectionException ex) { + _logger.LogWarning(ex, "Garnet connection lost; sandbox ToolHost will exit. ErrorSummary={ErrorSummary}", ex.GetLogSummary()); + linkedCts.Cancel(); + break; + } catch (Exception ex) { + _logger.LogError(ex, "Sandbox tool consumer loop failed. Payload={Payload}, ErrorSummary={ErrorSummary}", payload, ex.GetLogSummary()); + } + } + + try { + await Task.WhenAll(watchdogTask, heartbeatTask); + } catch (OperationCanceledException) { + } + } finally { + _redis.ConnectionFailed -= OnConnectionFailed; + } + } + + private async Task RunHeartbeatAsync(long chatId, string boxName, CancellationToken cancellationToken) { + var db = _redis.GetDatabase(); + var key = LlmAgentRedisKeys.SandboxToolHeartbeat(chatId); + while (!cancellationToken.IsCancellationRequested) { + await db.StringSetAsync( + key, + JsonConvert.SerializeObject(new { chatId, boxName, processId = Environment.ProcessId, updatedAtUtc = DateTime.UtcNow }), + TimeSpan.FromSeconds(15)); + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + } + + private async Task RunParentWatchdogAsync(int parentProcessId, long parentStartTicksUtc, CancellationTokenSource shutdownCts) { + while (!shutdownCts.IsCancellationRequested) { + if (!IsExpectedParentAlive(parentProcessId, parentStartTicksUtc)) { + _logger.LogWarning( + "Parent process is gone or PID was reused; sandbox ToolHost will exit. ParentPid={ParentPid}", + parentProcessId); + shutdownCts.Cancel(); + return; + } + + await Task.Delay(TimeSpan.FromSeconds(5), shutdownCts.Token); + } + } + + private static bool IsExpectedParentAlive(int parentProcessId, long parentStartTicksUtc) { + try { + using var process = System.Diagnostics.Process.GetProcessById(parentProcessId); + if (process.HasExited) { + return false; + } + + return process.StartTime.ToUniversalTime().Ticks == parentStartTicksUtc; + } catch { + return false; + } + } + + private async Task ExecuteAsync(SandboxToolTask task, string boxName, CancellationToken cancellationToken) { + var response = new SandboxToolResult { RequestId = task.RequestId }; + try { + if (task.ChatId == 0) { + throw new InvalidOperationException("Sandbox tool task is missing ChatId."); + } + + using var scope = _scopeFactory.CreateScope(); + var toolContext = new ToolContext { + ChatId = task.ChatId, + UserId = task.UserId, + MessageId = task.MessageId, + IsSandboxed = true, + SandboxBoxName = boxName + }; + + var result = await McpToolHelper.ExecuteRegisteredToolAsync( + task.ToolName, + task.Arguments, + scope.ServiceProvider, + toolContext); + response.Success = true; + response.Result = McpToolHelper.ConvertToolResultToString(result); + return response; + } catch (Exception ex) { + _logger.LogError(ex, "Sandbox tool execution failed. Tool={ToolName}, RequestId={RequestId}, ErrorSummary={ErrorSummary}", task.ToolName, task.RequestId, ex.GetLogSummary()); + response.Success = false; + response.ErrorMessage = ex.GetLogSummary(); + return response; + } + } + } +} diff --git a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs new file mode 100644 index 00000000..e39aefe9 --- /dev/null +++ b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Linq; +using TelegramSearchBot.Common; +using TelegramSearchBot.Service.AI.LLM; +using Xunit; + +namespace TelegramSearchBot.Test.Service.AI.LLM { + [Collection("AgentEnvSerial")] + public class SandboxieToolHostServiceTests { + [Fact] + public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() { + var originalGroupFilesRoot = Env.SandboxieGroupFilesRoot; + var originalDenyHostFileSystem = Env.SandboxieDenyHostFileSystem; + Env.SandboxieGroupFilesRoot = string.Empty; + Env.SandboxieDenyHostFileSystem = false; + + try { + var chatId = 12345L; + var ini = BuildIni(chatId); + + 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); + 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.DoesNotContain("Index_Data", ini, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("GroupFiles", ini, StringComparison.OrdinalIgnoreCase); + } finally { + Env.SandboxieGroupFilesRoot = originalGroupFilesRoot; + Env.SandboxieDenyHostFileSystem = originalDenyHostFileSystem; + } + } + + [Fact] + public void BuildPortableBoxIni_WhenGroupFilesRootConfigured_AllowsOnlyCurrentChatSubdirectory() { + var originalGroupFilesRoot = Env.SandboxieGroupFilesRoot; + var originalDenyHostFileSystem = Env.SandboxieDenyHostFileSystem; + Env.SandboxieGroupFilesRoot = Path.Combine(Env.WorkDir, "CustomGroupFiles"); + Env.SandboxieDenyHostFileSystem = false; + + try { + var chatId = 67890L; + var ini = BuildIni(chatId); + + Assert.Contains(ReadPath(Path.Combine(Env.SandboxieGroupFilesRoot, chatId.ToString())), ini); + Assert.Contains(ClosedPath(Env.SandboxieGroupFilesRoot), ini); + Assert.DoesNotContain(ReadPath(Path.Combine(Env.SandboxieGroupFilesRoot, "111")), ini); + } finally { + Env.SandboxieGroupFilesRoot = originalGroupFilesRoot; + Env.SandboxieDenyHostFileSystem = originalDenyHostFileSystem; + } + } + + [Fact] + public void BuildPortableBoxIni_WhenDenyHostFileSystemDisabled_DoesNotCloseDriveRoots() { + var originalDenyHostFileSystem = Env.SandboxieDenyHostFileSystem; + Env.SandboxieDenyHostFileSystem = false; + + try { + var ini = BuildIni(13579L); + foreach (var root in DriveInfo.GetDrives().Where(d => d.IsReady).Select(d => d.RootDirectory.FullName)) { + Assert.DoesNotContain(ClosedPath(root), ini); + } + } finally { + Env.SandboxieDenyHostFileSystem = originalDenyHostFileSystem; + } + } + + private static string BuildIni(long chatId) { + var instance = new SandboxieInstance( + chatId, + "TGSB_TEST", + Path.Combine(Env.WorkDir, "Sandboxie", "Boxes"), + Path.Combine(Env.WorkDir, "Sandboxie", "Boxes", "TGSB_TEST.ini"), + Path.Combine(Env.WorkDir, "Sandboxie", "Boxes", "TGSB_TEST")); + return SandboxieToolHostService.BuildPortableBoxIni(instance); + } + + private static string ReadPath(string path) => $"ReadFilePath={Normalize(path)}\\*"; + private static string ClosedPath(string path) => $"ClosedFilePath={Normalize(path)}{(Directory.Exists(path) ? "\\*" : string.Empty)}"; + + private static string Normalize(string path) => Path.GetFullPath(path.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } +} diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs index 6e165285..ddcfbb9a 100644 --- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs @@ -216,6 +216,11 @@ public static async Task Startup(string[] args) { McpToolHelper.EnsureInitialized(mainAssembly, llmAssembly, service, mcpLogger); Log.Information("McpToolHelper has been initialized with built-in tools."); + if (Env.EnableLlmSandboxie) { + RegisterSandboxieTools(service); + Log.Information("Sandboxie LLM tool sandbox is enabled."); + } + // Initialize external MCP tool servers try { var mcpServerManager = service.GetRequiredService(); @@ -264,5 +269,33 @@ public static async Task Startup(string[] args) { private static void RegisterExternalMcpTools(IMcpServerManager mcpServerManager) { McpToolHelper.RegisterExternalMcpTools(mcpServerManager); } + + private static void RegisterSandboxieTools(IServiceProvider services) { + var sandboxService = services.GetRequiredService(); + McpToolHelper.RegisterProxyTools( + SandboxieToolHostService.GetToolDefinitions(), + async (toolName, arguments) => { + long chatId = 0, userId = 0, messageId = 0; + if (arguments.TryGetValue("__chatId", out var cid)) { + long.TryParse(cid, out chatId); + arguments.Remove("__chatId"); + } + if (arguments.TryGetValue("__userId", out var uid)) { + long.TryParse(uid, out userId); + arguments.Remove("__userId"); + } + if (arguments.TryGetValue("__messageId", out var mid)) { + long.TryParse(mid, out messageId); + arguments.Remove("__messageId"); + } + + if (chatId == 0) { + throw new InvalidOperationException("Sandboxed tool execution requires a valid __chatId context value."); + } + + return await sandboxService.ExecuteToolAsync(toolName, arguments, chatId, userId, messageId); + }, + allowLocalOverride: true); + } } } diff --git a/TelegramSearchBot/AppBootstrap/SandboxToolHostBootstrap.cs b/TelegramSearchBot/AppBootstrap/SandboxToolHostBootstrap.cs new file mode 100644 index 00000000..e4512f15 --- /dev/null +++ b/TelegramSearchBot/AppBootstrap/SandboxToolHostBootstrap.cs @@ -0,0 +1,9 @@ +using TelegramSearchBot.LLMAgent; + +namespace TelegramSearchBot.AppBootstrap { + public static class SandboxToolHostBootstrap { + public static void Startup(string[] args) { + LLMAgentProgram.RunAsync(args).GetAwaiter().GetResult(); + } + } +} diff --git a/TelegramSearchBot/Properties/AssemblyInfo.cs b/TelegramSearchBot/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..1b9d37ab --- /dev/null +++ b/TelegramSearchBot/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TelegramSearchBot.Test")] diff --git a/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs b/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs new file mode 100644 index 00000000..864c9f88 --- /dev/null +++ b/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using StackExchange.Redis; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Common; +using TelegramSearchBot.Model.AI; + +namespace TelegramSearchBot.Service.AI.LLM { + /// + /// Creates Sandboxie Plus portable boxes per chat and routes dangerous tool calls to a sandboxed ToolHost. + /// Uses Sandboxie Plus ImportBox portable INI definitions so the main Sandboxie.ini only needs a single + /// ImportBox=...\* directive. + /// + [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] + public sealed class SandboxieToolHostService { + private static readonly HashSet SandboxedToolNames = new(StringComparer.OrdinalIgnoreCase) { + "ReadFile", "WriteFile", "EditFile", "SearchText", "ListFiles", "ExecuteCommand" + }; + + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + private readonly Dictionary _lastToolHostStarts = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public SandboxieToolHostService(IConnectionMultiplexer redis, ILogger logger) { + _redis = redis; + _logger = logger; + } + + public static IReadOnlyCollection ToolNames => SandboxedToolNames; + + public static List GetToolDefinitions() => new() { + new ProxyToolDefinition { Name = "ReadFile", Description = "Read the contents of a file inside the per-chat Sandboxie box.", Parameters = { + new ProxyToolParameter { Name = "path", Type = "string", Description = "Absolute or relative path to read.", Required = true }, + new ProxyToolParameter { Name = "startLine", Type = "int", Description = "Optional starting line number (1-based).", Required = false }, + new ProxyToolParameter { Name = "endLine", Type = "int", Description = "Optional ending line number (inclusive).", Required = false } + } }, + new ProxyToolDefinition { Name = "WriteFile", Description = "Write content to a file inside the per-chat Sandboxie box. Host writes are virtualized by Sandboxie.", Parameters = { + new ProxyToolParameter { Name = "path", Type = "string", Description = "Absolute or relative path to write.", Required = true }, + new ProxyToolParameter { Name = "content", Type = "string", Description = "Content to write.", Required = true } + } }, + new ProxyToolDefinition { Name = "EditFile", Description = "Edit a file inside the per-chat Sandboxie box by exact text replacement.", Parameters = { + new ProxyToolParameter { Name = "path", Type = "string", Description = "Absolute or relative path to edit.", Required = true }, + new ProxyToolParameter { Name = "oldText", Type = "string", Description = "Exact text to replace.", Required = true }, + new ProxyToolParameter { Name = "newText", Type = "string", Description = "Replacement text.", Required = true } + } }, + new ProxyToolDefinition { Name = "SearchText", Description = "Search text in files from inside the per-chat Sandboxie box.", Parameters = { + new ProxyToolParameter { Name = "pattern", Type = "string", Description = "Regex pattern to search for.", Required = true }, + new ProxyToolParameter { Name = "path", Type = "string", Description = "Directory to search.", Required = false }, + new ProxyToolParameter { Name = "fileGlob", Type = "string", Description = "File glob filter.", Required = false }, + new ProxyToolParameter { Name = "ignoreCase", Type = "bool", Description = "Whether to ignore case.", Required = false } + } }, + new ProxyToolDefinition { Name = "ListFiles", Description = "List files and directories from inside the per-chat Sandboxie box.", Parameters = { + new ProxyToolParameter { Name = "path", Type = "string", Description = "Directory to list.", Required = false }, + new ProxyToolParameter { Name = "pattern", Type = "string", Description = "Glob pattern.", Required = false } + } }, + new ProxyToolDefinition { Name = "ExecuteCommand", Description = "Execute a shell command inside the per-chat Sandboxie box.", Parameters = { + new ProxyToolParameter { Name = "command", Type = "string", Description = "Shell command to execute.", Required = true }, + new ProxyToolParameter { Name = "workingDirectory", Type = "string", Description = "Working directory.", Required = false }, + new ProxyToolParameter { Name = "timeoutMs", Type = "int", Description = "Timeout in milliseconds.", Required = false } + } } + }; + + public async Task ExecuteToolAsync(string toolName, Dictionary arguments, long chatId, long userId, long messageId, CancellationToken cancellationToken = default) { + if (!SandboxedToolNames.Contains(toolName)) { + throw new InvalidOperationException($"Tool '{toolName}' is not configured for Sandboxie execution."); + } + + var instance = await EnsureToolHostAsync(chatId, cancellationToken); + var task = new SandboxToolTask { + ToolName = toolName, + Arguments = arguments, + ChatId = chatId, + UserId = userId, + MessageId = messageId, + BoxName = instance.BoxName + }; + + var db = _redis.GetDatabase(); + await db.ListRightPushAsync(LlmAgentRedisKeys.SandboxToolQueue(chatId), JsonConvert.SerializeObject(task)); + var timeout = TimeSpan.FromSeconds(Math.Max(5, Env.SandboxieToolTimeoutSeconds)); + var startedAt = DateTime.UtcNow; + var resultKey = LlmAgentRedisKeys.SandboxToolResult(task.RequestId); + + while (DateTime.UtcNow - startedAt < timeout && !cancellationToken.IsCancellationRequested) { + var json = await db.StringGetAsync(resultKey); + if (json.HasValue && !string.IsNullOrWhiteSpace(json.ToString())) { + await db.KeyDeleteAsync(resultKey); + var result = JsonConvert.DeserializeObject(json.ToString()); + if (result == null) { + throw new InvalidOperationException($"Sandbox tool '{toolName}' returned an invalid result payload."); + } + if (!result.Success) { + throw new InvalidOperationException($"Sandbox tool '{toolName}' failed: {result.ErrorMessage}"); + } + return result.Result; + } + + await Task.Delay(200, cancellationToken); + } + + throw new TimeoutException($"Timed out waiting for sandbox tool '{toolName}' result after {timeout.TotalSeconds}s."); + } + + public async Task EnsureToolHostAsync(long chatId, CancellationToken cancellationToken = default) { + await _lock.WaitAsync(cancellationToken); + try { + var instance = BuildInstance(chatId); + if (Env.SandboxieAutoRegisterImportBox) { + EnsureImportBoxDirective(instance.BoxesDirectory); + } + EnsurePortableBoxDefinition(instance); + + if (await IsToolHostAliveAsync(chatId)) { + return instance; + } + + if (_lastToolHostStarts.TryGetValue(chatId, out var lastStartedAt) && DateTime.UtcNow - lastStartedAt < TimeSpan.FromSeconds(10)) { + return instance; + } + + StartToolHost(instance); + return instance; + } finally { + _lock.Release(); + } + } + + private void EnsureImportBoxDirective(string boxesDirectory) { + var iniPath = Env.SandboxieIniPath; + if (string.IsNullOrWhiteSpace(iniPath) || !File.Exists(iniPath)) { + _logger.LogWarning("Sandboxie.ini not found; portable boxes may not be imported automatically. Path={Path}", iniPath); + return; + } + + var directive = $"ImportBox={NormalizeSandboxiePath(boxesDirectory)}\\*"; + var text = File.ReadAllText(iniPath, Encoding.Unicode); + if (text.IndexOf(directive, StringComparison.OrdinalIgnoreCase) >= 0) { + return; + } + + try { + var marker = "[GlobalSettings]"; + var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (markerIndex < 0) { + text = marker + Environment.NewLine + directive + Environment.NewLine + text; + } else { + var insertAt = text.IndexOf(Environment.NewLine, markerIndex, StringComparison.Ordinal); + if (insertAt < 0) { + text += Environment.NewLine + directive + Environment.NewLine; + } else { + insertAt += Environment.NewLine.Length; + text = text.Insert(insertAt, directive + Environment.NewLine); + } + } + + File.WriteAllText(iniPath, text, Encoding.Unicode); + _logger.LogInformation("Added Sandboxie ImportBox directive. Ini={IniPath}, Directive={Directive}", iniPath, directive); + } catch (Exception ex) { + _logger.LogWarning(ex, "Failed to add Sandboxie ImportBox directive. Run once with permissions or add it manually: {Directive}", directive); + } + } + + private static SandboxieInstance BuildInstance(long chatId) { + var boxName = Env.SandboxieBoxPrefix + ComputeStableHash(chatId.ToString()); + var boxesDir = Env.SandboxieBoxImportDirectory; + return new SandboxieInstance( + chatId, + boxName, + boxesDir, + Path.Combine(boxesDir, boxName + ".ini"), + Path.Combine(boxesDir, boxName)); + } + + private void EnsurePortableBoxDefinition(SandboxieInstance instance) { + Directory.CreateDirectory(instance.BoxesDirectory); + var content = BuildPortableBoxIni(instance); + if (File.Exists(instance.BoxIniPath)) { + var existing = File.ReadAllText(instance.BoxIniPath, Encoding.Unicode); + if (string.Equals(existing, content, StringComparison.Ordinal)) { + return; + } + } + + File.WriteAllText(instance.BoxIniPath, content, Encoding.Unicode); + _logger.LogInformation("Wrote Sandboxie portable box definition. ChatId={ChatId}, Box={BoxName}, Path={Path}", instance.ChatId, instance.BoxName, instance.BoxIniPath); + } + + internal static string BuildPortableBoxIni(SandboxieInstance instance) { + var lines = new List { + $"[{instance.BoxName}]", + "Enabled=y", + "BlockNetworkFiles=y", + "AutoRecover=n", + "NeverDelete=y", + "ConfigLevel=10", + "Template=SkipHook", + "Template=FileCopy", + "Template=qWave", + "Template=BlockPorts", + "Template=LingerPrograms", + "Template=AutoRecoverIgnore" + }; + + var defaultReadPaths = GetDefaultToolHostReadPaths(instance.ChatId).ToList(); + foreach (var path in defaultReadPaths + .Concat(Env.SandboxieGlobalReadPaths) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Distinct(StringComparer.OrdinalIgnoreCase)) { + lines.Add($"ReadFilePath={NormalizeSandboxiePath(path)}\\*"); + } + + var defaultClosedPaths = GetDefaultClosedPaths().ToList(); + foreach (var path in defaultClosedPaths + .Concat(Env.SandboxieGlobalClosedPaths) + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Distinct(StringComparer.OrdinalIgnoreCase)) { + lines.Add($"ClosedFilePath={NormalizeSandboxiePath(path)}{(Directory.Exists(path) ? "\\*" : string.Empty)}"); + } + + lines.Add(string.Empty); + return string.Join(Environment.NewLine, lines); + } + + private void StartToolHost(SandboxieInstance instance) { + var startExe = Env.SandboxieStartExe; + if (!File.Exists(startExe)) { + throw new FileNotFoundException("Sandboxie Start.exe was not found. Configure SandboxieStartExe in Config.json.", startExe); + } + + var currentExe = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; + if (string.IsNullOrWhiteSpace(currentExe)) { + throw new InvalidOperationException("Unable to determine current executable path for sandbox tool host startup."); + } + + var psi = new ProcessStartInfo { + FileName = startExe, + UseShellExecute = false, + CreateNoWindow = true + }; + psi.ArgumentList.Add($"/box:{instance.BoxName}"); + psi.ArgumentList.Add(currentExe); + var currentProcess = Process.GetCurrentProcess(); + psi.ArgumentList.Add("SandboxToolHost"); + psi.ArgumentList.Add(instance.ChatId.ToString()); + psi.ArgumentList.Add(Env.SchedulerPort.ToString()); + psi.ArgumentList.Add(instance.BoxName); + psi.ArgumentList.Add(currentProcess.Id.ToString()); + psi.ArgumentList.Add(currentProcess.StartTime.ToUniversalTime().Ticks.ToString()); + + var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start Sandboxie tool host process."); + _lastToolHostStarts[instance.ChatId] = DateTime.UtcNow; + _logger.LogInformation("Started Sandboxie tool host launcher. ChatId={ChatId}, Box={BoxName}, LauncherPid={Pid}", instance.ChatId, instance.BoxName, process.Id); + } + + private async Task IsToolHostAliveAsync(long chatId) { + var value = await _redis.GetDatabase().StringGetAsync(LlmAgentRedisKeys.SandboxToolHeartbeat(chatId)); + return value.HasValue && !string.IsNullOrWhiteSpace(value.ToString()); + } + + private static string ComputeStableHash(string value) { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + return Convert.ToHexString(bytes, 0, 6); + } + + internal static IEnumerable GetDefaultToolHostReadPaths(long chatId) { + var chatIdText = chatId.ToString(); + yield return AppContext.BaseDirectory; + if (!string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot)) { + yield return Path.Combine(Env.SandboxieGroupFilesRoot, chatIdText); + } + yield return Path.Combine(Env.WorkDir, "Photos", chatIdText); + yield return Path.Combine(Env.WorkDir, "Audios", chatIdText); + yield return Path.Combine(Env.WorkDir, "Videos", chatIdText); + yield return Path.Combine(Env.WorkDir, "Files", chatIdText); + } + + internal static IEnumerable GetDefaultClosedPaths() { + if (Env.SandboxieDenyHostFileSystem) { + foreach (var root in GetHostDriveRoots()) { + yield return root; + } + } + + foreach (var path in GetChatResourceParentPaths()) { + yield return path; + } + + 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(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh"); + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + } + + internal static IEnumerable GetChatResourceParentPaths() { + if (!string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot)) { + yield return Env.SandboxieGroupFilesRoot; + } + yield return Path.Combine(Env.WorkDir, "Photos"); + yield return Path.Combine(Env.WorkDir, "Audios"); + yield return Path.Combine(Env.WorkDir, "Videos"); + yield return Path.Combine(Env.WorkDir, "Files"); + } + + private static IEnumerable GetHostDriveRoots() { + try { + return DriveInfo.GetDrives() + .Where(d => d.IsReady) + .Select(d => d.RootDirectory.FullName) + .ToList(); + } catch { + return Array.Empty(); + } + } + + private static string NormalizeSandboxiePath(string path) { + return Path.GetFullPath(path.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + } + + public sealed record SandboxieInstance(long ChatId, string BoxName, string BoxesDirectory, string BoxIniPath, string BoxRootPath); +}