Files
metona-ai-desktop/electron/harness/tools/built-in/filesystem.ts
T
thzxx 4554177db0 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 提前终止,避免大目录全量遍历
2026-07-14 21:42:46 +08:00

616 lines
23 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.
/**
* 文件系统工具(5 个)
*
* 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, 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,
extractErrorMessage,
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) 初始化为全 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');
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.',
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)' },
limit: { type: 'number', description: 'Maximum lines to read (default 500, 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));
// 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;
return {
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 };
}
}
}
// ===== 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') {
// 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 };
}
}
}
// ===== 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 (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: 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 {
// glob 过滤仅适用于文件
if (glob && !matchGlob(entry.name, glob)) continue;
const stats = await stat(fullPath);
// v0.3.2: 添加 modified timeISO 字符串)
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 (e.g., "*.py")' },
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;
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,
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 {
if (fileGlob && !matchGlob(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 };
}
}
}