Files
metona-ai-desktop/electron/harness/tools/built-in/file-guard.ts
T
thzxx 99d0c54129
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s
feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
2026-08-30 19:19:07 +08:00

311 lines
12 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.
/**
* File Guard — 受保护文件守卫
*
* 确保工作空间根目录的 MEMORY.md 只能由系统内部(WorkspaceService)管理,
* 任何工具(read_file / write_file / search_files / run_command 等)均禁止直接读写。
*
* 注意:仅保护工作空间根目录的 MEMORY.md,
* 子目录或其他位置的同名文件不受限制。
*/
import { resolve, sep } from 'path';
import { realpathSync } from 'fs';
/**
* 受保护文件名列表(工作空间根目录)
*/
const PROTECTED_FILES = ['MEMORY.md'];
/**
* 检查目标路径是否为工作空间根目录的受保护文件
*
* @param filePath 用户传入的文件路径(绝对或相对)
* @param workspacePath 当前工作空间根路径
* @returns true 如果路径指向受保护文件
*/
export function isProtectedWorkspaceFile(filePath: string, workspacePath: string): boolean {
const resolved = resolve(workspacePath, filePath);
const workspaceRoot = resolve(workspacePath);
for (const protectedName of PROTECTED_FILES) {
const protectedPath = resolve(workspaceRoot, protectedName);
if (resolved === protectedPath) {
return true;
}
}
return false;
}
/**
* 检查路径是否在工作空间内(防止路径遍历攻击)
*
* 修复前缀碰撞漏洞:`/home/user/app-evil` 不应被误判为在 `/home/user/app` 内。
*
* M-22 修复: 添加 realpathSync 二次校验防止符号链接逃逸
* 攻击场景:工作空间内创建符号链接 `ln -s /etc/passwd workspace/leak.txt`
* 字符串校验会通过(leak.txt 在 workspace 内),但实际读取的是 /etc/passwd。
*
* 注意:realpathSync 在路径不存在时会抛 ENOENT,此时降级为字符串校验
* write_file 的目标文件可能尚不存在,无法 realpath)。
*
* @see project_memory.md — sandbox validatePath must perform realpathSync secondary check
* @param filePath 用户传入的文件路径
* @param workspacePath 当前工作空间根路径
* @returns true 如果路径在工作空间内
*/
export function isPathWithinWorkspace(filePath: string, workspacePath: string): boolean {
const resolved = resolve(workspacePath, filePath);
const workspaceRoot = resolve(workspacePath);
// 第一层:字符串前缀校验(快速路径)
const stringCheck = resolved === workspaceRoot || resolved.startsWith(workspaceRoot + sep);
if (!stringCheck) return false;
// 第二层:realpathSync 二次校验(防范符号链接逃逸)
// 仅对实际存在的路径做 realpath 校验;不存在的路径(如 write_file 目标)降级为字符串校验
try {
const realResolved = realpathSync(resolved);
const realWorkspaceRoot = realpathSync(workspaceRoot);
return realResolved === realWorkspaceRoot || realResolved.startsWith(realWorkspaceRoot + sep);
} catch {
// 路径不存在(ENOENT)或 realpath 失败 → 降级为字符串校验结果
return stringCheck;
}
}
/**
* 检查命令字符串是否尝试访问工作空间根目录的受保护文件
*
* 用于 run_command 工具的命令校验。
* 仅匹配直接引用的 MEMORY.md(前面是命令起始/空白/引号/分号/管道),
* 不拦截子目录路径中的同名文件(如 subdir/MEMORY.md 或 subdir\MEMORY.md)。
*
* v0.7.4 P2-3 根治: 旧正则只匹配"前面是命令起始/空白/引号/分号/管道/&/>"
* `cat ./MEMORY.md`、`cat .\MEMORY.md`(前面是 . 或 / 或 \)不匹配 → 受保护
* 文件拦截名存实亡。现增加可选路径前缀组 `./`、`.\`、`~`、`~/` 及组合,
* 并把边界类扩展 `(`、`)`、`$`、反引号 —— 覆盖子 shell/命令替换/括号/重定向
* 无空格(`cat <MEMORY.md`)等形态。注意:`~` 在 shell 中展开为 HOME 而非 cwd
* 拦截 `~/MEMORY.md` 属防御性误拦(HOME 下的同名文件极少且无安全影响)。
* 真正语义锚点仍是 run_command 的 cwd=workspacePath 下的裸引用与 ./ 前缀。
* 本函数是 file-guard 层的精确防线;run_command 的 PolicyEngine 粗粒度正则
* /MEMORY\.md/i 深度扫描)作为第二层兜底,二者互补。
*
* @param command Shell 命令字符串
* @returns true 如果命令直接引用了受保护文件名
*/
export function commandTouchesProtectedFile(command: string): boolean {
const lowerCmd = command.toLowerCase();
for (const protectedName of PROTECTED_FILES) {
const lowerName = protectedName.toLowerCase();
// 前面是起始/空白/引号/分号/管道/&/>/</括号/$/反引号;后面是结束/空白/引号/分号/管道/&/</>/括号/反引号
// 中间允许可选的 ./ .\ ~ ~/ 及组合(如 ~/./)路径前缀(仍指工作空间根,必须拦截);
// subdir/MEMORY.md、subdir\MEMORY.mdMEMORY.md 前导为路径分隔符)不匹配
const escaped = lowerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(
`(?:^|[\\s"'|;&<>($\\\`])(?:(?:\\./|\\.\\\\|~/?)+)?${escaped}(?:$|[\\s"'|;&<)\\\`])`,
'i',
);
if (regex.test(lowerCmd)) {
return true;
}
}
return false;
}
// ===== 共享工具函数(v0.3.2 抽取,消除 filesystem.ts 与 file-editor.ts 的重复)=====
/**
* 共享路径解析 + 安全校验
*
* 合并两层安全检查:
* 1. isPathWithinWorkspace — 路径遍历防护(含符号链接 realpathSync 二次校验)
* 2. isProtectedWorkspaceFile — MEMORY.md 拦截
*
* @param filePath 用户传入的文件路径(绝对或相对)
* @param workspacePath 当前工作空间根路径
* @returns 解析后的绝对路径
* @throws Error 路径越界或访问受保护文件时抛出
*/
export 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 匹配(简易通配符 → 正则)
*
* 支持 `*`(任意字符序列)和 `?`(单字符),大小写不敏感。
* 其他正则元字符会被转义。
*
* @param name 待匹配的文件名
* @param glob 通配符模式(如 "*.ts"、"test?.js"
* @returns 是否匹配
*/
export function matchGlob(name: string, glob: string): boolean {
const pattern = glob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
return new RegExp(`^${pattern}$`, 'i').test(name);
}
/**
* F2-4: 多 glob 匹配(逗号分隔)
*
* 支持 "*.ts,*.js,*.tsx" 形式的多 glob 匹配,任一匹配即通过。
* 单个 glob 时等价于 matchGlob。空字符串或空白字符串视为匹配所有。
*
* @param name 待匹配的文件名
* @param globStr 通配符模式字符串(支持逗号分隔多 glob)
* @returns 是否匹配任一 glob
*/
export function matchAnyGlob(name: string, globStr: string): boolean {
// 按逗号分割,去除空白,过滤空字符串
const globs = globStr
.split(',')
.map((g) => g.trim())
.filter((g) => g.length > 0);
if (globs.length === 0) return true; // 空字符串视为匹配所有
for (const g of globs) {
if (matchGlob(name, g)) return true;
}
return false;
}
/**
* 共享错误提取
*
* 统一从 unknown 错误对象中提取 message 字符串,可选附加 stderr 信息
*
* F4-2: 支持 optional stderr 参数,用于 child_process 错误(git/command
*
* @param error catch 块中的 unknown 错误
* @param includeStderr 是否尝试从 error.stderr 提取 stderr 信息(默认 false
* @returns 错误消息字符串
*/
export function extractErrorMessage(error: unknown, includeStderr = false): string {
if (error instanceof Error) {
if (includeStderr) {
const stderr = (error as Error & { stderr?: string }).stderr ?? '';
return stderr ? `${error.message}\n${stderr}` : error.message;
}
return error.message;
}
return String(error);
}
/**
* F2-1: 智能文件编码检测与解码
*
* 支持 BOM 检测(UTF-8 / UTF-16 LE / UTF-16 BE)和无 BOM 时的编码推断
* UTF-8 strict → GBK → UTF-8 loose 三级降级)。
*
* 解决 Windows 中文环境 GBK 文件读取乱码问题,以及 UTF-16 文件读取问题。
*
* @param buffer 文件/命令输出的原始字节
* @returns 解码后的文本和检测到的编码名(utf-8 / utf-8-bom / utf-16le / utf-16be / gbk / utf-8-loose
*/
export function decodeBufferWithDetection(buffer: Buffer): { content: string; encoding: string } {
if (buffer.length === 0) {
return { content: '', encoding: 'utf-8' };
}
// BOM 检测
// UTF-8 BOM: EF BB BF
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
return { content: buffer.slice(3).toString('utf-8'), encoding: 'utf-8-bom' };
}
// UTF-16 LE BOM: FF FE
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
return { content: buffer.slice(2).toString('utf16le'), encoding: 'utf-16le' };
}
// UTF-16 BE BOM: FE FF
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
const body = buffer.slice(2);
// 偶数长度保护(UTF-16 每字符 2 字节)
const safe = body.length % 2 === 0 ? body : body.slice(0, body.length - 1);
const swapped = Buffer.from(safe); // 复制避免修改原 buffer
swapped.swap16(); // BE → LE 字节交换
return { content: swapped.toString('utf16le'), encoding: 'utf-16be' };
}
// 无 BOMUTF-8 strict → GBK → UTF-8 loose 三级降级
try {
return { content: new TextDecoder('utf-8', { fatal: true }).decode(buffer), encoding: 'utf-8' };
} catch {
try {
return { content: new TextDecoder('gbk').decode(buffer), encoding: 'gbk' };
} catch {
return { content: buffer.toString('utf-8'), encoding: 'utf-8-loose' };
}
}
}
/**
* 共享文件大小限制常量
*
* read_file/write_file/file_editor 共用,防止 OOM
*/
export const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
/**
* 共享超时常量(v0.3.2 统一文件工具 timeoutMs
*/
export const FILE_TOOL_TIMEOUT_MS = 15_000;
/**
* 共享单行最大长度(防止超长行爆 token)
*/
export const MAX_LINE_LENGTH = 10_000;
/**
* v0.7.4 P2-7 根治: 共享灾难性正则(ReDoS)检测 —— 从 file-editor.ts 提升为共享模块。
*
* 背景:file_editor 有 isPotentiallyCatastrophicRegex 防护,但 search_files 的
* content 搜索 `new RegExp(pattern, 'gi')` 仅限制长度 500`(a+)+$` 对超长行
* (单行可达 10MB 文件内)可指数级回溯阻塞主进程事件循环。
*
* 灾难性回溯通常由以下模式引起:
* - 嵌套量词:(a+)+、(a*)*、(a+)*
* - 重叠量词:a+a+、a+.*a+(两个量词之间无固定字符分隔)
* - 交替分支加量词:(a|a)*
*
* 这些模式在长字符串上执行时间指数级增长,可阻塞主进程。
*
* @param pattern 用户提供的正则模式字符串
* @returns true 如果检测到潜在灾难性模式
*/
export function isPotentiallyCatastrophicRegex(pattern: string): boolean {
// 审查修复: 放宽规则减少误报,补充漏报检测
// 1. 嵌套量词(捕获组内量词+外层量词)
// 审查修复: 区分外层量词类型 — 外层 +* 时组内一个量词即可触发(如 (a+)+),
// 外层 ? 时需组内两个量词才触发(排除 (\d+)? 误报)
if (/\([^)]*[+*?][^)]*\)[+*]/.test(pattern)) return true;
if (/\([^)]*[+*?][^)]*[+*?][^)]*\)[?]/.test(pattern)) return true;
// 2. 重叠量词 — 补充 a+a+ 漏报
if (/[+*][+*]/.test(pattern)) return true;
// 审查修复: 补充 a+a+ / a+.*a+ 等重叠量词检测
if (/\w[+*]\s*\w[+*]/.test(pattern)) return true;
if (/\.\*[+*]\.\*[+*]/.test(pattern)) return true;
// 3. 交替分支加量词 — 放宽: 仅当分支有重叠前缀时才危险
// 移除对 (GET|POST)+ 的误报,只检测真正危险的重叠分支
// (a|a)* 类型难以用正则精确检测,保留简化版
if (/\(([^)]+)\|(\1[^)]*)\)[+*?]/.test(pattern)) return true;
if (/\(([^)]*\|[^)]*)\)[+*?]/.test(pattern) && /(.)\1.*\|.*\1/.test(pattern)) return true;
return false;
}