Files
metona-ai-desktop/electron/harness/tools/built-in/filesystem.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

1180 lines
43 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,
mkdir,
open,
unlink,
rmdir,
rm,
rename,
realpath,
type FileHandle,
} from 'fs/promises';
import { join, relative, resolve, dirname } from 'path';
import { existsSync, realpathSync } from 'fs';
import { nanoid } from 'nanoid';
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,
matchAnyGlob,
extractErrorMessage,
decodeBufferWithDetection,
MAX_FILE_SIZE_BYTES,
FILE_TOOL_TIMEOUT_MS,
isPotentiallyCatastrophicRegex,
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 字节的纯文本文件为二进制。
*
* v0.8.0 P1-3.3 根治: UTF-16 文件(LE: FF FE / BE: FE FF BOM)几乎每个字符
* 都含 0x00 字节 —— NULL 字节启发式把一切 UTF-16 文本判为二进制并整体拒绝,
* 使 decodeBufferWithDetection 的 UTF-16 解码分支不可达(原缺陷被
* filesystem-tools.test.ts 锁定留档,本次随修复同步改约)。现先检测 UTF-16
* BOM:命中即视为文本(UTF-16 解码由 decodeBufferWithDetection 承担),
* 未命中 BOM 才走 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');
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
// v0.8.0 P1-3.3: UTF-16 LE/BE BOM → 文本(NUL 启发式对其无效)
if (bytesRead >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) return false; // UTF-16 LE BOM
if (bytesRead >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) return false; // UTF-16 BE BOM
// 仅检查实际读取的字节范围 [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. Smart encoding detection: supports UTF-8/UTF-8-BOM/UTF-16LE/UTF-16BE (BOM) and GBK/CP936 (fallback for Windows Chinese files). Returns the detected encoding in the response. Supports tail mode to read the last N lines (useful for logs).',
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). Ignored if tail is specified.',
},
limit: {
type: 'number',
description:
'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.',
},
tail: {
type: 'number',
description:
'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. 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));
// F2-2: tail 模式 — 从文件末尾读取 N 行(优先于 offset/limit
const tail =
args.tail !== undefined
? Math.min(2000, Math.max(1, Math.floor(args.tail as number)))
: undefined;
// 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,
};
}
// F2-1: 智能编码检测 — 读取原始 Buffer 后用 BOM 检测 + UTF-8/GBK 降级
// 解决 Windows 中文环境 GBK 文件读取乱码,以及 UTF-16 文件读取问题
const buffer = await readFile(filePath);
const { content, encoding } = decodeBufferWithDetection(buffer);
const lines = content.split('\n');
// F2-2: 根据 tail 参数选择切片模式
let slicedLines: string[];
let truncated: boolean;
let startLine: number;
if (tail !== undefined) {
// tail 模式:从末尾取 tail 行
slicedLines = lines.slice(-tail);
truncated = lines.length > tail;
startLine = Math.max(1, lines.length - tail + 1);
} else {
// offset/limit 模式
slicedLines = lines.slice(offset - 1, offset - 1 + limit);
truncated = lines.length > offset - 1 + limit;
startLine = offset;
}
// 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,
start_line: startLine, // F2-2: 返回实际起始行号
lines_truncated: truncatedLines,
file_size: stats.size,
encoding, // F2-1: 检测到的编码(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose
mode: tail !== undefined ? 'tail' : 'offset', // F2-2: 读取模式
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') {
// F1-4: append 模式增强 — 显式 open(O_APPEND) + write + fsync + close
// 修复 v0.3.2 注释错误:"append 本身是原子的"不准确
// 实际:fs.appendFile 内部是 open(O_APPEND) + write + close,存在:
// - 无 fsyncwrite 后数据在 page cache,崩溃可能丢失
// - 大内容(> 4KB)非完全原子:可能被分成多个 write
// 改进:显式 open + 循环 write + fsync + close,保证数据持久化到磁盘
// 保持 append 语义(O_APPEND 内核级追加),不改变并发行为
// v0.7.4 P2-4: append 模式 TOCTOU 防护 —— safeResolvePath 校验时目标可能
// 是普通文件(realpath 通过),但 open(filePath,'a') 跟随符号链接;攻击者
// 在校验后把目标替换为指向工作空间外文件的 symlink,O_APPEND 会写入外部
// 文件。仿 delete_file 的 #20 双 realpath 比对:open 前记录 realpath 与
// 工作空间边界比对,open 后(写入前)再次比对,任一不一致即拒绝。
const fileExisted = existsSync(filePath);
const oldSize = fileExisted ? (await stat(filePath)).size : 0;
// v0.7.4 P2-4 修正: 统一 TOCTOU 校验 —— 已存在与新文件分支都走
// "open 后 realpath 边界校验"。旧实现新文件分支(!fileExisted)无校验:
// 攻击者在 existsSync=false 与 open('a') 之间放置指向工作空间外文件的
// symlinkO_APPEND 会跟随写入外部文件(正是本防护要防的形态)。
// 统一流程:open → realpath(filePath) → isPathWithinWorkspace 校验 →
// 校验通过才写入;失败拒绝并关闭 fd。
let fd: FileHandle | null = null;
try {
fd = await open(filePath, 'a');
// open 后(写入前)realpath 校验 —— 防止目标被替换为工作空间外 symlink
let realAfter: string;
try {
realAfter = realpathSync(filePath);
} catch {
realAfter = filePath;
}
if (!isPathWithinWorkspace(realAfter, context.workspacePath)) {
return {
error: 'Path escape detected (TOCTOU protection)',
path: filePath,
success: false,
};
}
// 校验通过后正式执行追加写入
const buffer = Buffer.from(content, 'utf-8');
let totalWritten = 0;
while (totalWritten < buffer.length) {
const { bytesWritten } = await fd.write(
buffer,
totalWritten,
buffer.length - totalWritten,
);
totalWritten += bytesWritten;
}
await fd.sync(); // fsync 保证数据持久化到磁盘
} finally {
if (fd) {
try {
await fd.close();
} catch {
/* 忽略关闭错误 */
}
}
}
const newStats = await stat(filePath);
return {
bytes_written: contentBytes,
path: filePath,
mode: 'append',
created: !fileExisted,
old_size: oldSize,
new_file_size: newStats.size,
success: true,
};
}
// v0.3.2: overwrite 模式使用原子写入(临时文件 + rename,与 file_editor 一致)
// v0.8.0 P1-3.4: tmp 文件名追加 nanoid 随机段 —— 同毫秒并发写同一文件时
// Date.now() 精度的 tmp 名会互相覆盖(并行工具调用场景)
const tmpPath = `${filePath}.tmp_${Date.now()}_${nanoid(6)}`;
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. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")',
},
include_hidden: {
type: 'boolean',
description: 'Include hidden files/dirs starting with "." (default false)',
},
include_node_modules: {
type: 'boolean',
description:
'Include node_modules directories (default false — skipped for performance; set true to inspect dependency packages)',
},
},
},
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.8.0 P1-3.6: node_modules 可选纳入(默认跳过保持兼容)
const includeNodeModules = (args.include_node_modules 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,
includeNodeModules,
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,
includeNodeModules: 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;
// v0.8.0 P1-3.6: node_modules 默认跳过(太大);include_node_modules=true
// 时纳入(分析依赖包场景;上限保护仍生效)
if (entry.name === 'node_modules' && !includeNodeModules) 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,
includeNodeModules,
currentDepth + 1,
results,
maxEntries,
);
}
} else {
// F2-4: glob 过滤仅适用于文件,支持多 glob(逗号分隔,如 "*.ts,*.js"
if (glob && !matchAnyGlob(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. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")',
},
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".',
},
case_sensitive: {
type: 'boolean',
description:
'Case sensitive search (default false — v0.8.0 P1-3.5: previously hardcoded case-insensitive, breaking code_search fallback symmetry)',
},
context_before: {
type: 'number',
description: 'Lines of context before match (default = context_lines, max 5)',
},
context_after: {
type: 'number',
description: 'Lines of context after match (default = context_lines, max 5)',
},
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));
// v0.8.0 P1-3.5: 与 code_search 参数对称 —— case_sensitive 此前被硬编码
// 'gi' 忽略;context_before/after 此前在 code_search JS 回退路径静默丢失。
// 缺省时回退 context_lines(向后兼容)。
const caseSensitive = (args.case_sensitive as boolean) ?? false;
const contextBefore = Math.min(
5,
Math.max(0, (args.context_before as number) ?? contextLines),
);
const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? contextLines));
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,
contextBefore,
contextAfter,
caseSensitive,
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,
contextBefore: number,
contextAfter: number,
caseSensitive: boolean,
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,
};
}
// v0.7.4 P2-7: 灾难性正则(ReDoS)拦截 —— 与 file_editor 共用共享检测。
// 旧实现仅限制长度,`(a+)+$` 对超长行(单行可达 10MB 文件内)可指数级回溯
// 阻塞主进程事件循环。
if (isPotentiallyCatastrophicRegex(pattern)) {
return {
results: [],
count: 0,
error:
'Search pattern rejected: potentially catastrophic regex (ReDoS risk). Please simplify the pattern.',
success: false,
};
}
let regex: RegExp;
try {
// v0.8.0 P1-3.5: case_sensitive 参数生效(此前硬编码 'gi' 恒大小写不敏感)
regex = new RegExp(pattern, caseSensitive ? 'g' : '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;
// F2-3: 跳过二进制文件(避免读取乱码 + 提升性能)
// 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false
if (fileStats.size > 0 && (await isBinaryFile(filePath))) return;
// F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码)
const buffer = await readFile(filePath);
const { content } = decodeBufferWithDetection(buffer);
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 支持
// v0.8.0 P1-3.5: 前后文行数独立(code_search 对称性)
let context: string[] | undefined;
if (contextBefore > 0 || contextAfter > 0) {
const start = Math.max(0, i - contextBefore);
const end = Math.min(lines.length - 1, i + contextAfter);
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,
// #21 修复: 添加深度限制,防止深层嵌套目录触发栈溢出
depth: number = 0,
// #21 修复: 最大递归深度(默认 20),超过则停止递归
maxDepth: number = 20,
// #21 修复: 符号链接循环防护 — 记录已访问的 realpath,防止 A -> B -> A 死循环
visited: Set<string> = new Set(),
): Promise<void> {
// #21 修复: 深度限制 — 超过 maxDepth 立即返回,防止栈溢出
if (depth > maxDepth) return;
// #21 修复: 符号链接循环防护 — realpath 去重,防止 A -> B -> A 形成循环导致死循环
// 审查修复: 改用异步 fs.promises.realpath,避免同步 realpathSync 阻塞 Electron 主进程。
// realpath 失败(权限问题等)时回退到 dirPath 本身,保守继续。
const real = await realpath(dirPath).catch(() => dirPath);
if (visited.has(real)) return;
visited.add(real);
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()) {
// #21 修复: 不跟随符号链接目录,防止循环遍历与越权访问
if (!entry.isSymbolicLink()) {
await this.walkDir(
fullPath,
callback,
fileGlob,
includeHidden,
workspacePath,
depth + 1,
maxDepth,
visited,
);
}
} else {
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx"
if (fileGlob && !matchAnyGlob(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 {
// #20 修复: TOCTOU 竞态防护 — 删除前 realpath 二次校验
// 攻击场景:在校验(safeResolvePath)与删除(unlink/rmdir)之间,
// 攻击者用符号链接替换目标文件,使 unlink 实际删除符号链接指向的工作空间外文件
// 防护:记录删除前 realpath,删除前再次校验未被替换
let realBefore: string;
try {
realBefore = realpathSync(resolvedPath);
} catch {
// realpath 失败(路径不存在等),使用 resolvedPath
realBefore = resolvedPath;
}
if (!isPathWithinWorkspace(realBefore, context.workspacePath)) {
return {
error: 'Path escape detected (TOCTOU protection)',
path: filePath,
success: false,
};
}
const stats = await stat(resolvedPath);
const isDirectory = stats.isDirectory();
// #20 修复: 删除前再次 realpath 校验,确保文件未被替换为指向工作空间外的符号链接
let realAfter: string;
try {
realAfter = realpathSync(resolvedPath);
} catch {
realAfter = resolvedPath;
}
if (realAfter !== realBefore) {
return {
error: 'TOCTOU detected: file replaced during deletion',
path: filePath,
success: false,
};
}
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 };
}
}
}
// ===== 6. file_move =====
/**
* F3-1: 文件移动/重命名工具
*
* 安全策略:
* 1. 源路径和目标路径都必须在工作空间内(双重 safeResolvePath 校验)
* 2. 受保护文件(MEMORY.md)禁止移动(safeResolvePath 内置拦截)
* 3. 自动创建目标父目录
* 4. 支持覆盖已存在文件(overwrite 参数)
* 5. 禁止移动工作空间根目录
* 6. rename 在同文件系统上是原子操作
*/
export class FileMoveTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'file_move',
description:
'Move or rename a file or directory. Source and destination must be within workspace. Auto-creates destination parent directory. Use overwrite: true to replace existing destination. Atomic on same filesystem (uses rename).',
parameters: {
type: 'object',
properties: {
source_path: { type: 'string', description: 'Path to the file/directory to move' },
destination_path: { type: 'string', description: 'Destination path' },
overwrite: {
type: 'boolean',
description: 'Overwrite if destination exists (default false)',
},
},
required: ['source_path', 'destination_path'],
},
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 sourcePath = args.source_path as string;
const destinationPath = args.destination_path as string;
const overwrite = (args.overwrite as boolean) ?? false;
if (!sourcePath || !destinationPath) {
return { error: 'source_path and destination_path are required', success: false };
}
// F3-1: 双路径校验(源和目标都必须在工作空间内 + 非 MEMORY.md
let resolvedSource: string;
let resolvedDest: string;
try {
resolvedSource = safeResolvePath(sourcePath, context.workspacePath);
resolvedDest = safeResolvePath(destinationPath, context.workspacePath);
} catch (error) {
return { error: extractErrorMessage(error), success: false };
}
// 禁止移动工作空间根目录
const workspaceRoot = resolve(context.workspacePath);
if (resolvedSource === workspaceRoot) {
return {
error: 'Cannot move workspace root directory',
source_path: sourcePath,
success: false,
};
}
// 源必须存在
if (!existsSync(resolvedSource)) {
return { error: 'Source not found', path: sourcePath, success: false };
}
// 目标已存在处理
let overwritten = false;
if (existsSync(resolvedDest)) {
if (!overwrite) {
return {
error: 'Destination already exists. Use overwrite: true to replace.',
destination_path: destinationPath,
success: false,
};
}
// overwrite: true — 删除目标
const destStats = await stat(resolvedDest);
if (destStats.isDirectory()) {
await rm(resolvedDest, { recursive: true, force: false });
} else {
await unlink(resolvedDest);
}
overwritten = true;
}
// 自动创建目标父目录
const destParentDir = dirname(resolvedDest);
if (!existsSync(destParentDir)) {
await mkdir(destParentDir, { recursive: true });
}
// 执行移动(rename 在同文件系统上是原子操作)
await rename(resolvedSource, resolvedDest);
const stats = await stat(resolvedDest);
return {
source: sourcePath,
destination: destinationPath,
isDirectory: stats.isDirectory(),
overwritten,
success: true,
};
} catch (error) {
return { error: extractErrorMessage(error), success: false };
}
}
}
// ===== 7. file_info =====
/**
* F3-2: 文件信息查询工具
*
* 返回文件的元信息:大小、时间戳、类型、编码检测、权限位。
* 只读操作,风险等级 SAFE。
*
* 编码检测只读取前 8KB,避免大文件 OOM。
*/
export class FileInfoTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'file_info',
description:
'Get detailed file information: size, timestamps (created/modified/accessed), type (file/directory/symlink), encoding detection (UTF-8/UTF-16/GBK), binary check, and permission bits.',
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file or directory' },
},
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);
if (!existsSync(filePath)) {
return { error: 'File not found', path: args.file_path, success: false };
}
const stats = await stat(filePath);
const isDir = stats.isDirectory();
const isFile = stats.isFile();
const isSymlink = stats.isSymbolicLink();
const info: Record<string, unknown> = {
path: args.file_path,
absolute_path: filePath,
type: isDir ? 'directory' : isFile ? 'file' : isSymlink ? 'symlink' : 'unknown',
size: stats.size,
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
accessed: stats.atime.toISOString(),
mode: stats.mode.toString(8), // 八进制权限位
success: true,
};
// F3-2: 文件类型额外信息(编码 + 二进制检测)
// 只读前 8KB,避免大文件 OOM
if (isFile) {
let fd: FileHandle | null = null;
try {
fd = await open(filePath, 'r');
const buffer = Buffer.alloc(8192);
const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
const actualBuffer = buffer.slice(0, bytesRead);
// 二进制检测(NULL 字节检查)
let isBinary = false;
for (let i = 0; i < bytesRead; i++) {
if (actualBuffer[i] === 0) {
isBinary = true;
break;
}
}
info.is_binary = isBinary;
// 编码检测(仅非二进制文件)
if (!isBinary && bytesRead > 0) {
const { encoding } = decodeBufferWithDetection(actualBuffer);
info.encoding = encoding;
} else if (bytesRead === 0) {
info.encoding = 'utf-8'; // 空文件默认 UTF-8
}
} catch {
// 读取失败,不报告编码信息
} finally {
if (fd) {
try {
await fd.close();
} catch {
/* 忽略关闭错误 */
}
}
}
}
return info;
} catch (error) {
return { error: extractErrorMessage(error), success: false };
}
}
}