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
This commit is contained in:
thzxx
2026-07-12 12:54:52 +08:00
parent 469f53b623
commit 3c5aea8fb7
41 changed files with 4972 additions and 423 deletions
@@ -0,0 +1,212 @@
/**
* 文件编辑工具(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 };
}
}
}