硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。
P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层
P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}
P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)
Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。
验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
570 lines
21 KiB
TypeScript
570 lines
21 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 个)
|
||
// 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 },
|
||
];
|
||
|
||
/**
|
||
* v0.8.1 P2-1: 用户自定义策略解析(设置面板 ToolsSettings 存储)
|
||
*
|
||
* 存储契约:配置键 `tools.{toolName}.policy`(JSON 字符串),字段:
|
||
* - deniedPatterns / allowedPatterns: string[](正则源;加载时编译,非法正则跳过)
|
||
* - maxFrequency: number(次/分钟)
|
||
* - requireConfirmation: boolean
|
||
* 解析失败整体返回 null(回退默认策略),单条非法正则仅跳过该条 —— 配置错误
|
||
* 不放大执行面(fail-closed),也不让一条坏配置瘫痪整个策略引擎。
|
||
*/
|
||
export interface ParsedToolPolicy {
|
||
deniedPatterns?: RegExp[];
|
||
allowedPatterns?: RegExp[];
|
||
maxFrequency?: number;
|
||
requireConfirmation?: boolean;
|
||
}
|
||
|
||
export function parseToolPolicy(raw: unknown): ParsedToolPolicy | null {
|
||
let obj: unknown = raw;
|
||
if (typeof raw === 'string') {
|
||
try {
|
||
obj = JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null;
|
||
const o = obj as Record<string, unknown>;
|
||
const compileList = (value: unknown): RegExp[] | undefined => {
|
||
if (value === undefined) return undefined;
|
||
if (!Array.isArray(value)) return undefined;
|
||
const compiled: RegExp[] = [];
|
||
for (const item of value.slice(0, 50)) {
|
||
if (typeof item !== 'string' || item.length === 0 || item.length > 500) continue;
|
||
try {
|
||
compiled.push(new RegExp(item));
|
||
} catch {
|
||
/* 非法正则跳过 */
|
||
}
|
||
}
|
||
return compiled;
|
||
};
|
||
const out: ParsedToolPolicy = {};
|
||
const denied = compileList(o.deniedPatterns);
|
||
if (denied) out.deniedPatterns = denied;
|
||
const allowed = compileList(o.allowedPatterns);
|
||
if (allowed) out.allowedPatterns = allowed;
|
||
if (typeof o.maxFrequency === 'number' && Number.isFinite(o.maxFrequency) && o.maxFrequency > 0) {
|
||
out.maxFrequency = Math.floor(o.maxFrequency);
|
||
}
|
||
if (typeof o.requireConfirmation === 'boolean') {
|
||
out.requireConfirmation = o.requireConfirmation;
|
||
}
|
||
return Object.keys(out).length > 0 ? out : null;
|
||
}
|
||
|
||
export class PolicyEngine {
|
||
private policies: Map<string, PermissionPolicy> = new Map();
|
||
|
||
/**
|
||
* v0.8.1 P2-1: 用户自定义策略覆盖层(settings → 热加载)。
|
||
* resolvePolicy 的最高优先级 —— 覆盖层与默认策略按字段合并
|
||
* (未指定的安全字段如 deniedPatterns 保留默认值,与构造函数合并语义一致)。
|
||
*/
|
||
private policyOverrides: Map<string, PermissionPolicy> = new Map();
|
||
|
||
/** 设置/清除某工具的用户策略覆盖(null = 清除,回退默认策略) */
|
||
setPolicyOverride(toolName: string, override: ParsedToolPolicy | null): void {
|
||
if (!override) {
|
||
this.policyOverrides.delete(toolName);
|
||
return;
|
||
}
|
||
const base = this.policies.get(toolName);
|
||
this.policyOverrides.set(toolName, {
|
||
...(base ?? {
|
||
toolName,
|
||
requiredLevel: PermissionLevel.WRITE,
|
||
}),
|
||
toolName,
|
||
...override,
|
||
});
|
||
}
|
||
|
||
/** 获取某工具当前的覆盖策略(UI 回显用;无覆盖返回 null) */
|
||
getPolicyOverride(toolName: string): ParsedToolPolicy | null {
|
||
const o = this.policyOverrides.get(toolName);
|
||
if (!o) return null;
|
||
return {
|
||
deniedPatterns: o.deniedPatterns?.map((r) => r.source),
|
||
allowedPatterns: o.allowedPatterns?.map((r) => r.source),
|
||
maxFrequency: o.maxFrequency,
|
||
requireConfirmation: o.requireConfirmation,
|
||
} as unknown as ParsedToolPolicy;
|
||
}
|
||
|
||
/**
|
||
* 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 合并而非完全覆盖
|
||
*
|
||
* 合并策略: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 {
|
||
// v0.8.1 P2-1: 用户覆盖层最高优先级(settings 面板 → setPolicyOverride)
|
||
const override = this.policyOverrides.get(toolName);
|
||
if (override) return override;
|
||
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<string, unknown>,
|
||
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<unknown> = 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;
|
||
}
|
||
}
|