feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s

This commit is contained in:
2026-09-08 14:30:27 +08:00
parent 69776e447f
commit 4cd6e997b5
86 changed files with 4303 additions and 956 deletions
+3 -4
View File
@@ -234,10 +234,9 @@ describe('agent:sendMessage — 前置检查', () => {
const result = await handler(null, VALID_MESSAGE, 'sess_1');
expect((result as { success: boolean }).success).toBe(false);
expect((result as { error: string }).error).toContain('blocked by prompt injection defense');
// 用户消息不保存(在注入检测前已保存?—— 现实现:先保存检测,验证已保存)
expect(ctxRaw.sessionService.saveMessage).toHaveBeenCalledWith(
expect.objectContaining({ role: 'user', sessionId: 'sess_1' }),
);
// v0.8.2 P1-4: 检测前移 —— 被阻断的消息**不**落库(旧实现先保存检测,
// 恶意内容滞留会话历史)
expect(ctxRaw.sessionService.saveMessage).not.toHaveBeenCalled();
// 引擎不启动
expect(ctxRaw.agentEngineManager.getEngine).not.toHaveBeenCalled();
});
+72 -32
View File
@@ -44,6 +44,18 @@ function buildTimezoneLabel(): string {
}
}
/**
* v0.8.2 P2-4: 会话元数据实时刷新广播(专用 session:updated 通道)。
* 消费方:Sidebartitle / messageCount / updatedAt 实时刷新)。
* 语义清理:标题生成不再伪装成 config:changed(合成 key session.title.<id>)。
*/
function broadcastSessionUpdated(
sessionId: string,
patch: { title?: string; messageCount?: number; updatedAt?: number },
): void {
broadcast('session:updated', { sessionId, ...patch });
}
/** 单会话的 text_delta 节流状态 */
interface ThrottleState {
buffer: string;
@@ -509,10 +521,46 @@ export function registerAgentHandlers(ctx: IPCContext): void {
let engineUserMessage: MetonaMessage;
try {
// 提示注入检测(安全模块)
// F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测
// (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载)
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启
//
// v0.8.2 P1-4 根治: 检测提前到用户消息落库**之前** —— 旧顺序先 saveMessage
// 后 detectriskScore≥7 阻断时恶意内容已持久化进会话历史(被阻断的消息
// 仍可在历史中读到、参与后续上下文)。检测只依赖消息内容本身,无需
// systemPrompt,前置无任何依赖障碍。
const injectionEnabled =
configService.get<boolean>('security.promptInjectionDefense') !== false;
if (injectionEnabled) {
const injectionResult = promptInjectionDefender.detect(userMessage.content);
if (injectionResult.riskScore >= 7) {
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
sendErrorEvent(
`Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
sessionId,
);
await sessionRecorder.stopRecording(sessionId, {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
return { success: false, error: 'Message blocked by prompt injection defense' };
}
if (injectionResult.riskScore >= 4) {
log.warn(
'[PromptInjectionDefender] Suspicious patterns detected:',
injectionResult.findings,
);
}
}
// 保存用户消息到数据库
// v0.7.4 回归修复: 透传前端消息 idChatMessage.id 由 genMsgId 生成)——
// 否则 DB 用 msg_<nanoid> 生成不同 id,用户对刚发送消息"仅保存"
// updateMessageContent 按 id 匹配)会 0 行更新失败。
// v0.8.2 P1-4: 位于注入检测之后 —— 被阻断的内容不进入会话历史
sessionService.saveMessage({
sessionId,
role: 'user',
@@ -521,6 +569,15 @@ export function registerAgentHandlers(ctx: IPCContext): void {
id: (userMessage as MetonaMessage & { id?: string }).id,
});
// v0.8.2 P2-4: 用户消息落库后即时广播(Sidebar 的条数/时间同步刷新)
{
const updatedSession = sessionService.getSession(sessionId);
broadcastSessionUpdated(sessionId, {
messageCount: updatedSession?.messageCount,
updatedAt: updatedSession?.updatedAt,
});
}
// P2-11: 分层加载历史——存在滚动摘要时只加载 [摘要 + 近期原文]
history = sessionSummaryService.buildHistoryMessages(sessionId).slice(0, -1);
@@ -600,39 +657,9 @@ export function registerAgentHandlers(ctx: IPCContext): void {
return { success: false, error: prepErr };
}
// 提示注入检测在 try 内执行(需 systemPrompt 已构建,与引擎运行同域
// 提示注入检测已前移至数据准备 try 块的开头(v0.8.2 P1-4:先检测后落库
try {
// 提示注入检测(安全模块)
// F-8 接通: security.promptInjectionDefense=false 时跳过用户消息检测
// (工具结果侧的 SecurityScanHook 由 main.ts 按同一配置决定是否挂载)
// fail-secure: 仅显式 false 才关闭 —— 配置值异常(空串/null/类型错误)时保持防护开启
const injectionEnabled =
configService.get<boolean>('security.promptInjectionDefense') !== false;
if (injectionEnabled) {
const injectionResult = promptInjectionDefender.detect(userMessage.content);
if (injectionResult.riskScore >= 7) {
log.warn('[PromptInjectionDefender] Blocked message:', injectionResult.findings);
sendErrorEvent(
`Message blocked by prompt injection defense: ${injectionResult.recommendation}`,
sessionId,
);
await sessionRecorder.stopRecording(sessionId, {
totalIterations: 0,
totalTokens: 0,
durationMs: 0,
terminationReason: 'error',
});
return { success: false, error: 'Message blocked by prompt injection defense' };
}
if (injectionResult.riskScore >= 4) {
log.warn(
'[PromptInjectionDefender] Suspicious patterns detected:',
injectionResult.findings,
);
}
}
// TRACE 层:记录上下文构建
sessionRecorder.recordContextBuilt(sessionId, {
tokenCount: estimateMessagesTokens(history),
@@ -813,13 +840,15 @@ export function registerAgentHandlers(ctx: IPCContext): void {
}
// v0.7.3 P4-1: 首个完成的 run 之后生成精炼会话标题(每会话幂等,失败静默)
// v0.8.2 P2-4: 标题广播改走专用 session:updated 事件 —— 此前伪装成
// config:changed(合成 key session.title.<id>),语义混用、渲染层需特判。
if (output.terminationReason === 'completed') {
titleGenerator
.maybeGenerateTitle(sessionId, userMessage.content, output.finalAnswer)
.then((title) => {
if (title) {
// 广播重命名结果,前端 Sidebar 实时刷新标题
broadcast('config:changed', { key: `session.title.${sessionId}`, value: title });
broadcastSessionUpdated(sessionId, { title });
}
})
.catch(() => {
@@ -827,6 +856,17 @@ export function registerAgentHandlers(ctx: IPCContext): void {
});
}
// v0.8.2 P2-4: 会话元数据实时刷新 —— 此前 Sidebar 仅挂载时 list() 一次,
// 流式过程中 messageCount/updatedAt 停留在旧值直到重启。run 收尾统一
// 广播 session:updated(含 DB 最新 messageCount/updatedAt)。
{
const updatedSession = sessionService.getSession(sessionId);
broadcastSessionUpdated(sessionId, {
messageCount: updatedSession?.messageCount,
updatedAt: updatedSession?.updatedAt,
});
}
// TOOL 层:记录会话结束 / TRACE 层:停止录制
auditService.logSessionEnd({
sessionId,
+18 -3
View File
@@ -10,6 +10,8 @@ import type { IPCContext } from './context';
import type { AuditEventType } from '../services/audit.service';
import { UpdateService } from '../services/update.service';
import { assertSafeConfigTarget } from '../harness/tools/built-in/ssrf-guard';
// v0.8.2 P2-5: 用户可见文案出层(ui.locale 驱动)
import { mt } from '../utils/main-locale';
import log from 'electron-log';
export function registerAppHandlers(ctx: IPCContext): void {
@@ -36,17 +38,30 @@ export function registerAppHandlers(ctx: IPCContext): void {
});
// ===== v0.8.0 P2-3: 下载并安装更新(electron-updater;未启用时明确失败) =====
// v0.8.2 P1-3: 语义拆分 —— updateInstall 仅下载(完成后广播 downloaded
// 不再自动重启退出);重启安装由用户确认后经 app:updateInstallNow 触发
ipcMain.handle('app:updateInstall', async () => {
const { getAutoUpdaterHandle } = await import('../services/update.service');
const handle = getAutoUpdaterHandle();
if (!handle) {
return { success: false, error: '自动更新未启用(未配置更新源或开发模式)' };
return { success: false, error: mt('app.update.disabled') };
}
// fire-and-forget:进度经 update:status 广播,完成后 quitAndInstall
// fire-and-forget:进度经 update:status 广播,完成后等待用户确认安装
void handle.downloadAndInstall();
return { success: true };
});
// ===== v0.8.2 P1-3: 安装已下载的更新并重启(用户显式确认后调用) =====
ipcMain.handle('app:updateInstallNow', async () => {
const { getAutoUpdaterHandle } = await import('../services/update.service');
const handle = getAutoUpdaterHandle();
if (!handle) {
return { success: false, error: mt('app.update.disabled') };
}
handle.installNow();
return { success: true };
});
// ===== v0.7.3 P3-4: 健康快照(SLO 指标 + 最近健康检查报告)=====
ipcMain.handle('app:healthSnapshot', async () => {
try {
@@ -116,7 +131,7 @@ export function registerAppHandlers(ctx: IPCContext): void {
const result = await dialog.showOpenDialog(callerWin, {
properties: ['openDirectory', 'createDirectory'],
defaultPath: defaultPath ?? app.getPath('home'),
title: '选择工作空间目录',
title: mt('app.dialog.selectWorkspace.title'),
});
if (result.canceled || result.filePaths.length === 0) return { canceled: true, path: '' };
return { canceled: false, path: result.filePaths[0] };
+14
View File
@@ -53,6 +53,20 @@ export function registerMCPHandlers(ctx: IPCContext): void {
) {
return { success: false, error: 'command is required for stdio transport' };
}
// v0.8.2 P2-7: args 类型校验 —— 此前原样透传(非数组也能写入 DB,靠读取侧
// safeParseArgs 兜底为空数组),配置期静默丢参。现显式校验:可选、必须是
// 字符串数组、单项 ≤512 字符、总数 ≤64(防把 args 当数据通道滥用)。
if (config.args !== undefined) {
if (!Array.isArray(config.args) || config.args.some((a) => typeof a !== 'string')) {
return { success: false, error: 'args must be an array of strings' };
}
if (config.args.length > 64) {
return { success: false, error: 'args supports at most 64 entries' };
}
if (config.args.some((a) => (a as string).length > 512)) {
return { success: false, error: 'each arg must be at most 512 characters' };
}
}
// sse / streamable-http 类型必须有合法 url
if (config.transport === 'sse' || config.transport === 'streamable-http') {
if (typeof config.url !== 'string' || !config.url.trim()) {
+8 -2
View File
@@ -10,6 +10,8 @@ import log from 'electron-log';
import type { IPCContext } from './context';
import { broadcast } from './context';
import { isSensitiveConfigKey } from '../utils/secure-config';
// v0.8.2 P1-5: 掩码实现单源
import { maskSensitiveValue } from '../utils/mask';
// v0.8.1 P2-1: 工具自定义策略解析
import { parseToolPolicy } from '../harness/sandbox/permissions';
// v0.8.0 P1-5: 配置 URL 深校验(域名真实 DNS 解析,拦"解析到云元数据 IP"绕过)
@@ -77,10 +79,14 @@ export const LLM_CONFIG_KEYS = [
'llm.fallbackBaseURL',
];
/** 敏感配置值脱敏(审计日志用:长值保留后 4 位,短值完全掩码) */
/**
* 敏感配置值脱敏(长值保留后 4 位,短值完全掩码)
* v0.8.2 P1-5: 掩码实现单源到 utils/mask.maskSensitiveValue
* (原就地实现与审计脱敏存在漂移风险)
*/
export function maskSensitive(key: string, value: unknown): unknown {
if (isSensitiveConfigKey(key) && typeof value === 'string' && value.length > 0) {
return value.length > 4 ? '***' + value.slice(-4) : '***';
return maskSensitiveValue(value);
}
return value;
}
+6 -4
View File
@@ -8,6 +8,8 @@ import { ipcMain } from 'electron';
import { join } from 'path';
import type { IPCContext } from './context';
import log from 'electron-log';
// v0.8.2 P2-5: 工作空间校验文案出层(ui.locale 驱动)
import { mt } from '../utils/main-locale';
export function registerWorkspaceHandlers(ctx: IPCContext): void {
const { workspaceService } = ctx;
@@ -43,7 +45,7 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
// 校验工作空间路径:检测路径有效性 + 必需文件状态 + 数据库是否存在
ipcMain.handle('workspace:check', async (_event, targetPath: string) => {
if (!targetPath || typeof targetPath !== 'string') {
return { valid: false, reason: '路径不能为空' };
return { valid: false, reason: mt('workspace.reason.empty') };
}
const { existsSync, statSync } = await import('fs');
@@ -60,7 +62,7 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
missingFiles: ['SOUL.md', 'MEMORY.md'],
isNewWorkspace: true,
dbExists: false,
reason: '目录不存在,将在切换后自动创建',
reason: mt('workspace.reason.notExists'),
};
}
@@ -68,10 +70,10 @@ export function registerWorkspaceHandlers(ctx: IPCContext): void {
try {
const stat = statSync(resolvedPath);
if (!stat.isDirectory()) {
return { valid: false, reason: '路径不是目录' };
return { valid: false, reason: mt('workspace.reason.notDirectory') };
}
} catch {
return { valid: false, reason: '无法访问路径' };
return { valid: false, reason: mt('workspace.reason.inaccessible') };
}
// 校验 3: 检测 2 个必需文件状态(SOUL.md + MEMORY.md