/** * 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)。 * * v0.7.4 P2-3 根治: 旧正则只匹配"前面是命令起始/空白/引号/分号/管道/&/>", * `cat ./MEMORY.md`、`cat .\MEMORY.md`(前面是 . 或 / 或 \)不匹配 → 受保护 * 文件拦截名存实亡。现增加可选路径前缀组 `./`、`.\`、`~`、`~/` 及组合, * 并把边界类扩展 `(`、`)`、`$`、反引号 —— 覆盖子 shell/命令替换/括号/重定向 * 无空格(`cat //括号/反引号 // 中间允许可选的 ./ .\ ~ ~/ 及组合(如 ~/./)路径前缀(仍指工作空间根,必须拦截); // subdir/MEMORY.md、subdir\MEMORY.md(MEMORY.md 前导为路径分隔符)不匹配 const escaped = lowerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp( `(?:^|[\\s"'|;&<>($\\\`])(?:(?:\\./|\\.\\\\|~/?)+)?${escaped}(?:$|[\\s"'|;&<)\\\`])`, 'i', ); if (regex.test(lowerCmd)) { return true; } } return false; } // ===== 共享工具函数(v0.3.2 抽取,消除 filesystem.ts 与 file-editor.ts 的重复)===== /** * 共享路径解析 + 安全校验 * * 合并两层安全检查: * 1. isPathWithinWorkspace — 路径遍历防护(含符号链接 realpathSync 二次校验) * 2. isProtectedWorkspaceFile — MEMORY.md 拦截 * * @param filePath 用户传入的文件路径(绝对或相对) * @param workspacePath 当前工作空间根路径 * @returns 解析后的绝对路径 * @throws Error 路径越界或访问受保护文件时抛出 */ export function safeResolvePath(filePath: string, workspacePath: string): string { const resolved = resolve(workspacePath, filePath); // 安全检查:路径遍历防护(修复前缀碰撞漏洞) if (!isPathWithinWorkspace(filePath, workspacePath)) { throw new Error(`Path traversal detected: ${filePath}`); } // 受保护文件检查:MEMORY.md 仅由系统内部管理 if (isProtectedWorkspaceFile(filePath, workspacePath)) { throw new Error( 'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via file tools', ); } return resolved; } /** * 共享 glob 匹配(简易通配符 → 正则) * * 支持 `*`(任意字符序列)和 `?`(单字符),大小写不敏感。 * 其他正则元字符会被转义。 * * @param name 待匹配的文件名 * @param glob 通配符模式(如 "*.ts"、"test?.js") * @returns 是否匹配 */ export function matchGlob(name: string, glob: string): boolean { const pattern = glob .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*') .replace(/\?/g, '.'); return new RegExp(`^${pattern}$`, 'i').test(name); } /** * F2-4: 多 glob 匹配(逗号分隔) * * 支持 "*.ts,*.js,*.tsx" 形式的多 glob 匹配,任一匹配即通过。 * 单个 glob 时等价于 matchGlob。空字符串或空白字符串视为匹配所有。 * * @param name 待匹配的文件名 * @param globStr 通配符模式字符串(支持逗号分隔多 glob) * @returns 是否匹配任一 glob */ export function matchAnyGlob(name: string, globStr: string): boolean { // 按逗号分割,去除空白,过滤空字符串 const globs = globStr .split(',') .map((g) => g.trim()) .filter((g) => g.length > 0); if (globs.length === 0) return true; // 空字符串视为匹配所有 for (const g of globs) { if (matchGlob(name, g)) return true; } return false; } /** * 共享错误提取 * * 统一从 unknown 错误对象中提取 message 字符串,可选附加 stderr 信息 * * F4-2: 支持 optional stderr 参数,用于 child_process 错误(git/command) * * @param error catch 块中的 unknown 错误 * @param includeStderr 是否尝试从 error.stderr 提取 stderr 信息(默认 false) * @returns 错误消息字符串 */ export function extractErrorMessage(error: unknown, includeStderr = false): string { if (error instanceof Error) { if (includeStderr) { const stderr = (error as Error & { stderr?: string }).stderr ?? ''; return stderr ? `${error.message}\n${stderr}` : error.message; } return error.message; } return String(error); } /** * F2-1: 智能文件编码检测与解码 * * 支持 BOM 检测(UTF-8 / UTF-16 LE / UTF-16 BE)和无 BOM 时的编码推断 * (UTF-8 strict → GBK → UTF-8 loose 三级降级)。 * * 解决 Windows 中文环境 GBK 文件读取乱码问题,以及 UTF-16 文件读取问题。 * * @param buffer 文件/命令输出的原始字节 * @returns 解码后的文本和检测到的编码名(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose) */ export function decodeBufferWithDetection(buffer: Buffer): { content: string; encoding: string } { if (buffer.length === 0) { return { content: '', encoding: 'utf-8' }; } // BOM 检测 // UTF-8 BOM: EF BB BF if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) { return { content: buffer.slice(3).toString('utf-8'), encoding: 'utf-8-bom' }; } // UTF-16 LE BOM: FF FE if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { return { content: buffer.slice(2).toString('utf16le'), encoding: 'utf-16le' }; } // UTF-16 BE BOM: FE FF if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) { const body = buffer.slice(2); // 偶数长度保护(UTF-16 每字符 2 字节) const safe = body.length % 2 === 0 ? body : body.slice(0, body.length - 1); const swapped = Buffer.from(safe); // 复制避免修改原 buffer swapped.swap16(); // BE → LE 字节交换 return { content: swapped.toString('utf16le'), encoding: 'utf-16be' }; } // 无 BOM:UTF-8 strict → GBK → UTF-8 loose 三级降级 try { return { content: new TextDecoder('utf-8', { fatal: true }).decode(buffer), encoding: 'utf-8' }; } catch { try { return { content: new TextDecoder('gbk').decode(buffer), encoding: 'gbk' }; } catch { return { content: buffer.toString('utf-8'), encoding: 'utf-8-loose' }; } } } /** * 共享文件大小限制常量 * * read_file/write_file/file_editor 共用,防止 OOM */ export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB /** * 共享超时常量(v0.3.2 统一文件工具 timeoutMs) */ export const FILE_TOOL_TIMEOUT_MS = 15_000; /** * 共享单行最大长度(防止超长行爆 token) */ export const MAX_LINE_LENGTH = 10_000; /** * v0.7.4 P2-7 根治: 共享灾难性正则(ReDoS)检测 —— 从 file-editor.ts 提升为共享模块。 * * 背景:file_editor 有 isPotentiallyCatastrophicRegex 防护,但 search_files 的 * content 搜索 `new RegExp(pattern, 'gi')` 仅限制长度 500,`(a+)+$` 对超长行 * (单行可达 10MB 文件内)可指数级回溯阻塞主进程事件循环。 * * 灾难性回溯通常由以下模式引起: * - 嵌套量词:(a+)+、(a*)*、(a+)* * - 重叠量词:a+a+、a+.*a+(两个量词之间无固定字符分隔) * - 交替分支加量词:(a|a)* * * 这些模式在长字符串上执行时间指数级增长,可阻塞主进程。 * * @param pattern 用户提供的正则模式字符串 * @returns true 如果检测到潜在灾难性模式 */ export function isPotentiallyCatastrophicRegex(pattern: string): boolean { // 审查修复: 放宽规则减少误报,补充漏报检测 // 1. 嵌套量词(捕获组内量词+外层量词) // 审查修复: 区分外层量词类型 — 外层 +* 时组内一个量词即可触发(如 (a+)+), // 外层 ? 时需组内两个量词才触发(排除 (\d+)? 误报) if (/\([^)]*[+*?][^)]*\)[+*]/.test(pattern)) return true; if (/\([^)]*[+*?][^)]*[+*?][^)]*\)[?]/.test(pattern)) return true; // 2. 重叠量词 — 补充 a+a+ 漏报 if (/[+*][+*]/.test(pattern)) return true; // 审查修复: 补充 a+a+ / a+.*a+ 等重叠量词检测 if (/\w[+*]\s*\w[+*]/.test(pattern)) return true; if (/\.\*[+*]\.\*[+*]/.test(pattern)) return true; // 3. 交替分支加量词 — 放宽: 仅当分支有重叠前缀时才危险 // 移除对 (GET|POST)+ 的误报,只检测真正危险的重叠分支 // (a|a)* 类型难以用正则精确检测,保留简化版 if (/\(([^)]+)\|(\1[^)]*)\)[+*?]/.test(pattern)) return true; if (/\(([^)]*\|[^)]*)\)[+*?]/.test(pattern) && /(.)\1.*\|.*\1/.test(pattern)) return true; return false; }