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
+3
View File
@@ -83,6 +83,9 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
// v0.3.1: 图片查看工具(1 个)— 只读
{ toolName: 'view_image', requiredLevel: PermissionLevel.READ },
// v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认
{ toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
];
export class PolicyEngine {
+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) };
}
}
}
@@ -106,3 +106,74 @@ export function commandTouchesProtectedFile(command: string): boolean {
}
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);
}
/**
* 共享错误提取
*
* 统一从 unknown 错误对象中提取 message 字符串
*
* @param error catch 块中的 unknown 错误
* @returns 错误消息字符串
*/
export function extractErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* 共享文件大小限制常量
*
* 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;
+372 -114
View File
@@ -1,55 +1,61 @@
/**
* 文件系统工具(4 个)
* 文件系统工具(5 个)
*
* read_file, write_file, list_directory, search_files
* read_file, write_file, list_directory, search_files, delete_file
*
* 安全策略:
* 1. 路径遍历防护 — 所有路径必须解析到工作空间内(修复前缀碰撞漏洞)
* 2. 受保护文件拦截 — MEMORY.md 仅由系统内部 WorkspaceService 管理,
* 禁止任何文件工具直接读写(仅限工作空间根目录,子目录不受限)
*
* v0.3.2 全面优化:
* - 抽取共享代码到 file-guard.tssafeResolvePath/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, appendFile, mkdir, open, type FileHandle } from 'fs/promises';
import { readFile, writeFile, readdir, stat, appendFile, 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 } from './file-guard';
import {
isProtectedWorkspaceFile,
isPathWithinWorkspace,
safeResolvePath,
matchGlob,
extractErrorMessage,
MAX_FILE_SIZE_BYTES,
FILE_TOOL_TIMEOUT_MS,
MAX_LINE_LENGTH,
} from './file-guard';
/** 共享路径解析 + 安全校验 */
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 匹配(简易通配符 → 正则) */
function matchGlob(name: string, glob: string): boolean {
const pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
return new RegExp(`^${pattern}$`, 'i').test(name);
}
/** 二进制文件检测:读取前 8KB 检查是否含 NULL 字节 */
/**
* 二进制文件检测:读取前 8KB 检查是否含 NULL 字节
*
* v0.3.2 修复 FAIL: 使用 bytesRead 限制循环上界,而非 buffer.length。
* Buffer.alloc(8192) 初始化为全 0fd.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');
await fd.read(buffer, 0, 8192, 0);
// 含 NULL 字节 → 二进制
for (let i = 0; i < buffer.length; i++) {
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;
@@ -63,12 +69,21 @@ async function isBinaryFile(filePath: string): Promise<boolean> {
}
}
/** 截断超长行(防止超长单行爆 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 file. Returns the full text content. Supports line offset and limit for large files.',
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.',
parameters: {
type: 'object',
properties: {
@@ -81,37 +96,65 @@ export class ReadFileTool implements IMetonaTool {
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 10_000,
timeoutMs: FILE_TOOL_TIMEOUT_MS,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
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));
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));
// 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,
};
}
const content = await readFile(filePath, 'utf-8');
const lines = content.split('\n');
const slicedLines = lines.slice(offset - 1, offset - 1 + limit);
// v0.3.2: 超长行截断
const processedLines = slicedLines.map((line) => truncateLine(line));
const truncatedLines = processedLines.filter((l) => l.truncated).length;
// 二进制文件检测 — 避免读取图片/可执行文件产生乱码
if (await isBinaryFile(filePath)) {
return {
content: '',
total_lines: 0,
returned_lines: 0,
truncated: false,
file_size: (await stat(filePath)).size,
error: 'Binary file detected. Use web_browser screenshot or other tools for binary content.',
content: processedLines.map((l) => l.text).join('\n'),
total_lines: lines.length,
returned_lines: slicedLines.length,
truncated: lines.length > offset - 1 + limit,
lines_truncated: truncatedLines,
file_size: stats.size,
success: true,
};
} catch (error) {
return { error: extractErrorMessage(error), success: false };
}
const content = await readFile(filePath, 'utf-8');
const lines = content.split('\n');
const slicedLines = lines.slice(offset - 1, offset - 1 + limit);
return {
content: slicedLines.join('\n'),
total_lines: lines.length,
returned_lines: slicedLines.length,
truncated: lines.length > offset - 1 + limit,
file_size: Buffer.byteLength(content, 'utf-8'),
};
}
}
@@ -120,7 +163,8 @@ export class ReadFileTool implements IMetonaTool {
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.',
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: {
@@ -133,27 +177,70 @@ export class WriteFileTool implements IMetonaTool {
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> {
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const content = args.content as string;
const mode = (args.mode as string) ?? 'overwrite';
try {
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
const content = (args.content as string) ?? '';
const mode = (args.mode as string) ?? 'overwrite';
// 自动创建父目录(递归
const parentDir = dirname(filePath);
if (!existsSync(parentDir)) {
await mkdir(parentDir, { recursive: true });
// 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') {
// append 模式:直接 appendFileappend 本身是原子的)
await appendFile(filePath, content, 'utf-8');
const newStats = await stat(filePath);
return {
bytes_written: contentBytes,
path: filePath,
mode: 'append',
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 };
}
if (mode === 'append') {
await appendFile(filePath, content, 'utf-8');
} else {
await writeFile(filePath, content, 'utf-8');
}
return { bytes_written: Buffer.byteLength(content, 'utf-8'), path: filePath };
}
}
@@ -162,30 +249,45 @@ export class WriteFileTool implements IMetonaTool {
export class ListDirectoryTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'list_directory',
description: 'List the contents of a directory. Supports recursive depth control and glob filtering.',
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 (e.g., "*.ts")' },
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
},
},
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 10_000,
timeoutMs: FILE_TOOL_TIMEOUT_MS,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
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;
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;
const entries = await this.listDir(dirPath, dirPath, depth, glob, 0);
return { entries, count: entries.length };
// 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(
@@ -193,16 +295,23 @@ export class ListDirectoryTool implements IMetonaTool {
dirPath: string,
maxDepth: number,
glob: string | undefined,
includeHidden: boolean,
currentDepth: number,
): Promise<Array<{ name: string; path: string; type: string; size?: number }>> {
const results: Array<{ name: string; path: string; type: string; size?: 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) {
// 跳过隐藏文件和 node_modules
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
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);
// 相对路径始终以根请求目录为基准
@@ -212,21 +321,25 @@ export class ListDirectoryTool implements IMetonaTool {
// 目录始终列出(不受 glob 过滤),保证递归可进入子目录
results.push({ name: entry.name, path: relativePath, type: 'directory' });
if (currentDepth < maxDepth - 1) {
const subEntries = await this.listDir(rootPath, fullPath, maxDepth, glob, currentDepth + 1);
results.push(...subEntries);
await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries);
}
} else {
// glob 过滤仅适用于文件
if (glob && !matchGlob(entry.name, glob)) continue;
const stats = await stat(fullPath);
results.push({ name: entry.name, path: relativePath, type: 'file', size: stats.size });
// v0.3.2: 添加 modified timeISO 字符串)
results.push({
name: entry.name,
path: relativePath,
type: 'file',
size: stats.size,
modified: stats.mtime.toISOString(),
});
}
}
} catch {
// 目录读取失败,跳过
}
return results;
}
}
@@ -235,7 +348,8 @@ export class ListDirectoryTool implements IMetonaTool {
export class SearchFilesTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'search_files',
description: 'Search for files by name pattern or content regex. Uses efficient file scanning.',
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: {
@@ -243,32 +357,47 @@ export class SearchFilesTool implements IMetonaTool {
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 (e.g., "*.py")' },
limit: { type: 'number', description: 'Maximum results (default 50)' },
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: 30_000,
timeoutMs: FILE_TOOL_TIMEOUT_MS,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
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));
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 this.searchByFilename(searchPath, pattern, fileGlob, limit);
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 };
}
return this.searchByContent(searchPath, pattern, fileGlob, limit, context.workspacePath);
}
private async searchByFilename(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
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) => {
@@ -276,23 +405,31 @@ export class SearchFilesTool implements IMetonaTool {
if (matchGlob(name, pattern)) {
results.push({ path: relative(dirPath, filePath), name });
}
}, fileGlob);
}, fileGlob, includeHidden, workspacePath);
return { results, count: results.length };
return { results, count: results.length, success: true };
}
private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number, workspacePath: string): Promise<unknown> {
const results: Array<{ path: string; line: number; match: string }> = [];
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)' };
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}` };
return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}`, success: false };
}
await this.walkDir(dirPath, async (filePath) => {
@@ -302,40 +439,58 @@ export class SearchFilesTool implements IMetonaTool {
return;
}
try {
// v0.3.2: 跳过大文件(避免读取超大文件导致 OOM)
const fileStats = await stat(filePath);
if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
const content = await readFile(filePath, 'utf-8');
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: lines[i].trim().slice(0, 200),
match,
context,
});
}
regex.lastIndex = 0;
}
} catch {
// 跳过不可读文件
}
}, fileGlob);
}, fileGlob, includeHidden, workspacePath);
return { results, count: results.length };
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) {
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
// 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);
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath);
} else {
if (fileGlob && !matchGlob(entry.name, fileGlob)) continue;
await callback(fullPath, entry.name);
@@ -355,3 +510,106 @@ export class SearchFilesTool implements IMetonaTool {
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 };
}
}
}
+5 -2
View File
@@ -1,6 +1,9 @@
/**
* 内置工具导出
*
* v0.3.2: 从 26 个工具扩展到 27 个工具
* 新增:delete_file
*
* v0.3.1: 从 15 个工具扩展到 26 个工具
* 新增:git_status, git_diff, git_log, git_commit, lint_code, run_tests,
* project_info, http_request, todo_write, think, view_image
@@ -11,8 +14,8 @@
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
*/
// 文件系统工具(4 个)
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool } from './filesystem';
// 文件系统工具(5 个)
export { ReadFileTool, WriteFileTool, ListDirectoryTool, SearchFilesTool, DeleteFileTool } from './filesystem';
// v0.2.0: 精准文件编辑工具
export { FileEditorTool } from './file-editor';
// v0.2.0: ripgrep 代码搜索工具
+5
View File
@@ -56,6 +56,8 @@ import {
TodoWriteTool,
ThinkTool,
ViewImageTool,
// v0.3.2 新增工具(1 个)
DeleteFileTool,
} from './harness/tools/built-in';
import { AuditLogHook, MemoryTriggerHook, PermissionCheckHook, RateLimitHook } from './harness/hooks';
import { ConfirmationHook } from './harness/hooks/confirmation-hook';
@@ -189,6 +191,9 @@ async function initialize(): Promise<void> {
toolRegistry.registerBuiltin(new ListDirectoryTool());
toolRegistry.registerBuiltin(new SearchFilesTool());
// v0.3.2: 文件删除工具(破坏性操作,HIGH + requireConfirmation
toolRegistry.registerBuiltin(new DeleteFileTool());
// v0.2.0: 新增文件工具
toolRegistry.registerBuiltin(new FileEditorTool());
toolRegistry.registerBuiltin(new CodeSearchTool());
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "metona-ai-desktop",
"version": "0.3.2",
"version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "metona-ai-desktop",
"version": "0.3.2",
"version": "0.3.3",
"license": "MIT",
"dependencies": {
"@emotion/react": "^11.14.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "metona-ai-desktop",
"version": "0.3.2",
"version": "0.3.3",
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
"main": "dist-electron/main/main.js",
"author": "Metona Team",