feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m25s
CI / 全量测试 (Electron ABI, experimental) (push) Failing after 5m19s
CI / 产物编译验证 (push) Successful in 10m3s

工程化(从零到一):
- 新增 Gitea Actions CI(debian-latest):类型检查 + Lint + 单元测试 + 产物编译验证
- 新增 husky + lint-staged 预提交钩子(lint-staged + typecheck 门禁)
- 移除坏脚本 test:e2e(无 Playwright 配置必失败);prebuild 改用内置 fs.rmSync
- 依赖清理:移除死依赖 sql.js(2MB)/@playwright/test,@types/shell-quote 移至 devDependencies

安全加固:
- PolicyEngine 频率限制按会话隔离(多会话并发不再互抢配额)
- ConfirmationHook 拒绝记忆加 10 分钟 TTL + 恢复询问入口(新增 2 个 IPC 通道)
- Windows run_command 白名单工具(git/node/npm/npx/pnpm/yarn/tsc)改走 cmd.exe /c + 参数数组执行,收窄 shell 注入面
- web_search 四引擎 HTML 解析迁移 node-html-parser(结构化主层 + 正则降级)

缺陷修复(测试驱动发现):
- mapError 大小写缺陷:网络错误码永远落入 UNKNOWN 无法触发重试
- 搜狗解析器自我过滤:相对链接补全后又被 sogou.com 过滤导致结果全丢
- 百度复合类名重复收录:class="result c-container" 被双重匹配

测试补齐(113 → 194 用例):
- 新增 5 个测试文件:sse-stream / base-adapter / confirmation-hook / ipc-agent 编排链路 / web-search 解析器
- 覆盖 sendMessage 全分支、SSE 流解析、错误映射、确认钩子竞态/超时/批量审批

