/** * File Guard — 受保护文件守卫 * * 确保工作空间根目录的 MEMORY.md 只能由系统内部(WorkspaceService)管理, * 任何工具(read_file / write_file / search_files / run_command 等)均禁止直接读写。 * * 注意:仅保护工作空间根目录的 MEMORY.md, * 子目录或其他位置的同名文件不受限制。 */ import { resolve, sep } from 'path'; import { realpathSync } from 'fs'; /** * 受保护文件名列表(工作空间根目录) */ const PROTECTED_FILES = ['MEMORY.md']; /** * 检查目标路径是否为工作空间根目录的受保护文件 * * @param filePath 用户传入的文件路径(绝对或相对) * @param workspacePath 当前工作空间根路径 * @returns true 如果路径指向受保护文件 */ export function isProtectedWorkspaceFile( filePath: string, workspacePath: string, ): boolean { const resolved = resolve(workspacePath, filePath); const workspaceRoot = resolve(workspacePath); for (const protectedName of PROTECTED_FILES) { const protectedPath = resolve(workspaceRoot, protectedName); if (resolved === protectedPath) { return true; } } return false; } /** * 检查路径是否在工作空间内(防止路径遍历攻击) * * 修复前缀碰撞漏洞:`/home/user/app-evil` 不应被误判为在 `/home/user/app` 内。 * * M-22 修复: 添加 realpathSync 二次校验防止符号链接逃逸 * 攻击场景:工作空间内创建符号链接 `ln -s /etc/passwd workspace/leak.txt`, * 字符串校验会通过(leak.txt 在 workspace 内),但实际读取的是 /etc/passwd。 * * 注意:realpathSync 在路径不存在时会抛 ENOENT,此时降级为字符串校验 * (write_file 的目标文件可能尚不存在,无法 realpath)。 * * @see project_memory.md — sandbox validatePath must perform realpathSync secondary check * @param filePath 用户传入的文件路径 * @param workspacePath 当前工作空间根路径 * @returns true 如果路径在工作空间内 */ export function isPathWithinWorkspace( filePath: string, workspacePath: string, ): boolean { const resolved = resolve(workspacePath, filePath); const workspaceRoot = resolve(workspacePath); // 第一层:字符串前缀校验(快速路径) const stringCheck = resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep); if (!stringCheck) return false; // 第二层:realpathSync 二次校验(防范符号链接逃逸) // 仅对实际存在的路径做 realpath 校验;不存在的路径(如 write_file 目标)降级为字符串校验 try { const realResolved = realpathSync(resolved); const realWorkspaceRoot = realpathSync(workspaceRoot); return realResolved === realWorkspaceRoot || realResolved.startsWith(realWorkspaceRoot + sep); } catch { // 路径不存在(ENOENT)或 realpath 失败 → 降级为字符串校验结果 return stringCheck; } } /** * 检查命令字符串是否尝试访问工作空间根目录的受保护文件 * * 用于 run_command 工具的命令校验。 * 仅匹配直接引用的 MEMORY.md(前面是命令起始/空白/引号/分号/管道), * 不拦截子目录路径中的同名文件(如 subdir/MEMORY.md 或 subdir\MEMORY.md)。 * * 注意:run_command 的工作目录固定为 workspacePath,因此裸引用 MEMORY.md * 等价于工作空间根目录的 MEMORY.md。 * * @param command Shell 命令字符串 * @returns true 如果命令直接引用了受保护文件名 */ export function commandTouchesProtectedFile(command: string): boolean { const lowerCmd = command.toLowerCase(); for (const protectedName of PROTECTED_FILES) { const lowerName = protectedName.toLowerCase(); // 前面是起始/空白/引号/分号/管道/&/>;后面是结束/空白/引号/分号/管道/&/ // 这样 subdir/MEMORY.md 和 subdir\MEMORY.md 不会被匹配(前面是 / 或 \) const escaped = lowerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(?:^|[\\s"'|;&>])${escaped}(?:$|[\\s"'|;&<])`, 'i'); if (regex.test(lowerCmd)) { return true; } } return false; }