diff --git a/Docs/Windows_Native_Process_Sandbox_Feasibility.md b/Docs/Windows_Native_Process_Sandbox_Feasibility.md new file mode 100644 index 00000000..8b5f72f4 --- /dev/null +++ b/Docs/Windows_Native_Process_Sandbox_Feasibility.md @@ -0,0 +1,593 @@ +# Windows 原生进程 Sandbox 可行性研究 + +> 研究目标:允许 LLM 执行任意 PowerShell、CMD、Git Bash、脚本和子进程,但由 Windows 内核安全边界把它们限制在每个 Task 被授权的资源内。 +> +> 研究日期:2026-08-11。目标平台应明确限定为受支持的 Windows 11 x64/NTFS;不要把结论外推到 FAT/exFAT、旧版 Windows 或 Wine。 + +## 1.0 第一阶段目标 + +第一阶段只解决一个核心问题:**限制 Sandbox 内程序访问宿主文件目录。** + +保留现有 Redis/Garnet、心跳、任务队列和网络行为,不在这一阶段更换 IPC 或设计 WFP 策略。实施内容是: + +1. ToolHost 运行在普通 AppContainer 中。 +2. 给该 AppContainer SID 只授权指定 workspace 和必要输入目录。 +3. workspace 使用 NTFS DACL 授予该 SID `Modify`,并设置 Low Integrity 可写标签。 +4. Bot 的 `Config.json`、`Data.sqlite`、日志、用户凭据目录和其他 workspace 不授予该 SID。 +5. ToolHost 启动的 CMD、PowerShell、Git、Python、Node 等正常子进程继承同一个 AppContainer token,因此接受同一套目录权限。 +6. Windows、PowerShell、.NET 和其他运行时所需的系统文件保持只读可执行;不能把“只允许 workspace”理解为连系统 DLL 都禁止读取。 + +第一阶段的验收标准是:允许目录能够正常读写,未授权的数据目录返回 `Access denied`。网络暂不作为安全边界,Redis 只需保持现有功能可用。 + +## 1. 结论摘要 + +### 1.1 可行性判断 + +**有条件可行,但不能只靠 Restricted Token、Low Integrity 或 Job Object。** + +能接近目标的稳定公开 API 组合是: + +1. 每个 Task 一个独立 AppContainer SID;首版使用普通 AppContainer,LPAC 作为兼容性验证通过后的增强档。 +2. 第一阶段直接用 AppContainer low-box token 作为目录 ACL 边界;Restricted Token 作为后续 defense-in-depth,避免影响现有 Win32/PowerShell 兼容。 +3. 仅给 Task workspace、运行时文件和专用 IPC 对象授予该 AppContainer SID 的最小 ACL。 +4. 用 Job Object 包住整个正常创建的子进程树,并设置 kill-on-close、进程数、内存、CPU 和生命周期限制。 +5. 通过 `STARTUPINFOEX` 只继承 stdin/stdout/stderr 等明确列出的 HANDLE。 +6. 构造全新的最小环境块,不继承 Bot 的环境变量。 +7. 第一阶段为了继续使用 Redis,只做让 AppContainer 能连接现有 `localhost:{SchedulerPort}` 所必需的网络配置;这只是兼容措施,不是第一阶段的安全目标。目录隔离完成后,再单独处理 IPC 和网络限制。 +8. Sandbox 内只运行最小 runner/shell,不加载 Bot 的 DI 容器、配置、数据库客户端或凭据。 + +这个组合可以让 `cmd.exe`、`powershell.exe`、`python.exe`、`node.exe`、`rundll32.exe` 等程序即使被任意调用,也仍使用相同的低权限 token。**可执行某个程序不等于获得该程序在宿主用户上下文中的权限。** + +但仍有两个必须通过 PoC 才能决定是否上线的条件: + +- **兼容性条件**:CMD 有微软 LPAC 示例;Windows PowerShell、PowerShell 7、Git Bash/MSYS2、Python、Node 只能说机制上可以启动普通 Win32 EXE,不能在未测试前承诺完整可用。它们可能依赖 registry、COM、ConPTY、named pipe、字体、证书库、模块目录、JIT 或未带 AppContainer ACL 的 DLL。 +- **安全条件**:微软的 CMD LPAC 示例要求 `lpacCom` 和 `registryRead`。一旦开放 COM/RPC broker,必须验证 WMI、Task Scheduler、Shell COM、BITS 等是否可能代替调用者创建脱离 Job 或拥有更多权限的进程/副作用。Job 文档明确指出 `Win32_Process.Create` 创建的进程不自动进入调用方 Job。这不是可以忽略的兼容性细节。 + +因此,推荐判断是: + +- **适合**:隔离可信 Windows 安装上的 LLM 任意命令,保护 Bot 文件、其他 Task、用户文件和本地服务,接受 Windows 内核/系统 broker 是 TCB。 +- **不等价于 VM**:不适合把未知恶意二进制当作与宿主内核隔离的样本执行环境。 +- **不能用 Restricted Token 单独实现**:普通 Low IL 默认阻止写高完整性对象,但仍可读取 DACL 允许的文件;Restricted Token 也不自带 AppContainer 的默认拒绝资源和网络模型。 + +### 1.2 新实验性 API + +微软已公开: + +- `Experimental_CreateProcessInSandbox` +- `Experimental_CreateProcessAsUserInSandbox` + +它们位于 `processmodel.dll`,接受 FlatBuffer `SandboxSpec`,可以声明 AppContainer、`fs_read_only`、`fs_read_write`、network policy、capabilities、integrity、Win32k 和 Job UI 限制。本机存在 `C:\Windows\System32\processmodel.dll`,文件版本为 `10.0.26100.8737`。 + +但该接口明确标为 **Experimental**,目前需要 `LoadLibraryExW` + `GetProcAddress`,没有稳定 SDK 头文件。生产实现不应只依赖它;PoC 可以同时比较: + +- A:稳定公开 AppContainer/Token/Job/ACL API 手工组合。 +- B:实验性 Create Process In Sandbox API。 + +B 若可用,可显著减少 ACL 和策略拼装错误,但必须提供版本检测和 A 路径回退,且启动时必须 fail closed。 + +### 1.3 本机 PoC 结果 + +已在当前 Windows 11 `10.0.26200.8875` 上编译微软 `SandboxSecurityTools/LaunchAppContainer`,先使用微软示例中的 `lpacCom`、`registryRead` capabilities 做对照,再验证普通 AppContainer 在 **零 capability** 下运行。参考工具原项目固定 VS2022 `v143`,本机用已安装的 VS 18 `v145` toolset 成功构建。 + +实测结果: + +| 测试 | 普通 AppContainer | LPAC | 结论 | +|---|---:|---:|---| +| `cmd.exe` 执行并写授权 workspace | 未单独区分 | 成功 | CMD 可作为首版 shell | +| `git.exe --version`,工作目录为授权 workspace | 未单独区分 | 成功,`2.52.0.windows.1` | 原生 Git 可用 | +| Windows PowerShell 5.1 执行 `.ps1` 并写 workspace | 成功 | 失败 | 首版不能默认 LPAC | +| Git Bash/MSYS2 `bash.exe --version` | 退出 `66` | 退出 `66` | 不能无损替代 Sandboxie 的 Git Bash 支持 | +| 读取未授权的项目 `README.md` | `Access denied`,读取 0 字节 | `Access denied`,读取 0 字节 | 普通 AppContainer 已提供有效文件边界 | +| 写入带 AppContainer ACL + Low IL 的 workspace | 成功 | 成功 | workspace 授权模型成立 | + +LPAC 下 Windows PowerShell 5.1 的实际失败链为: + +```text +System.Management.Automation.AmsiUtils + -> PSEtwLog + -> PSEtwLogProvider + -> EventProvider.EtwRegister() + -> Win32Exception: Access denied +``` + +普通 AppContainer 下 PowerShell 脚本成功写出 `PS_OK`,但产生 `FileSystem` provider 初始化默认 drive 失败的警告,说明核心命令可用,部分 provider/drive 仍需兼容测试。进一步移除全部 capability 后,CMD 仍写出 `CMD_NOCAPS_OK`,PowerShell 仍写出 `PS_OK` 并退出 `0`。因此生产默认 capability 可以为空,不需要为了启动 shell 预先授予 `lpacCom`、`registryRead` 或任何网络 capability。 + +Git Bash 的失败并非简单的 EXE/DLL 读取 ACL:`bash.exe`、`msys-2.0.dll` 已有 `ALL RESTRICTED APPLICATION PACKAGES (RX)`,且普通 AppContainer 与 LPAC 均退出 `66`。更可能是 MSYS2 对 named object、shared memory、console/PTY 或初始化环境的假设与 AppContainer 冲突。首版应支持 CMD、Windows PowerShell 和原生 `git.exe`,把 Git Bash 标为不支持;若 Bash 是硬需求,需单独研究 MSYS2 兼容或采用纯 Win32 shell,不能声称 drop-in 替换。 + +PoC 还确认了 `lpCurrentDirectory` 必须显式设置为授权 workspace。微软参考 launcher 传 `NULL` 并继承不可访问的宿主项目目录时,Git 报“当前目录无效”;切换到授权目录后立即正常。 + +因此迁移结论调整为:**有戏,第一阶段使用普通 AppContainer + Job Object,以 package SID + NTFS ACL 实现目录隔离。** Restricted Token 和 LPAC 留到后续硬化;本机 PowerShell 5.1 在 LPAC 下不可用。 + +## 2. 威胁模型 + +### 2.1 要防御 + +假设 Task 内代码完全不可信,可以: + +- 执行任意 shell、EXE、DLL、脚本、native syscall。 +- 枚举文件、registry、process、named objects、pipes、COM/RPC 服务和网络端点。 +- 创建任意数量的正常子进程、尝试后台驻留和拒绝退出。 +- 使用 junction、symlink、hardlink、UNC path、device path、alternate data stream 等路径形式。 +- 读取自身 token、环境、命令行、内存和继承 HANDLE。 +- 攻击同一 Task 内其他进程。 + +目标是它只能访问显式授权的 Task workspace、运行时依赖和窄 IPC;不同 Task 相互隔离。 + +### 2.2 不在保证内 + +这套方案明确不能防御: + +- Windows kernel、win32k、驱动或允许访问的系统 broker 的提权/沙箱逃逸漏洞。 +- 已被管理员或其他恶意软件控制的宿主机。 +- Bot/broker 自己的内存安全、反序列化、路径验证或 confused-deputy 漏洞。 +- 管理员、SYSTEM、调试权限持有者、物理攻击者。 +- 已授权 workspace 内容被破坏或删除。 +- 已允许网络后的数据外传和远端副作用。 +- CPU cache、时间、存储占用等侧信道和不能被配额完全消除的 DoS。 +- FAT/exFAT 等没有 NTFS DACL/MIC 语义的卷。 +- NTFS journal、pagefile、crash dump、AV/索引、备份或 SSD 中的数据残留;“销毁 Task”不是取证级安全擦除。 + +## 3. 十二个重点问题的回答 + +### 3.1 普通 Win32 程序能否运行在 AppContainer / Restricted Token 中 + +**能启动,不代表能正常工作。** + +- AppContainer 不要求目标 EXE 是 UWP/MSIX。非打包 launcher 可以用 `CreateAppContainerProfile`、`SECURITY_CAPABILITIES`、`PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES` 和 `CreateProcess[AsUser]W` 启动普通 Win32 EXE。 +- Restricted Token 可作为 primary token 传给 `CreateProcessAsUserW`。 +- LPAC 比普通 AppContainer 更严格,不接受很多授予 `ALL APPLICATION PACKAGES` 的环境权限;目标 EXE、DLL、资源文件和 registry 依赖必须有明确可达权限。 +- 微软 `SandboxSecurityTools/LaunchAppContainer` 给出了 `cmd.exe` 的 LPAC 运行示例,示例需要 `lpacCom` 与 `registryRead` capability。 + +### 3.2 PowerShell、CMD、Git Bash/Bash 和任意子进程 + +- `cmd.exe`:官方参考实现证明可以在 LPAC 中启动;仍需实测批处理、管道、重定向、ConPTY 和常用内建命令。 +- Windows PowerShell 5.1:是普通 Win32/.NET Framework 程序,但高度依赖 registry、COM、模块、证书和系统服务,兼容风险最高。 +- PowerShell 7:通常更适合作为目标,但依赖 CoreCLR/JIT、安装目录 DLL、模块和 native library。不能开启 `ProhibitDynamicCode`/ACG,否则 JIT 很可能失败。 +- Git Bash/MSYS2:需要给 Git 安装目录 RX,验证 MSYS runtime、fork 模拟、PTY、named pipe、`git.exe` 和 helper 子进程。不能只验证 `bash --version`。 +- Python/Node:可以继承相同 token;需给解释器/runtime RX 和 workspace RW。Node/.NET/Python 扩展可能需要动态代码或加载 workspace native DLL,因此 CIG/ACG/image-load mitigations 要按兼容性分级。 + +建议生产支持列表采用 allowlisted runtime 安装根目录,但**命令内容和由这些 runtime 执行的代码不做语义拦截**。allowlist 的目的只是确保所需二进制可加载,不是命令过滤。 + +### 3.3 子进程能否可靠继承限制 + +普通 `CreateProcess` 子进程通常继承父进程 AppContainer token;Job 中进程创建的正常子进程默认进入同一 Job。不要设置: + +- `JOB_OBJECT_LIMIT_BREAKAWAY_OK` +- `JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` + +初始目标必须以 `CREATE_SUSPENDED` 启动,先 `AssignProcessToJobObject`,再 `ResumeThread`,否则存在它先执行/派生再入 Job 的竞争窗口。 + +但“整个进程树可靠”不能仅靠 Job: + +- 文档明确说 WMI `Win32_Process.Create` 创建的进程不自动关联调用方 Job。 +- COM/RPC/服务/计划任务本质上可能是宿主 broker 代做操作。 +- 因此 LPAC 下应尽量不授予 `lpacCom`;若 shell 启动必须授予,WMI/COM 逃逸测试是上线阻断项。 +- 即使第三方 broker 创建的进程不在 Job,也必须验证其 token 仍是 AppContainer/Restricted Token,不能仅检查父子 PID。 + +### 3.4 只访问指定 workspace + +第一阶段实现按 chat 创建长期 AppContainer profile/SID,而不是按 Task 创建。`Photos/Audios/Videos/Files/` 和可选的 `SandboxieGroupFilesRoot/` 属于该 chat 的共享可写目录;同一 chat 内的多个任务属于同一信任边界,不提供相互隔离。最终若需要不互信 Task 隔离,再升级为: + +```text +TelegramSearchBot.Task. +``` + +在专用 NTFS workspace 上: + +1. 禁止从宿主敏感父目录继承宽 ACL,使用受控根目录。 +2. 保留 broker/维护账户所需权限。 +3. 只给该 Task package SID 所需的 `RX` 或 `RWX`,使用对象/容器继承。 +4. 给目录设置允许 Low IL 写入的 mandatory label;DACL 仍须命中该 Task SID,Low label 本身不会授予访问。 +5. 只给 runtime 目录 package SID `RX`,绝不 `W`。 +6. 不给 `ALL APPLICATION PACKAGES` 或 `ALL RESTRICTED APPLICATION PACKAGES` 广泛写权限。 + +AppContainer 访问是普通 user/group 权限与 package/capability 权限的交集。因此即使宿主用户能读整个磁盘,没有该 AppContainer SID/capability 的对象仍应拒绝访问。 + +Junction/symlink 不能凭空增加 token 权限:重解析到宿主路径后,目标对象仍会执行 ACL/AppContainer/MIC 检查。但 broker 在复制、回收、发布结果时必须防止 TOCTOU:按 handle 操作,检查 `FILE_ATTRIBUTE_REPARSE_POINT`、最终路径、volume/file ID,并避免让高权限 broker 跟随 Task 创建的链接写到外部。 + +### 3.5 任意解释器、rundll32、COM、pipe、registry 的绕过 + +- `cmd`、PowerShell、Python、Node、`rundll32` 只能在当前 token 下执行;换一个系统 EXE 不会恢复被删除的 SID/privilege。 +- DLL 在 `rundll32` 进程内仍使用同一 token。高风险来自 DLL/系统服务漏洞,而不是文件名。 +- Registry 是 securable object。LPAC 默认更严格;`registryRead` 会扩大可读面,必须测试是否暴露产品密钥、连接信息和第三方凭据。 +- Named pipe 必须使用随机每 Task 名称和显式 DACL,不能接受默认 DACL。AppContainer 场景按要求使用 `LOCAL\...` 命名。 +- COM/RPC 是最大 broker 面。允许的 COM server 可能在高权限进程中执行操作,安全性取决于 server 是否正确识别 AppContainer caller。 +- 不允许 Sandbox 打开共享 Garnet/Redis、Bot 管理 pipe、Docker named pipe、SSH agent、浏览器调试端口等宿主 IPC。 + +Windows ACL/AppContainer 可以阻止直接资源访问,但不能修复一个把高权限操作暴露给低权限 caller 的宿主服务。 + +### 3.6 防止读取 Bot 配置、环境变量、凭据、数据库和其他 Task + +必须同时处理“可命名资源”和“启动时带进去的资源”: + +- Bot 的 `%LOCALAPPDATA%\TelegramSearchBot\Config.json`、`Data.sqlite`、日志、向量索引和其他 Task 根目录不授予 Task package SID。 +- 不把完整 TelegramSearchBot 进程作为 sandbox ToolHost。创建独立最小 runner 项目,不引用 `Env`;当前 `Env` 静态初始化会直接读取 `Config.json`。 +- `CreateProcessAsUserW(lpEnvironment = NULL)` 会继承调用方环境。必须构造 allowlist 环境块,只保留 `SystemRoot`、`ComSpec`、受控 `PATH`、locale 和 Task 自己的 `TEMP/TMP/HOME/USERPROFILE`。 +- API key、Bot token、数据库连接和代理凭据一律不进入环境、命令行或可继承 handle。第一阶段的 Scheduler port/localhost endpoint 不属于凭据,会通过命令行传入 ToolHost。 +- 不共享 Bot 内存映射、日志 sink、credential handle、token handle 或数据库连接。 +- 每 Task 使用不同 AppContainer SID、Job、workspace、temp、IPC 名称;同一 SID 的两个 Task 不能视为隔离。 + +当前 Sandboxie ToolHost 路径启动的是当前可执行文件、加载完整服务,并通过共享 Redis/Garnet 通信;这不满足上述“最小无凭据 runner”要求,不能直接平移到 AppContainer。 + +### 3.7 HANDLE inheritance + +把 HANDLE 当作不可伪造的 capability。已打开 handle 可以绕过后续按名称打开时的 ACL 检查。 + +推荐: + +1. 默认所有 broker handle 非 inheritable。 +2. 用 `STARTUPINFOEX` + `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` 只列出三个匿名 pipe/必要 IPC handle。 +3. pipe 的 broker 端立即用 `SetHandleInformation(..., HANDLE_FLAG_INHERIT, 0)` 清除继承。 +4. 不继承 Job、process、thread、token、section、registry、file、socket、named pipe server 或 completion-port handle。 +5. 启动后在 target 内枚举 handle 做 PoC 审计。 + +不能只依赖 `.NET ProcessStartInfo` 的默认值来证明安全;生产 launcher 应直接控制 Win32 process creation 参数。 + +### 3.8 进程树、CPU、内存、数量和生命周期 + +Job Object 非常适合这部分,但它不是文件/网络 sandbox。 + +每 Task 独立 Job,至少设置: + +- `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` +- `JOB_OBJECT_LIMIT_ACTIVE_PROCESS` +- `JOB_OBJECT_LIMIT_JOB_MEMORY`,必要时再加 per-process memory +- `JOBOBJECT_CPU_RATE_CONTROL_INFORMATION` hard cap 或 weight +- per-job user time / wall-clock supervisor timeout +- UI restrictions(若兼容) +- I/O completion port 监听创建、退出、limit violation + +结束 chat sandbox 时关闭 Job handle;`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 会终止 ToolHost 及正常派生的整个进程树。当前原生实现已经使用 `CREATE_SUSPENDED -> AssignProcessToJobObject -> ResumeThread` 消除启动竞态,并配置 aggregate `JobMemoryLimit` 与 `ActiveProcessLimit`。CPU rate、I/O completion port 和 wall-clock supervisor 仍是后续硬化项。 + +Job 没有可靠的每 Task 磁盘容量限制。可选方案是专用受配额 volume/VHDX、按账户 NTFS quota,或由 broker 监控并在超限时终止;监控不是严格的瞬时配额安全边界。 + +### 3.9 网络、localhost、LAN 和数据库 + +最终安全目标仍是:**默认不给任何 network capability。** AppContainer 默认阻断网络,且 loopback 默认单独隔离。 + +但为了降低从 Sandboxie 迁移的首阶段风险,可以暂时保持现有 Redis/Garnet IPC: + +1. 给普通 AppContainer token 增加 `internetClient` 和 `privateNetworkClientServer` capability。只需要客户端网络时不授予 `internetClientServer`,避免无必要的公网入站权限。 +2. 第一阶段延续现有模型,为每个 chat 创建一个长期 AppContainer profile/SID,并给该 SID 添加 loopback exemption,使其可以继续连接 `localhost:{SchedulerPort}`。 +3. 保留现有 Redis queue/result/heartbeat 协议,不在第一阶段引入 pipe broker。 +4. 每次命令或 Task 仍使用独立 Job 和 workspace;但同一 chat 共享 package SID,所以不能声称同一 chat 内不同 Task 之间有 SID/ACL 隔离。 +5. 明确把该阶段定义为“文件与进程隔离迁移版”,不声称提供网络、localhost、LAN 或 Redis 控制面隔离。 + +loopback exemption 是机器级配置。实现时优先用 `NetworkIsolationGetAppContainerConfig` 读取已有 SID 列表,再合并目标 SID 后调用 `NetworkIsolationSetAppContainerConfig`;setter 会替换列表,不能覆盖其他应用已有 exemption。也可以在 PoC 中使用 `CheckNetIsolation.exe LoopbackExempt -a -p=`,但生产代码需要幂等添加、删除和崩溃恢复,并处理通常需要提升权限的问题。 + +这个第一阶段的风险必须接受并记录: + +- Sandbox 内任意程序都能连接宿主 Garnet/Redis;如果服务没有身份认证和 key 级授权,它可以枚举或修改其他 chat/Task 队列、伪造结果、删除 key,甚至执行服务支持的管理命令。 +- Sandbox 可以扫描和攻击其他 localhost 服务、LAN 服务和数据库端口。 +- Sandbox 可以把已授权 workspace 内容外传到公网。 +- 网络服务或协议解析器会进入 TCB;文件 ACL 和 AppContainer 不能修复一个向低权限 caller 暴露高权限操作的服务。 + +因此第一阶段仍应至少保证 Garnet 只监听 loopback、不暴露到 LAN;不要把 Bot token、数据库密码或其他凭据通过 Redis payload、环境变量或命令行发送给 ToolHost。Redis 未授权访问和跨 chat key 操作必须加入测试,并作为第二阶段 IPC 替换的阻断项。当前开发会话不是 elevated,不能假设 Bot 能直接维护机器级 loopback exemption;安装器或一次性管理员 provisioning helper 必须负责注册 per-chat SID,或者 Bot 明确以具备该权限的受控账户运行。 + +第二阶段再取消 loopback exemption 和 broad network capabilities,改用 package-SID ACL 的 named/anonymous pipe 或继承 HANDLE IPC。若以后确需公网: + +1. 只授予 `internetClient`,不授予 `privateNetworkClientServer` 或 server capability。 +2. 不添加 loopback exemption。 +3. 用 WFP 在 ALE connect/receive-accept v4/v6 层按 `FWPM_CONDITION_ALE_PACKAGE_ID` 绑定 Task package SID。 +4. 显式拒绝 loopback、宿主地址、RFC1918、CGNAT、IPv4 link-local、IPv6 loopback/link-local/ULA、LAN、云 metadata 和数据库端口。 +5. 只在确有需求时放行 DNS/代理;代理本身必须认证 Task 身份。 + +WFP policy 安装通常需要提升权限,应放在极小的系统 broker 中并使用 dynamic session/事务,Task 回收时删除规则。 + +### 3.10 长期运行基础设施与大量 Task + +第一阶段使用 per-chat AppContainer SID、共享 chat workspace 和长期 ToolHost,因此同一 chat 内任务属于同一信任边界。最终目标若需要不互信 Task 隔离,隔离单元才升级为: + +```text +Task = unique AppContainer SID + workspace + temp + Job + IPC + optional WFP filters +``` + +可以复用只读 runtime 安装和 broker 进程;第一阶段不能让不互信 chat 共享 AppContainer SID、可写目录、Job 或 worker 进程。最终 per-Task 模式则不允许不互信 Task 共享这些资源。每个隔离单元至少需要一个进程树;这是原生进程开销,不是 VM 开销。 + +大量 profile 的创建/删除、ACL 和 WFP 更新需要队列化、幂等和崩溃恢复。启动时扫描 orphan profile/workspace/filter/job metadata;profile 名使用不可猜随机 ID,不使用 chat ID 作为唯一安全边界。 + +### 3.11 每 Task 独立与完整销毁 + +推荐销毁顺序: + +1. 停止接收新命令并关闭 broker IPC。 +2. `TerminateJobObject`,确认所有进程退出。 +3. 关闭 target 相关 HANDLE。 +4. 删除 WFP dynamic filters/loopback 配置(正常设计不应有 loopback exemption)。 +5. 以“不跟随 reparse point”的方式删除 workspace/temp/profile data。 +6. `DeleteAppContainerProfile`。 +7. 删除持久化 Task metadata。 + +销毁是资源撤销和最佳努力清理,不是文件内容安全擦除,也不能撤销已发生的网络/外部系统副作用。 + +### 3.12 实际安全边界 + +边界由以下交集组成: + +```text +有效权限 = base user/restricted token + ∩ AppContainer/LPAC package + capabilities + ∩ object DACL + ∩ MIC mandatory policy + ∩ network isolation/WFP + + 已继承/复制的 HANDLE + + 允许访问的 broker/COM/RPC 接口 +``` + +Job Object 负责进程拓扑和资源,不参与文件 ACL 授权。Process mitigation 降低漏洞可利用面,也不负责 workspace 隔离。 + +## 4. 推荐架构 + +```text +TelegramSearchBot (medium IL, owns secrets) + | + | authenticated local IPC; no secret payloads + v +Sandbox Broker / Supervisor (minimal Windows-only component) + | + | create task identity, ACL, job, clean env, pipes + | CREATE_SUSPENDED -> AssignProcessToJobObject -> ResumeThread + v +Task Runner (AppContainer low-box token, no Bot assemblies/config) + | + +-- cmd.exe / powershell.exe / bash.exe + +-- python/node/git/rundll32/any normal child + | + `-- unique task workspace/temp only +``` + +### 4.1 Broker + +职责只包括 policy 编译、launch、stdin/stdout/stderr、Job 监督、超时和清理。它不能提供“读取任意路径”“以 Bot 身份发 HTTP”“执行任意 COM”等万能代理。 + +首版可以在 Bot 进程内实现 Windows-only launcher,但安全成熟后应考虑独立 broker:减少 Bot 巨大依赖图成为 target 可攻击 IPC 面的概率。若引入 LocalSystem/WFP 服务,应再拆分为极小 provisioning service;不要让 LocalSystem broker 暴露通用文件或进程 API。 + +### 4.2 Task Runner + +Runner 应是新建的小项目,不能引用 `TelegramSearchBot.Common.Env`。协议只需要: + +- 接收 command、cwd、timeout。 +- 启动 shell 并流式返回 stdout/stderr/exit code。 +- 响应取消。 +- 不持有 Bot token、Redis credential、数据库连接或 HTTP client credential。 + +如果每个命令都由 broker 直接启动 shell,可以不要 runner;若要长期会话、PTY 或多命令状态,才保留 runner。 + +### 4.3 身份选择 + +推荐分阶段部署: + +1. **阶段 1:目录访问隔离**。普通 AppContainer + per-chat package SID + Job Object;只给指定 workspace/输入目录配置 ACL,保留 Redis、心跳、队列和网络。验收重点是未授权目录访问被 Windows 拒绝。 +2. **阶段 2:IPC 与网络隔离**。改为 package-SID ACL 的本地 pipe/HANDLE IPC,取消 loopback exemption,默认不给网络 capability。 +3. **阶段 3:更严格模式**。按需评估 per-Task SID、LPAC 和 WFP。LPAC 仅用于已验证兼容的 CMD/原生工具;本机 Windows PowerShell 5.1 在 LPAC ETW 初始化失败。 +4. **独立低权限本地账户 + AppContainer**:需要进一步隔离 Bot 用户资源时采用,运维成本较高。 +5. **Restricted Token + Low/Untrusted IL + ACL**:仅兼容性回退,不应声称与 AppContainer 等价。 + +不推荐依赖未文档化的 `NtCreateLowBoxToken`、Job silo/server silo API。Windows container silo 没有稳定公开的通用 user-mode 创建 API。 + +## 5. 各机制的职责 + +| 机制 | 负责 | 不负责/注意 | +|---|---|---| +| Restricted Token | 删除 privilege、禁用管理员/用户组、限制 DACL 授权 | 不自动阻止读取用户本来可读的资源;不是网络 sandbox | +| AppContainer | package identity、capability/default-deny、进程/凭据/网络隔离 | 兼容性依赖;允许的 broker 仍是攻击面 | +| LPAC | 移除普通 AppContainer 对 `ALL APPLICATION PACKAGES` 的 ambient access | runtime/registry/COM ACL 配置更繁琐 | +| NTFS ACL | 精确授权 workspace、runtime、pipe、registry | 只对 securable object 有效;broker 必须防 reparse/TOCTOU | +| MIC/Low IL | 主要阻止 write-up、降低 UI interaction | 默认通常不阻止 read-up,不能单独保护秘密 | +| Job Object | 子进程树、kill、CPU、内存、数量、UI restrictions | 不限制文件、registry、network;brokered/WMI process 可能不在 Job | +| HANDLE allowlist | 防止把 Bot 已打开资源直接交给 target | 一个泄漏 handle 就可能绕过按名称 ACL | +| WFP/AppContainer network | 限制公网、LAN、localhost、入站/出站 | WFP 管理需高权限;规则必须同时覆盖 IPv4/IPv6 | +| Process mitigations | 降低 win32k、DLL、JIT、extension-point 攻击面 | ACG/CIG/Win32k lockdown 会破坏大量 shell/runtime | +| Alternate desktop/window station | 防窗口消息、hooks、clipboard/UI 攻击 | headless + Win32k lockdown 时价值降低;不是文件边界 | +| Minimal broker | 提供经过验证的少量特权操作 | broker policy/解析错误会成为直接逃逸 | + +## 6. C#/.NET 所需关键 API + +### 6.1 Profile、SID、token + +- `CreateAppContainerProfile` +- `DeriveAppContainerSidFromAppContainerName` +- `DeleteAppContainerProfile` +- `DeriveCapabilitySidsFromName` +- `OpenProcessToken` +- `CreateRestrictedToken` +- `DuplicateTokenEx` +- `SetTokenInformation(TokenIntegrityLevel, ...)` +- `GetTokenInformation(TokenIsAppContainer/TokenAppContainerSid/TokenCapabilities/TokenIsLessPrivilegedAppContainer)` +- `CreateEnvironmentBlock` / `DestroyEnvironmentBlock`,或完全手工构造 allowlist Unicode environment block + +### 6.2 启动与 HANDLE + +- `InitializeProcThreadAttributeList` +- `UpdateProcThreadAttribute` +- `DeleteProcThreadAttributeList` +- `CreateProcessAsUserW` +- `CreatePipe` / `CreateNamedPipeW` +- `SetHandleInformation` +- `ResumeThread` + +需要的 attributes: + +- `PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES` +- `PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY`(LPAC) +- `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` +- `PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY` + +以及: + +- `SECURITY_CAPABILITIES` +- `STARTUPINFOEXW` +- `EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED` + +### 6.3 Job + +- `CreateJobObjectW` +- `SetInformationJobObject` +- `AssignProcessToJobObject` +- `QueryInformationJobObject` +- `TerminateJobObject` +- `CreateIoCompletionPort` / `GetQueuedCompletionStatus` + +结构至少包括: + +- `JOBOBJECT_EXTENDED_LIMIT_INFORMATION` +- `JOBOBJECT_CPU_RATE_CONTROL_INFORMATION` +- `JOBOBJECT_ASSOCIATE_COMPLETION_PORT` +- 可兼容时的 `JOBOBJECT_BASIC_UI_RESTRICTIONS` + +### 6.4 ACL/MIC + +- `GetNamedSecurityInfoW` / `SetNamedSecurityInfoW` +- `SetEntriesInAclW` +- `ConvertStringSecurityDescriptorToSecurityDescriptorW` +- `SetSecurityInfo(..., LABEL_SECURITY_INFORMATION, ...)` +- 文件 handle 查询:`GetFinalPathNameByHandleW`、`GetFileInformationByHandleEx` + +.NET 可用 `FileSystemAclExtensions`/`DirectorySecurity` 封装常规 DACL,但 AppContainer SID、mandatory label、WFP 和 `STARTUPINFOEX` 仍需 P/Invoke。所有 native HANDLE/SID/ACL 内存都应有 `SafeHandle`/RAII 包装。 + +### 6.5 Network/WFP + +- `FwpmEngineOpen0` / `FwpmEngineClose0` +- `FwpmTransactionBegin0` / commit/abort +- `FwpmProviderAdd0` +- `FwpmSubLayerAdd0` +- `FwpmFilterAdd0` +- ALE connect/receive-accept v4/v6 layers +- `FWPM_CONDITION_ALE_PACKAGE_ID` + +### 6.6 实验性接口 + +- `LoadLibraryExW("processmodel.dll", ..., LOAD_LIBRARY_SEARCH_SYSTEM32)` +- `GetProcAddress("Experimental_CreateProcessInSandbox")` +- `GetProcAddress("Experimental_CreateProcessAsUserInSandbox")` +- FlatBuffers `SandboxSpec.fbs` / `SBOX` blob + +建议第一版稳定 API 用单独 Windows-only C# library + CsWin32/手写 `LibraryImport`;若 marshalling 和 ACL/WFP 代码变得复杂,改为小型 C++ launcher/helper 比在 Bot 内堆大量不安全 P/Invoke 更容易审计。 + +## 7. 当前项目的具体影响 + +现有实现可作为兼容性基线,但不是本文目标边界: + +- `SandboxieToolHostService` 是 Sandboxie-Plus driver/virtualization 方案,不是只依赖公开 AppContainer/ACL API。 +- box 当前按 chat 而非 Task 隔离,且配置含 `NeverDelete=y`。 +- `SandboxieDenyHostFileSystem` 默认值为 `false`;仅关闭若干敏感路径不能证明“其余宿主文件默认不可读”。 +- ToolHost 启动当前 TelegramSearchBot 可执行文件,`SandboxToolConsumer` 使用完整 DI scope。 +- ToolHost 通过 `127.0.0.1` Garnet/Redis 队列通信;这与“不开放 localhost”的目标冲突。 +- `BashToolService` 默认 working directory 是 `Env.WorkDir`,新路径必须强制为 Task workspace。 +- 现有 `AppBootstrap.ChildProcessManager` 已有 Job 封装,但进程先启动后入 Job,且只设置 kill-on-close 和 per-process memory,不足以启动敌对代码。 + +因此建议新建独立接口,例如: + +```text +ISandboxBroker.CreateTaskAsync(policy) +ISandboxTask.ExecuteAsync(command, cwd, timeout) +ISandboxTask.TerminateAsync() +ISandboxTask.DisposeAsync() +``` + +不要在原 `Process.Start` 周围逐项打补丁后宣称完成安全隔离。 + +## 8. 最小 PoC 验证矩阵 + +PoC 不是只验证“命令返回 0”,而要同时验证允许路径、拒绝路径、token、进程树和 IPC/network。 + +### Phase A:稳定 API launcher 与现有 Redis IPC + +1. 创建或复用 per-chat AppContainer profile,打印并断言 package SID。 +2. 启动后查询 token,断言 IL 为 Low、`TokenIsAppContainer=1` 且 package SID 与 profile 一致;Restricted Token 放到后续硬化 PoC。 +3. 为 Task NTFS workspace 配该 chat SID RW 和 Low mandatory label;记录同 chat 任务共享 SID 的过渡期限制。 +4. 清空环境,只传 allowlist;第一阶段仅通过参数传入 chat ID、Redis endpoint、workspace 和父进程标识,不让 ToolHost 读取 Bot `Config.json`。 +5. 用 handle list + suspended launch + 独立 Job 启动测试程序。 +6. 第一阶段授予 `internetClient`、`privateNetworkClientServer`,由提升权限的 provisioning 路径幂等添加当前 per-chat AppContainer SID 的 loopback exemption,验证现有 Redis queue/result/heartbeat 全链路。 +7. 在 target 内枚举 token、privileges、groups、capabilities、IL、Job membership、environment、handles。 +8. 验证 AppContainer profile 删除时同步删除 loopback exemption;模拟崩溃后由启动扫描清理 orphan exemption。 + +### Phase B:shell/runtime 兼容 + +逐项测试: + +- `cmd.exe /d /s /c`: echo、dir、重定向、管道、批处理、后台 child。 +- Windows PowerShell 5.1 `-NoProfile -NonInteractive`:filesystem、pipeline、module import、native child、COM/WMI 失败预期。 +- PowerShell 7(安装后):同上。 +- Git Bash:`bash -lc`、MSYS pipe、`git status`、`git diff`、child tree、PTY/无 PTY。 +- Python、Node、git、编译器等实际 Task 依赖。 + +每个 runtime 记录为启动所增加的 ACL/capability;若必须增加 broad capability,重新做安全评估。 + +### Phase C:必须拒绝的资源 + +从每种 shell 和 native test EXE 尝试: + +- 读/写 Bot `Config.json`、`Data.sqlite`、logs、进程可执行目录中的敏感测试文件。 +- 读用户 `.ssh`、`.aws`、`.azure`、浏览器 profile、Credential Manager/DPAPI test secret。 +- 读写另一 Task workspace/temp/profile。 +- 枚举/打开 Bot process、token、memory、named objects。 +- 访问 HKCU/HKLM 敏感测试 key。 +- 连接 Bot/Garnet、本机数据库、Docker pipe、SSH agent pipe。 +- 连接 `127.0.0.1`、`::1`、宿主 LAN IP、RFC1918、IPv6 ULA、公网。 + +预期拒绝必须由 Win32 error/token/WFP trace 证明,而不是仅靠 shell 文本。 + +### Phase D:绕过与 confused deputy + +必须覆盖: + +- `cmd -> powershell -> python/node -> child` 多层继承。 +- `rundll32`、`mshta`、`regsvr32`、WMI `Win32_Process.Create`、Task Scheduler、Shell COM、BITS。 +- named pipe 猜测/枚举、Redis/Garnet 未认证访问。 +- junction、symlink、hardlink、UNC、device path、ADS、8.3 path、case/normalization。 +- 继承/重复 process、token、file、socket、section、pipe handle。 +- 尝试 `CREATE_BREAKAWAY_FROM_JOB`、nested job、orphan/daemon。 +- target 在 broker 清理期间并发替换目录为 reparse point。 + +如果授予 `lpacCom` 后 WMI/COM 能产生非 AppContainer token 或越过 workspace 的副作用,LPAC shell 路线应判定为不满足目标,而不是增加命令黑名单。 + +### Phase E:配额与清理 + +- fork bomb 命中 active-process limit。 +- 单进程/多进程内存命中 aggregate Job limit。 +- CPU hard cap、wall timeout、取消和 Bot 崩溃后 kill-on-close。 +- 大量 stdout/stderr 不撑爆 Bot 内存。 +- 大文件/小文件耗尽磁盘场景。 +- 强制结束后无存活 PID、无 WFP filter、无 profile、无可访问 workspace;重启 broker 可清理 orphan metadata。 + +### Phase F:实验性 API 对照 + +在本机对 `processmodel.dll` 做 `GetProcAddress`,用相同测试矩阵比较声明式 `fs_read_only/fs_read_write/network_policy` 与手工 API。任何 symbol/schema/OS build 不匹配都必须安全失败并回退稳定实现,不能无 sandbox 启动。 + +## 9. 上线门槛 + +只有同时满足以下条件才应称为“Windows 原生 Sandbox”: + +- 所有目标 shell/runtime 通过正向兼容测试。 +- 负向文件、registry、环境、HANDLE、IPC、localhost/LAN、跨 Task 测试全部由内核拒绝。 +- 任意正常子进程保持 package SID/restricted token/Job。 +- `lpacCom`/`registryRead` 增权经过单独攻击面测试。 +- broker 不加载 Bot secrets,IPC 是每 Task 认证和 ACL 隔离的。 +- 崩溃恢复与回收测试通过。 +- Windows build/runtime/ACL 前置条件在启动时验证,失败时 fail closed。 + +若 PowerShell/Git Bash 为正常运行必须开放能产生高权限 broker 副作用的 COM/RPC/capability,则应直接判定该 shell 在此安全级别下不受支持,或改用 Sandboxie/VM 级方案;不能用命令过滤掩盖边界缺口。 + +## 10. 参考资料 + +Microsoft 官方: + +- [Create Process In Sandbox APIs](https://learn.microsoft.com/en-us/windows/win32/secauthz/createprocessinsandbox) +- [Launch an AppContainer](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer) +- [AppContainer isolation](https://learn.microsoft.com/en-us/windows/win32/secauthz/appcontainer-isolation) +- [Restricted Tokens](https://learn.microsoft.com/en-us/windows/win32/secauthz/restricted-tokens) +- [Mandatory Integrity Control](https://learn.microsoft.com/en-us/windows/win32/secauthz/mandatory-integrity-control) +- [Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects) +- [UpdateProcThreadAttribute](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute) +- [Create processes / handle inheritance](https://learn.microsoft.com/en-us/windows/win32/procthread/creating-processes) +- [Named Pipe Security and Access Rights](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights) +- [Windows application IPC](https://learn.microsoft.com/en-us/windows/apps/develop/communication/interprocess-communication) + +参考实现: + +- [Microsoft SandboxSecurityTools / LaunchAppContainer](https://github.com/microsoft/SandboxSecurityTools/tree/main/LaunchAppContainer) +- [Chromium Windows sandbox design](https://chromium.googlesource.com/chromium/src/+/HEAD/docs/design/sandbox.md) +- [Sandboxie-Plus](https://github.com/sandboxie-plus/Sandboxie) + +证据等级说明:Microsoft Learn 的安全模型/API 语义作为主要依据;Chromium 用于组合架构与工程经验;SandboxSecurityTools 用于证明普通 Win32/CMD 的启动方式;PowerShell/Git Bash 的完整兼容与所有 broker 绕过结论必须由目标 Windows build 上的 PoC 得出。 diff --git a/README.md b/README.md index f0c4e490..d458fb8f 100644 --- a/README.md +++ b/README.md @@ -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": "", @@ -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`: 可选的额外每群文件根目录;为空时不开放。配置后,每个群只读开放 `/`。 - - 程序默认会关闭聊天资源父目录 `Photos`、`Audios`、`Videos`、`Files`,再仅为当前群的既有聊天媒体/文件目录生成只读授权:`Photos/`、`Audios/`、`Videos/`、`Files/`。其他群的资源目录默认不可读。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`: 为兼容旧配置保留。配置后 `/` 会授予当前群 AppContainer SID 真实读写权限,并作为相对路径和 shell 的默认工作目录。 + - 默认读写授权当前群的 `Photos/`、`Audios/`、`Videos/`、`Files/`;其他群目录及 `Config.json`、`Data.sqlite`、日志和索引不授权。与 Sandboxie 虚拟化不同,写入授权目录会直接修改真实文件。 + - `SandboxieGlobalReadPaths`: 为兼容旧配置保留的额外全局只读路径。程序安装目录也只授予读取/执行权限。 + - `SandboxieToolHostStartupTimeoutSeconds`: 启动 AppContainer ToolHost 后等待 Redis 心跳的超时,默认15秒。 + - `SandboxieToolTimeoutSeconds`: 沙箱工具调用等待超时,默认120秒。 + - 第一阶段保留 localhost Redis IPC。AppContainer profile 首次使用前需要管理员执行日志提示的 `CheckNetIsolation.exe LoopbackExempt -a -p=`;未配置时程序会 fail closed,不会回退到非沙箱执行。网络暂不属于本阶段安全边界。旧 `EnableLlmSandboxie=true` 仍会启用原生沙箱,便于平滑升级,但其他 Sandboxie Plus 配置已不再使用。 启用 `EnableLLMAgentProcess=true` 后,主进程会负责任务排队、Telegram 发消息和流式转发;独立 Agent 进程负责执行 LLM 循环、本地工具和故障恢复。主进程会在 Agent 心跳超时、任务超时或配置切换时执行恢复、重试、死信投递和优雅停机。 diff --git a/TelegramSearchBot.Common/Env.cs b/TelegramSearchBot.Common/Env.cs index 964efc95..8492a8fa 100644 --- a/TelegramSearchBot.Common/Env.cs +++ b/TelegramSearchBot.Common/Env.cs @@ -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(); - SandboxieGlobalClosedPaths = config.SandboxieGlobalClosedPaths ?? new List(); - 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(); CodingAgentDeniedPathPrefixes = ResolveCodingAgentDeniedPathPrefixes(config.CodingAgentDeniedPathPrefixes); @@ -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 SandboxieGlobalReadPaths { get; set; } = new List(); - public static List SandboxieGlobalClosedPaths { get; set; } = new List(); - 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 CodingAgentAllowedGroupIds { get; set; } = new List(); public static List CodingAgentDeniedPathPrefixes { get; set; } = new List(); @@ -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 SandboxieGlobalReadPaths { get; set; } = new List(); - public List SandboxieGlobalClosedPaths { get; set; } = new List(); - 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 CodingAgentAllowedGroupIds { get; set; } = new List(); public List CodingAgentDeniedPathPrefixes { get; set; } = new List(); diff --git a/TelegramSearchBot.Common/Model/ToolContext.cs b/TelegramSearchBot.Common/Model/ToolContext.cs index 126de733..46d155ea 100644 --- a/TelegramSearchBot.Common/Model/ToolContext.cs +++ b/TelegramSearchBot.Common/Model/ToolContext.cs @@ -15,8 +15,18 @@ public class ToolContext { public bool IsSandboxed { get; set; } /// - /// Optional sandbox box name used for diagnostics and routing. + /// Optional sandbox profile name used for diagnostics and routing. /// public string SandboxBoxName { get; set; } = string.Empty; + + /// + /// Default directory for relative file paths and shell commands inside the sandbox. + /// + public string SandboxWorkingDirectory { get; set; } = string.Empty; + + /// + /// Cancels work when the sandbox host stops or the current tool call times out. + /// + public System.Threading.CancellationToken CancellationToken { get; set; } } } diff --git a/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs index 3aa909b5..46c0f6d2 100644 --- a/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/Tools/BashToolServiceTests.cs @@ -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 }; diff --git a/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs b/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs index 40c269bc..7053a9e6 100644 --- a/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs +++ b/TelegramSearchBot.LLM.Test/Service/Tools/FileToolServiceTests.cs @@ -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"); diff --git a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs index 9f8faf9b..f68ee79b 100644 --- a/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/BashToolService.cs @@ -84,11 +84,11 @@ internal static string FindExecutableOnPath(string fileName) { 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("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."; } @@ -99,7 +99,10 @@ public async Task 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); if (!Directory.Exists(workDir)) { return $"Error: Working directory '{workDir}' does not exist."; } @@ -150,7 +153,8 @@ public async Task 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) { @@ -160,6 +164,10 @@ public async Task 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]"; diff --git a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs index a22dd959..43cdcb42 100644 --- a/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/FileToolService.cs @@ -44,7 +44,7 @@ public async Task ReadFile( } try { - path = ResolvePath(path); + path = ResolvePath(path, toolContext); if (!File.Exists(path)) { return $"Error: File not found: {path}"; @@ -94,7 +94,7 @@ public async Task WriteFile( } try { - path = ResolvePath(path); + path = ResolvePath(path, toolContext); var directory = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { @@ -122,7 +122,7 @@ public async Task EditFile( } try { - path = ResolvePath(path); + path = ResolvePath(path, toolContext); if (!File.Exists(path)) { return $"Error: File not found: {path}"; @@ -162,7 +162,7 @@ public async Task EditFile( public async Task SearchText( [BuiltInParameter("Regex pattern to search for")] string pattern, ToolContext toolContext, - [BuiltInParameter("Directory to search in. Defaults to bot work directory.", IsRequired = false)] string path = null, + [BuiltInParameter("Directory to search in. Defaults to the sandbox working directory for sandboxed calls, otherwise the bot work directory.", IsRequired = false)] string path = null, [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) { @@ -171,7 +171,7 @@ public async Task SearchText( } try { - path = ResolvePath(path ?? Env.WorkDir); + path = ResolvePath(path, toolContext); if (!Directory.Exists(path)) { return $"Error: Directory not found: {path}"; @@ -234,7 +234,7 @@ public async Task SearchText( [BuiltInTool("List files and directories at a given path. Supports glob patterns.")] public async Task ListFiles( ToolContext toolContext, - [BuiltInParameter("Directory path to list. Defaults to bot work directory.", IsRequired = false)] string path = null, + [BuiltInParameter("Directory path to list. Defaults to the sandbox working directory for sandboxed calls, otherwise the 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 (!IsFileToolAllowed(toolContext)) { @@ -242,7 +242,7 @@ public async Task ListFiles( } try { - path = ResolvePath(path ?? Env.WorkDir); + path = ResolvePath(path, toolContext); if (!Directory.Exists(path)) { return $"Error: Directory not found: {path}"; @@ -274,15 +274,18 @@ public async Task ListFiles( } private static bool IsFileToolAllowed(ToolContext toolContext) { - return toolContext != null && ( toolContext.UserId == Env.AdminId || toolContext.IsSandboxed ); + return toolContext != null && ( toolContext.IsSandboxed || toolContext.UserId == Env.AdminId ); } - private static string ResolvePath(string path) { + private static string ResolvePath(string? path, ToolContext toolContext) { + var basePath = toolContext is { IsSandboxed: true } && !string.IsNullOrWhiteSpace(toolContext.SandboxWorkingDirectory) + ? toolContext.SandboxWorkingDirectory + : Env.WorkDir; if (string.IsNullOrWhiteSpace(path)) { - return Env.WorkDir; + return basePath; } if (!Path.IsPathRooted(path)) { - return Path.GetFullPath(Path.Combine(Env.WorkDir, path)); + return Path.GetFullPath(Path.Combine(basePath, path)); } return Path.GetFullPath(path); } diff --git a/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs b/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs index 57530092..16b21542 100644 --- a/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs +++ b/TelegramSearchBot.LLMAgent/LLMAgentProgram.cs @@ -24,7 +24,7 @@ public static async Task RunAsync(string[] args) { if (effectiveArgs.Length != 2 || !long.TryParse(effectiveArgs[0], out var chatId) || !int.TryParse(effectiveArgs[1], out var port)) { - Console.Error.WriteLine("Usage: LLMAgent | SandboxToolHost "); + Console.Error.WriteLine("Usage: LLMAgent | SandboxToolHost "); Environment.ExitCode = 1; return; } @@ -42,17 +42,18 @@ public static async Task RunAsync(string[] args) { } private static async Task RunSandboxToolHostAsync(string[] args) { - if (args.Length != 5 || + if (args.Length != 6 || !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 "); + !int.TryParse(args[5], out var toolTimeoutSeconds)) { + Console.Error.WriteLine("Usage: SandboxToolHost "); Environment.ExitCode = 1; return; } - var boxName = args[2]; + var profileName = args[2]; + var workingDirectory = args[4]; using var services = BuildServices(port); var logger = services.GetRequiredService().CreateLogger("SandboxToolHost"); McpToolHelper.EnsureInitialized( @@ -61,7 +62,7 @@ private static async Task RunSandboxToolHostAsync(string[] args) { using var shutdownCts = CreateShutdownTokenSource(); var consumer = services.GetRequiredService(); - await consumer.RunAsync(chatId, boxName, parentProcessId, parentStartTicksUtc, shutdownCts.Token); + await consumer.RunAsync(chatId, profileName, parentProcessId, workingDirectory, toolTimeoutSeconds, shutdownCts.Token); } private static CancellationTokenSource CreateShutdownTokenSource() { diff --git a/TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs b/TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs index c5fe303f..5bdd7b08 100644 --- a/TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs +++ b/TelegramSearchBot.LLMAgent/Service/SandboxToolConsumer.cs @@ -9,8 +9,8 @@ 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. + /// Runs inside a Windows AppContainer and executes dangerous local tools on behalf of the main process. + /// File/process access is enforced by the AppContainer token and NTFS ACLs; Redis remains the phase-one IPC. /// public sealed class SandboxToolConsumer { private readonly IConnectionMultiplexer _redis; @@ -23,7 +23,7 @@ public SandboxToolConsumer(IConnectionMultiplexer redis, IServiceScopeFactory sc _logger = logger; } - public async Task RunAsync(long chatId, string boxName, int parentProcessId, long parentStartTicksUtc, CancellationToken cancellationToken) { + public async Task RunAsync(long chatId, string profileName, int parentProcessId, string workingDirectory, int toolTimeoutSeconds, CancellationToken cancellationToken) { using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); void OnConnectionFailed(object? sender, ConnectionFailedEventArgs args) { _logger.LogWarning( @@ -34,14 +34,13 @@ void OnConnectionFailed(object? sender, ConnectionFailedEventArgs args) { } _redis.ConnectionFailed += OnConnectionFailed; - var watchdogTask = RunParentWatchdogAsync(parentProcessId, parentStartTicksUtc, linkedCts); - var heartbeatTask = RunHeartbeatAsync(chatId, boxName, parentProcessId, linkedCts.Token); + var heartbeatTask = RunHeartbeatAsync(chatId, profileName, parentProcessId, 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, + profileName, queueKey, parentProcessId); @@ -67,11 +66,16 @@ void OnConnectionFailed(object? sender, ConnectionFailedEventArgs args) { continue; } - var response = await ExecuteAsync(task, boxName, linkedCts.Token); + var response = await ExecuteWithTimeoutAsync( + task, + profileName, + workingDirectory, + toolTimeoutSeconds, + linkedCts.Token); await db.StringSetAsync( LlmAgentRedisKeys.SandboxToolResult(task.RequestId), JsonConvert.SerializeObject(response), - TimeSpan.FromSeconds(Math.Max(Env.SandboxieToolTimeoutSeconds * 2, 60))); + TimeSpan.FromSeconds(Math.Max(toolTimeoutSeconds * 2, 60))); } catch (OperationCanceledException) { break; } catch (RedisConnectionException ex) { @@ -84,7 +88,7 @@ await db.StringSetAsync( } try { - await Task.WhenAll(watchdogTask, heartbeatTask); + await heartbeatTask; } catch (OperationCanceledException) { } } finally { @@ -110,34 +114,32 @@ await db.StringSetAsync( } } - 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) { + private async Task ExecuteWithTimeoutAsync( + SandboxToolTask task, + string profileName, + string workingDirectory, + int toolTimeoutSeconds, + CancellationToken hostCancellationToken) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(hostCancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Max(5, toolTimeoutSeconds))); try { - using var process = System.Diagnostics.Process.GetProcessById(parentProcessId); - if (process.HasExited) { - return false; - } - - return process.StartTime.ToUniversalTime().Ticks == parentStartTicksUtc; - } catch { - return false; + return await ExecuteAsync(task, profileName, workingDirectory, timeoutCts.Token) + .WaitAsync(timeoutCts.Token); + } catch (OperationCanceledException) when (!hostCancellationToken.IsCancellationRequested) { + _logger.LogWarning( + "Sandbox tool timed out. Tool={ToolName}, RequestId={RequestId}, TimeoutSeconds={TimeoutSeconds}", + task.ToolName, + task.RequestId, + toolTimeoutSeconds); + return new SandboxToolResult { + RequestId = task.RequestId, + Success = false, + ErrorMessage = $"Sandbox tool timed out after {Math.Max(5, toolTimeoutSeconds)} seconds." + }; } } - private async Task ExecuteAsync(SandboxToolTask task, string boxName, CancellationToken cancellationToken) { + private async Task ExecuteAsync(SandboxToolTask task, string profileName, string workingDirectory, CancellationToken cancellationToken) { var response = new SandboxToolResult { RequestId = task.RequestId }; try { if (task.ChatId == 0) { @@ -150,7 +152,9 @@ private async Task ExecuteAsync(SandboxToolTask task, string UserId = task.UserId, MessageId = task.MessageId, IsSandboxed = true, - SandboxBoxName = boxName + SandboxBoxName = profileName, + SandboxWorkingDirectory = workingDirectory, + CancellationToken = cancellationToken }; var result = await McpToolHelper.ExecuteRegisteredToolAsync( diff --git a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs deleted file mode 100644 index 78d0ded0..00000000 --- a/TelegramSearchBot.Test/Service/AI/LLM/SandboxieToolHostServiceTests.cs +++ /dev/null @@ -1,161 +0,0 @@ -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 BuildBoxName_PreservesUnderscores() { - var boxName = SandboxieToolHostService.BuildBoxName(12345L, "TGSB_G_"); - - Assert.StartsWith("TGSB_G_", boxName, StringComparison.Ordinal); - Assert.Equal(19, boxName.Length); - } - - [Theory] - [InlineData("TGSB-G-")] - [InlineData("TGSB G ")] - [InlineData("沙箱_")] - public void BuildBoxName_RejectsInvalidPrefixes(string prefix) { - Assert.Throws(() => SandboxieToolHostService.BuildBoxName(12345L, prefix)); - } - - [Fact] - public void BuildBoxName_EnforcesSandboxieLengthLimit() { - Assert.Equal(38, SandboxieToolHostService.BuildBoxName(12345L, new string('A', 26)).Length); - Assert.Throws(() => - SandboxieToolHostService.BuildBoxName(12345L, new string('A', 27))); - } - - [Fact] - public void BuildPortableBoxIni_AllowsOnlyCurrentChatResourceDirectories() { - var originalGroupFilesRoot = Env.SandboxieGroupFilesRoot; - var originalDenyHostFileSystem = Env.SandboxieDenyHostFileSystem; - Env.SandboxieGroupFilesRoot = string.Empty; - Env.SandboxieDenyHostFileSystem = false; - - 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); - 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(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"); - if (Directory.Exists(indexDataDir)) { - Assert.Contains($"ClosedFilePath={Normalize(indexDataDir)}", 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; - } - } - - [Fact] - public void EnsureBoxesDirectory_CreatesMissingDirectory() { - var testDir = Path.Combine(Path.GetTempPath(), "TGSB_SandboxieBoxes_" + Guid.NewGuid().ToString("N")); - try { - Assert.False(Directory.Exists(testDir)); - - SandboxieToolHostService.EnsureBoxesDirectory(testDir); - - Assert.True(Directory.Exists(testDir)); - } finally { - if (Directory.Exists(testDir)) { - Directory.Delete(testDir, recursive: true); - } - } - } - - [Fact] - public void GetDefaultWorkDirClosedPaths_DoesNotCloseAllowedAppDirectory() { - var appDir = Path.Combine(Env.WorkDir, "app"); - var otherDir = Path.Combine(Env.WorkDir, "scratch"); - Directory.CreateDirectory(appDir); - Directory.CreateDirectory(otherDir); - - try { - var closedPaths = SandboxieToolHostService.GetDefaultWorkDirClosedPaths(new[] { appDir }).ToList(); - - Assert.DoesNotContain(Normalize(appDir), closedPaths, StringComparer.OrdinalIgnoreCase); - Assert.Contains(Normalize(otherDir), closedPaths, StringComparer.OrdinalIgnoreCase); - } finally { - if (Directory.Exists(otherDir)) { - Directory.Delete(otherDir, recursive: true); - } - } - } - - private static string BuildIni(long chatId) { - var instance = new SandboxieInstance( - chatId, - "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.Test/Service/AI/LLM/WindowsAppContainerToolHostServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/WindowsAppContainerToolHostServiceTests.cs new file mode 100644 index 00000000..19f56b29 --- /dev/null +++ b/TelegramSearchBot.Test/Service/AI/LLM/WindowsAppContainerToolHostServiceTests.cs @@ -0,0 +1,168 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Principal; +using System.Runtime.Versioning; +using System.Threading.Tasks; +using TelegramSearchBot.Common; +using TelegramSearchBot.Service.AI.LLM; +using Xunit; + +namespace TelegramSearchBot.Test.Service.AI.LLM; + +[SupportedOSPlatform("windows")] +[Collection("AgentEnvSerial")] +public class WindowsAppContainerToolHostServiceTests { + [Fact] + public void BuildProfileName_IsStableAndValid() { + var first = WindowsAppContainerToolHostService.BuildProfileName(12345, "TelegramSearchBot.Chat."); + var second = WindowsAppContainerToolHostService.BuildProfileName(12345, "TelegramSearchBot.Chat."); + + Assert.Equal(first, second); + Assert.StartsWith("TelegramSearchBot.Chat.", first, StringComparison.Ordinal); + Assert.All(first, character => Assert.True(char.IsAsciiLetterOrDigit(character) || character is '.' or '-' or '_')); + } + + [Theory] + [InlineData("bad prefix ")] + [InlineData("沙箱.")] + public void BuildProfileName_RejectsInvalidPrefix(string prefix) { + Assert.Throws(() => + WindowsAppContainerToolHostService.BuildProfileName(12345, prefix)); + } + + [Fact] + public void BuildPathPolicy_ReusesOriginalSandboxiePathSet() { + var originalGroupRoot = Env.SandboxieGroupFilesRoot; + var originalReadPaths = Env.SandboxieGlobalReadPaths; + var customRoot = Path.Combine(Path.GetTempPath(), "TGSB_GroupFiles_" + Guid.NewGuid().ToString("N")); + var globalRead = Path.Combine(Path.GetTempPath(), "TGSB_GlobalRead_" + Guid.NewGuid().ToString("N")); + Env.SandboxieGroupFilesRoot = customRoot; + Env.SandboxieGlobalReadPaths = [globalRead]; + try { + var chatId = 67890L; + var policy = WindowsAppContainerToolHostService.BuildPathPolicy(chatId); + + Assert.Contains(Path.GetFullPath(AppContext.BaseDirectory).TrimEnd(Path.DirectorySeparatorChar), policy.ReadOnlyPaths, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Path.GetFullPath(globalRead), policy.ReadOnlyPaths, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Path.Combine(customRoot, chatId.ToString()), policy.WritablePaths, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Path.Combine(Env.WorkDir, "Photos", chatId.ToString()), policy.WritablePaths, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Path.Combine(Env.WorkDir, "Audios", chatId.ToString()), policy.WritablePaths, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Path.Combine(Env.WorkDir, "Videos", chatId.ToString()), policy.WritablePaths, StringComparer.OrdinalIgnoreCase); + Assert.Contains(Path.Combine(Env.WorkDir, "Files", chatId.ToString()), policy.WritablePaths, StringComparer.OrdinalIgnoreCase); + Assert.Equal(Path.Combine(customRoot, chatId.ToString()), policy.DefaultWorkingDirectory, ignoreCase: true); + Assert.DoesNotContain(Path.Combine(customRoot, "111"), policy.WritablePaths, StringComparer.OrdinalIgnoreCase); + } finally { + Env.SandboxieGroupFilesRoot = originalGroupRoot; + Env.SandboxieGlobalReadPaths = originalReadPaths; + } + } + + [Fact] + public void BuildPathPolicy_UsesChatFilesAsDefaultWhenGroupRootIsEmpty() { + var originalGroupRoot = Env.SandboxieGroupFilesRoot; + Env.SandboxieGroupFilesRoot = string.Empty; + try { + var policy = WindowsAppContainerToolHostService.BuildPathPolicy(24680); + Assert.Equal(Path.Combine(Env.WorkDir, "Files", "24680"), policy.DefaultWorkingDirectory, ignoreCase: true); + } finally { + Env.SandboxieGroupFilesRoot = originalGroupRoot; + } + } + + [Theory] + [InlineData("plain", "plain")] + [InlineData("two words", "\"two words\"")] + [InlineData("", "\"\"")] + [InlineData("quote\"here", "\"quote\\\"here\"")] + public void QuoteArgument_UsesWindowsCommandLineRules(string value, string expected) { + Assert.Equal(expected, WindowsAppContainerNative.QuoteArgument(value)); + } + + [Fact] + public async Task AppContainer_CanLoadTelegramSearchBotWithoutBotWorkDirectoryAccess() { + if (!OperatingSystem.IsWindows() || + string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase)) { + return; + } + + var executable = Path.Combine(AppContext.BaseDirectory, "TelegramSearchBot.exe"); + if (!File.Exists(executable)) return; + var profile = "TelegramSearchBot.Test." + Guid.NewGuid().ToString("N"); + WindowsAppContainerNative.AppContainerProcess? process = null; + try { + var sid = WindowsAppContainerNative.EnsureProfile(profile, profile); + WindowsAppContainerNative.GrantReadOnlyDirectory(AppContext.BaseDirectory, sid); + process = WindowsAppContainerNative.Start( + sid, + executable, + ["SandboxToolHost"], + AppContext.BaseDirectory, + [], + 4, + 512L * 1024 * 1024); + + using var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(20)); + await process.Process.WaitForExitAsync(timeout.Token); + + Assert.Equal(1, process.Process.ExitCode); + } finally { + process?.Dispose(); + try { WindowsAppContainerNative.DeleteProfile(profile); } catch { } + } + } + + [Fact] + public async Task AppContainerAcl_AllowsAuthorizedDirectoryAndDeniesSibling() { + if (!OperatingSystem.IsWindows()) return; + + var root = Path.Combine(Env.WorkDir, "WindowsSandboxTests", Guid.NewGuid().ToString("N")); + var allowed = Path.Combine(root, "allowed"); + var denied = Path.Combine(root, "denied"); + var profile = "TelegramSearchBot.Test." + Guid.NewGuid().ToString("N"); + Directory.CreateDirectory(allowed); + Directory.CreateDirectory(denied); + await File.WriteAllTextAsync(Path.Combine(denied, "secret.txt"), "SECRET"); + WindowsAppContainerNative.AppContainerProcess? process = null; + try { + var sid = WindowsAppContainerNative.EnsureProfile(profile, profile); + WindowsAppContainerNative.GrantWritableDirectory(allowed, sid); + process = WindowsAppContainerNative.Start( + sid, + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + ["/d", "/c", "echo OK>write.txt"], + allowed, + ["internetClient", "privateNetworkClientServer"], + 8, + 256L * 1024 * 1024); + + using var timeout = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(20)); + await process.Process.WaitForExitAsync(timeout.Token); + Assert.True(File.Exists(Path.Combine(allowed, "write.txt")), $"Authorized write failed; cmd exit code was {process.Process.ExitCode}."); + Assert.Equal("OK", (await File.ReadAllTextAsync(Path.Combine(allowed, "write.txt"))).Trim()); + process.Dispose(); + process = null; + + process = WindowsAppContainerNative.Start( + sid, + Path.Combine(Environment.SystemDirectory, "cmd.exe"), + ["/d", "/c", $"type \"{Path.Combine(denied, "secret.txt")}\">secret-copy.txt"], + allowed, + [], + 8, + 256L * 1024 * 1024); + using var secondTimeout = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(20)); + await process.Process.WaitForExitAsync(secondTimeout.Token); + + Assert.Equal(1, process.Process.ExitCode); + Assert.True(File.Exists(Path.Combine(allowed, "secret-copy.txt"))); + Assert.Equal(0, new FileInfo(Path.Combine(allowed, "secret-copy.txt")).Length); + } finally { + process?.Dispose(); + try { WindowsAppContainerNative.RemoveDirectoryRules(root, WindowsAppContainerNative.EnsureProfile(profile, profile)); } catch { } + try { WindowsAppContainerNative.DeleteProfile(profile); } catch { } + try { Directory.Delete(root, recursive: true); } catch { } + } + } +} diff --git a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs index d8461c0f..3a769749 100644 --- a/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs +++ b/TelegramSearchBot/AppBootstrap/GeneralBootstrap.cs @@ -232,9 +232,12 @@ 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."); + if (Env.EnableLlmWindowsSandbox) { + if (!OperatingSystem.IsWindows()) { + throw new PlatformNotSupportedException("EnableLlmWindowsSandbox requires Windows."); + } + RegisterWindowsSandboxTools(service); + Log.Information("Windows AppContainer LLM tool sandbox is enabled."); } // Initialize external MCP tool servers @@ -277,10 +280,11 @@ private static void RegisterExternalMcpTools(IMcpServerManager mcpServerManager) McpToolHelper.RegisterExternalMcpTools(mcpServerManager); } - private static void RegisterSandboxieTools(IServiceProvider services) { - var sandboxService = services.GetRequiredService(); + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static void RegisterWindowsSandboxTools(IServiceProvider services) { + var sandboxService = services.GetRequiredService(); McpToolHelper.RegisterProxyTools( - SandboxieToolHostService.GetToolDefinitions(), + WindowsAppContainerToolHostService.GetToolDefinitions(), async (toolName, arguments) => { long chatId = 0, userId = 0, messageId = 0; if (arguments.TryGetValue("__chatId", out var cid)) { diff --git a/TelegramSearchBot/Program.cs b/TelegramSearchBot/Program.cs index ba76ec6f..97e0647d 100644 --- a/TelegramSearchBot/Program.cs +++ b/TelegramSearchBot/Program.cs @@ -11,6 +11,11 @@ namespace TelegramSearchBot { class Program { static async Task Main(string[] args) { + if (args.Length > 0 && args[0].Equals("SandboxToolHost", StringComparison.OrdinalIgnoreCase)) { + SandboxToolHostBootstrap.Startup(args); + return; + } + // Separate logger for EF Core - writes only to logs/efcore-.txt LoggerHolders.EfCoreLogger = new LoggerConfiguration() .MinimumLevel.Information() diff --git a/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs b/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs deleted file mode 100644 index 106b2df0..00000000 --- a/TelegramSearchBot/Service/AI/LLM/SandboxieToolHostService.cs +++ /dev/null @@ -1,559 +0,0 @@ -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 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); - EnsureBoxesDirectory(instance.BoxesDirectory); - EnsurePortableBoxDefinition(instance); - if (Env.SandboxieAutoRegisterImportBox) { - await EnsureImportBoxDirectiveAsync(instance.BoxesDirectory, cancellationToken); - } - - if (await IsToolHostAliveAsync(instance)) { - return instance; - } - - await ReloadSandboxieConfigurationAsync(cancellationToken); - await EnsureSandboxieBoxLoadedAsync(instance, cancellationToken); - using var launcher = StartToolHost(instance); - await WaitForToolHostStartupAsync(instance, launcher, cancellationToken); - return instance; - } finally { - _lock.Release(); - } - } - - private async Task EnsureImportBoxDirectiveAsync(string boxesDirectory, CancellationToken cancellationToken) { - var importPath = $"{NormalizeSandboxiePath(boxesDirectory)}\\*"; - var directive = $"ImportBox={importPath}"; - var sbieIniExe = Path.Combine(Path.GetDirectoryName(Env.SandboxieStartExe) ?? string.Empty, "SbieIni.exe"); - - if (File.Exists(sbieIniExe)) { - var query = await RunSandboxieCommandAsync( - sbieIniExe, - new[] { "query", "GlobalSettings", "ImportBox" }, - Env.SandboxieCommandTimeoutSeconds, - cancellationToken); - if (query.ExitCode == 0 && query.StandardOutput - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Any(line => string.Equals(line, importPath, StringComparison.OrdinalIgnoreCase) || - string.Equals(line, directive, StringComparison.OrdinalIgnoreCase))) { - return; - } - - var append = await RunSandboxieCommandAsync( - sbieIniExe, - new[] { "append", "/drv", "GlobalSettings", "ImportBox", importPath }, - Env.SandboxieCommandTimeoutSeconds, - cancellationToken); - var verify = await RunSandboxieCommandAsync( - sbieIniExe, - new[] { "query", "GlobalSettings", "ImportBox" }, - Env.SandboxieCommandTimeoutSeconds, - cancellationToken); - if (append.ExitCode != 0 || verify.ExitCode != 0 || !verify.StandardOutput - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Any(line => string.Equals(line, importPath, StringComparison.OrdinalIgnoreCase) || - string.Equals(line, directive, StringComparison.OrdinalIgnoreCase))) { - throw new InvalidOperationException( - $"Sandboxie failed to register the portable box directory. ExitCode={append.ExitCode}, Error={append.StandardError.Trim()}"); - } - - _logger.LogInformation("Registered Sandboxie ImportBox through SbieIni. Directive={Directive}", directive); - return; - } - - var iniPath = Env.SandboxieIniPath; - if (string.IsNullOrWhiteSpace(iniPath) || !File.Exists(iniPath)) { - throw new FileNotFoundException( - "Neither Sandboxie's SbieIni.exe nor the configured Sandboxie.ini was found; the portable box directory cannot be registered automatically.", - iniPath); - } - - var text = await File.ReadAllTextAsync(iniPath, Encoding.Unicode, cancellationToken); - 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); - } - } - - await File.WriteAllTextAsync(iniPath, text, Encoding.Unicode, cancellationToken); - _logger.LogInformation("Added Sandboxie ImportBox directive. Ini={IniPath}, Directive={Directive}", iniPath, directive); - } catch (OperationCanceledException) { - throw; - } catch (Exception ex) { - throw new InvalidOperationException( - $"Failed to register the portable box directory. Add '{directive}' under [GlobalSettings] or grant Sandboxie configuration access.", - ex); - } - } - - internal static string BuildBoxName(long chatId, string prefix) { - var boxName = prefix + ComputeStableHash(chatId.ToString()); - if (boxName.Length is 0 or > 38 || boxName.Any(c => - !(c is >= 'A' and <= 'Z') && - !(c is >= 'a' and <= 'z') && - !(c is >= '0' and <= '9') && - c != '_')) { - throw new InvalidOperationException( - $"Sandboxie box name '{boxName}' is invalid. Sandboxie allows 1-38 ASCII letters, digits, and underscores."); - } - - return boxName; - } - - private static SandboxieInstance BuildInstance(long chatId) { - var boxName = BuildBoxName(chatId, Env.SandboxieBoxPrefix); - var boxesDir = Env.SandboxieBoxImportDirectory; - return new SandboxieInstance( - chatId, - boxName, - boxesDir, - Path.Combine(boxesDir, boxName + ".ini"), - 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) { - EnsureBoxesDirectory(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(); - 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)) - .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)}"); - } - - foreach (var path in GetDefaultWorkDirClosedPaths(defaultReadPaths, globalReadPaths)) { - lines.Add($"ClosedFilePath={path}\\*"); - } - - lines.Add(string.Empty); - return string.Join(Environment.NewLine, lines); - } - - private async Task ReloadSandboxieConfigurationAsync(CancellationToken cancellationToken) { - var startExe = Env.SandboxieStartExe; - if (!File.Exists(startExe)) { - throw new FileNotFoundException("Sandboxie Start.exe was not found. Configure SandboxieStartExe in Config.json.", startExe); - } - - var result = await RunSandboxieCommandAsync( - startExe, - new[] { "/silent", "/reload" }, - Env.SandboxieCommandTimeoutSeconds, - cancellationToken); - if (result.ExitCode != 0) { - throw new InvalidOperationException( - $"Sandboxie configuration reload failed. ExitCode={result.ExitCode}, Error={result.StandardError.Trim()}"); - } - } - - private async Task EnsureSandboxieBoxLoadedAsync(SandboxieInstance instance, CancellationToken cancellationToken) { - var sbieIniExe = Path.Combine(Path.GetDirectoryName(Env.SandboxieStartExe) ?? string.Empty, "SbieIni.exe"); - if (!File.Exists(sbieIniExe)) { - return; - } - - var result = await RunSandboxieCommandAsync( - sbieIniExe, - new[] { "query", "/boxes", "*" }, - Env.SandboxieCommandTimeoutSeconds, - cancellationToken); - var isLoaded = result.ExitCode == 0 && result.StandardOutput - .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Any(line => string.Equals(line, instance.BoxName, StringComparison.OrdinalIgnoreCase)); - if (!isLoaded) { - throw new InvalidOperationException( - $"Sandboxie did not load box '{instance.BoxName}' after configuration reload. Verify ImportBox, the INI filename/section name, and that the box is enabled."); - } - } - - private Process 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("/silent"); - 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."); - _logger.LogInformation("Started Sandboxie tool host launcher. ChatId={ChatId}, Box={BoxName}, LauncherPid={Pid}", instance.ChatId, instance.BoxName, process.Id); - return process; - } - - private async Task WaitForToolHostStartupAsync(SandboxieInstance instance, Process launcher, CancellationToken cancellationToken) { - var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(Env.SandboxieToolHostStartupTimeoutSeconds); - while (DateTime.UtcNow < deadline) { - if (await IsToolHostAliveAsync(instance)) { - return; - } - - if (launcher.HasExited && launcher.ExitCode != 0) { - throw new InvalidOperationException( - $"Sandboxie could not start box '{instance.BoxName}'. Start.exe exited with code {launcher.ExitCode}."); - } - - await Task.Delay(200, cancellationToken); - } - - if (!launcher.HasExited) { - try { - launcher.Kill(entireProcessTree: true); - } catch { - } - } - - throw new TimeoutException( - $"Sandboxie started box '{instance.BoxName}', but its tool host did not report a heartbeat within {Env.SandboxieToolHostStartupTimeoutSeconds} seconds."); - } - - private static async Task<(int ExitCode, string StandardOutput, string StandardError)> RunSandboxieCommandAsync( - string executable, - IEnumerable arguments, - int timeoutSeconds, - CancellationToken cancellationToken) { - var startInfo = new ProcessStartInfo { - FileName = executable, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - }; - foreach (var argument in arguments) { - startInfo.ArgumentList.Add(argument); - } - - using var process = Process.Start(startInfo) ?? - throw new InvalidOperationException($"Failed to start Sandboxie command '{executable}'."); - var standardOutput = process.StandardOutput.ReadToEndAsync(cancellationToken); - var standardError = process.StandardError.ReadToEndAsync(cancellationToken); - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); - try { - await process.WaitForExitAsync(timeoutCts.Token); - } catch (OperationCanceledException) { - try { - process.Kill(entireProcessTree: true); - await process.WaitForExitAsync(CancellationToken.None); - } catch { - } - - if (cancellationToken.IsCancellationRequested) { - throw; - } - - throw new TimeoutException( - $"Sandboxie command '{Path.GetFileName(executable)}' timed out after {timeoutSeconds} seconds."); - } - - return ( - process.ExitCode, - await standardOutput, - await standardError); - } - - private async Task IsToolHostAliveAsync(SandboxieInstance instance) { - var value = await _redis.GetDatabase().StringGetAsync(LlmAgentRedisKeys.SandboxToolHeartbeat(instance.ChatId)); - if (!value.HasValue || string.IsNullOrWhiteSpace(value.ToString())) { - return false; - } - - try { - var heartbeat = JsonConvert.DeserializeObject(value.ToString()); - return heartbeat != null && - heartbeat.ParentProcessId == Environment.ProcessId && - string.Equals(heartbeat.BoxName, instance.BoxName, StringComparison.OrdinalIgnoreCase); - } catch (JsonException) { - return false; - } - } - - private sealed class SandboxToolHeartbeatState { - public string BoxName { get; set; } = string.Empty; - public int ParentProcessId { get; set; } - } - - 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 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; - } - 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); - } - - 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); -} diff --git a/TelegramSearchBot/Service/AI/LLM/WindowsAppContainerNative.cs b/TelegramSearchBot/Service/AI/LLM/WindowsAppContainerNative.cs new file mode 100644 index 00000000..2a277986 --- /dev/null +++ b/TelegramSearchBot/Service/AI/LLM/WindowsAppContainerNative.cs @@ -0,0 +1,406 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Runtime.Versioning; +using Microsoft.Win32.SafeHandles; + +namespace TelegramSearchBot.Service.AI.LLM; + +[SupportedOSPlatform("windows")] +internal static class WindowsAppContainerNative { + private static readonly IReadOnlyDictionary CapabilitySidValues = + new Dictionary(StringComparer.OrdinalIgnoreCase) { + ["internetClient"] = "S-1-15-3-1", + ["privateNetworkClientServer"] = "S-1-15-3-3" + }; + + private const uint ErrorAlreadyExists = 183; + private const uint SeGroupEnabled = 0x00000004; + private const uint ExtendedStartupInfoPresent = 0x00080000; + private const uint CreateUnicodeEnvironment = 0x00000400; + private const uint CreateSuspended = 0x00000004; + private const uint JobObjectLimitKillOnJobClose = 0x00002000; + private const uint JobObjectLimitActiveProcess = 0x00000008; + private const uint JobObjectLimitJobMemory = 0x00000200; + private const int ProcThreadAttributeSecurityCapabilities = 9 | 0x00020000; + private const int JobObjectExtendedLimitInformationClass = 9; + + internal static SecurityIdentifier EnsureProfile(string profileName, string displayName) { + EnsureWindows(); + var hr = CreateAppContainerProfile(profileName, displayName, displayName, IntPtr.Zero, 0, out var sid); + if (hr == HResultFromWin32(ErrorAlreadyExists)) { + hr = DeriveAppContainerSidFromAppContainerName(profileName, out sid); + } + Marshal.ThrowExceptionForHR(hr); + try { + return new SecurityIdentifier(sid); + } finally { + FreeSid(sid); + } + } + + internal static void DeleteProfile(string profileName) { + EnsureWindows(); + var hr = DeleteAppContainerProfile(profileName); + if (hr != 0) Marshal.ThrowExceptionForHR(hr); + } + + internal static void GrantReadOnlyDirectory(string path, SecurityIdentifier sid) { + if (!Directory.Exists(path)) return; + GrantParentTraversal(path, sid); + var info = new DirectoryInfo(path); + var security = info.GetAccessControl(AccessControlSections.Access); + var inheritance = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; + security.AddAccessRule(new FileSystemAccessRule( + sid, + FileSystemRights.ReadAndExecute | FileSystemRights.Synchronize, + inheritance, + PropagationFlags.None, + AccessControlType.Allow)); + info.SetAccessControl(security); + } + + internal static void GrantWritableDirectory(string path, SecurityIdentifier sid) { + Directory.CreateDirectory(path); + GrantParentTraversal(path, sid); + var info = new DirectoryInfo(path); + var security = info.GetAccessControl(AccessControlSections.Access); + var inheritance = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit; + security.AddAccessRule(new FileSystemAccessRule( + sid, + FileSystemRights.Modify | FileSystemRights.Synchronize, + inheritance, + PropagationFlags.None, + AccessControlType.Allow)); + info.SetAccessControl(security); + + var result = SetLowIntegrityLabel(path); + if (result != 0) { + throw new Win32Exception(result, $"Failed to set Low integrity label on '{path}'."); + } + } + + internal static void GrantParentTraversal(string path, SecurityIdentifier sid) { + var parent = Directory.GetParent(Path.GetFullPath(path)); + if (parent == null) return; + var security = parent.GetAccessControl(AccessControlSections.Access); + security.AddAccessRule(new FileSystemAccessRule( + sid, + FileSystemRights.Traverse, + InheritanceFlags.None, + PropagationFlags.None, + AccessControlType.Allow)); + parent.SetAccessControl(security); + } + + internal static void RemoveDirectoryRules(string path, SecurityIdentifier sid) { + if (!Directory.Exists(path)) return; + var info = new DirectoryInfo(path); + var security = info.GetAccessControl(AccessControlSections.Access); + security.PurgeAccessRules(sid); + info.SetAccessControl(security); + } + + internal static AppContainerProcess Start( + SecurityIdentifier appContainerSid, + string executable, + IReadOnlyList arguments, + string workingDirectory, + IReadOnlyList capabilityNames, + int activeProcessLimit, + long jobMemoryLimitBytes) { + EnsureWindows(); + var sidBytes = new byte[appContainerSid.BinaryLength]; + appContainerSid.GetBinaryForm(sidBytes, 0); + var sidPtr = Marshal.AllocHGlobal(sidBytes.Length); + Marshal.Copy(sidBytes, 0, sidPtr, sidBytes.Length); + + var capabilitySids = new List(); + IntPtr capabilitiesPtr = IntPtr.Zero; + IntPtr securityCapabilitiesPtr = IntPtr.Zero; + IntPtr attributeList = IntPtr.Zero; + SafeJobHandle? job = null; + try { + foreach (var name in capabilityNames) { + capabilitySids.Add(DeriveCapabilitySid(name)); + } + + if (capabilitySids.Count > 0) { + var itemSize = Marshal.SizeOf(); + capabilitiesPtr = Marshal.AllocHGlobal(itemSize * capabilitySids.Count); + for (var i = 0; i < capabilitySids.Count; i++) { + Marshal.StructureToPtr(new SidAndAttributes { + Sid = capabilitySids[i], + Attributes = SeGroupEnabled + }, capabilitiesPtr + i * itemSize, false); + } + } + + var capabilities = new SecurityCapabilities { + AppContainerSid = sidPtr, + Capabilities = capabilitiesPtr, + CapabilityCount = capabilitySids.Count + }; + securityCapabilitiesPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(capabilities, securityCapabilitiesPtr, false); + + nuint attributeListSize = 0; + InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref attributeListSize); + attributeList = Marshal.AllocHGlobal(checked((int)attributeListSize)); + if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize)) { + throw new Win32Exception(); + } + if (!UpdateProcThreadAttribute( + attributeList, + 0, + (IntPtr)ProcThreadAttributeSecurityCapabilities, + securityCapabilitiesPtr, + (nuint)Marshal.SizeOf(), + IntPtr.Zero, + IntPtr.Zero)) { + throw new Win32Exception(); + } + + job = CreateConfiguredJob(activeProcessLimit, jobMemoryLimitBytes); + var startupInfo = new StartupInfoEx { + StartupInfo = new StartupInfo { Cb = Marshal.SizeOf() }, + AttributeList = attributeList + }; + var commandLine = new System.Text.StringBuilder(BuildCommandLine(executable, arguments)); + var environment = BuildEnvironmentBlock(workingDirectory); + try { + if (!CreateProcessW( + executable, + commandLine, + IntPtr.Zero, + IntPtr.Zero, + false, + ExtendedStartupInfoPresent | CreateUnicodeEnvironment | CreateSuspended, + environment, + workingDirectory, + ref startupInfo, + out var processInfo)) { + throw new Win32Exception(); + } + + var processHandle = new SafeProcessHandle(processInfo.Process, true); + var threadHandle = new SafeWaitHandle(processInfo.Thread, true); + try { + if (!AssignProcessToJobObject(job, processHandle)) { + throw new Win32Exception(); + } + if (ResumeThread(threadHandle) == uint.MaxValue) { + throw new Win32Exception(); + } + return new AppContainerProcess(Process.GetProcessById(processInfo.ProcessId), processHandle, threadHandle, job); + } catch { + TerminateProcess(processHandle, 1); + processHandle.Dispose(); + threadHandle.Dispose(); + throw; + } + } finally { + Marshal.FreeHGlobal(environment); + } + } catch { + job?.Dispose(); + throw; + } finally { + if (attributeList != IntPtr.Zero) { + DeleteProcThreadAttributeList(attributeList); + Marshal.FreeHGlobal(attributeList); + } + if (securityCapabilitiesPtr != IntPtr.Zero) Marshal.FreeHGlobal(securityCapabilitiesPtr); + if (capabilitiesPtr != IntPtr.Zero) Marshal.FreeHGlobal(capabilitiesPtr); + foreach (var capabilitySid in capabilitySids) Marshal.FreeHGlobal(capabilitySid); + Marshal.FreeHGlobal(sidPtr); + } + } + + internal static bool HasLoopbackExemption(SecurityIdentifier sid) { + var output = RunCheckNetIsolation("LoopbackExempt", "-s"); + return output.ExitCode == 0 && output.StandardOutput.Contains(sid.Value, StringComparison.OrdinalIgnoreCase); + } + + internal static void EnsureLoopbackExemption(SecurityIdentifier sid) { + if (HasLoopbackExemption(sid)) return; + var result = RunCheckNetIsolation("LoopbackExempt", "-a", $"-p={sid.Value}"); + if (result.ExitCode != 0) { + throw new InvalidOperationException( + $"AppContainer loopback exemption is required for Redis. Run elevated: CheckNetIsolation.exe LoopbackExempt -a -p={sid.Value}. " + + result.StandardError.Trim()); + } + } + + private static (int ExitCode, string StandardOutput, string StandardError) RunCheckNetIsolation(params string[] arguments) { + var startInfo = new ProcessStartInfo { + FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "CheckNetIsolation.exe"), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start CheckNetIsolation.exe."); + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + return (process.ExitCode, stdout, stderr); + } + + private static SafeJobHandle CreateConfiguredJob(int activeProcessLimit, long jobMemoryLimitBytes) { + var job = new SafeJobHandle(CreateJobObjectW(IntPtr.Zero, null), true); + if (job.IsInvalid) throw new Win32Exception(); + + var info = new JobObjectExtendedLimitInformation { + BasicLimitInformation = new JobObjectBasicLimitInformation { + LimitFlags = JobObjectLimitKillOnJobClose | JobObjectLimitActiveProcess | JobObjectLimitJobMemory, + ActiveProcessLimit = (uint)Math.Max(1, activeProcessLimit) + }, + JobMemoryLimit = (nuint)Math.Max(64L * 1024 * 1024, jobMemoryLimitBytes) + }; + var size = Marshal.SizeOf(); + var ptr = Marshal.AllocHGlobal(size); + try { + Marshal.StructureToPtr(info, ptr, false); + if (!SetInformationJobObject(job, JobObjectExtendedLimitInformationClass, ptr, (uint)size)) { + throw new Win32Exception(); + } + } catch { + job.Dispose(); + throw; + } finally { + Marshal.FreeHGlobal(ptr); + } + return job; + } + + private static IntPtr DeriveCapabilitySid(string name) { + if (!CapabilitySidValues.TryGetValue(name, out var sidValue)) { + throw new InvalidOperationException($"Unsupported AppContainer capability '{name}'."); + } + var sid = new SecurityIdentifier(sidValue); + var bytes = new byte[sid.BinaryLength]; + sid.GetBinaryForm(bytes, 0); + var pointer = Marshal.AllocHGlobal(bytes.Length); + Marshal.Copy(bytes, 0, pointer, bytes.Length); + return pointer; + } + + private static string BuildCommandLine(string executable, IReadOnlyList arguments) { + return string.Join(' ', new[] { QuoteArgument(executable) }.Concat(arguments.Select(QuoteArgument))); + } + + internal static string QuoteArgument(string argument) { + if (argument.Length > 0 && !argument.Any(char.IsWhiteSpace) && !argument.Contains('"')) return argument; + var result = new System.Text.StringBuilder(argument.Length + 2).Append('"'); + var backslashes = 0; + foreach (var c in argument) { + if (c == '\\') { backslashes++; continue; } + if (c == '"') result.Append('\\', backslashes * 2 + 1).Append('"'); + else result.Append('\\', backslashes).Append(c); + backslashes = 0; + } + return result.Append('\\', backslashes * 2).Append('"').ToString(); + } + + private static IntPtr BuildEnvironmentBlock(string workingDirectory) { + var values = new SortedDictionary(StringComparer.OrdinalIgnoreCase) { + ["ALLUSERSPROFILE"] = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + ["APPDATA"] = workingDirectory, + ["COMSPEC"] = Environment.GetEnvironmentVariable("COMSPEC") ?? Path.Combine(Environment.SystemDirectory, "cmd.exe"), + ["HOME"] = workingDirectory, + ["LOCALAPPDATA"] = workingDirectory, + ["PATH"] = Environment.GetEnvironmentVariable("PATH") ?? string.Empty, + ["PATHEXT"] = Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD", + ["PROGRAMDATA"] = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + ["PROGRAMFILES"] = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + ["PROGRAMFILES(X86)"] = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + ["PROGRAMW6432"] = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + ["SYSTEMDRIVE"] = Path.GetPathRoot(Environment.SystemDirectory)?.TrimEnd(Path.DirectorySeparatorChar) ?? "C:", + ["SYSTEMROOT"] = Environment.GetFolderPath(Environment.SpecialFolder.Windows), + ["TEMP"] = workingDirectory, + ["TMP"] = workingDirectory, + ["USERNAME"] = Environment.UserName, + ["USERPROFILE"] = workingDirectory, + ["WINDIR"] = Environment.GetFolderPath(Environment.SpecialFolder.Windows) + }; + var text = string.Join('\0', values.Select(pair => $"{pair.Key}={pair.Value}")) + "\0\0"; + var bytes = Encoding.Unicode.GetBytes(text); + var buffer = Marshal.AllocHGlobal(bytes.Length); + Marshal.Copy(bytes, 0, buffer, bytes.Length); + return buffer; + } + + private static int SetLowIntegrityLabel(string path) { + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW("S:(ML;OICI;NW;;;LW)", 1, out var descriptor, out _)) { + return Marshal.GetLastWin32Error(); + } + try { + var saclPresent = false; + var saclDefaulted = false; + if (!GetSecurityDescriptorSacl(descriptor, out saclPresent, out var sacl, out saclDefaulted) || !saclPresent) { + return Marshal.GetLastWin32Error(); + } + return (int)SetNamedSecurityInfoW(path, 1, 0x00000010, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, sacl); + } finally { + LocalFree(descriptor); + } + } + + private static void EnsureWindows() { + if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Windows AppContainer sandbox is only available on Windows."); + } + + private static int HResultFromWin32(uint error) => error <= 0 ? (int)error : unchecked((int)(0x80070000u | error)); + + [StructLayout(LayoutKind.Sequential)] private struct SidAndAttributes { public IntPtr Sid; public uint Attributes; } + [StructLayout(LayoutKind.Sequential)] private struct SecurityCapabilities { public IntPtr AppContainerSid; public IntPtr Capabilities; public int CapabilityCount; public int Reserved; } + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct StartupInfo { public int Cb; public string? Reserved; public string? Desktop; public string? Title; public uint X; public uint Y; public uint XSize; public uint YSize; public uint XCountChars; public uint YCountChars; public uint FillAttribute; public uint Flags; public ushort ShowWindow; public ushort Reserved2; public IntPtr Reserved2Ptr; public IntPtr StdInput; public IntPtr StdOutput; public IntPtr StdError; } + [StructLayout(LayoutKind.Sequential)] private struct StartupInfoEx { public StartupInfo StartupInfo; public IntPtr AttributeList; } + [StructLayout(LayoutKind.Sequential)] private struct ProcessInformation { public IntPtr Process; public IntPtr Thread; public int ProcessId; public int ThreadId; } + [StructLayout(LayoutKind.Sequential)] private struct IoCounters { public ulong ReadOperationCount; public ulong WriteOperationCount; public ulong OtherOperationCount; public ulong ReadTransferCount; public ulong WriteTransferCount; public ulong OtherTransferCount; } + [StructLayout(LayoutKind.Sequential)] private struct JobObjectBasicLimitInformation { public long PerProcessUserTimeLimit; public long PerJobUserTimeLimit; public uint LimitFlags; public nuint MinimumWorkingSetSize; public nuint MaximumWorkingSetSize; public uint ActiveProcessLimit; public nuint Affinity; public uint PriorityClass; public uint SchedulingClass; } + [StructLayout(LayoutKind.Sequential)] private struct JobObjectExtendedLimitInformation { public JobObjectBasicLimitInformation BasicLimitInformation; public IoCounters IoInfo; public nuint ProcessMemoryLimit; public nuint JobMemoryLimit; public nuint PeakProcessMemoryUsed; public nuint PeakJobMemoryUsed; } + + internal sealed class AppContainerProcess : IDisposable { + private readonly SafeProcessHandle _processHandle; + private readonly SafeWaitHandle _threadHandle; + private readonly SafeJobHandle _jobHandle; + internal AppContainerProcess(Process process, SafeProcessHandle processHandle, SafeWaitHandle threadHandle, SafeJobHandle jobHandle) { Process = process; _processHandle = processHandle; _threadHandle = threadHandle; _jobHandle = jobHandle; } + internal Process Process { get; } + public void Dispose() { Process.Dispose(); _threadHandle.Dispose(); _processHandle.Dispose(); _jobHandle.Dispose(); } + } + + internal sealed class SafeJobHandle : SafeHandleZeroOrMinusOneIsInvalid { + internal SafeJobHandle(IntPtr handle, bool ownsHandle) : base(ownsHandle) => SetHandle(handle); + protected override bool ReleaseHandle() => CloseHandle(handle); + } + + [DllImport("userenv.dll", CharSet = CharSet.Unicode)] private static extern int CreateAppContainerProfile(string name, string displayName, string description, IntPtr capabilities, uint capabilityCount, out IntPtr sid); + [DllImport("userenv.dll", CharSet = CharSet.Unicode)] private static extern int DeleteAppContainerProfile(string name); + [DllImport("userenv.dll", CharSet = CharSet.Unicode)] private static extern int DeriveAppContainerSidFromAppContainerName(string name, out IntPtr sid); + [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool ConvertStringSecurityDescriptorToSecurityDescriptorW(string descriptor, uint revision, out IntPtr securityDescriptor, out uint size); + [DllImport("advapi32.dll", SetLastError = true)] private static extern bool GetSecurityDescriptorSacl(IntPtr securityDescriptor, out bool saclPresent, out IntPtr sacl, out bool saclDefaulted); + [DllImport("advapi32.dll", CharSet = CharSet.Unicode)] private static extern uint SetNamedSecurityInfoW(string objectName, int objectType, uint securityInfo, IntPtr owner, IntPtr group, IntPtr dacl, IntPtr sacl); + [DllImport("kernel32.dll")] private static extern IntPtr LocalFree(IntPtr memory); + [DllImport("advapi32.dll")] private static extern IntPtr FreeSid(IntPtr sid); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool InitializeProcThreadAttributeList(IntPtr attributeList, int attributeCount, int flags, ref nuint size); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool UpdateProcThreadAttribute(IntPtr attributeList, uint flags, IntPtr attribute, IntPtr value, nuint size, IntPtr previousValue, IntPtr returnSize); + [DllImport("kernel32.dll")] private static extern void DeleteProcThreadAttributeList(IntPtr attributeList); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CreateProcessW(string applicationName, System.Text.StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern IntPtr CreateJobObjectW(IntPtr attributes, string? name); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetInformationJobObject(SafeJobHandle job, int infoClass, IntPtr info, uint length); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool AssignProcessToJobObject(SafeJobHandle job, SafeProcessHandle process); + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool TerminateProcess(SafeProcessHandle process, uint exitCode); + [DllImport("kernel32.dll", SetLastError = true)] private static extern uint ResumeThread(SafeWaitHandle thread); + [DllImport("kernel32.dll")] private static extern bool CloseHandle(IntPtr handle); +} diff --git a/TelegramSearchBot/Service/AI/LLM/WindowsAppContainerToolHostService.cs b/TelegramSearchBot/Service/AI/LLM/WindowsAppContainerToolHostService.cs new file mode 100644 index 00000000..188dd1f2 --- /dev/null +++ b/TelegramSearchBot/Service/AI/LLM/WindowsAppContainerToolHostService.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Runtime.Versioning; +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; + +/// +/// Runs dangerous local tools in a per-chat Windows AppContainer. Redis remains the phase-one IPC; +/// file access is enforced by the AppContainer SID and NTFS ACLs. +/// +[SupportedOSPlatform("windows")] +[Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] +public sealed class WindowsAppContainerToolHostService : IDisposable { + private static readonly HashSet SandboxedToolNames = new(StringComparer.OrdinalIgnoreCase) { + "ReadFile", "WriteFile", "EditFile", "SearchText", "ListFiles", "ExecuteCommand" + }; + private static readonly string[] NetworkCapabilities = ["internetClient", "privateNetworkClientServer"]; + + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + private readonly ConcurrentDictionary _instances = new(); + private bool _disposed; + + public WindowsAppContainerToolHostService( + IConnectionMultiplexer redis, + ILogger logger) { + _redis = redis; + _logger = logger; + } + + public static IReadOnlyCollection ToolNames => SandboxedToolNames; + + public static List GetToolDefinitions() => [ + new() { Name = "ReadFile", Description = "Read a file from the current chat's authorized Windows sandbox directories.", Parameters = { + new() { Name = "path", Type = "string", Description = "Absolute or relative path to read.", Required = true }, + new() { Name = "startLine", Type = "int", Description = "Optional starting line number (1-based).", Required = false }, + new() { Name = "endLine", Type = "int", Description = "Optional ending line number (inclusive).", Required = false } + } }, + new() { Name = "WriteFile", Description = "Write a file in the current chat's authorized Windows sandbox directories.", Parameters = { + new() { Name = "path", Type = "string", Description = "Absolute or relative path to write.", Required = true }, + new() { Name = "content", Type = "string", Description = "Content to write.", Required = true } + } }, + new() { Name = "EditFile", Description = "Edit a file in the current chat's authorized Windows sandbox directories.", Parameters = { + new() { Name = "path", Type = "string", Description = "Absolute or relative path to edit.", Required = true }, + new() { Name = "oldText", Type = "string", Description = "Exact text to replace.", Required = true }, + new() { Name = "newText", Type = "string", Description = "Replacement text.", Required = true } + } }, + new() { Name = "SearchText", Description = "Search files in the current chat's authorized Windows sandbox directories.", Parameters = { + new() { Name = "pattern", Type = "string", Description = "Regex pattern to search for.", Required = true }, + new() { Name = "path", Type = "string", Description = "Directory to search.", Required = false }, + new() { Name = "fileGlob", Type = "string", Description = "File glob filter.", Required = false }, + new() { Name = "ignoreCase", Type = "bool", Description = "Whether to ignore case.", Required = false } + } }, + new() { Name = "ListFiles", Description = "List files in the current chat's authorized Windows sandbox directories.", Parameters = { + new() { Name = "path", Type = "string", Description = "Directory to list.", Required = false }, + new() { Name = "pattern", Type = "string", Description = "Glob pattern.", Required = false } + } }, + new() { Name = "ExecuteCommand", Description = "Execute a shell command inside the per-chat Windows AppContainer.", Parameters = { + new() { Name = "command", Type = "string", Description = "Shell command to execute.", Required = true }, + new() { Name = "workingDirectory", Type = "string", Description = "Working directory.", Required = false }, + new() { 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) { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!SandboxedToolNames.Contains(toolName)) { + throw new InvalidOperationException($"Tool '{toolName}' is not configured for Windows sandbox execution."); + } + + var instance = await EnsureToolHostAsync(chatId, cancellationToken); + var task = new SandboxToolTask { + ToolName = toolName, + Arguments = arguments, + ChatId = chatId, + UserId = userId, + MessageId = messageId, + BoxName = instance.ProfileName + }; + + var db = _redis.GetDatabase(); + await db.ListRightPushAsync(LlmAgentRedisKeys.SandboxToolQueue(chatId), JsonConvert.SerializeObject(task)); + var timeout = TimeSpan.FromSeconds(Math.Max(5, Env.SandboxieToolTimeoutSeconds)); + var deadline = DateTime.UtcNow + timeout; + var resultKey = LlmAgentRedisKeys.SandboxToolResult(task.RequestId); + while (DateTime.UtcNow < deadline && !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()) + ?? 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."); + } + + internal async Task EnsureToolHostAsync(long chatId, CancellationToken cancellationToken = default) { + ObjectDisposedException.ThrowIf(_disposed, this); + await _lock.WaitAsync(cancellationToken); + try { + if (_instances.TryGetValue(chatId, out var existing) && + !existing.Process.Process.HasExited && + await IsToolHostAliveAsync(existing)) { + return existing; + } + if (existing != null) { + existing.Process.Dispose(); + _instances.TryRemove(chatId, out _); + } + + var profileName = BuildProfileName(chatId, Env.WindowsSandboxProfilePrefix); + var sid = WindowsAppContainerNative.EnsureProfile(profileName, $"TelegramSearchBot chat {chatId}"); + var paths = BuildPathPolicy(chatId); + ApplyPathPolicy(sid, paths); + WindowsAppContainerNative.EnsureLoopbackExemption(sid); + + var currentExe = Environment.ProcessPath + ?? throw new InvalidOperationException("Unable to determine TelegramSearchBot executable path."); + var parent = Process.GetCurrentProcess(); + var process = WindowsAppContainerNative.Start( + sid, + currentExe, + [ + "SandboxToolHost", + chatId.ToString(), + Env.SchedulerPort.ToString(), + profileName, + parent.Id.ToString(), + paths.DefaultWorkingDirectory, + Env.SandboxieToolTimeoutSeconds.ToString() + ], + paths.DefaultWorkingDirectory, + NetworkCapabilities, + Env.WindowsSandboxActiveProcessLimit, + (long)Env.WindowsSandboxJobMemoryLimitMb * 1024 * 1024); + + var instance = new WindowsAppContainerInstance(chatId, profileName, sid, paths, process); + _instances[chatId] = instance; + try { + await WaitForToolHostStartupAsync(instance, cancellationToken); + } catch { + _instances.TryRemove(chatId, out _); + process.Dispose(); + throw; + } + _logger.LogInformation( + "Started Windows AppContainer ToolHost. ChatId={ChatId}, Profile={Profile}, Sid={Sid}, Pid={Pid}", + chatId, profileName, sid.Value, process.Process.Id); + return instance; + } finally { + _lock.Release(); + } + } + + internal static WindowsSandboxPathPolicy BuildPathPolicy(long chatId) { + var id = chatId.ToString(); + var readOnly = new[] { AppContext.BaseDirectory } + .Concat(Env.SandboxieGlobalReadPaths) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizePath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var writable = new List(); + if (!string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot)) { + writable.Add(Path.Combine(Env.SandboxieGroupFilesRoot, id)); + } + writable.Add(Path.Combine(Env.WorkDir, "Photos", id)); + writable.Add(Path.Combine(Env.WorkDir, "Audios", id)); + writable.Add(Path.Combine(Env.WorkDir, "Videos", id)); + writable.Add(Path.Combine(Env.WorkDir, "Files", id)); + writable = writable.Select(NormalizePath).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + var defaultWorkingDirectory = !string.IsNullOrWhiteSpace(Env.SandboxieGroupFilesRoot) + ? NormalizePath(Path.Combine(Env.SandboxieGroupFilesRoot, id)) + : NormalizePath(Path.Combine(Env.WorkDir, "Files", id)); + return new WindowsSandboxPathPolicy(readOnly, writable, defaultWorkingDirectory); + } + + internal static void ApplyPathPolicy(SecurityIdentifier sid, WindowsSandboxPathPolicy policy) { + foreach (var path in policy.ReadOnlyPaths) { + WindowsAppContainerNative.GrantReadOnlyDirectory(path, sid); + } + foreach (var path in policy.WritablePaths) { + WindowsAppContainerNative.GrantWritableDirectory(path, sid); + } + } + + internal static string BuildProfileName(long chatId, string prefix) { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(chatId.ToString())), 0, 8); + var value = (string.IsNullOrWhiteSpace(prefix) ? "TelegramSearchBot.Chat." : prefix.Trim()) + hash; + if (value.Length > 64 || value.Any(character => !(char.IsAsciiLetterOrDigit(character) || character is '.' or '-' or '_'))) { + throw new InvalidOperationException($"Windows sandbox profile name '{value}' is invalid."); + } + return value; + } + + private async Task WaitForToolHostStartupAsync(WindowsAppContainerInstance instance, CancellationToken cancellationToken) { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(Env.SandboxieToolHostStartupTimeoutSeconds); + while (DateTime.UtcNow < deadline) { + if (await IsToolHostAliveAsync(instance)) return; + if (instance.Process.Process.HasExited) { + throw new InvalidOperationException( + $"Windows AppContainer ToolHost exited during startup with code {instance.Process.Process.ExitCode}."); + } + await Task.Delay(200, cancellationToken); + } + throw new TimeoutException( + $"Windows AppContainer ToolHost did not report a heartbeat within {Env.SandboxieToolHostStartupTimeoutSeconds} seconds."); + } + + private async Task IsToolHostAliveAsync(WindowsAppContainerInstance instance) { + var value = await _redis.GetDatabase().StringGetAsync(LlmAgentRedisKeys.SandboxToolHeartbeat(instance.ChatId)); + if (!value.HasValue || string.IsNullOrWhiteSpace(value.ToString())) return false; + try { + var heartbeat = JsonConvert.DeserializeObject(value.ToString()); + return heartbeat != null && + heartbeat.ProcessId == instance.Process.Process.Id && + heartbeat.ParentProcessId == Environment.ProcessId && + string.Equals(heartbeat.BoxName, instance.ProfileName, StringComparison.OrdinalIgnoreCase); + } catch (JsonException) { + return false; + } + } + + private static string NormalizePath(string path) => + Path.GetFullPath(path.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + public void Dispose() { + if (_disposed) return; + _disposed = true; + foreach (var instance in _instances.Values) instance.Process.Dispose(); + _instances.Clear(); + _lock.Dispose(); + } + + private sealed class SandboxToolHeartbeatState { + public string BoxName { get; set; } = string.Empty; + public int ProcessId { get; set; } + public int ParentProcessId { get; set; } + } +} + +public sealed record WindowsSandboxPathPolicy( + IReadOnlyList ReadOnlyPaths, + IReadOnlyList WritablePaths, + string DefaultWorkingDirectory); + +internal sealed record WindowsAppContainerInstance( + long ChatId, + string ProfileName, + SecurityIdentifier Sid, + WindowsSandboxPathPolicy Paths, + WindowsAppContainerNative.AppContainerProcess Process);