体验升级:
- OutputValidator 验证结果可见化(VALIDATION 流事件 → 聊天流提示卡)
- SettingsModal 巨型组件拆分(1503 行 → 10 个文件,可独立维护)
- MessageList 接入 react-virtuoso 真虚拟滚动(千条消息恒定开销)
- MCP 新增 streamable HTTP 传输支持(SDK 内置传输 + DB 迁移 6 + UI 双模式)
This commit is contained in:
2026-08-21 13:58:48 +08:00
parent 2230bcec3f
commit 49c9b25538
41 changed files with 6254 additions and 2608 deletions
@@ -9,12 +9,34 @@ import { PolicyEngine, DEFAULT_POLICIES } from '../permissions';
describe('PolicyEngine 默认策略', () => {
it('所有内置工具均有策略配置', () => {
const knownTools = [
'read_file', 'write_file', 'list_directory', 'search_files', 'delete_file',
'file_move', 'file_info', 'file_editor', 'code_search', 'diff_viewer',
'web_search', 'web_fetch', 'web_browser', 'http_request',
'memory_store', 'memory_search', 'run_command', 'task_manager',
'delegate_task', 'git_status', 'git_diff', 'git_log', 'git_commit',
'lint_code', 'run_tests', 'project_info', 'think', 'view_image',
'read_file',
'write_file',
'list_directory',
'search_files',
'delete_file',
'file_move',
'file_info',
'file_editor',
'code_search',
'diff_viewer',
'web_search',
'web_fetch',
'web_browser',
'http_request',
'memory_store',
'memory_search',
'run_command',
'task_manager',
'delegate_task',
'git_status',
'git_diff',
'git_log',
'git_commit',
'lint_code',
'run_tests',
'project_info',
'think',
'view_image',
];
for (const tool of knownTools) {
expect(DEFAULT_POLICIES.some((p) => p.toolName === tool)).toBe(true);
@@ -57,7 +79,9 @@ describe('deniedPatterns 深度扫描', () => {
it('正常路径不误判', () => {
const engine = new PolicyEngine();
expect(engine.checkAuthorization('read_file', { file_path: 'src/main.ts' }).authorized).toBe(true);
expect(engine.checkAuthorization('read_file', { file_path: 'src/main.ts' }).authorized).toBe(
true,
);
});
});
@@ -91,3 +115,45 @@ describe('通配符策略(mcp_*', () => {
expect(result.requiresConfirmation).toBe(true);
});
});
describe('频率限制会话隔离(v0.4.1', () => {
it('不同会话各自独立配额(一个会话耗尽不影响另一个)', () => {
const engine = new PolicyEngine();
// web_search 默认 maxFrequency: 10
// 会话 A 耗尽全部配额
for (let i = 0; i < 10; i++) {
expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-A').authorized).toBe(
true,
);
engine.recordCall('web_search', 'session-A');
}
// 会话 A 已被限流
expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-A').authorized).toBe(
false,
);
// 会话 B 配额不受影响
expect(engine.checkAuthorization('web_search', { query: 'x' }, 'session-B').authorized).toBe(
true,
);
expect(engine.recordCall('web_search', 'session-B') === undefined).toBe(true);
});
it('recordCall 与 checkAuthorization 使用相同的会话 key', () => {
const engine = new PolicyEngine();
// 会话 A 记录 10 次
for (let i = 0; i < 10; i++) engine.recordCall('web_search', 'session-A');
// 会话 A 限流,会话 B 不限
expect(engine.checkAuthorization('web_search', {}, 'session-A').authorized).toBe(false);
expect(engine.checkAuthorization('web_search', {}, 'session-B').authorized).toBe(true);
});
it('无 sessionId 时计入 global 桶(向后兼容)', () => {
const engine = new PolicyEngine();
// 旧式调用(无 sessionId)共享 global 桶
for (let i = 0; i < 10; i++) {
engine.recordCall('web_search');
}
expect(engine.checkAuthorization('web_search', {}).authorized).toBe(false);
expect(engine.checkAuthorization('web_search', {}, 'any-session').authorized).toBe(true);
});
});
+123 -20
View File
@@ -32,21 +32,65 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
// H-5 修复: 移除 /MEMORY\.md/i 粗粒度正则 — 之前会误拦子目录的 MEMORY.md
// 改为在 engine.ts executeToolSafely 中进行精确的根目录校验(仅保护 workspacePath/MEMORY.md
// @see project_memory.md — Only the MEMORY.md in the workspace root directory is protected
{ toolName: 'read_file', requiredLevel: PermissionLevel.READ, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i] },
{
toolName: 'read_file',
requiredLevel: PermissionLevel.READ,
deniedPatterns: [
/\/etc(?:\/|["'\s,}]|$)/,
/\/proc(?:\/|["'\s,}]|$)/,
/C:\\Windows\\/i,
/C:\\System32\\/i,
],
},
{ toolName: 'web_search', requiredLevel: PermissionLevel.READ, maxFrequency: 10 },
{ toolName: 'list_directory', requiredLevel: PermissionLevel.READ },
{ toolName: 'search_files', requiredLevel: PermissionLevel.READ },
{ toolName: 'memory_search', requiredLevel: PermissionLevel.READ },
{ toolName: 'write_file', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 5 },
{
toolName: 'write_file',
requiredLevel: PermissionLevel.WRITE,
deniedPatterns: [
/\/etc(?:\/|["'\s,}]|$)/,
/\/proc(?:\/|["'\s,}]|$)/,
/\/System(?:\/|["'\s,}]|$)/,
/C:\\Windows\\/i,
/C:\\System32\\/i,
],
requireConfirmation: true,
maxFrequency: 5,
},
{ toolName: 'memory_store', requiredLevel: PermissionLevel.WRITE },
{ toolName: 'run_command', requiredLevel: PermissionLevel.EXTERNAL_ACTION, deniedPatterns: [/MEMORY\.md/i], requireConfirmation: true, maxFrequency: 10 },
{
toolName: 'run_command',
requiredLevel: PermissionLevel.EXTERNAL_ACTION,
deniedPatterns: [/MEMORY\.md/i],
requireConfirmation: true,
maxFrequency: 10,
},
{ toolName: 'web_fetch', requiredLevel: PermissionLevel.READ },
// web_browser — 统一浏览器工具(合并自 9 个独立 browser_* 工具)
// 由于该工具可执行 JS、点击元素等高风险操作,统一设为 EXTERNAL_ACTION
{ toolName: 'web_browser', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
{
toolName: 'web_browser',
requiredLevel: PermissionLevel.EXTERNAL_ACTION,
requireConfirmation: true,
maxFrequency: 20,
},
// v0.3.0 修复: 补全缺失的工具策略 — 之前这5个工具未配置策略,导致被 PolicyEngine 拦截
// file_editor — 精准文件编辑(WRITE),与 write_file 同级安全约束
{ toolName: 'file_editor', requiredLevel: PermissionLevel.WRITE, deniedPatterns: [/\/etc(?:\/|["'\s,}]|$)/, /\/proc(?:\/|["'\s,}]|$)/, /\/System(?:\/|["'\s,}]|$)/, /C:\\Windows\\/i, /C:\\System32\\/i], requireConfirmation: true, maxFrequency: 10 },
{
toolName: 'file_editor',
requiredLevel: PermissionLevel.WRITE,
deniedPatterns: [
/\/etc(?:\/|["'\s,}]|$)/,
/\/proc(?:\/|["'\s,}]|$)/,
/\/System(?:\/|["'\s,}]|$)/,
/C:\\Windows\\/i,
/C:\\System32\\/i,
],
requireConfirmation: true,
maxFrequency: 10,
},
// code_search — 基于 ripgrep 的只读搜索(READ
{ toolName: 'code_search', requiredLevel: PermissionLevel.READ },
// diff_viewer — 文件/文本差异对比(只读,READ)
@@ -54,17 +98,32 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
// task_manager — 任务管理(数据库读写,低风险 WRITE)
{ toolName: 'task_manager', requiredLevel: PermissionLevel.WRITE },
// delegate_task — 子任务委派(启动 SubAgentEXTERNAL_ACTION
{ toolName: 'delegate_task', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: false, maxFrequency: 5 },
{
toolName: 'delegate_task',
requiredLevel: PermissionLevel.EXTERNAL_ACTION,
requireConfirmation: false,
maxFrequency: 5,
},
// C-7 修复: MCP 工具通配符策略 — MCP 工具名称动态生成(mcp_{serverName}_{toolName}
// 无法预先配置精确策略,使用 mcp_* 通配符匹配所有 MCP 工具
// @see project_memory.md — All tools must have a configured policy in DEFAULT_POLICIES
{ toolName: 'mcp_*', requiredLevel: PermissionLevel.EXTERNAL_ACTION, requireConfirmation: true, maxFrequency: 20 },
{
toolName: 'mcp_*',
requiredLevel: PermissionLevel.EXTERNAL_ACTION,
requireConfirmation: true,
maxFrequency: 20,
},
// v0.3.1: Git 工具集(4 个)
{ toolName: 'git_status', requiredLevel: PermissionLevel.READ },
{ toolName: 'git_diff', requiredLevel: PermissionLevel.READ },
{ toolName: 'git_log', requiredLevel: PermissionLevel.READ },
{ toolName: 'git_commit', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 10 },
{
toolName: 'git_commit',
requiredLevel: PermissionLevel.WRITE,
requireConfirmation: true,
maxFrequency: 10,
},
// v0.3.1: 开发工具集(3 个)
{ toolName: 'lint_code', requiredLevel: PermissionLevel.READ },
@@ -81,10 +140,20 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
{ toolName: 'view_image', requiredLevel: PermissionLevel.READ },
// v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认
{ toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
{
toolName: 'delete_file',
requiredLevel: PermissionLevel.WRITE,
requireConfirmation: true,
maxFrequency: 30,
},
// v0.3.3: 文件移动/重命名工具(1 个)— 可能覆盖目标,需确认
{ toolName: 'file_move', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30 },
{
toolName: 'file_move',
requiredLevel: PermissionLevel.WRITE,
requireConfirmation: true,
maxFrequency: 30,
},
// v0.3.3: 文件信息查询工具(1 个)— 只读
{ toolName: 'file_info', requiredLevel: PermissionLevel.READ },
@@ -93,12 +162,22 @@ export const DEFAULT_POLICIES: PermissionPolicy[] = [
export class PolicyEngine {
private policies: Map<string, PermissionPolicy> = new Map();
/** v0.3.0: 工具调用频率追踪 — 工具名 -> 调用时间戳列表 */
/**
* v0.4.1: 工具调用频率追踪 — 频率 key -> 调用时间戳列表
* key 格式: `${sessionId}:${toolName}`(会话隔离)
* 历史问题:v0.3.0 以 toolName 为 key,所有会话共享同一配额——
* P2-10 支持多会话并发后,一个会话可耗尽另一个会话的配额(如 web_search 10 次/分钟)
*/
private callFrequency: Map<string, number[]> = new Map();
/** v0.3.0: 频率限制的时间窗口(1分钟 = 60秒) */
private readonly FREQ_WINDOW_MS = 60_000;
/** v0.4.1: 构造会话隔离的频率 keysessionId 缺失时回退 'global' 保持兼容) */
private freqKey(toolName: string, sessionId?: string): string {
return `${sessionId || 'global'}:${toolName}`;
}
/**
* v0.3.0 修复:customPolicies 与 DEFAULT_POLICIES 合并而非完全覆盖
*
@@ -120,7 +199,20 @@ export class PolicyEngine {
}
}
checkAuthorization(toolName: string, args: Record<string, unknown>): {
/**
* 权限校验
*
* v0.4.1: 新增可选 sessionId 参数 — 频率限制按会话隔离(多会话并发时各自独立配额)
*
* @param toolName 工具名
* @param args 工具参数
* @param sessionId 会话 ID(可选;缺失时频率配额计入 'global' 桶保持向后兼容)
*/
checkAuthorization(
toolName: string,
args: Record<string, unknown>,
sessionId?: string,
): {
authorized: boolean;
reason?: string;
level: PermissionLevel;
@@ -223,9 +315,9 @@ export class PolicyEngine {
}
}
// v0.3.0: 频率限制检查
// v0.3.0: 频率限制检查v0.4.1: 按会话隔离)
if (policy.maxFrequency !== undefined) {
const freqCheck = this.checkFrequency(toolName, policy.maxFrequency);
const freqCheck = this.checkFrequency(toolName, policy.maxFrequency, sessionId);
if (!freqCheck.allowed) {
return {
authorized: false,
@@ -252,23 +344,31 @@ export class PolicyEngine {
* v0.3.0 修复:
* - 将 validCalls 写回 Map,避免 callFrequency 数组无限增长(内存泄漏)
*
* v0.4.1: 新增可选 sessionId 参数 — 频率配额按会话隔离
*
* @param toolName 工具名称
* @param maxFreq 最大频率(每分钟)
* @param sessionId 会话 ID(可选;缺失时计入 'global' 桶)
* @returns 检查结果
*/
checkFrequency(toolName: string, maxFreq?: number): { allowed: boolean; reason?: string } {
checkFrequency(
toolName: string,
maxFreq?: number,
sessionId?: string,
): { allowed: boolean; reason?: string } {
const policy = this.policies.get(toolName);
const limit = maxFreq ?? policy?.maxFrequency;
if (limit === undefined) return { allowed: true };
const key = this.freqKey(toolName, sessionId);
const now = Date.now();
const calls = this.callFrequency.get(toolName) ?? [];
const calls = this.callFrequency.get(key) ?? [];
// 移除时间窗口外的调用记录
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
// v0.3.0 修复:将清理后的 validCalls 写回 Map,避免数组无限增长
if (validCalls.length !== calls.length) {
this.callFrequency.set(toolName, validCalls);
this.callFrequency.set(key, validCalls);
}
if (validCalls.length >= limit) {
@@ -284,16 +384,19 @@ export class PolicyEngine {
* v0.3.0: 记录工具调用(工具成功执行后调用)
*
* v0.3.0 修复:同时清理过期记录,防止数组无限增长
* v0.4.1: 新增可选 sessionId 参数 — 与 checkFrequency 的会话隔离配对使用
*
* @param toolName 工具名称
* @param sessionId 会话 ID(可选;缺失时计入 'global' 桶)
*/
recordCall(toolName: string): void {
recordCall(toolName: string, sessionId?: string): void {
const key = this.freqKey(toolName, sessionId);
const now = Date.now();
const calls = this.callFrequency.get(toolName) ?? [];
const calls = this.callFrequency.get(key) ?? [];
// v0.3.0 修复:记录新调用时同时清理过期记录
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
validCalls.push(now);
this.callFrequency.set(toolName, validCalls);
this.callFrequency.set(key, validCalls);
}
// v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理,