Files
metona-ai-desktop/electron/harness/tools/built-in/file-editor.ts
T
thzxx 3c5aea8fb7 feat: 升级至 v0.2.1 — 流式渲染修复、安全增强、工具自动执行
流式渲染修复:
- runId 机制防止 abort 后旧流事件污染新 run
- run lock 防止并发 run 污染引擎状态
- abort race 提前退出工具执行等待
- TERMINATED 状态通过 stateChange 发射
- tool_call_delta 流式参数拼接 + pending 占位替换
- 首轮卡片创建路径统一,traceStep 按 ID 精确匹配
- compressed 事件转发为 toast 通知

安全增强:
- ConfirmationHook 支持持久化自动执行(跨会话)
- 设置面板新增自动执行工具管理 UI
- SandboxManager 双重安全校验 fail-closed
- 审计日志链式哈希防篡改
- PromptInjectionDefender 中文注入标记清理
- scanCode 28 模式 + base64/$() 检测
- validatePath realpathSync 防符号链接逃逸
- code-search 使用 execFile 防命令注入

新增工具:
- file_editor、code_search、task_manager、diff_viewer

其他:
- Agent Loop 加 PARSING/REFLECTING 状态 + 指数退避重试
- MemoryManager TF-IDF 语义检索
- run_command Windows 中文编码修复(chcp 65001)
- 版本号 0.2.0 → 0.2.1
2026-07-12 12:54:52 +08:00

213 lines
8.4 KiB
TypeScript
Raw 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.
/**
* 文件编辑工具(1 个)
*
* file_editor — 精准文件编辑,支持行替换/插入/删除/正则替换
*
* 相比 write_file 的整文件覆盖,file_editor 支持精准定位修改,
* 避免大文件全量重写,减少 token 消耗和出错风险。
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
*/
import { readFile, writeFile, rename, mkdir } 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;
}
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.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file to edit' },
operation: {
type: 'string',
description: 'Edit operation',
enum: ['replace', 'insert', 'delete', 'regex'],
},
start_line: { type: 'number', description: 'Start line number (1-indexed). Used by replace/insert/delete/regex' },
end_line: { type: 'number', description: 'End line number (inclusive). Used by replace/delete' },
content: { type: 'string', description: 'New content for replace/insert operations' },
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")' },
},
required: ['file_path', 'operation'],
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.MEDIUM,
requiresPermission: true,
timeoutMs: 15_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
try {
// F1.5: file_path 空值校验
if (!args.file_path || typeof args.file_path !== 'string') {
return { success: false, error: 'file_path is required' };
}
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const operation = args.operation as 'replace' | 'insert' | 'delete' | 'regex';
// 文件必须存在(不支持创建新文件,请用 write_file
if (!existsSync(filePath)) {
return { success: false, error: `File not found: ${args.file_path}. Use write_file to create new files.` };
}
const originalContent = await readFile(filePath, 'utf-8');
const lines = originalContent.split('\n');
let newLines: string[];
let affectedRange: { start: number; end: number };
switch (operation) {
case 'replace': {
const startLine = Math.max(1, (args.start_line as number) ?? 1);
const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine);
if (endLine < startLine) {
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
}
const content = (args.content as string) ?? '';
const contentLines = content.split('\n');
newLines = [
...lines.slice(0, startLine - 1),
...contentLines,
...lines.slice(endLine),
];
affectedRange = { start: startLine, end: endLine };
break;
}
case 'insert': {
let startLine = Math.max(1, (args.start_line as number) ?? 1);
startLine = Math.min(startLine, lines.length + 1); // 允许追加到末尾
const content = (args.content as string) ?? '';
const contentLines = content.split('\n');
newLines = [
...lines.slice(0, startLine - 1),
...contentLines,
...lines.slice(startLine - 1),
];
affectedRange = { start: startLine, end: startLine + contentLines.length - 1 };
break;
}
case 'delete': {
const startLine = Math.max(1, (args.start_line as number) ?? 1);
const endLine = Math.min(lines.length, (args.end_line as number) ?? startLine);
if (endLine < startLine) {
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
}
newLines = [
...lines.slice(0, startLine - 1),
...lines.slice(endLine),
];
affectedRange = { start: startLine, end: endLine };
break;
}
case 'regex': {
const pattern = args.pattern as string;
const replacement = (args.replacement as string) ?? '';
const flags = (args.flags as string) ?? 'g';
if (!pattern) {
return { success: false, error: 'Regex operation requires "pattern" parameter' };
}
// F1.3: regex pattern 长度限制
if (pattern.length > 500) {
return { success: false, error: 'Regex pattern too long (max 500 chars)' };
}
const startLine = Math.max(1, (args.start_line as number) ?? 1);
const endLine = Math.min(lines.length, (args.end_line as number) ?? lines.length);
if (endLine < startLine) {
return { success: false, error: `end_line (${endLine}) must be >= start_line (${startLine})` };
}
let regex: RegExp;
try {
regex = new RegExp(pattern, flags);
} catch (err) {
return { success: false, error: `Invalid regex: ${(err as Error).message}` };
}
// 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) => {
const matches = line.match(countRegex);
if (matches) replaceCount += matches.length;
return line.replace(regex, replacement);
});
newLines = [
...lines.slice(0, startLine - 1),
...replacedLines,
...lines.slice(endLine),
];
affectedRange = { start: startLine, end: endLine };
// 如果没有替换,返回提示
if (replaceCount === 0) {
return {
success: true,
operation,
file_path: args.file_path,
message: 'No matches found for regex pattern',
replacements: 0,
affected_range: affectedRange,
};
}
break;
}
default:
return { success: false, error: `Unknown operation: ${operation}` };
}
// 自动创建父目录 (F1.8: 异步 mkdir)
const parentDir = dirname(filePath);
if (!existsSync(parentDir)) {
await mkdir(parentDir, { recursive: true });
}
const newContent = newLines.join('\n');
// F1.7: 原子写入 - 临时文件 + rename
const tmpPath = `${filePath}.tmp_${Date.now()}`;
await writeFile(tmpPath, newContent, 'utf-8');
await rename(tmpPath, filePath);
return {
success: true,
operation,
file_path: args.file_path,
affected_range: affectedRange,
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 };
}
}
}