Files
metona-ai-desktop/electron/harness/sandbox/permissions.ts
T
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(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 全项留档。
2026-09-08 09:35:58 +08:00

570 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 — 子任务委派(启动 SubAgentEXTERNAL_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: 构造会话隔离的频率 keysessionId 缺失时回退 '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;
}
}