Files
thzxx 025f00171b feat: 升级至 v0.3.0 — 安全增强、死循环检测、六轮全面审计修复
大版本迭代,新增安全增强、Agent Loop 增强、UI/UX 增强,经六轮全面审计修复所有问题。

新增功能:
- OutputValidator 事实一致性检查 + 幻觉检测
- PromptInjectionDefender 语义级检测(指令性动词密度、角色边界、分隔符嵌套)
- DeadLoopError 死循环检测(连续3轮相同工具调用自动终止)
- PolicyEngine maxFrequency 滑动窗口频率限制
- MemoryManager TF-IDF 语义检索 + IDF 缓存原子替换
- 斜杠命令(/tool /memory /clear /export)+ 快捷键(Ctrl+B/J/Shift+F/N/[/])
- 专注模式(Ctrl+Shift+F)带面板状态快照保存/恢复

六轮审计修复(共修复 3 CRITICAL + 8 HIGH + 13 MEDIUM + 11 LOW):

CRITICAL:
- main.ts 传入 createAdapter 而非 reloadAdapter,导致 Provider 切换完全失效
- Ctrl+N/Ctrl+[/Ctrl+] 不同步 agent-store,导致消息发到错误会话
- engine.ts emit('error') 无监听器导致 DONE 事件丢失、前端卡死

HIGH:
- 死循环检测在工具执行之后(移入 PARSING 后 EXECUTING 前)
- deadLoop 事件前端未处理
- ConfirmationHook.clearPending 从未调用导致定时器泄漏
- engine.ts retry abort listener 未移除导致监听器堆积
- MCPManager JSON.parse 无 try-catch 导致初始化崩溃
- sendMessage 自动创建会话不同步 session-store
- setCurrentSession 竞态导致旧请求覆盖新会话数据

MEDIUM:
- toggleFocusMode 覆盖用户原有面板状态
- 正则检测可被常见词绕过
- 频率限制内存泄漏 + customPolicies 覆盖
- LIKE 回退转义未包含反斜杠
- OutputValidator 新功能未传入 toolResults/context
- MemoryConsolidator LLM 调用无超时保护
- AuditService 每次 log 都查询数据库
- browser-window-manager 超时后未停止页面加载
- output-validator URL 比较大小写敏感
- FileReader 无 onerror 导致 Promise 永久挂起
- /clear /export 不关闭斜杠菜单
- 专注模式下 toggleSidebar/toggleDetail 未恢复另一面板快照

LOW:
- abortPromise 事件监听器堆积
- RateLimitHook Map 内存泄漏
- memory.ts await 同步方法
- PolicyEngine 死代码清理
- Orchestrator sessionDepth 会话结束不清理
- workspace.service.ts 元数据插入边界问题
2026-07-12 19:46:47 +08:00

