Files
metona-ai-desktop/electron/harness/tools/built-in/filesystem.ts
T
thzxx e4d81d8247 feat: 升级至 v0.3.1 — 全量代码审计修复 + 安全增强
本次升级基于完整代码审查,修复 Critical/High/Medium/Low 四级共 96 项问题,
并通过返工审计修复 10 项遗留问题,tsc 双端类型检查零错误。

Critical (10/10 完成):
- C-4: command.ts 接入 shell-quote 进行 token-level 注入检测,替代原有正则匹配
  可防御 r"m" -rf /、$'rm'、$(echo rm) 等字符串拼接绕过

High (11/11 完成):
- 竞态保护、Promise.allSettled、AbortController 资源泄漏、IPC 参数校验等

Medium (55/55 完成):
- 事务保护、敏感数据脱敏、枚举校验、MUI v9 Stack prop 迁移、
  React 组件 cancelled 标志、类型收窄等

Low (20/20 完成):
- 辅助方法提取(flushToolCallBuffer/scoreAndPushMemory/tryAddColumn 等)
- nanoid 统一替代 Date.now()+Math.random()
- confirm() 替换为 MUI Dialog、useMemo 缓存、魔法数字命名化等

返工审计修复 (10/10 完成):
- L-11: LogsSettings 残留的原生 confirm()/alert() 全部替换为 MUI Dialog/Alert
- M-53: MemoryViewer handleSearch 独立 ref,修复 searching 状态卡死
- M-42: 脱敏短值(length <= 4)泄露修复
- M-47: tasks:update 补全 title/description 类型校验
- L-9: ollama.adapter 非流式路径 nanoid 统一
- M-45: audit:query limit 策略与 memory:listAll 一致化
- SettingsModal handleConfirmRemove 补全 try/catch + loadServers cleanup
- L-15: CommandPalette useMemo 补全 sessions 响应式依赖
- useAgentStream 事件类型补全 seq/timestamp 字段

新增依赖: shell-quote + @types/shell-quote
版本号: 0.3.0 -> 0.3.1
2026-07-13 22:36:58 +08:00

358 lines
13 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, mkdir, open, 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';
/** 共享路径解析 + 安全校验 */
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 字节 */
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++) {
if (buffer[i] === 0) return true;
}
return false;
} catch {
return false;
} finally {
// M-20 修复: 无论 read 成功或失败,都关闭文件句柄
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
}
}
}
// ===== 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));
// 二进制文件检测 — 避免读取图片/可执行文件产生乱码
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.',
};
}
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'),
};
}
}
// ===== 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';
// 自动创建父目录(递归)
const parentDir = dirname(filePath);
if (!existsSync(parentDir)) {
await mkdir(parentDir, { recursive: true });
}
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, dirPath, depth, glob, 0);
return { entries, count: entries.length };
}
private async listDir(
rootPath: string,
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(rootPath, fullPath);
if (entry.isDirectory()) {
// 目录始终列出(不受 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);
}
} 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 });
}
}
} catch {
// 目录读取失败,跳过
}
return results;
}
}
// ===== 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 }> = [];
await this.walkDir(dirPath, async (filePath, name) => {
if (results.length >= limit) return;
if (matchGlob(name, pattern)) {
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 }> = [];
// 正则安全加固:限制 pattern 长度 + try-catch 防止 ReDoS
if (pattern.length > 500) {
return { results: [], count: 0, error: 'Search pattern too long (max 500 chars)' };
}
let regex: RegExp;
try {
regex = new RegExp(pattern, 'gi');
} catch {
return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}` };
}
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 && !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;
}
}