1. 工具批量审批:解决并行工具审批弹框覆盖问题,新增批量 IPC 通道和列表 UI,支持同工具多次调用分组展示;2. 审批弹框健壮性:超时 toast 防风暴、agent 状态同步清空、ErrorBoundary 防白屏;3. run_command maxFrequency 从 3 调整为 10;4. 开发规范新增 Toast 通知铁律(必须使用 MeToast)
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
/**
|
||
* 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 个)
|
||
{ toolName: 'lint_code', requiredLevel: PermissionLevel.READ },
|
||
{ toolName: 'run_tests', requiredLevel: PermissionLevel.READ },
|
||
{ toolName: 'project_info', requiredLevel: PermissionLevel.READ },
|
||
|
||
// v0.3.1: HTTP 请求工具(1 个)
|
||
{ toolName: 'http_request', requiredLevel: PermissionLevel.READ, maxFrequency: 20 },
|
||
|
||
// v0.3.1: TODO 管理工具(1 个)— 内存存储,无持久化副作用
|
||
// v0.3.1 修复 WARN-9: 改为 READ(内存操作,非数据库)
|
||
{ toolName: 'todo_write', requiredLevel: PermissionLevel.READ },
|
||
|
||
// 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 },
|
||
];
|
||
|
||
export class PolicyEngine {
|
||
private policies: Map<string, PermissionPolicy> = new Map();
|
||
|
||
/** v0.3.0: 工具调用频率追踪 — 工具名 -> 调用时间戳列表 */
|
||
private callFrequency: Map<string, number[]> = new Map();
|
||
|
||
/** v0.3.0: 频率限制的时间窗口(1分钟 = 60秒) */
|
||
private readonly FREQ_WINDOW_MS = 60_000;
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
checkAuthorization(toolName: string, args: Record<string, unknown>): {
|
||
authorized: boolean;
|
||
reason?: string;
|
||
level: PermissionLevel;
|
||
requiresConfirmation: boolean;
|
||
} {
|
||
let policy = this.policies.get(toolName);
|
||
|
||
// C-7 修复: 支持通配符策略匹配(如 mcp_* 匹配所有 MCP 工具)
|
||
// MCP 工具名称动态生成(mcp_{serverName}_{toolName}),无法预先配置精确策略
|
||
if (!policy) {
|
||
for (const [pattern, p] of this.policies) {
|
||
if (pattern.endsWith('*') && toolName.startsWith(pattern.slice(0, -1))) {
|
||
policy = p;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// v0.2.0: allowedPatterns 白名单校验 — 若定义了白名单,参数必须匹配其中之一
|
||
if (policy.allowedPatterns && policy.allowedPatterns.length > 0) {
|
||
let matchedAllowed = false;
|
||
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,
|
||
};
|
||
}
|
||
}
|
||
|
||
if (policy.deniedPatterns) {
|
||
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: 频率限制检查
|
||
if (policy.maxFrequency !== undefined) {
|
||
const freqCheck = this.checkFrequency(toolName, policy.maxFrequency);
|
||
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 数组无限增长(内存泄漏)
|
||
*
|
||
* @param toolName 工具名称
|
||
* @param maxFreq 最大频率(每分钟)
|
||
* @returns 检查结果
|
||
*/
|
||
checkFrequency(toolName: string, maxFreq?: number): { allowed: boolean; reason?: string } {
|
||
const policy = this.policies.get(toolName);
|
||
const limit = maxFreq ?? policy?.maxFrequency;
|
||
if (limit === undefined) return { allowed: true };
|
||
|
||
const now = Date.now();
|
||
const calls = this.callFrequency.get(toolName) ?? [];
|
||
// 移除时间窗口外的调用记录
|
||
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);
|
||
}
|
||
|
||
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 修复:同时清理过期记录,防止数组无限增长
|
||
*
|
||
* @param toolName 工具名称
|
||
*/
|
||
recordCall(toolName: string): void {
|
||
const now = Date.now();
|
||
const calls = this.callFrequency.get(toolName) ?? [];
|
||
// v0.3.0 修复:记录新调用时同时清理过期记录
|
||
const validCalls = calls.filter((t) => now - t < this.FREQ_WINDOW_MS);
|
||
validCalls.push(now);
|
||
this.callFrequency.set(toolName, validCalls);
|
||
}
|
||
|
||
// v0.3.0 修复: cleanupFrequencyRecords 已删除 — checkFrequency 和 recordCall 已做内联清理,
|
||
// 该方法属于死代码,删除以减少维护负担
|
||
}
|