/** * Policy Engine — 权限策略引擎 * * 三级权限模型:Read / Write / External Action * * v0.3.0 增强: * - 实现 maxFrequency 频率限制(滑动窗口算法) * - 添加 checkFrequency 和 recordCall 方法 * - 添加 cleanupFrequencyRecords 防止内存泄漏 * * @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章 */ export enum PermissionLevel { READ = 'read', WRITE = 'write', EXTERNAL_ACTION = 'external', } export interface PermissionPolicy { toolName: string; requiredLevel: PermissionLevel; allowedPatterns?: RegExp[]; deniedPatterns?: RegExp[]; maxFrequency?: number; requireConfirmation?: boolean; } export const DEFAULT_POLICIES: PermissionPolicy[] = [ // v0.3.0 修复:deniedPatterns 使用 (?:\/|["'\s,}]|$) 匹配, // 覆盖 /etc/ 和 /etc(无尾斜杠,在 JSON 字符串中后跟引号的情况) // 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: '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: 'memory_store', requiredLevel: PermissionLevel.WRITE }, { 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, }, // 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, }, // code_search — 基于 ripgrep 的只读搜索(READ) { toolName: 'code_search', requiredLevel: PermissionLevel.READ }, // diff_viewer — 文件/文本差异对比(只读,READ) { toolName: 'diff_viewer', requiredLevel: PermissionLevel.READ }, // 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, }, // 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, }, // 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, }, // v0.3.1: 开发工具集(3 个) // v0.7.4 P2-8: lint_code 升 WRITE + 确认 —— 其通过 npx tsc/eslint 执行工作区代码, // 与 run_command 的执行边界对齐;run_tests 升 WRITE + 确认 —— npm test 执行 // package.json scripts.test 的任意命令(被污染工作区可诱导任意代码执行)。 { toolName: 'lint_code', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 20, }, { toolName: 'run_tests', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 10, }, { toolName: 'project_info', requiredLevel: PermissionLevel.READ }, // v0.3.1: HTTP 请求工具(1 个) { toolName: 'http_request', requiredLevel: PermissionLevel.READ, maxFrequency: 20 }, // v0.3.1: 结构化思考工具(1 个)— 无副作用 { toolName: 'think', requiredLevel: PermissionLevel.READ }, // v0.3.1: 图片查看工具(1 个)— 只读 { toolName: 'view_image', requiredLevel: PermissionLevel.READ }, // v0.3.2: 文件删除工具(1 个)— 破坏性操作,必须确认 { toolName: 'delete_file', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30, }, // v0.3.3: 文件移动/重命名工具(1 个)— 可能覆盖目标,需确认 { toolName: 'file_move', requiredLevel: PermissionLevel.WRITE, requireConfirmation: true, maxFrequency: 30, }, // v0.3.3: 文件信息查询工具(1 个)— 只读 { toolName: 'file_info', requiredLevel: PermissionLevel.READ }, ]; export class PolicyEngine { private policies: Map = new Map(); /** * v0.4.1: 工具调用频率追踪 — 频率 key -> 调用时间戳列表 * key 格式: `${sessionId}:${toolName}`(会话隔离) * 历史问题:v0.3.0 以 toolName 为 key,所有会话共享同一配额—— * P2-10 支持多会话并发后,一个会话可耗尽另一个会话的配额(如 web_search 10 次/分钟) */ private callFrequency: Map = 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 合并而非完全覆盖 * * 合并策略:customPolicies 中的字段覆盖默认策略的同名字段, * 未指定的字段保留默认值(如 deniedPatterns 等安全配置不会被丢失) */ constructor(customPolicies: PermissionPolicy[] = []) { for (const policy of DEFAULT_POLICIES) { this.policies.set(policy.toolName, { ...policy }); } for (const policy of customPolicies) { const existing = this.policies.get(policy.toolName); if (existing) { // v0.3.0 修复:合并而非替换,保留默认的安全配置(如 deniedPatterns) this.policies.set(policy.toolName, { ...existing, ...policy }); } else { this.policies.set(policy.toolName, policy); } } } /** * v0.6.4: 解析工具对应的策略(精确名 → 通配符前缀 → 无) * 供 checkAuthorization 与 requiresConfirmation 共用匹配逻辑,消除双份漂移。 */ private resolvePolicy(toolName: string): PermissionPolicy | undefined { const exact = this.policies.get(toolName); if (exact) return exact; // C-7 修复: 支持通配符策略匹配(如 mcp_* 匹配所有 MCP 工具) // MCP 工具名称动态生成(mcp_{serverName}_{toolName}),无法预先配置精确策略 for (const [pattern, p] of this.policies) { if (pattern.endsWith('*') && toolName.startsWith(pattern.slice(0, -1))) { return p; } } return undefined; } /** * v0.6.4 P2-1: 查询某工具按策略引擎的配置是否需要用户确认。 * * 背景(跨层防线不一致):ConfirmationHook 原先只读工具定义的 * `requiresPermission || riskLevel∈{high,critical}` —— 而 MCPToolAdapter 把 * 全部 MCP 工具标为 requiresPermission:false + MEDIUM,导致 PolicyEngine 为 * `mcp_*` 配置的 requireConfirmation 形同虚设:外部 MCP server 的任意工具 * 都被免确认执行。此方法让 ConfirmationHook 能消费策略层的声明。 * * @param toolName 工具名 * @returns true 表示有策略且其 requireConfirmation=true;无策略时返回 false * (未知工具由 PermissionCheckHook 的 fail-closed 负责拒绝) */ requiresConfirmation(toolName: string): boolean { return this.resolvePolicy(toolName)?.requireConfirmation ?? false; } /** * 权限校验 * * v0.4.1: 新增可选 sessionId 参数 — 频率限制按会话隔离(多会话并发时各自独立配额) * * @param toolName 工具名 * @param args 工具参数 * @param sessionId 会话 ID(可选;缺失时频率配额计入 'global' 桶保持向后兼容) */ checkAuthorization( toolName: string, args: Record, sessionId?: string, ): { authorized: boolean; reason?: string; level: PermissionLevel; requiresConfirmation: boolean; } { // v0.6.4: 复用统一的策略解析(精确 → 通配符 → 无) const policy = this.resolvePolicy(toolName); if (!policy) { return { authorized: false, reason: `No policy configured for tool: ${toolName}`, level: PermissionLevel.EXTERNAL_ACTION, requiresConfirmation: true, }; } // v0.3.0 修复:使用 try-catch 防止循环引用导致 JSON.stringify 抛错 let argsStr: string; try { argsStr = JSON.stringify(args); } catch { // 循环引用等异常情况,降级为 toString argsStr = String(args); } // #14 修复: 深度递归扫描所有字符串字段值,防止 Unicode 转义/嵌套对象/字符串拼接绕过 // 原 JSON.stringify 后整体正则匹配可被 \u0029 编码、嵌套对象伪装、正则注入等方式绕过 // 现对每个字符串字段值单独匹配,同时保留整体 argsStr 兜底匹配 const stringValues = this.deepScanStrings(args); // v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一 // #14: 字段级匹配 + 整体兜底,任一匹配即通过 if (policy.allowedPatterns && policy.allowedPatterns.length > 0) { let matchedAllowed = false; // 先做字段级匹配 for (const val of stringValues) { for (const pattern of policy.allowedPatterns) { if (pattern.test(val)) { matchedAllowed = true; break; } } if (matchedAllowed) break; } // 兜底:整体 argsStr 匹配 if (!matchedAllowed) { for (const pattern of policy.allowedPatterns) { if (pattern.test(argsStr)) { matchedAllowed = true; break; } } } if (!matchedAllowed) { return { authorized: false, reason: 'Arguments do not match any allowed pattern', level: policy.requiredLevel, requiresConfirmation: policy.requireConfirmation ?? false, }; } } // #14 修复: deniedPatterns 字段级深度扫描 — 对每个字符串值单独匹配 // 防止危险内容隐藏在嵌套对象或 Unicode 编码中绕过整体 stringify 匹配 if (policy.deniedPatterns) { for (const val of stringValues) { for (const pattern of policy.deniedPatterns) { if (pattern.test(val)) { return { authorized: false, reason: 'Command blocked by security policy', level: policy.requiredLevel, requiresConfirmation: false, }; } } } // 兜底:整体 argsStr 匹配(保留原有行为,捕获跨字段拼接的危险模式) for (const pattern of policy.deniedPatterns) { if (pattern.test(argsStr)) { return { authorized: false, reason: 'Command blocked by security policy', level: policy.requiredLevel, requiresConfirmation: false, }; } } } // v0.3.0: 频率限制检查(v0.4.1: 按会话隔离) if (policy.maxFrequency !== undefined) { const freqCheck = this.checkFrequency(toolName, policy.maxFrequency, sessionId); if (!freqCheck.allowed) { return { authorized: false, reason: freqCheck.reason, level: policy.requiredLevel, requiresConfirmation: false, }; } } return { authorized: true, level: policy.requiredLevel, requiresConfirmation: policy.requireConfirmation ?? false, }; } /** * v0.3.0: 频率限制检查(滑动窗口算法) * * 检查指定工具在时间窗口内的调用次数是否超过限制。 * 注意:此方法仅检查,不记录调用。调用成功后需调用 recordCall()。 * * v0.3.0 修复: * - 将 validCalls 写回 Map,避免 callFrequency 数组无限增长(内存泄漏) * * v0.4.1: 新增可选 sessionId 参数 — 频率配额按会话隔离 * * @param toolName 工具名称 * @param maxFreq 最大频率(每分钟) * @param sessionId 会话 ID(可选;缺失时计入 'global' 桶) * @returns 检查结果 */ 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(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(key, validCalls); } if (validCalls.length >= limit) { return { allowed: false, reason: `Rate limit exceeded for ${toolName}: max ${limit} calls per minute (current: ${validCalls.length})`, }; } return { allowed: true }; } /** * v0.3.0: 记录工具调用(工具成功执行后调用) * * v0.3.0 修复:同时清理过期记录,防止数组无限增长 * v0.4.1: 新增可选 sessionId 参数 — 与 checkFrequency 的会话隔离配对使用 * * @param toolName 工具名称 * @param sessionId 会话 ID(可选;缺失时计入 'global' 桶) */ recordCall(toolName: string, sessionId?: string): void { const key = this.freqKey(toolName, sessionId); const now = Date.now(); 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(key, validCalls); } // v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理, // 该方法属于死代码,删除以减少维护负担 /** * #14 修复: 深度递归扫描对象,提取所有字符串字段值 * * 替代 JSON.stringify 后整体正则匹配的方式,防止: * - Unicode 转义绕过(\u0029 等编码) * - 嵌套对象伪装({a: {b: 'dangerous'}}) * - 字符串拼接绕过('rm ' + '-rf /' 在 stringify 后是合法字符串) * - 正则注入(输入中包含正则元字符破坏匹配逻辑) * * @param obj 待扫描的对象 * @returns 所有字符串字段值的数组 */ // 审查修复: 添加 visited Set 参数防止循环引用导致无限递归栈溢出 private deepScanStrings(obj: unknown, visited: Set = new Set()): string[] { const results: string[] = []; if (typeof obj === 'string') { results.push(obj); } else if (Array.isArray(obj)) { if (visited.has(obj)) return results; visited.add(obj); for (const item of obj) { results.push(...this.deepScanStrings(item, visited)); } } else if (obj && typeof obj === 'object') { if (visited.has(obj)) return results; visited.add(obj); for (const v of Object.values(obj)) { results.push(...this.deepScanStrings(v, visited)); } } return results; } }