feat: v0.4.1 质量加固版 — 工程化基线 + 安全加固 + 测试补齐 + 体验升级
工程化(从零到一): - 新增 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 — 子任务委派(启动 SubAgent,EXTERNAL_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: 构造会话隔离的频率 key(sessionId 缺失时回退 '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 已做内联清理,
|
||||
|
||||
Reference in New Issue
Block a user