feat: MEMORY.md 访问保护、格式简化及预存问题修复
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
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
*
|
||||
* read_file, write_file, list_directory, search_files
|
||||
*
|
||||
* 安全策略:
|
||||
* 1. 路径遍历防护 — 所有路径必须解析到工作空间内(修复前缀碰撞漏洞)
|
||||
* 2. 受保护文件拦截 — MEMORY.md 仅由系统内部 WorkspaceService 管理,
|
||||
* 禁止任何文件工具直接读写(仅限工作空间根目录,子目录不受限)
|
||||
*
|
||||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具
|
||||
* @see standard/开发规范.md — 使用 fs/path 内置模块
|
||||
*/
|
||||
@@ -13,6 +18,21 @@ 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 =====
|
||||
|
||||
@@ -36,7 +56,7 @@ export class ReadFileTool implements IMetonaTool {
|
||||
};
|
||||
|
||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||
const filePath = this.resolvePath(args.file_path as string, context.workspacePath);
|
||||
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));
|
||||
|
||||
@@ -52,15 +72,6 @@ export class ReadFileTool implements IMetonaTool {
|
||||
file_size: content.length,
|
||||
};
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string, workspacePath: string): string {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
// 安全检查:路径遍历防护
|
||||
if (!resolved.startsWith(resolve(workspacePath))) {
|
||||
throw new Error(`Path traversal detected: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 2. write_file =====
|
||||
@@ -85,7 +96,7 @@ export class WriteFileTool implements IMetonaTool {
|
||||
};
|
||||
|
||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||
const filePath = this.resolvePath(args.file_path as string, context.workspacePath);
|
||||
const filePath = safeResolvePath(args.file_path as string, context.workspacePath);
|
||||
const content = args.content as string;
|
||||
const mode = (args.mode as string) ?? 'overwrite';
|
||||
|
||||
@@ -97,14 +108,6 @@ export class WriteFileTool implements IMetonaTool {
|
||||
|
||||
return { bytes_written: Buffer.byteLength(content, 'utf-8'), path: filePath };
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string, workspacePath: string): string {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
if (!resolved.startsWith(resolve(workspacePath))) {
|
||||
throw new Error(`Path traversal detected: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 3. list_directory =====
|
||||
@@ -129,7 +132,7 @@ export class ListDirectoryTool implements IMetonaTool {
|
||||
|
||||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||||
const dirPath = args.dir_path
|
||||
? this.resolvePath(args.dir_path as string, context.workspacePath)
|
||||
? 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;
|
||||
@@ -176,14 +179,6 @@ export class ListDirectoryTool implements IMetonaTool {
|
||||
const pattern = glob.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string, workspacePath: string): string {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
if (!resolved.startsWith(resolve(workspacePath))) {
|
||||
throw new Error(`Path traversal detected: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 4. search_files =====
|
||||
@@ -213,7 +208,7 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
const pattern = args.pattern as string;
|
||||
const target = (args.target as string) ?? 'content';
|
||||
const searchPath = args.path
|
||||
? this.resolvePath(args.path as string, context.workspacePath)
|
||||
? 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));
|
||||
@@ -221,7 +216,7 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
if (target === 'files') {
|
||||
return this.searchByFilename(searchPath, pattern, fileGlob, limit);
|
||||
}
|
||||
return this.searchByContent(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> {
|
||||
@@ -239,12 +234,16 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
return { results, count: results.length };
|
||||
}
|
||||
|
||||
private async searchByContent(dirPath: string, pattern: string, fileGlob: string | undefined, limit: number): Promise<unknown> {
|
||||
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');
|
||||
@@ -295,9 +294,10 @@ export class SearchFilesTool implements IMetonaTool {
|
||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||||
}
|
||||
|
||||
private resolvePath(filePath: string, workspacePath: string): string {
|
||||
/** search_files 的路径解析:仅需遍历防护,不需要 MEMORY.md 拦截(搜索时单独跳过) */
|
||||
private resolveSearchPath(filePath: string, workspacePath: string): string {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
if (!resolved.startsWith(resolve(workspacePath))) {
|
||||
if (!isPathWithinWorkspace(filePath, workspacePath)) {
|
||||
throw new Error(`Path traversal detected: ${filePath}`);
|
||||
}
|
||||
return resolved;
|
||||
|
||||
Reference in New Issue
Block a user