Files
thzxx 058ee2de36 feat: 升级至 v0.3.12 — 文件与代码类工具全面优化(16 项)
【阶段一 · 紧急修复 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)
2026-07-21 16:08:39 +08:00

257 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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' };
}
// 无 BOMUTF-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;