Files
metona-ai-desktop/electron/harness/verification/output-validator.ts
T
thzxx 2230bcec3f feat: v0.4.0 四阶段迭代 — 安全加固 + 工程基线 + 架构重构 + 双 Provider 扩展
P0 安全修复:
- API Key 加密存储(safeStorage 密钥链,版本化前缀,历史明文平滑兼容)
- 间接提示注入防护(SecurityScanHook 工具结果深扫描,网络工具脱敏/本地工具警示分级)
- error:report IPC 断链修复(渲染进程错误上报落 electron-log + 审计)
- abort 信号贯通工具层(run_command/dev-tools 子进程随会话中断终止)
- run_command 沙箱加固(cd 系统目录/敏感文件读取拦截 + chcp 前缀剥离防解析退化)
- .env 真实生效(dotenv 回退加载,应用内配置优先)

P1 工程基础:
- ESLint 9 flat config + 全部 34 条存量 warnings 清零(零容忍基线)
- 测试基线 118 用例 11 文件(token/文件防护/权限/沙箱/注入/命令/引擎/注册表/审计链/摘要分层)
- test:electron 双模式(ELECTRON_RUN_AS_NODE 跑 Electron ABI,SQLite 套件全执行)
- SessionRecorder 多会话隔离 + 9 种 TRACE 事件补全(含最终轮 iteration_end)
- Provider 故障转移(重试耗尽/不可重试一次性切换 fallback + 前端通知)
- MCP 真就绪(等待全部连接完成再广播 tools:ready)
- SLO/HealthChecker 真实接入(60s 巡检 + 托盘状态)
- CONFIG_DEFAULTS 单一来源(消除 SEED 双源漂移)

P2 架构升级:
- handlers.ts 1940 行拆分为 13 个 IPC 域模块(防重入注册 + 多窗口广播)
- AgentEngineManager 每会话独立引擎(LRU 30 + adapter 工厂隔离 abort 信号)
- TaskOrchestrator EngineProvider 改造 + abortByParent 联动中断 SubAgent
- 会话摘要分层上下文(session_summaries 滚动摘要 + 截断游标清理防因果污染)
- 消息编辑重发/重新生成(truncateAfter IPC + store 动作 + UI)
- Markdown 导出 / WebSearch 并行抓取(并发 3)/ 记忆 TF 缓存 / 版本构建期注入

P3 能力扩展:
- OpenAI Adapter(o 系列推理模型 reasoning_effort/max_completion_tokens)
- Anthropic Adapter(原生 Messages API:tool_use 块/角色合并/thinking budget/图片 base64/SSE 事件机)
- 设置页/Onboarding 六 Provider 全链路接入
2026-08-20 23:17:02 +08:00

328 lines
12 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.
/**
* 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));
}
}