/** * 文件系统工具(4 个) * * read_file, write_file, list_directory, search_files * * 安全策略: * 1. 路径遍历防护 — 所有路径必须解析到工作空间内(修复前缀碰撞漏洞) * 2. 受保护文件拦截 — MEMORY.md 仅由系统内部 WorkspaceService 管理, * 禁止任何文件工具直接读写(仅限工作空间根目录,子目录不受限) * * @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 * @see standard/开发规范.md — 使用 fs/path 内置模块 */ import { readFile, writeFile, readdir, stat, appendFile } from 'fs/promises'; import { join, relative, resolve } 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'; /** 共享路径解析 + 安全校验 */ 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; } // ===== 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.', 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: 10_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { 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)); 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: content.length, }; } } // ===== 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.', 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: 15_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { const filePath = safeResolvePath(args.file_path as string, context.workspacePath); const content = args.content as string; const mode = (args.mode as string) ?? 'overwrite'; 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 }; } } // ===== 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 and glob filtering.', 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")' }, }, }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 10_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { 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 entries = await this.listDir(dirPath, depth, glob, 0); return { entries, count: entries.length }; } private async listDir(dirPath: string, maxDepth: number, glob: string | undefined, currentDepth: number): Promise> { const results: Array<{ name: string; path: string; type: string; size?: number }> = []; try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { // 跳过隐藏文件和 node_modules if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; const fullPath = join(dirPath, entry.name); const relativePath = relative(dirPath, fullPath); // glob 过滤 if (glob && !this.matchGlob(entry.name, glob)) continue; if (entry.isDirectory()) { results.push({ name: entry.name, path: relativePath, type: 'directory' }); if (currentDepth < maxDepth - 1) { const subEntries = await this.listDir(fullPath, maxDepth, glob, currentDepth + 1); results.push(...subEntries); } } else { const stats = await stat(fullPath); results.push({ name: entry.name, path: relativePath, type: 'file', size: stats.size }); } } } catch { // 目录读取失败,跳过 } return results; } private matchGlob(name: string, glob: string): boolean { const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.'); return new RegExp(`^${pattern}$`, 'i').test(name); } } // ===== 4. search_files ===== 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.', 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)' }, }, required: ['pattern'], }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 30_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { 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)); if (target === 'files') { return this.searchByFilename(searchPath, pattern, fileGlob, limit); } return this.searchByContent(searchPath, pattern, fileGlob, limit, context.workspacePath); } private async searchByFilename(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise { const results: Array<{ path: string; name: string }> = []; const globPattern = pattern.replace(/\*/g, '.*').replace(/\?/g, '.'); const regex = new RegExp(globPattern, 'i'); await this.walkDir(dirPath, async (filePath, name) => { if (results.length >= limit) return; if (regex.test(name)) { results.push({ path: relative(dirPath, filePath), name }); } }, fileGlob); return { results, count: results.length }; } private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number, workspacePath: string): Promise { const results: Array<{ path: string; line: number; match: string }> = []; const regex = new RegExp(pattern, 'gi'); await this.walkDir(dirPath, async (filePath) => { if (results.length >= limit) return; // 跳过工作空间根目录的 MEMORY.md(受保护文件,使用完整路径精确匹配) if (isProtectedWorkspaceFile(filePath, workspacePath)) { return; } try { 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; if (regex.test(lines[i])) { results.push({ path: relative(dirPath, filePath), line: i + 1, match: lines[i].trim().slice(0, 200), }); } regex.lastIndex = 0; } } catch { // 跳过不可读文件 } }, fileGlob); return { results, count: results.length }; } private async walkDir( dirPath: string, callback: (filePath: string, name: string) => Promise, fileGlob?: string, ): Promise { try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; const fullPath = join(dirPath, entry.name); if (entry.isDirectory()) { await this.walkDir(fullPath, callback, fileGlob); } else { if (fileGlob && !this.matchGlob(entry.name, fileGlob)) continue; await callback(fullPath, entry.name); } } } catch { // 目录读取失败 } } private matchGlob(name: string, glob: string): boolean { const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.'); return new RegExp(`^${pattern}$`, 'i').test(name); } /** 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; } }