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
593 changes: 593 additions & 0 deletions Docs/Windows_Native_Process_Sandbox_Feasibility.md

Large diffs are not rendered by default.

36 changes: 14 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,12 @@
"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_",
"EnableLlmWindowsSandbox": false,
"WindowsSandboxProfilePrefix": "TelegramSearchBot.Chat.",
"WindowsSandboxActiveProcessLimit": 32,
"WindowsSandboxJobMemoryLimitMb": 1024,
"SandboxieGroupFilesRoot": "",
"SandboxieGlobalReadPaths": [],
"SandboxieGlobalClosedPaths": [],
"SandboxieCommandTimeoutSeconds": 10,
"SandboxieToolHostStartupTimeoutSeconds": 15,
"SandboxieToolTimeoutSeconds": 120,
"OLTPAuth": "",
Expand Down Expand Up @@ -128,19 +123,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` 路径。程序会使用同目录的 `SbieIni.exe` 注册 portable box 目录,并用 `Start.exe /reload` 重新加载配置和启动 ToolHost。
- `SandboxieIniPath`: Sandboxie 主配置路径。仅当 `SandboxieAutoRegisterImportBox=true` 且 `Start.exe` 同目录不存在 `SbieIni.exe` 时,作为直接写入 `ImportBox` 的回退路径。
- `SandboxieAutoRegisterImportBox`: 是否由程序自动把 portable box 目录注册到 Sandboxie 主配置(默认true)。程序先写 box INI,再注册目录、重载配置并启动 box;自动注册失败会立即报告具体错误。如希望自行在 Sandboxie Plus 中添加便携容器目录,可设为 false。
- `SandboxieDenyHostFileSystem`: 是否默认关闭宿主机盘符根目录访问(默认false)。保持 false 时更适合运行 bash/npm/python 等工具链;写入仍由 Sandboxie 虚拟化,敏感项目数据仍会通过 `ClosedFilePath` 阻断。需要极严格白名单模式时可设为 true。
- `SandboxieBoxImportDirectory`: portable box ini 目录;为空时默认 `%LOCALAPPDATA%/TelegramSearchBot/Sandboxie/Boxes`。每个群聊的 box ini 和虚拟文件根都生成在这里。
- `SandboxieBoxPrefix`: 每群 box 名称前缀。Sandboxie 名称只允许 1-38 个 ASCII 字母、数字和下划线;下划线是合法字符,程序会原样保留。前缀与 12 位稳定哈希拼接后的总长度不能超过 38。
- `SandboxieGroupFilesRoot`: 可选的额外每群文件根目录;为空时不开放。配置后,每个群只读开放 `<root>/<chatId>`。
- 程序默认会关闭聊天资源父目录 `Photos`、`Audios`、`Videos`、`Files`,再仅为当前群的既有聊天媒体/文件目录生成只读授权:`Photos/<chatId>`、`Audios/<chatId>`、`Videos/<chatId>`、`Files/<chatId>`。其他群的资源目录默认不可读。Lucene `Index_Data` 不开放给 ToolHost;搜索仍由主进程侧服务完成。
- `SandboxieGlobalReadPaths` / `SandboxieGlobalClosedPaths`: 额外全局只读开放/禁止访问路径。
- `SandboxieCommandTimeoutSeconds`: `SbieIni.exe` 和 `Start.exe /reload` 等 Sandboxie 配置命令的等待超时(默认10秒)。
- `SandboxieToolHostStartupTimeoutSeconds`: 启动 box 后等待 ToolHost 心跳的超时(默认15秒)。宿主负载较高时可适当增大。
- `SandboxieToolTimeoutSeconds`: 沙箱工具调用等待超时(默认120秒)。
- `EnableLlmWindowsSandbox`: 是否启用 Windows 原生 AppContainer LLM 工具沙箱(默认false)。仅支持 Windows;不再需要安装 Sandboxie Plus。启用后 `ReadFile`/`WriteFile`/`EditFile`/`SearchText`/`ListFiles`/`ExecuteCommand` 会通过每群一个 AppContainer ToolHost 执行。
- `WindowsSandboxProfilePrefix`: 每群 AppContainer profile 名称前缀,默认 `TelegramSearchBot.Chat.`。
- `WindowsSandboxActiveProcessLimit`: ToolHost Job Object 中允许的最大进程数,默认32。
- `WindowsSandboxJobMemoryLimitMb`: ToolHost 及所有子进程的 Job 总提交内存上限,默认1024MB。
- `SandboxieGroupFilesRoot`: 为兼容旧配置保留。配置后 `<root>/<chatId>` 会授予当前群 AppContainer SID 真实读写权限,并作为相对路径和 shell 的默认工作目录。
- 默认读写授权当前群的 `Photos/<chatId>`、`Audios/<chatId>`、`Videos/<chatId>`、`Files/<chatId>`;其他群目录及 `Config.json`、`Data.sqlite`、日志和索引不授权。与 Sandboxie 虚拟化不同,写入授权目录会直接修改真实文件。
- `SandboxieGlobalReadPaths`: 为兼容旧配置保留的额外全局只读路径。程序安装目录也只授予读取/执行权限。
- `SandboxieToolHostStartupTimeoutSeconds`: 启动 AppContainer ToolHost 后等待 Redis 心跳的超时,默认15秒。
- `SandboxieToolTimeoutSeconds`: 沙箱工具调用等待超时,默认120秒。
- 第一阶段保留 localhost Redis IPC。AppContainer profile 首次使用前需要管理员执行日志提示的 `CheckNetIsolation.exe LoopbackExempt -a -p=<SID>`;未配置时程序会 fail closed,不会回退到非沙箱执行。网络暂不属于本阶段安全边界。旧 `EnableLlmSandboxie=true` 仍会启用原生沙箱,便于平滑升级,但其他 Sandboxie Plus 配置已不再使用。

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

Expand Down
44 changes: 14 additions & 30 deletions TelegramSearchBot.Common/Env.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,27 +61,19 @@ static Env() {
AgentMaxRecoveryAttempts = config.AgentMaxRecoveryAttempts;
AgentQueueBacklogWarningThreshold = config.AgentQueueBacklogWarningThreshold;
AgentProcessMemoryLimitMb = config.AgentProcessMemoryLimitMb;
EnableLlmWindowsSandbox = config.EnableLlmWindowsSandbox || config.EnableLlmSandboxie;
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>();
SandboxieCommandTimeoutSeconds = Math.Clamp(config.SandboxieCommandTimeoutSeconds, 1, 3600);
SandboxieToolHostStartupTimeoutSeconds = Math.Clamp(config.SandboxieToolHostStartupTimeoutSeconds, 1, 3600);
SandboxieToolTimeoutSeconds = Math.Clamp(config.SandboxieToolTimeoutSeconds, 5, 3600);
WindowsSandboxProfilePrefix = string.IsNullOrWhiteSpace(config.WindowsSandboxProfilePrefix)
? "TelegramSearchBot.Chat."
: config.WindowsSandboxProfilePrefix.Trim();
WindowsSandboxActiveProcessLimit = Math.Clamp(config.WindowsSandboxActiveProcessLimit, 1, 256);
WindowsSandboxJobMemoryLimitMb = Math.Clamp(config.WindowsSandboxJobMemoryLimitMb, 64, 32768);
EnableCodingAgentTool = config.EnableCodingAgentTool;
CodingAgentAllowedGroupIds = config.CodingAgentAllowedGroupIds ?? new List<long>();
CodingAgentDeniedPathPrefixes = ResolveCodingAgentDeniedPathPrefixes(config.CodingAgentDeniedPathPrefixes);
Expand Down Expand Up @@ -171,19 +163,15 @@ 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 EnableLlmWindowsSandbox { get; set; } = false;
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 SandboxieCommandTimeoutSeconds { get; set; } = 10;
public static int SandboxieToolHostStartupTimeoutSeconds { get; set; } = 15;
public static int SandboxieToolTimeoutSeconds { get; set; } = 120;
public static string WindowsSandboxProfilePrefix { get; set; } = "TelegramSearchBot.Chat.";
public static int WindowsSandboxActiveProcessLimit { get; set; } = 32;
public static int WindowsSandboxJobMemoryLimitMb { get; set; } = 1024;
public static bool EnableCodingAgentTool { get; set; } = false;
public static List<long> CodingAgentAllowedGroupIds { get; set; } = new List<long>();
public static List<string> CodingAgentDeniedPathPrefixes { get; set; } = new List<string>();
Expand Down Expand Up @@ -340,19 +328,15 @@ public class Config {
public int AgentMaxRecoveryAttempts { get; set; } = 2;
public int AgentQueueBacklogWarningThreshold { get; set; } = 20;
public int AgentProcessMemoryLimitMb { get; set; } = 256;
public bool EnableLlmWindowsSandbox { get; set; } = false;
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 SandboxieCommandTimeoutSeconds { get; set; } = 10;
public int SandboxieToolHostStartupTimeoutSeconds { get; set; } = 15;
public int SandboxieToolTimeoutSeconds { get; set; } = 120;
public string WindowsSandboxProfilePrefix { get; set; } = "TelegramSearchBot.Chat.";
public int WindowsSandboxActiveProcessLimit { get; set; } = 32;
public int WindowsSandboxJobMemoryLimitMb { get; set; } = 1024;
public bool EnableCodingAgentTool { get; set; } = false;
public List<long> CodingAgentAllowedGroupIds { get; set; } = new List<long>();
public List<string> CodingAgentDeniedPathPrefixes { get; set; } = new List<string>();
Expand Down
12 changes: 11 additions & 1 deletion TelegramSearchBot.Common/Model/ToolContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,18 @@ public class ToolContext {
public bool IsSandboxed { get; set; }

/// <summary>
/// Optional sandbox box name used for diagnostics and routing.
/// Optional sandbox profile name used for diagnostics and routing.
/// </summary>
public string SandboxBoxName { get; set; } = string.Empty;

/// <summary>
/// Default directory for relative file paths and shell commands inside the sandbox.
/// </summary>
public string SandboxWorkingDirectory { get; set; } = string.Empty;

/// <summary>
/// Cancels work when the sandbox host stops or the current tool call times out.
/// </summary>
public System.Threading.CancellationToken CancellationToken { get; set; }
}
}
45 changes: 45 additions & 0 deletions TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,51 @@ public async Task ExecuteCommand_AdminUser_ExecutesSuccessfully() {
Assert.Contains("hello test", result);
}

[Fact]
public async Task ExecuteCommand_SandboxedMissingWorkingDirectory_UsesSandboxDirectory() {
var testDir = Path.Combine(Path.GetTempPath(), "BashToolSandbox_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(testDir);
try {
var context = new ToolContext {
ChatId = 1,
UserId = long.MaxValue - 1,
IsSandboxed = true,
SandboxWorkingDirectory = testDir
};
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "[Environment]::CurrentDirectory; (Get-Location).Path"
: "pwd";

var result = await _service.ExecuteCommand(command, context);

Assert.Contains("Exit code: 0", result);
Assert.Contains(testDir, result, StringComparison.OrdinalIgnoreCase);
} finally {
Directory.Delete(testDir, recursive: true);
}
}

[Fact]
public async Task ExecuteCommand_SandboxCancellation_StopsCommand() {
using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var context = new ToolContext {
ChatId = 1,
UserId = long.MaxValue - 1,
IsSandboxed = true,
SandboxWorkingDirectory = Path.GetTempPath(),
CancellationToken = cts.Token
};
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "Start-Sleep -Seconds 30"
: "sleep 30";

var started = DateTime.UtcNow;
var result = await _service.ExecuteCommand(command, context, timeoutMs: 300000);

Assert.Contains("cancelled", result, StringComparison.OrdinalIgnoreCase);
Assert.True(DateTime.UtcNow - started < TimeSpan.FromSeconds(10));
}

[Fact]
public async Task ExecuteCommand_TimeoutClamped() {
var toolContext = new ToolContext { ChatId = 1, UserId = Env.AdminId };
Expand Down
31 changes: 31 additions & 0 deletions TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,37 @@ public async Task ReadFile_WithLineRange_ReadsPartialContent() {
Assert.DoesNotContain("1. line1", result);
}

[Fact]
public async Task WriteFile_SandboxedRelativePath_UsesSandboxWorkingDirectory() {
var context = new ToolContext {
ChatId = 1,
UserId = long.MaxValue - 1,
IsSandboxed = true,
SandboxWorkingDirectory = _testDir
};

var result = await _service.WriteFile("sandbox.txt", "sandbox content", context);

Assert.Contains("Successfully", result);
Assert.Equal("sandbox content", await File.ReadAllTextAsync(Path.Combine(_testDir, "sandbox.txt")));
}

[Fact]
public async Task ListFiles_SandboxedMissingPath_UsesSandboxWorkingDirectory() {
await File.WriteAllTextAsync(Path.Combine(_testDir, "sandbox-list.txt"), "content");
var context = new ToolContext {
ChatId = 1,
UserId = long.MaxValue - 1,
IsSandboxed = true,
SandboxWorkingDirectory = _testDir
};

var result = await _service.ListFiles(context);

Assert.Contains("sandbox-list.txt", result);
Assert.Contains(_testDir, result);
}

[Fact]
public async Task WriteFile_CreatesNewFile() {
var filePath = Path.Combine(_testDir, "new.txt");
Expand Down
16 changes: 12 additions & 4 deletions TelegramSearchBot.LLM/Service/Tools/BashToolService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ internal static string FindExecutableOnPath(string fileName) {
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("Working directory for command execution. Defaults to the sandbox working directory for sandboxed calls, otherwise the bot 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 or OS-sandboxed tool hosts.
if (toolContext == null || ( toolContext.UserId != Env.AdminId && !toolContext.IsSandboxed )) {
if (toolContext == null || ( !toolContext.IsSandboxed && toolContext.UserId != Env.AdminId )) {
return "Error: Command execution is only available to admin users or sandboxed tool hosts.";
}

Expand All @@ -99,7 +99,10 @@ public async Task<string> ExecuteCommand(
// Limit timeout to reasonable bounds
timeoutMs = Math.Clamp(timeoutMs, 1000, 300000); // 1s to 5min

var workDir = workingDirectory ?? Env.WorkDir;
var workDir = workingDirectory ??
(toolContext is { IsSandboxed: true } && !string.IsNullOrWhiteSpace(toolContext.SandboxWorkingDirectory)
? toolContext.SandboxWorkingDirectory
: Env.WorkDir);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!Directory.Exists(workDir)) {
return $"Error: Working directory '{workDir}' does not exist.";
}
Expand Down Expand Up @@ -150,7 +153,8 @@ public async Task<string> ExecuteCommand(
process.BeginOutputReadLine();
process.BeginErrorReadLine();

using var cts = new CancellationTokenSource(timeoutMs);
using var timeoutCts = new CancellationTokenSource(timeoutMs);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, toolContext.CancellationToken);
try {
await process.WaitForExitAsync(cts.Token);
} catch (OperationCanceledException) {
Expand All @@ -160,6 +164,10 @@ public async Task<string> ExecuteCommand(
}
} catch { }

if (toolContext.CancellationToken.IsCancellationRequested && !timeoutCts.IsCancellationRequested) {
return "Command cancelled by the sandbox host.";
}

var partialOutput = outputBuilder.ToString();
if (partialOutput.Length > MaxOutputLength) {
partialOutput = partialOutput[..MaxOutputLength] + "\n... [output truncated]";
Expand Down
Loading
Loading