工程化(从零到一): - 新增 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 双模式)
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
/**
|
||
* PolicyEngine 单元测试(P1-14 测试基线)
|
||
* 覆盖:默认策略、deniedPatterns 深度扫描、频率限制、通配符策略
|
||
*/
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
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',
|
||
];
|
||
for (const tool of knownTools) {
|
||
expect(DEFAULT_POLICIES.some((p) => p.toolName === tool)).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('未配置策略的工具被拒绝(fail-closed)', () => {
|
||
const engine = new PolicyEngine();
|
||
const result = engine.checkAuthorization('unknown_tool_xyz', {});
|
||
expect(result.authorized).toBe(false);
|
||
expect(result.reason).toContain('No policy configured');
|
||
});
|
||
|
||
it('read_file 默认放行', () => {
|
||
const engine = new PolicyEngine();
|
||
expect(engine.checkAuthorization('read_file', { file_path: 'a.ts' }).authorized).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('deniedPatterns 深度扫描', () => {
|
||
it('read_file 访问 /etc/passwd 被拒绝', () => {
|
||
const engine = new PolicyEngine();
|
||
const result = engine.checkAuthorization('read_file', { file_path: '/etc/passwd' });
|
||
expect(result.authorized).toBe(false);
|
||
});
|
||
|
||
it('嵌套对象中的危险路径被拒绝(deepScanStrings)', () => {
|
||
const engine = new PolicyEngine();
|
||
const result = engine.checkAuthorization('read_file', {
|
||
nested: { deep: { path: '/etc/passwd' } },
|
||
});
|
||
expect(result.authorized).toBe(false);
|
||
});
|
||
|
||
it('run_command 参数中包含 MEMORY.md 被拒绝', () => {
|
||
const engine = new PolicyEngine();
|
||
const result = engine.checkAuthorization('run_command', { command: 'cat MEMORY.md' });
|
||
expect(result.authorized).toBe(false);
|
||
});
|
||
|
||
it('正常路径不误判', () => {
|
||
const engine = new PolicyEngine();
|
||
expect(engine.checkAuthorization('read_file', { file_path: 'src/main.ts' }).authorized).toBe(
|
||
true,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('频率限制(滑动窗口)', () => {
|
||
it('超过 maxFrequency 后被限流', () => {
|
||
const engine = new PolicyEngine();
|
||
// web_search 默认 maxFrequency: 10
|
||
for (let i = 0; i < 10; i++) {
|
||
expect(engine.checkAuthorization('web_search', { query: 'x' }).authorized).toBe(true);
|
||
engine.recordCall('web_search');
|
||
}
|
||
const blocked = engine.checkAuthorization('web_search', { query: 'x' });
|
||
expect(blocked.authorized).toBe(false);
|
||
expect(blocked.reason).toContain('Rate limit exceeded');
|
||
});
|
||
|
||
it('限流只影响对应工具', () => {
|
||
const engine = new PolicyEngine();
|
||
for (let i = 0; i < 10; i++) engine.recordCall('web_search');
|
||
// read_file 无频率限制
|
||
expect(engine.checkAuthorization('read_file', {}).authorized).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('通配符策略(mcp_*)', () => {
|
||
it('MCP 动态工具命中 mcp_* 通配策略', () => {
|
||
const engine = new PolicyEngine();
|
||
const result = engine.checkAuthorization('mcp_filesystem_read_file', {});
|
||
// mcp_* 策略:EXTERNAL_ACTION + requireConfirmation
|
||
expect(result.authorized).toBe(true);
|
||
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);
|
||
});
|
||
});
|