feat: 升级至 v0.3.3 — 新增 delete_file 工具 + 文件工具全面优化

## 主要变更

### 1. 新增 delete_file 工具(26 → 27 个)
- 支持:删除文件/空目录(默认)/递归删除非空目录(recursive: true)
- 5 层安全防护:
  * isPathWithinWorkspace — 路径遍历防护
  * isProtectedWorkspaceFile — MEMORY.md 拦截
  * 工作空间根目录保护 — 禁止删除 workspace 本身
  * 递归删除需显式开启 — 默认仅删空目录
  * riskLevel: HIGH + requiresPermission — 破坏性操作必须确认
- 友好错误处理:ENOTEMPTY 时提示设置 recursive: true

### 2. 文件工具全面优化(6 个工具)
- 抽取共享代码到 file-guard.ts:
  * safeResolvePath — 合并路径遍历 + MEMORY.md 校验
  * matchGlob — 简易通配符匹配
  * extractErrorMessage — 统一错误提取
  * MAX_FILE_SIZE_BYTES (10MB) / FILE_TOOL_TIMEOUT_MS (15s) / MAX_LINE_LENGTH (10000)
- read_file:stat 预检 + 超长行截断 + 二进制检测 + 大小上限
- write_file:原子写入(临时文件+rename)+ 内容大小上限 + append 返回 new_file_size
- list_directory:1000 结果上限 + modified time + include_hidden 参数 + 提前终止优化
- search_files:regex lastIndex 修复 + context_lines + include_hidden + 大文件跳过
- file_editor:dry_run 预览模式 + 文件大小上限

