【阶段一 · 紧急修复 F1】 - F1-1 file_editor regex 计数 bug:强制 g 标志 + 同一 RegExp 实例做 match+replace - F1-2 code_search 统一 safeResolvePath(含路径遍历 + MEMORY.md 拦截) - F1-3 git_commit amend 模式:message 可选 + --no-edit 防止编辑器 hang - F1-4 write_file append 模式原子化:显式 open(O_APPEND)+write+fsync+close 【阶段二 · 增强现有工具 F2】 - F2-1 read_file 智能编码检测:BOM(UTF-8/UTF-16 LE/BE) + GBK 降级 - F2-2 read_file 支持 tail 模式(读取末尾 N 行,适用日志) - F2-3 search_files 二进制过滤 + 智能编码检测 - F2-4 search_files 多 glob 匹配(逗号分隔,如 *.ts,*.js) - F2-5 file_editor 新增 find_replace 操作(字面量替换,规避正则歧义) - F2-6 file_editor regex 支持跨行匹配(multiline 参数) - F2-7 file_editor 新增 backup 参数(编辑前 .bak 备份) 【阶段三 · 新增工具 F3】 - F3-1 file_move:双路径校验 + overwrite + 自动建父目录 + rename 原子 - F3-2 file_info:大小/时间/类型/编码/二进制/权限位 【阶段四 · 优化 F4】 - F4-1 diff_viewer Uint32Array→Uint16Array(省一半内存)+ safeResolvePath + 智能编码 - F4-2 错误处理统一:diff-viewer/file-editor/code-search catch 块改用 extractErrorMessage 【阶段五 · 验证 F5】 - tsc --noEmit 类型检查通过 - 人工审查通过:路径校验/错误处理/原子性/资源释放/权限策略/导出注册 工具数量:13 → 15(新增 file_move / file_info)
257 lines
9.5 KiB
TypeScript
257 lines
9.5 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
|
||
// ===== 共享工具函数(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;
|