【阶段一 · 紧急修复 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)
886 lines
34 KiB
TypeScript
886 lines
34 KiB
TypeScript
/**
|
||
* 文件系统工具(5 个)
|
||
*
|
||
* read_file, write_file, list_directory, search_files, delete_file
|
||
*
|
||
* 安全策略:
|
||
* 1. 路径遍历防护 — 所有路径必须解析到工作空间内(修复前缀碰撞漏洞)
|
||
* 2. 受保护文件拦截 — MEMORY.md 仅由系统内部 WorkspaceService 管理,
|
||
* 禁止任何文件工具直接读写(仅限工作空间根目录,子目录不受限)
|
||
*
|
||
* v0.3.2 全面优化:
|
||
* - 抽取共享代码到 file-guard.ts(safeResolvePath/matchGlob/extractErrorMessage)
|
||
* - 统一错误处理(try-catch + { error, success: false })
|
||
* - 统一返回值(成功添加 success: true)
|
||
* - 文件大小上限(防止 OOM)
|
||
* - 原子写入(write_file 也使用临时文件+rename)
|
||
* - 大文件 stat 预检、超长行截断
|
||
* - list_directory 增强:modified time、include_hidden、结果上限
|
||
* - search_files 增强:context lines、可配置忽略目录、regex bug 修复
|
||
*
|
||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||
*/
|
||
|
||
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, type FileHandle } from 'fs/promises';
|
||
import { join, relative, resolve, dirname } from 'path';
|
||
import { existsSync } from 'fs';
|
||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||
import type { MetonaToolDef } from '../../../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||
import {
|
||
isProtectedWorkspaceFile,
|
||
isPathWithinWorkspace,
|
||
safeResolvePath,
|
||
matchGlob,
|
||
matchAnyGlob,
|
||
extractErrorMessage,
|
||
decodeBufferWithDetection,
|
||
MAX_FILE_SIZE_BYTES,
|
||
FILE_TOOL_TIMEOUT_MS,
|
||
MAX_LINE_LENGTH,
|
||
} from './file-guard';
|
||
|
||
/**
|
||
* 二进制文件检测:读取前 8KB 检查是否含 NULL 字节
|
||
*
|
||
* v0.3.2 修复 FAIL: 使用 bytesRead 限制循环上界,而非 buffer.length。
|
||
* Buffer.alloc(8192) 初始化为全 0,fd.read 只覆盖 [0, bytesRead) 区间,
|
||
* 若用 buffer.length 遍历会误判所有 < 8192 字节的纯文本文件为二进制。
|
||
*/
|
||
async function isBinaryFile(filePath: string): Promise<boolean> {
|
||
// M-20 修复: 使用 finally 块确保文件句柄关闭,防止 fd 泄漏
|
||
let fd: FileHandle | null = null;
|
||
try {
|
||
const buffer = Buffer.alloc(8192);
|
||
fd = await open(filePath, 'r');
|
||
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
|
||
// 仅检查实际读取的字节范围 [0, bytesRead)
|
||
// 含 NULL 字节 → 二进制;空文件(bytesRead=0)→ 非二进制
|
||
for (let i = 0; i < bytesRead; i++) {
|
||
if (buffer[i] === 0) return true;
|
||
}
|
||
return false;
|
||
} catch {
|
||
return false;
|
||
} finally {
|
||
// M-20 修复: 无论 read 成功或失败,都关闭文件句柄
|
||
if (fd) {
|
||
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 截断超长行(防止超长单行爆 token) */
|
||
function truncateLine(line: string): { text: string; truncated: boolean } {
|
||
if (line.length > MAX_LINE_LENGTH) {
|
||
return { text: line.slice(0, MAX_LINE_LENGTH) + '... [line truncated]', truncated: true };
|
||
}
|
||
return { text: line, truncated: false };
|
||
}
|
||
|
||
// ===== 1. read_file =====
|
||
|
||
export class ReadFileTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'read_file',
|
||
description:
|
||
'Read the contents of a text file. Returns lines with offset/limit for large files. Auto-detects and rejects binary files (suggest view_image for images). File size limit: 10MB. Lines longer than 10000 chars are truncated. Smart encoding detection: supports UTF-8/UTF-8-BOM/UTF-16LE/UTF-16BE (BOM) and GBK/CP936 (fallback for Windows Chinese files). Returns the detected encoding in the response. Supports tail mode to read the last N lines (useful for logs).',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
file_path: { type: 'string', description: 'Absolute or relative path to the file to read' },
|
||
offset: { type: 'number', description: 'Start line number (1-indexed, default 1). Ignored if tail is specified.' },
|
||
limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.' },
|
||
tail: { type: 'number', description: 'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. Max 2000.' },
|
||
},
|
||
required: ['file_path'],
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
||
const offset = Math.max(1, (args.offset as number) ?? 1);
|
||
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
|
||
// F2-2: tail 模式 — 从文件末尾读取 N 行(优先于 offset/limit)
|
||
const tail = args.tail !== undefined
|
||
? Math.min(2000, Math.max(1, Math.floor(args.tail as number)))
|
||
: undefined;
|
||
|
||
// v0.3.2: 文件存在性 + 大小预检(先 stat 再决定是否读取,避免大文件 OOM)
|
||
let stats;
|
||
try {
|
||
stats = await stat(filePath);
|
||
} catch {
|
||
return { error: 'File not found', path: args.file_path, success: false };
|
||
}
|
||
|
||
if (stats.size > MAX_FILE_SIZE_BYTES) {
|
||
return {
|
||
error: `File too large (${stats.size} bytes, max ${MAX_FILE_SIZE_BYTES} bytes). Use offset/limit or search_files instead.`,
|
||
path: args.file_path,
|
||
file_size: stats.size,
|
||
success: false,
|
||
};
|
||
}
|
||
|
||
// 二进制文件检测 — 避免读取图片/可执行文件产生乱码
|
||
if (await isBinaryFile(filePath)) {
|
||
return {
|
||
content: '',
|
||
total_lines: 0,
|
||
returned_lines: 0,
|
||
truncated: false,
|
||
file_size: stats.size,
|
||
error: 'Binary file detected. Use view_image tool for images, or run_command for other binary content.',
|
||
success: false,
|
||
};
|
||
}
|
||
|
||
// F2-1: 智能编码检测 — 读取原始 Buffer 后用 BOM 检测 + UTF-8/GBK 降级
|
||
// 解决 Windows 中文环境 GBK 文件读取乱码,以及 UTF-16 文件读取问题
|
||
const buffer = await readFile(filePath);
|
||
const { content, encoding } = decodeBufferWithDetection(buffer);
|
||
const lines = content.split('\n');
|
||
|
||
// F2-2: 根据 tail 参数选择切片模式
|
||
let slicedLines: string[];
|
||
let truncated: boolean;
|
||
let startLine: number;
|
||
if (tail !== undefined) {
|
||
// tail 模式:从末尾取 tail 行
|
||
slicedLines = lines.slice(-tail);
|
||
truncated = lines.length > tail;
|
||
startLine = Math.max(1, lines.length - tail + 1);
|
||
} else {
|
||
// offset/limit 模式
|
||
slicedLines = lines.slice(offset - 1, offset - 1 + limit);
|
||
truncated = lines.length > offset - 1 + limit;
|
||
startLine = offset;
|
||
}
|
||
|
||
// v0.3.2: 超长行截断
|
||
const processedLines = slicedLines.map((line) => truncateLine(line));
|
||
const truncatedLines = processedLines.filter((l) => l.truncated).length;
|
||
|
||
return {
|
||
content: processedLines.map((l) => l.text).join('\n'),
|
||
total_lines: lines.length,
|
||
returned_lines: slicedLines.length,
|
||
truncated,
|
||
start_line: startLine, // F2-2: 返回实际起始行号
|
||
lines_truncated: truncatedLines,
|
||
file_size: stats.size,
|
||
encoding, // F2-1: 检测到的编码(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose)
|
||
mode: tail !== undefined ? 'tail' : 'offset', // F2-2: 读取模式
|
||
success: true,
|
||
};
|
||
} catch (error) {
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 2. write_file =====
|
||
|
||
export class WriteFileTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'write_file',
|
||
description:
|
||
'Write content to a file. Creates the file if it doesn\'t exist, overwrites if it does. Supports append mode. Uses atomic write (temp file + rename) for safety. Content size limit: 10MB.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
file_path: { type: 'string', description: 'Path to the file to write' },
|
||
content: { type: 'string', description: 'Content to write to the file' },
|
||
mode: { type: 'string', description: 'Write mode: "overwrite" (default) or "append"', enum: ['overwrite', 'append'] },
|
||
},
|
||
required: ['file_path', 'content'],
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||
requiresPermission: true,
|
||
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
||
const content = (args.content as string) ?? '';
|
||
const mode = (args.mode as string) ?? 'overwrite';
|
||
|
||
// v0.3.2: content 参数必须提供(undefined 时报错),但允许空字符串(用于创建空文件)
|
||
if (args.content === undefined) {
|
||
return { error: 'content is required (use empty string to create empty file)', success: false };
|
||
}
|
||
|
||
// v0.3.2: 内容大小上限(防止 OOM)
|
||
const contentBytes = Buffer.byteLength(content, 'utf-8');
|
||
if (contentBytes > MAX_FILE_SIZE_BYTES) {
|
||
return {
|
||
error: `Content too large (${contentBytes} bytes, max ${MAX_FILE_SIZE_BYTES} bytes)`,
|
||
success: false,
|
||
};
|
||
}
|
||
|
||
// 自动创建父目录(递归)
|
||
const parentDir = dirname(filePath);
|
||
if (!existsSync(parentDir)) {
|
||
await mkdir(parentDir, { recursive: true });
|
||
}
|
||
|
||
if (mode === 'append') {
|
||
// F1-4: append 模式增强 — 显式 open(O_APPEND) + write + fsync + close
|
||
// 修复 v0.3.2 注释错误:"append 本身是原子的"不准确
|
||
// 实际:fs.appendFile 内部是 open(O_APPEND) + write + close,存在:
|
||
// - 无 fsync:write 后数据在 page cache,崩溃可能丢失
|
||
// - 大内容(> 4KB)非完全原子:可能被分成多个 write
|
||
// 改进:显式 open + 循环 write + fsync + close,保证数据持久化到磁盘
|
||
// 保持 append 语义(O_APPEND 内核级追加),不改变并发行为
|
||
const fileExisted = existsSync(filePath);
|
||
const oldSize = fileExisted ? (await stat(filePath)).size : 0;
|
||
|
||
let fd: FileHandle | null = null;
|
||
try {
|
||
fd = await open(filePath, 'a');
|
||
// 'a' 模式下 write 追加到末尾(O_APPEND 内核级保证)
|
||
// 循环写入确保大内容完整写入(单次 write 可能不完整)
|
||
const buffer = Buffer.from(content, 'utf-8');
|
||
let totalWritten = 0;
|
||
while (totalWritten < buffer.length) {
|
||
const { bytesWritten } = await fd.write(buffer, totalWritten, buffer.length - totalWritten);
|
||
totalWritten += bytesWritten;
|
||
}
|
||
await fd.sync(); // fsync 保证数据持久化到磁盘
|
||
} finally {
|
||
if (fd) {
|
||
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||
}
|
||
}
|
||
|
||
const newStats = await stat(filePath);
|
||
return {
|
||
bytes_written: contentBytes,
|
||
path: filePath,
|
||
mode: 'append',
|
||
created: !fileExisted,
|
||
old_size: oldSize,
|
||
new_file_size: newStats.size,
|
||
success: true,
|
||
};
|
||
}
|
||
|
||
// v0.3.2: overwrite 模式使用原子写入(临时文件 + rename,与 file_editor 一致)
|
||
const tmpPath = `${filePath}.tmp_${Date.now()}`;
|
||
try {
|
||
await writeFile(tmpPath, content, 'utf-8');
|
||
await rename(tmpPath, filePath);
|
||
} catch (err) {
|
||
// 原子写入失败,清理临时文件
|
||
if (existsSync(tmpPath)) {
|
||
try { await unlink(tmpPath); } catch { /* 忽略清理错误 */ }
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
return {
|
||
bytes_written: contentBytes,
|
||
path: filePath,
|
||
mode: 'overwrite',
|
||
success: true,
|
||
};
|
||
} catch (error) {
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 3. list_directory =====
|
||
|
||
export class ListDirectoryTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'list_directory',
|
||
description:
|
||
'List the contents of a directory. Supports recursive depth control, glob filtering, and shows file size + modified time. Hidden files (.prefix) are excluded by default; set include_hidden: true to show them. Result limit: 1000 entries.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
dir_path: { type: 'string', description: 'Directory path (default: workspace root)' },
|
||
depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' },
|
||
glob: { type: 'string', description: 'Filename filter pattern. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")' },
|
||
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
|
||
},
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const dirPath = args.dir_path
|
||
? safeResolvePath(args.dir_path as string, context.workspacePath)
|
||
: context.workspacePath;
|
||
const depth = Math.min(5, Math.max(1, (args.depth as number) ?? 1));
|
||
const glob = args.glob as string | undefined;
|
||
const includeHidden = (args.include_hidden as boolean) ?? false;
|
||
|
||
// v0.3.2 修复 WARN-3: listDir 内部提前终止,避免大目录全量遍历
|
||
const MAX_ENTRIES = 1000;
|
||
const entries: Array<{ name: string; path: string; type: string; size?: number; modified?: string }> = [];
|
||
await this.listDir(dirPath, dirPath, depth, glob, includeHidden, 0, entries, MAX_ENTRIES);
|
||
return {
|
||
entries,
|
||
count: entries.length,
|
||
truncated: entries.length >= MAX_ENTRIES,
|
||
success: true,
|
||
};
|
||
} catch (error) {
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
|
||
private async listDir(
|
||
rootPath: string,
|
||
dirPath: string,
|
||
maxDepth: number,
|
||
glob: string | undefined,
|
||
includeHidden: boolean,
|
||
currentDepth: number,
|
||
results: Array<{ name: string; path: string; type: string; size?: number; modified?: string }>,
|
||
maxEntries: number,
|
||
): Promise<void> {
|
||
// v0.3.2 修复 WARN-3: 达到上限立即返回,不再遍历剩余目录
|
||
if (results.length >= maxEntries) return;
|
||
|
||
try {
|
||
const entries = await readdir(dirPath, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
if (results.length >= maxEntries) return;
|
||
// v0.3.2: include_hidden 参数控制隐藏文件
|
||
if (!includeHidden && entry.name.startsWith('.')) continue;
|
||
// node_modules 始终跳过(太大)
|
||
if (entry.name === 'node_modules') continue;
|
||
|
||
const fullPath = join(dirPath, entry.name);
|
||
// 相对路径始终以根请求目录为基准
|
||
const relativePath = relative(rootPath, fullPath);
|
||
|
||
if (entry.isDirectory()) {
|
||
// 目录始终列出(不受 glob 过滤),保证递归可进入子目录
|
||
results.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||
if (currentDepth < maxDepth - 1) {
|
||
await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries);
|
||
}
|
||
} else {
|
||
// F2-4: glob 过滤仅适用于文件,支持多 glob(逗号分隔,如 "*.ts,*.js")
|
||
if (glob && !matchAnyGlob(entry.name, glob)) continue;
|
||
const stats = await stat(fullPath);
|
||
// v0.3.2: 添加 modified time(ISO 字符串)
|
||
results.push({
|
||
name: entry.name,
|
||
path: relativePath,
|
||
type: 'file',
|
||
size: stats.size,
|
||
modified: stats.mtime.toISOString(),
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// 目录读取失败,跳过
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 4. search_files =====
|
||
|
||
export class SearchFilesTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'search_files',
|
||
description:
|
||
'Search for files by name pattern (glob) or content (regex). Returns matching files or content matches with line numbers and optional context lines. Supports file type filter and configurable ignore dirs.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' },
|
||
target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] },
|
||
path: { type: 'string', description: 'Search directory (default: workspace root)' },
|
||
file_glob: { type: 'string', description: 'Limit to specific file types. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")' },
|
||
limit: { type: 'number', description: 'Maximum results (default 50, max 200)' },
|
||
context_lines: { type: 'number', description: 'Lines of context to show around content matches (default 0, max 5). Only for target="content".' },
|
||
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
|
||
},
|
||
required: ['pattern'],
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const pattern = args.pattern as string;
|
||
const target = (args.target as string) ?? 'content';
|
||
const searchPath = args.path
|
||
? this.resolveSearchPath(args.path as string, context.workspacePath)
|
||
: context.workspacePath;
|
||
const fileGlob = args.file_glob as string | undefined;
|
||
const limit = Math.min(200, Math.max(1, (args.limit as number) ?? 50));
|
||
const contextLines = Math.min(5, Math.max(0, (args.context_lines as number) ?? 0));
|
||
const includeHidden = (args.include_hidden as boolean) ?? false;
|
||
|
||
if (target === 'files') {
|
||
return await this.searchByFilename(searchPath, pattern, fileGlob, limit, includeHidden, context.workspacePath);
|
||
}
|
||
return await this.searchByContent(searchPath, pattern, fileGlob, limit, contextLines, includeHidden, context.workspacePath);
|
||
} catch (error) {
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
|
||
private async searchByFilename(
|
||
dirPath: string,
|
||
pattern: string,
|
||
fileGlob: string | undefined,
|
||
limit: number,
|
||
includeHidden: boolean,
|
||
workspacePath: string,
|
||
): Promise<unknown> {
|
||
const results: Array<{ path: string; name: string }> = [];
|
||
|
||
await this.walkDir(dirPath, async (filePath, name) => {
|
||
if (results.length >= limit) return;
|
||
if (matchGlob(name, pattern)) {
|
||
results.push({ path: relative(dirPath, filePath), name });
|
||
}
|
||
}, fileGlob, includeHidden, workspacePath);
|
||
|
||
return { results, count: results.length, success: true };
|
||
}
|
||
|
||
private async searchByContent(
|
||
dirPath: string,
|
||
pattern: string,
|
||
fileGlob: string | undefined,
|
||
limit: number,
|
||
contextLines: number,
|
||
includeHidden: boolean,
|
||
workspacePath: string,
|
||
): Promise<unknown> {
|
||
const results: Array<{ path: string; line: number; match: string; context?: string[] }> = [];
|
||
|
||
// 正则安全加固:限制 pattern 长度 + try-catch 防止 ReDoS
|
||
if (pattern.length > 500) {
|
||
return { results: [], count: 0, error: 'Search pattern too long (max 500 chars)', success: false };
|
||
}
|
||
let regex: RegExp;
|
||
try {
|
||
regex = new RegExp(pattern, 'gi');
|
||
} catch {
|
||
return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}`, success: false };
|
||
}
|
||
|
||
await this.walkDir(dirPath, async (filePath) => {
|
||
if (results.length >= limit) return;
|
||
// 跳过工作空间根目录的 MEMORY.md(受保护文件,使用完整路径精确匹配)
|
||
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
|
||
return;
|
||
}
|
||
try {
|
||
// v0.3.2: 跳过大文件(避免读取超大文件导致 OOM)
|
||
const fileStats = await stat(filePath);
|
||
if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
|
||
|
||
// F2-3: 跳过二进制文件(避免读取乱码 + 提升性能)
|
||
// 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false)
|
||
if (fileStats.size > 0 && await isBinaryFile(filePath)) return;
|
||
|
||
// F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码)
|
||
const buffer = await readFile(filePath);
|
||
const { content } = decodeBufferWithDetection(buffer);
|
||
const lines = content.split('\n');
|
||
for (let i = 0; i < lines.length; i++) {
|
||
if (results.length >= limit) break;
|
||
// v0.3.2 修复 regex bug: 每次匹配前重置 lastIndex,防止 g 标志导致漏匹配
|
||
regex.lastIndex = 0;
|
||
if (regex.test(lines[i])) {
|
||
const match = lines[i].trim().slice(0, 200);
|
||
// v0.3.2: context lines 支持
|
||
let context: string[] | undefined;
|
||
if (contextLines > 0) {
|
||
const start = Math.max(0, i - contextLines);
|
||
const end = Math.min(lines.length - 1, i + contextLines);
|
||
context = lines.slice(start, end + 1);
|
||
}
|
||
results.push({
|
||
path: relative(dirPath, filePath),
|
||
line: i + 1,
|
||
match,
|
||
context,
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// 跳过不可读文件
|
||
}
|
||
}, fileGlob, includeHidden, workspacePath);
|
||
|
||
return { results, count: results.length, success: true };
|
||
}
|
||
|
||
private async walkDir(
|
||
dirPath: string,
|
||
callback: (filePath: string, name: string) => Promise<void>,
|
||
fileGlob?: string,
|
||
includeHidden?: boolean,
|
||
workspacePath?: string,
|
||
): Promise<void> {
|
||
try {
|
||
const entries = await readdir(dirPath, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
// v0.3.2: include_hidden 参数化
|
||
if (!includeHidden && entry.name.startsWith('.')) continue;
|
||
if (entry.name === 'node_modules') continue;
|
||
|
||
const fullPath = join(dirPath, entry.name);
|
||
if (entry.isDirectory()) {
|
||
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath);
|
||
} else {
|
||
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx")
|
||
if (fileGlob && !matchAnyGlob(entry.name, fileGlob)) continue;
|
||
await callback(fullPath, entry.name);
|
||
}
|
||
}
|
||
} catch {
|
||
// 目录读取失败
|
||
}
|
||
}
|
||
|
||
/** search_files 的路径解析:仅需遍历防护,不需要 MEMORY.md 拦截(搜索时单独跳过) */
|
||
private resolveSearchPath(filePath: string, workspacePath: string): string {
|
||
const resolved = resolve(workspacePath, filePath);
|
||
if (!isPathWithinWorkspace(filePath, workspacePath)) {
|
||
throw new Error(`Path traversal detected: ${filePath}`);
|
||
}
|
||
return resolved;
|
||
}
|
||
}
|
||
|
||
// ===== 5. delete_file =====
|
||
|
||
/**
|
||
* 文件删除工具
|
||
*
|
||
* 安全策略:
|
||
* 1. 路径遍历防护 — 通过 safeResolvePath 复用 isPathWithinWorkspace 校验
|
||
* 2. 受保护文件拦截 — MEMORY.md 禁止删除
|
||
* 3. 工作空间根目录保护 — 禁止删除工作空间本身(防止清空整个项目)
|
||
* 4. 递归删除需显式开启 — 默认仅删除文件或空目录,recursive: true 才递归
|
||
* 5. 风险等级 HIGH + requiresPermission — 破坏性操作必须用户确认
|
||
*/
|
||
export class DeleteFileTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'delete_file',
|
||
description:
|
||
'Delete a file or directory. By default only deletes files or empty directories; set recursive: true to delete non-empty directories. Path must be within workspace. Cannot delete workspace root or MEMORY.md.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
file_path: { type: 'string', description: 'Path to the file or directory to delete' },
|
||
recursive: {
|
||
type: 'boolean',
|
||
description: 'Allow recursive deletion of non-empty directories (default false). Use with caution.',
|
||
},
|
||
},
|
||
required: ['file_path'],
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.HIGH,
|
||
requiresPermission: true,
|
||
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
const filePath = args.file_path as string;
|
||
const recursive = (args.recursive as boolean) ?? false;
|
||
|
||
if (!filePath) {
|
||
return { error: 'file_path is required', success: false };
|
||
}
|
||
|
||
let resolvedPath: string;
|
||
try {
|
||
// 复用共享路径校验:isPathWithinWorkspace + isProtectedWorkspaceFile
|
||
resolvedPath = safeResolvePath(filePath, context.workspacePath);
|
||
} catch (error) {
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
|
||
// 额外安全:禁止删除工作空间根目录本身(防止清空整个项目)
|
||
const workspaceRoot = resolve(context.workspacePath);
|
||
if (resolvedPath === workspaceRoot) {
|
||
return {
|
||
error: 'Cannot delete workspace root directory',
|
||
path: filePath,
|
||
success: false,
|
||
};
|
||
}
|
||
|
||
// 目标必须存在
|
||
if (!existsSync(resolvedPath)) {
|
||
return { error: 'File or directory not found', path: filePath, success: false };
|
||
}
|
||
|
||
try {
|
||
const stats = await stat(resolvedPath);
|
||
const isDirectory = stats.isDirectory();
|
||
|
||
if (isDirectory) {
|
||
if (recursive) {
|
||
// 递归删除非空目录
|
||
await rm(resolvedPath, { recursive: true, force: false });
|
||
} else {
|
||
// 仅删除空目录(非空时 rmdir 抛 ENOTEMPTY)
|
||
await rmdir(resolvedPath);
|
||
}
|
||
} else {
|
||
// 删除文件
|
||
await unlink(resolvedPath);
|
||
}
|
||
|
||
return {
|
||
path: filePath,
|
||
wasDirectory: isDirectory,
|
||
recursive,
|
||
success: true,
|
||
};
|
||
} catch (error) {
|
||
const errMsg = extractErrorMessage(error);
|
||
// 区分"目录非空"错误,给用户清晰提示
|
||
if (typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOTEMPTY') {
|
||
return {
|
||
error: 'Directory not empty. Set recursive: true to delete non-empty directories.',
|
||
path: filePath,
|
||
success: false,
|
||
};
|
||
}
|
||
return { error: errMsg, path: filePath, success: false };
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 6. file_move =====
|
||
|
||
/**
|
||
* F3-1: 文件移动/重命名工具
|
||
*
|
||
* 安全策略:
|
||
* 1. 源路径和目标路径都必须在工作空间内(双重 safeResolvePath 校验)
|
||
* 2. 受保护文件(MEMORY.md)禁止移动(safeResolvePath 内置拦截)
|
||
* 3. 自动创建目标父目录
|
||
* 4. 支持覆盖已存在文件(overwrite 参数)
|
||
* 5. 禁止移动工作空间根目录
|
||
* 6. rename 在同文件系统上是原子操作
|
||
*/
|
||
export class FileMoveTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'file_move',
|
||
description:
|
||
'Move or rename a file or directory. Source and destination must be within workspace. Auto-creates destination parent directory. Use overwrite: true to replace existing destination. Atomic on same filesystem (uses rename).',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
source_path: { type: 'string', description: 'Path to the file/directory to move' },
|
||
destination_path: { type: 'string', description: 'Destination path' },
|
||
overwrite: { type: 'boolean', description: 'Overwrite if destination exists (default false)' },
|
||
},
|
||
required: ['source_path', 'destination_path'],
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.MEDIUM,
|
||
requiresPermission: true,
|
||
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const sourcePath = args.source_path as string;
|
||
const destinationPath = args.destination_path as string;
|
||
const overwrite = (args.overwrite as boolean) ?? false;
|
||
|
||
if (!sourcePath || !destinationPath) {
|
||
return { error: 'source_path and destination_path are required', success: false };
|
||
}
|
||
|
||
// F3-1: 双路径校验(源和目标都必须在工作空间内 + 非 MEMORY.md)
|
||
let resolvedSource: string;
|
||
let resolvedDest: string;
|
||
try {
|
||
resolvedSource = safeResolvePath(sourcePath, context.workspacePath);
|
||
resolvedDest = safeResolvePath(destinationPath, context.workspacePath);
|
||
} catch (error) {
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
|
||
// 禁止移动工作空间根目录
|
||
const workspaceRoot = resolve(context.workspacePath);
|
||
if (resolvedSource === workspaceRoot) {
|
||
return {
|
||
error: 'Cannot move workspace root directory',
|
||
source_path: sourcePath,
|
||
success: false,
|
||
};
|
||
}
|
||
|
||
// 源必须存在
|
||
if (!existsSync(resolvedSource)) {
|
||
return { error: 'Source not found', path: sourcePath, success: false };
|
||
}
|
||
|
||
// 目标已存在处理
|
||
let overwritten = false;
|
||
if (existsSync(resolvedDest)) {
|
||
if (!overwrite) {
|
||
return {
|
||
error: 'Destination already exists. Use overwrite: true to replace.',
|
||
destination_path: destinationPath,
|
||
success: false,
|
||
};
|
||
}
|
||
// overwrite: true — 删除目标
|
||
const destStats = await stat(resolvedDest);
|
||
if (destStats.isDirectory()) {
|
||
await rm(resolvedDest, { recursive: true, force: false });
|
||
} else {
|
||
await unlink(resolvedDest);
|
||
}
|
||
overwritten = true;
|
||
}
|
||
|
||
// 自动创建目标父目录
|
||
const destParentDir = dirname(resolvedDest);
|
||
if (!existsSync(destParentDir)) {
|
||
await mkdir(destParentDir, { recursive: true });
|
||
}
|
||
|
||
// 执行移动(rename 在同文件系统上是原子操作)
|
||
await rename(resolvedSource, resolvedDest);
|
||
|
||
const stats = await stat(resolvedDest);
|
||
return {
|
||
source: sourcePath,
|
||
destination: destinationPath,
|
||
isDirectory: stats.isDirectory(),
|
||
overwritten,
|
||
success: true,
|
||
};
|
||
} catch (error) {
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
}
|
||
|
||
// ===== 7. file_info =====
|
||
|
||
/**
|
||
* F3-2: 文件信息查询工具
|
||
*
|
||
* 返回文件的元信息:大小、时间戳、类型、编码检测、权限位。
|
||
* 只读操作,风险等级 SAFE。
|
||
*
|
||
* 编码检测只读取前 8KB,避免大文件 OOM。
|
||
*/
|
||
export class FileInfoTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'file_info',
|
||
description:
|
||
'Get detailed file information: size, timestamps (created/modified/accessed), type (file/directory/symlink), encoding detection (UTF-8/UTF-16/GBK), binary check, and permission bits.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
file_path: { type: 'string', description: 'Path to the file or directory' },
|
||
},
|
||
required: ['file_path'],
|
||
},
|
||
category: MetonaToolCategory.FILESYSTEM,
|
||
riskLevel: MetonaRiskLevel.SAFE,
|
||
requiresPermission: false,
|
||
timeoutMs: FILE_TOOL_TIMEOUT_MS,
|
||
};
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
try {
|
||
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
||
|
||
if (!existsSync(filePath)) {
|
||
return { error: 'File not found', path: args.file_path, success: false };
|
||
}
|
||
|
||
const stats = await stat(filePath);
|
||
const isDir = stats.isDirectory();
|
||
const isFile = stats.isFile();
|
||
const isSymlink = stats.isSymbolicLink();
|
||
|
||
const info: Record<string, unknown> = {
|
||
path: args.file_path,
|
||
absolute_path: filePath,
|
||
type: isDir ? 'directory' : isFile ? 'file' : isSymlink ? 'symlink' : 'unknown',
|
||
size: stats.size,
|
||
created: stats.birthtime.toISOString(),
|
||
modified: stats.mtime.toISOString(),
|
||
accessed: stats.atime.toISOString(),
|
||
mode: stats.mode.toString(8), // 八进制权限位
|
||
success: true,
|
||
};
|
||
|
||
// F3-2: 文件类型额外信息(编码 + 二进制检测)
|
||
// 只读前 8KB,避免大文件 OOM
|
||
if (isFile) {
|
||
let fd: FileHandle | null = null;
|
||
try {
|
||
fd = await open(filePath, 'r');
|
||
const buffer = Buffer.alloc(8192);
|
||
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
|
||
const actualBuffer = buffer.slice(0, bytesRead);
|
||
|
||
// 二进制检测(NULL 字节检查)
|
||
let isBinary = false;
|
||
for (let i = 0; i < bytesRead; i++) {
|
||
if (actualBuffer[i] === 0) {
|
||
isBinary = true;
|
||
break;
|
||
}
|
||
}
|
||
info.is_binary = isBinary;
|
||
|
||
// 编码检测(仅非二进制文件)
|
||
if (!isBinary && bytesRead > 0) {
|
||
const { encoding } = decodeBufferWithDetection(actualBuffer);
|
||
info.encoding = encoding;
|
||
} else if (bytesRead === 0) {
|
||
info.encoding = 'utf-8'; // 空文件默认 UTF-8
|
||
}
|
||
} catch {
|
||
// 读取失败,不报告编码信息
|
||
} finally {
|
||
if (fd) {
|
||
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
return info;
|
||
} catch (error) {
|
||
return { error: extractErrorMessage(error), success: false };
|
||
}
|
||
}
|
||
}
|