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)
This commit is contained in:
@@ -23,10 +23,7 @@ const PROTECTED_FILES = ['MEMORY.md'];
|
||||
* @param workspacePath 当前工作空间根路径
|
||||
* @returns true 如果路径指向受保护文件
|
||||
*/
|
||||
export function isProtectedWorkspaceFile(
|
||||
filePath: string,
|
||||
workspacePath: string,
|
||||
): boolean {
|
||||
export function isProtectedWorkspaceFile(filePath: string, workspacePath: string): boolean {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
const workspaceRoot = resolve(workspacePath);
|
||||
|
||||
@@ -56,10 +53,7 @@ export function isProtectedWorkspaceFile(
|
||||
* @param workspacePath 当前工作空间根路径
|
||||
* @returns true 如果路径在工作空间内
|
||||
*/
|
||||
export function isPathWithinWorkspace(
|
||||
filePath: string,
|
||||
workspacePath: string,
|
||||
): boolean {
|
||||
export function isPathWithinWorkspace(filePath: string, workspacePath: string): boolean {
|
||||
const resolved = resolve(workspacePath, filePath);
|
||||
const workspaceRoot = resolve(workspacePath);
|
||||
|
||||
@@ -86,8 +80,15 @@ export function isPathWithinWorkspace(
|
||||
* 仅匹配直接引用的 MEMORY.md(前面是命令起始/空白/引号/分号/管道),
|
||||
* 不拦截子目录路径中的同名文件(如 subdir/MEMORY.md 或 subdir\MEMORY.md)。
|
||||
*
|
||||
* 注意:run_command 的工作目录固定为 workspacePath,因此裸引用 MEMORY.md
|
||||
* 等价于工作空间根目录的 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 如果命令直接引用了受保护文件名
|
||||
@@ -96,10 +97,14 @@ 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.md 不会被匹配(前面是 / 或 \)
|
||||
// 前面是起始/空白/引号/分号/管道/&/>/</括号/$/反引号;后面是结束/空白/引号/分号/管道/&/</>/括号/反引号
|
||||
// 中间允许可选的 ./ .\ ~ ~/ 及组合(如 ~/./)路径前缀(仍指工作空间根,必须拦截);
|
||||
// subdir/MEMORY.md、subdir\MEMORY.md(MEMORY.md 前导为路径分隔符)不匹配
|
||||
const escaped = lowerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(?:^|[\\s"'|;&>])${escaped}(?:$|[\\s"'|;&<])`, 'i');
|
||||
const regex = new RegExp(
|
||||
`(?:^|[\\s"'|;&<>($\\\`])(?:(?:\\./|\\.\\\\|~/?)+)?${escaped}(?:$|[\\s"'|;&<)\\\`])`,
|
||||
'i',
|
||||
);
|
||||
if (regex.test(lowerCmd)) {
|
||||
return true;
|
||||
}
|
||||
@@ -129,7 +134,9 @@ export function safeResolvePath(filePath: string, workspacePath: string): string
|
||||
}
|
||||
// 受保护文件检查: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');
|
||||
throw new Error(
|
||||
'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via file tools',
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -145,7 +152,10 @@ export function safeResolvePath(filePath: string, workspacePath: string): string
|
||||
* @returns 是否匹配
|
||||
*/
|
||||
export function matchGlob(name: string, glob: string): boolean {
|
||||
const pattern = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||
const pattern = glob
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
return new RegExp(`^${pattern}$`, 'i').test(name);
|
||||
}
|
||||
|
||||
@@ -161,7 +171,10 @@ export function matchGlob(name: string, glob: string): boolean {
|
||||
*/
|
||||
export function matchAnyGlob(name: string, globStr: string): boolean {
|
||||
// 按逗号分割,去除空白,过滤空字符串
|
||||
const globs = globStr.split(',').map((g) => g.trim()).filter((g) => g.length > 0);
|
||||
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;
|
||||
@@ -209,15 +222,15 @@ export function decodeBufferWithDetection(buffer: Buffer): { content: string; en
|
||||
|
||||
// BOM 检测
|
||||
// UTF-8 BOM: EF BB BF
|
||||
if (buffer.length >= 3 && buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
@@ -254,3 +267,44 @@ 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user