MEMORY.md 保护: 新建 file-guard.ts 共享守卫模块; read_file/write_file/search_files/run_command 禁止访问根目录 MEMORY.md; permissions.ts 增加 deniedPatterns 深度防御。格式简化: 元数据从 # 注释改为 > 引用语法; 校验规则从 6 条简化为 3 条; context-builder extractContent 适配。预存问题: 修复路径遍历前缀碰撞漏洞; 移除 browser_extract 内容截断; SearXNG 配置实时读取; web_search/web_fetch 移除截断; 侧边栏动态获取工具列表; 版本号 0.1.1
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
/**
|
|
* 文件系统工具(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<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));
|
|
|
|
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<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';
|
|
|
|
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<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;
|
|
|
|
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<Array<{ name: string; path: string; type: string; size?: number }>> {
|
|
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<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));
|
|
|
|
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<unknown> {
|
|
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<unknown> {
|
|
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<void>,
|
|
fileGlob?: 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;
|
|
|
|
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;
|
|
}
|
|
}
|