Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down Expand Up @@ -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=<SandboxieBoxImportDirectory>\\*`。
- `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`: 可选的额外每群文件根目录;为空时不开放。配置后,每个群只读开放 `<root>/<chatId>`。
- 程序默认会关闭聊天资源父目录 `Photos`、`Audios`、`Videos`、`Files`,再仅为当前群的既有聊天媒体/文件目录生成只读授权:`Photos/<chatId>`、`Audios/<chatId>`、`Videos/<chatId>`、`Files/<chatId>`。其他群的资源目录默认不可读。Lucene `Index_Data` 不开放给 ToolHost;搜索仍由主进程侧服务完成。
- `SandboxieGlobalReadPaths` / `SandboxieGlobalClosedPaths`: 额外全局只读开放/禁止访问路径。
- `SandboxieToolTimeoutSeconds`: 沙箱工具调用等待超时(默认120秒)。

启用 `EnableLLMAgentProcess=true` 后,主进程会负责任务排队、Telegram 发消息和流式转发;独立 Agent 进程负责执行 LLM 循环、本地工具和故障恢复。主进程会在 Agent 心跳超时、任务超时或配置切换时执行恢复、重试、死信投递和优雅停机。

Expand Down
41 changes: 41 additions & 0 deletions TelegramSearchBot.Common/Env.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
SandboxieGlobalClosedPaths = config.SandboxieGlobalClosedPaths ?? new List<string>();
SandboxieToolTimeoutSeconds = Math.Clamp(config.SandboxieToolTimeoutSeconds, 5, 3600);
}

public static BotApiEndpointSettings ResolveBotApiEndpoint(Config config) {
Expand Down Expand Up @@ -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<string> SandboxieGlobalReadPaths { get; set; } = new List<string>();
public static List<string> SandboxieGlobalClosedPaths { get; set; } = new List<string>();
public static int SandboxieToolTimeoutSeconds { get; set; } = 120;

public static Dictionary<string, string> Configuration { get; set; } = new Dictionary<string, string>();

Expand Down Expand Up @@ -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<string> SandboxieGlobalReadPaths { get; set; } = new List<string>();
public List<string> SandboxieGlobalClosedPaths { get; set; } = new List<string>();
public int SandboxieToolTimeoutSeconds { get; set; } = 120;
}

public sealed record BotApiEndpointSettings(string BaseUrl, bool IsLocalApi, string ExternalLocalBotApiBaseUrl);
Expand Down
22 changes: 22 additions & 0 deletions TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> Arguments { get; set; } = new Dictionary<string, string>(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; }
Expand Down Expand Up @@ -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}";
}
}
12 changes: 12 additions & 0 deletions TelegramSearchBot.Common/Model/ToolContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,17 @@ public class ToolContext {
/// The original user message ID, used as default reply target for tool actions (e.g. sending photos).
/// </summary>
public long MessageId { get; set; }

/// <summary>
/// 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.
/// </summary>
public bool IsSandboxed { get; set; }

/// <summary>
/// Optional sandbox box name used for diagnostics and routing.
/// </summary>
public string SandboxBoxName { get; set; } = string.Empty;
}
}
9 changes: 5 additions & 4 deletions TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1309,11 +1309,12 @@ await redis.GetDatabase().StringSetAsync(
/// <summary>
/// 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.
/// </summary>
public static void RegisterProxyTools(
List<ProxyToolDefinition> toolDefinitions,
Func<string, Dictionary<string, string>, Task<string>> executor) {
Func<string, Dictionary<string, string>, Task<string>> executor,
bool allowLocalOverride = false) {
_proxyToolExecutor = executor;
ProxyToolRegistry.Clear();

Expand All @@ -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;
}
Expand Down
10 changes: 5 additions & 5 deletions TelegramSearchBot.LLM/Service/Tools/BashToolService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
[Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Transient)]
public class BashToolService : IService, IBashToolService {
Expand Down Expand Up @@ -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<string> 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.";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (string.IsNullOrWhiteSpace(command)) {
Expand Down
24 changes: 14 additions & 10 deletions TelegramSearchBot.LLM/Service/Tools/FileToolService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public async Task<string> 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 {
Expand Down Expand Up @@ -89,8 +89,8 @@ public async Task<string> 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 {
Expand All @@ -117,8 +117,8 @@ public async Task<string> 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 {
Expand Down Expand Up @@ -166,8 +166,8 @@ public async Task<string> 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 {
Expand Down Expand Up @@ -237,8 +237,8 @@ public async Task<string> 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 {
Expand Down Expand Up @@ -273,6 +273,10 @@ public async Task<string> 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;
Expand Down
Loading
Loading