### 3. 审计修复(1 FAIL + 3 WARN)
- FAIL: isBinaryFile 用 bytesRead 限制循环,修复 < 8KB 文本误判为二进制
- WARN-1: file-editor dry_run preview 分模式计算,修复 insert 范围过大
- WARN-2: write_file 允许空字符串创建空文件
- WARN-3: list_directory listDir 提前终止,避免大目录全量遍历
This commit is contained in:
thzxx
2026-07-14 21:42:46 +08:00
parent 0fc589e73b
commit 4554177db0
8 changed files with 553 additions and 140 deletions
+94 -21
View File
@@ -6,34 +6,34 @@
* 相比 write_file 的整文件覆盖,file_editor 支持精准定位修改,
* 避免大文件全量重写,减少 token 消耗和出错风险。
*
* v0.3.2 优化:
* - 使用共享 safeResolvePath(消除重复代码)
* - 使用共享 extractErrorMessage、FILE_TOOL_TIMEOUT_MS、MAX_FILE_SIZE_BYTES
* - 新增 dry_run 模式(预览变更不写入)
* - 文件大小上限(防止 OOM)
* - 统一 timeoutMs 为 15_000
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
*/
import { readFile, writeFile, rename, mkdir } from 'fs/promises';
import { readFile, writeFile, rename, mkdir, stat, unlink } from 'fs/promises';
import { 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 } from './file-guard';
import { resolve } from 'path';
/** 安全校验:路径遍历防护 + 受保护文件拦截 */
function safeResolvePath(filePath: string, workspacePath: string): string {
const resolved = resolve(workspacePath, filePath);
if (!isPathWithinWorkspace(filePath, workspacePath)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
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;
}
import {
safeResolvePath,
extractErrorMessage,
MAX_FILE_SIZE_BYTES,
FILE_TOOL_TIMEOUT_MS,
} from './file-guard';
export class FileEditorTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'file_editor',
description: 'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range). More efficient than write_file for targeted edits.',
description:
'Edit a file with precision. Supports operations: replace (replace lines), insert (insert at line), delete (delete lines), regex (regex replace in range). More efficient than write_file for targeted edits. Supports dry_run mode for preview. File size limit: 10MB.',
parameters: {
type: 'object',
properties: {
@@ -49,13 +49,14 @@ export class FileEditorTool implements IMetonaTool {
pattern: { type: 'string', description: 'Regex pattern for regex operation' },
replacement: { type: 'string', description: 'Replacement string for regex operation' },
flags: { type: 'string', description: 'Regex flags (default "g")' },
dry_run: { type: 'boolean', description: 'Preview mode: return the diff without writing to file (default false)' },
},
required: ['file_path', 'operation'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 15_000,
timeoutMs: FILE_TOOL_TIMEOUT_MS,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
@@ -66,17 +67,28 @@ export class FileEditorTool implements IMetonaTool {
}
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex';
const dryRun = (args.dry_run as boolean) ?? false;
// 文件必须存在(不支持创建新文件,请用 write_file
if (!existsSync(filePath)) {
return { success: false, error: `File not found: ${args.file_path}. Use write_file to create new files.` };
}
// v0.3.2: 文件大小上限(防止 OOM)
const stats = await stat(filePath);
if (stats.size > MAX_FILE_SIZE_BYTES) {
return {
success: false,
error: `File too large (${stats.size} bytes, max ${MAX_FILE_SIZE_BYTES} bytes). Consider using write_file to rewrite.`,
};
}
const originalContent = await readFile(filePath, 'utf-8');
const lines = originalContent.split('\n');
let newLines: string[];
let affectedRange: { start: number; end: number };
let replaceCount = 0; // v0.3.2: 统一在外层声明,供 dry_run 返回
switch (operation) {
case 'replace': {
@@ -93,6 +105,7 @@ export class FileEditorTool implements IMetonaTool {
...lines.slice(endLine),
];
affectedRange = { start: startLine, end: endLine };
replaceCount = endLine - startLine + 1;
break;
}
@@ -107,6 +120,7 @@ export class FileEditorTool implements IMetonaTool {
...lines.slice(startLine - 1),
];
affectedRange = { start: startLine, end: startLine + contentLines.length - 1 };
replaceCount = contentLines.length;
break;
}
@@ -121,6 +135,7 @@ export class FileEditorTool implements IMetonaTool {
...lines.slice(endLine),
];
affectedRange = { start: startLine, end: endLine };
replaceCount = endLine - startLine + 1;
break;
}
@@ -149,7 +164,6 @@ export class FileEditorTool implements IMetonaTool {
}
// F1.4: 用带 g 标志的正则统计匹配数
let replaceCount = 0;
const targetLines = lines.slice(startLine - 1, endLine);
const countRegex = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
const replacedLines = targetLines.map((line) => {
@@ -174,6 +188,7 @@ export class FileEditorTool implements IMetonaTool {
message: 'No matches found for regex pattern',
replacements: 0,
affected_range: affectedRange,
dry_run: dryRun,
};
}
@@ -184,6 +199,55 @@ export class FileEditorTool implements IMetonaTool {
return { success: false, error: `Unknown operation: ${operation}` };
}
// v0.3.2: dry_run 模式 — 预览变更不写入
if (dryRun) {
// v0.3.2 修复 WARN-1: 分模式计算 preview 范围
// - insert: affectedRange.end 是新文件行号,原文件无被替换内容 → original 为空
// - replace/delete/regex: affectedRange.end 是原文件行号,original/modified 取相同范围
let originalPreview: string;
let modifiedPreview: string;
if (operation === 'insert') {
// insert: 原文件无被替换内容;新文件显示插入的内容
originalPreview = '';
modifiedPreview = newLines.slice(
affectedRange.start - 1,
Math.min(affectedRange.end, newLines.length),
).join('\n');
} else {
// replace/delete/regex: 原文件取 [start-1, end) 区间
originalPreview = lines.slice(
affectedRange.start - 1,
Math.min(affectedRange.end, lines.length),
).join('\n');
// 新文件取相同范围 + 行数差修正(replace 可能改变行数,delete 后该位置为空)
const lineDelta = newLines.length - lines.length;
const modifiedEnd = Math.min(
affectedRange.end + lineDelta,
newLines.length,
);
modifiedPreview = newLines.slice(
affectedRange.start - 1,
Math.max(affectedRange.start - 1, modifiedEnd),
).join('\n');
}
return {
success: true,
dry_run: true,
operation,
file_path: args.file_path,
affected_range: affectedRange,
replacements: replaceCount,
lines_before: lines.length,
lines_after: newLines.length,
preview: {
original: originalPreview,
modified: modifiedPreview,
},
};
}
// 自动创建父目录 (F1.8: 异步 mkdir)
const parentDir = dirname(filePath);
if (!existsSync(parentDir)) {
@@ -193,20 +257,29 @@ export class FileEditorTool implements IMetonaTool {
const newContent = newLines.join('\n');
// F1.7: 原子写入 - 临时文件 + rename
const tmpPath = `${filePath}.tmp_${Date.now()}`;
await writeFile(tmpPath, newContent, 'utf-8');
await rename(tmpPath, filePath);
try {
await writeFile(tmpPath, newContent, 'utf-8');
await rename(tmpPath, filePath);
} catch (err) {
// 原子写入失败,清理临时文件
if (existsSync(tmpPath)) {
try { await unlink(tmpPath); } catch { /* 忽略清理错误 */ }
}
throw err;
}
return {
success: true,
operation,
file_path: args.file_path,
affected_range: affectedRange,
replacements: replaceCount,
lines_before: lines.length,
lines_after: newLines.length,
bytes_written: Buffer.byteLength(newContent, 'utf-8'),
};
} catch (err) {
return { success: false, error: (err as Error).message };
return { success: false, error: extractErrorMessage(err) };
}
}
}