328 lines
12 KiB
TypeScript
Raw Permalink 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.
/**
* Output Validator — 输出验证器
*
* 在 Agent 输出最终答案之前进行验证:
* 1. 格式验证(Markdown/JSON 是否合法)
* 2. 内容安全检测(敏感词、PII 泄露)
* 3. 事实一致性检查(与工具结果是否矛盾)
* 4. 幻觉检测(无根据的断言)
*
* v0.3.0 增强:
* - 实现事实一致性检查(基于工具结果验证输出声明)
* - 实现幻觉检测(检测上下文中无依据的具体声明)
*
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第五章
*/
export interface ValidationResult {
valid: boolean;
issues: ValidationIssue[];
score: number; // 0-1, 越高越好
}
export interface ValidationIssue {
severity: 'error' | 'warning' | 'info';
type: string;
message: string;
}
export interface ValidationOptions {
/** 工具调用结果文本列表,用于事实一致性检查 */
toolResults?: string[];
/** 对话上下文文本,用于幻觉检测 */
context?: string;
}
/** 敏感词模式 */
const SENSITIVE_PATTERNS = [
{ pattern: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, type: 'credit_card', message: 'Possible credit card number detected' },
// v0.3.0 修复:移除字符类中的 | 字面字符([A-Z|a-z] → [A-Za-z]
{ pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/, type: 'email', message: 'Email address detected' },
{ pattern: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/, type: 'phone', message: 'Phone number detected' },
{ pattern: /\b(sk-[a-zA-Z0-9]{20,})\b/, type: 'api_key', message: 'Possible API key detected' },
];
/** 不安全内容模式 */
const UNSAFE_PATTERNS = [
{ pattern: /ignore\s+(all\s+)?(previous|above|prior)\s+(instructions|commands)/i, message: 'Possible prompt injection attempt' },
{ pattern: /\brm\s+-rf\s+\//, message: 'Dangerous filesystem command detected' },
{ pattern: /\bsudo\b/, message: 'Privilege escalation command detected' },
];
export class OutputValidator {
/**
* 验证输出内容
*
* v0.3.0: 新增事实一致性检查和幻觉检测
*/
async validate(output: string, options?: ValidationOptions): Promise<ValidationResult> {
const issues: ValidationIssue[] = [];
// 1. 格式验证
issues.push(...this.checkFormat(output));
// 2. 内容安全检测
issues.push(...this.checkSafety(output));
// 3. 事实一致性检查(v0.3.0 新增)
if (options?.toolResults && options.toolResults.length > 0) {
issues.push(...this.checkFactConsistency(output, options.toolResults));
}
// 4. 幻觉检测(v0.3.0 新增)
if (options?.context) {
issues.push(...this.checkHallucination(output, options.context));
}
// 5. 空内容检查
if (!output.trim()) {
issues.push({ severity: 'error', type: 'empty', message: 'Output is empty' });
}
// 6. 过短内容检查
if (output.trim().length < 10 && output.trim().length > 0) {
issues.push({ severity: 'warning', type: 'short', message: 'Output is suspiciously short' });
}
return {
valid: issues.filter((i) => i.severity === 'error').length === 0,
issues,
score: this.calculateScore(issues),
};
}
/**
* 事实一致性检查 — 验证输出中的声明是否与工具结果矛盾
*
* v0.3.0 实现的检查策略:
* 1. 工具报告错误但输出声称成功
* 2. 工具报告文件不存在但输出引用文件内容
* 3. 工具报告的数值与输出中的数值矛盾
*
* v0.3.0 修复:
* - errorIndicators 改为更精确的匹配,避免常见词 "error" 误报
* 使用 "error:" 或 "error occurred" 等上下文限定
*/
private checkFactConsistency(output: string, toolResults: string[]): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const toolContext = toolResults.join('\n');
// 检查1:工具报告错误但输出声称成功
// v0.3.0 修复:errorIndicators 改为更精确的匹配,避免 "error" 单独出现导致误报
// 要求 error 后跟冒号、消息或特定错误模式
const errorIndicators = /(?:error\s*[:\)]|error\s+occurred|failed\s+to|not\s+found|does\s+not\s+exist|enoent|permission\s+denied|cannot\s+access|no\s+such\s+file|exception|traceback|exit\s+code\s+[1-9])/i;
const hasErrorInTools = errorIndicators.test(toolContext);
if (hasErrorInTools) {
const successClaims = [
/\b(?:successfully|success|done|completed|created|written|deleted|installed|updated|finished)\b/i,
/(?:已(?:完成|创建|写入|删除|安装|更新|成功))/,
];
for (const pattern of successClaims) {
const match = output.match(pattern);
if (match) {
issues.push({
severity: 'warning',
type: 'fact_inconsistency',
message: `Output claims "${match[0]}" but tool results contain errors`,
});
break;
}
}
}
// 检查2:工具报告文件不存在但输出引用文件内容
const notFoundPattern = /(?:not\s+found|no\s+such\s+file|does\s+not\s+exist|文件(?:不存在|未找到|找不到))/i;
if (notFoundPattern.test(toolContext)) {
const contentClaimPatterns = [
/(?:file\s+(?:contains|says|shows)|文件(?:内容|包含|显示))/i,
/(?:the\s+content\s+is|内容是)/i,
];
for (const pattern of contentClaimPatterns) {
if (pattern.test(output)) {
issues.push({
severity: 'error',
type: 'fact_inconsistency',
message: 'Output references file content but tool reported file not found',
});
break;
}
}
}
// 检查3:工具报告退出码非零但输出声称命令成功
const exitCodePattern = /exit\s+code[:\s]+(\d+)/i;
const exitMatch = toolContext.match(exitCodePattern);
if (exitMatch && parseInt(exitMatch[1], 10) !== 0) {
const commandSuccessPattern = /\b(?:command\s+(?:succeeded|completed\s+successfully|ran\s+successfully))\b/i;
if (commandSuccessPattern.test(output)) {
issues.push({
severity: 'error',
type: 'fact_inconsistency',
message: `Output claims command success but tool reported exit code ${exitMatch[1]}`,
});
}
}
return issues;
}
/**
* 幻觉检测 — 检测输出中无依据的具体声明
*
* v0.3.0 实现的检查策略:
* 1. 提取输出中的文件路径,检查是否在上下文中出现
* 2. 提取输出中的 URL,检查是否在上下文中出现
* 3. 检测虚构的 API 响应模式
*
* v0.3.0 修复:
* - Windows 路径正则包含反斜杠字符类,正确匹配 C:\Users\test 等完整路径
* - apiResponsePattern 在循环前重置 lastIndex,避免间歇性漏检
* - 路径比较改为大小写不敏感(Windows 路径不敏感)
*/
private checkHallucination(output: string, context: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// 检查1:文件路径声称但上下文中不存在
// v0.3.0 修复:Windows 路径正则 [A-Z]:\\[\w\\][\w.-]+ 不含反斜杠,改为 [A-Z]:\\[\w\\.-]+(?:\\[\w\\.-]+)*
const pathPattern = /(?:\/[\w][\w.-]*){2,}|[A-Z]:\\[\w\\.-]+(?:\\[\w\\.-]+)*/g;
const paths = output.match(pathPattern) || [];
const seenPaths = new Set<string>();
// v0.3.0 修复:上下文比较改为小写(Windows 路径大小写不敏感)
const contextLower = context.toLowerCase();
for (const path of paths) {
if (seenPaths.has(path)) continue;
seenPaths.add(path);
// 检查输出是否声称读取/访问了该路径
const pathIndex = output.indexOf(path);
const surrounding = output.substring(
Math.max(0, pathIndex - 80),
Math.min(output.length, pathIndex + path.length + 80),
);
const accessClaim = /(?:read|loaded|opened|accessed|found|exists|contains|读取|加载|打开|访问|存在|包含)/i;
// v0.3.0 修复:路径比较改为大小写不敏感
if (accessClaim.test(surrounding) && !contextLower.includes(path.toLowerCase()) && path.length > 8) {
issues.push({
severity: 'warning',
type: 'hallucination',
message: `Path "${path.substring(0, 60)}" claimed in output but not found in context`,
});
}
}
// 检查2:URL 声称但上下文中不存在
const urlPattern = /https?:\/\/[^\s<>"]{8,}/g;
const urls = output.match(urlPattern) || [];
const seenUrls = new Set<string>();
for (const url of urls) {
if (seenUrls.has(url)) continue;
seenUrls.add(url);
// URL 在上下文中不存在且输出声称访问了它
const urlIndex = output.indexOf(url);
const surrounding = output.substring(
Math.max(0, urlIndex - 60),
Math.min(output.length, urlIndex + url.length + 60),
);
const fetchClaim = /(?:fetched|retrieved|loaded|returned|visited|获取|抓取|访问|返回)/i;
// v0.3.0 修复: URL 比较改为大小写不敏感,因为 URL host 部分大小写不敏感
if (fetchClaim.test(surrounding) && !context.toLowerCase().includes(url.toLowerCase())) {
issues.push({
severity: 'info',
type: 'hallucination',
message: `URL in output not found in conversation context`,
});
}
}
// 检查3:检测虚构的 API 响应(声称返回了特定 JSON 但上下文中没有)
const apiResponsePattern = /(?:API\s+(?:returned|responded)|接口(?:返回|响应))[:\s]*(\{[^}]{10,}\})/gi;
// v0.3.0 修复:循环前重置 lastIndex,避免带 g flag 的正则在 break 后 lastIndex 未重置导致漏检
apiResponsePattern.lastIndex = 0;
let apiMatch;
while ((apiMatch = apiResponsePattern.exec(output)) !== null) {
const jsonSnippet = apiMatch[1];
if (!context.includes(jsonSnippet.substring(0, 20))) {
issues.push({
severity: 'warning',
type: 'hallucination',
message: 'API response in output not found in tool results',
});
break;
}
}
return issues;
}
/**
* 格式验证
*/
private checkFormat(output: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// 未闭合的代码块
const openCodeBlocks = (output.match(/```/g) || []).length;
if (openCodeBlocks % 2 !== 0) {
issues.push({ severity: 'warning', type: 'format', message: 'Unclosed code block detected' });
}
// 未闭合的 HTML 标签
const openTags = (output.match(/<[a-z][a-z0-9]*[^/]*>/gi) || []).length;
const closeTags = (output.match(/<\/[a-z][a-z0-9]*>/gi) || []).length;
if (openTags > closeTags + 5) {
issues.push({ severity: 'info', type: 'format', message: 'Possible unclosed HTML tags' });
}
// 未闭合的括号
const openParens = (output.match(/\(/g) || []).length;
const closeParens = (output.match(/\)/g) || []).length;
if (Math.abs(openParens - closeParens) > 3) {
issues.push({ severity: 'info', type: 'format', message: 'Mismatched parentheses detected' });
}
return issues;
}
/**
* 内容安全检测
*/
private checkSafety(output: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
// 敏感信息检测
for (const { pattern, type, message } of SENSITIVE_PATTERNS) {
if (pattern.test(output)) {
issues.push({ severity: 'warning', type: `sensitive_${type}`, message });
}
}
// 不安全内容检测
for (const { pattern, message } of UNSAFE_PATTERNS) {
if (pattern.test(output)) {
issues.push({ severity: 'error', type: 'unsafe', message });
}
}
return issues;
}
/**
* 计算质量分数
*/
private calculateScore(issues: ValidationIssue[]): number {
let score = 1.0;
for (const issue of issues) {
switch (issue.severity) {
case 'error': score -= 0.3; break;
case 'warning': score -= 0.1; break;
case 'info': score -= 0.02; break;
}
}
return Math.max(0, Math.min(1, score));
}
}