Files
metona-ai-desktop/electron/services/audit.service.ts
T
thzxx 7e8b4882a0
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m38s
CI / 产物编译验证 (push) Successful in 10m15s
CI / 全量测试 (Electron ABI) (push) Failing after 5m27s
feat: v0.5.0 审计修复版 — 类型基线重建 + 会话隔离 + SubAgent 可观测性 + 三项功能补全
P0 安全与工程基线(止血):
- .npmrc 移除硬编码 Gitea npm 凭据,改为 GITEA_NPM_AUTH 环境变量注入(已验证未设变量时 401)
- 修复 typecheck 空操作缺陷:solution-style 根 tsconfig 改为双工程真检查(node + web),
  pre-commit 与 CI 门禁恢复拦截能力
- 修复 4 处 v0.4.1 遗留类型错误:confirmation-hook.test 枚举名 FILE_SYSTEM→FILESYSTEM、
  agent.ts VALIDATION 事件 severity 类型谓词收窄、ContextMenu.tsx 导出 attachments 类型
- 补装 v0.4.1 声明但未安装的 node-html-parser 依赖

P1 逻辑缺陷修复(跨模块边界):
- ConfirmationHook 会话隔离:rememberedDecisions 与 pendingConfirmations 按 sessionId 隔离,
  abortSession 只清本会话 pending(修复 A 会话中断误杀 B 会话确认、拒绝记忆跨会话污染)
- SubAgent 可观测性:orchestrator 六个事件此前全项目零消费者,现接入
  ① subagent:event 生命周期广播(AgentMonitor 新增 SubAgent 状态区)
  ② SubEngine 流事件独立 TRACE 录制(sessionId=taskId 的 JSONL 文件)
- main.ts 启动链路异常兜底:初始化失败时记录日志 + 系统错误对话框 + 退出(原为白屏挂起)

P2 工程强化:
- CI:typecheck 双工程真检查;electron-test 从 experimental(continue-on-error)转正为阻塞门禁;
  GITEA_NPM_AUTH secret 注入说明
- 渲染 bundle 代码分割:单 2630KB chunk 拆为 main 557KB + vendor-react/mui/markdown/icons
  (业务代码变更不再使 vendor 缓存失效)
- database 建表 mcp_servers CHECK 直接含 streamable-http(新库不再依赖迁移 6 立即重建)

P3 功能补全:
- DeepSeek 余额显示:新增 llm:getBalance IPC + LLMSettings 余额卡片(复用适配器原死代码 getBalance)
- FTS5 会话内容搜索:messages_fts 虚表 + INSERT/UPDATE/DELETE 触发器实时同步 +
  存量库 rebuild 迁移 + sessions:searchContent IPC + Sidebar 搜索框标题∪内容联合搜索
  (短语转义防 FTS 运算符注入,按会话聚合展示 snippet)
- 审计日志导出:audit:export IPC(JSONL / CSV RFC 4180 转义)+ LogsSettings 导出按钮

文档一致性大扫除:
- README:工具数统一为 28(原 26/27/30 三口径)、handlers.ts→ipc/、录制事件名更正、
  删除虚构的审计导出/归档宣称与 Schema 虚构字段、MCP 三种传输、配置 key 更正、
  项目结构树对齐实际(settings 10 文件/lib 6 文件/react-virtuoso)、clone 地址改为 Gitea、
  新增 GITEA_NPM_AUTH 配置说明、测试数 207
- 架构/构建指南/UI UX/IR 标准 4 份 HTML 设计文档同步修正(工具数、表数 10、
  磁盘文件 2 个现状注记、ipc/*.ts 路径)
- eslint.config.js 与开发规范.md 注释对齐零容忍基线与 better-sqlite3 选型

测试: 199→207 用例(新增 ConfirmationHook 跨会话隔离 5 用例 + FTS5 搜索/审计导出 8 用例)
验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 207 全过 / build 成功
2026-08-21 21:07:01 +08:00

498 lines
14 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.
/**
* Audit Service — 审计日志服务(TOOL 层)
*
* 负责将工具调用、权限检查、错误等审计记录写入 SQLite audit_logs 表。
* 设计原则:
* 1. 所有重要操作必须记录
* 2. 日志不可篡改(INSERT-ONLY
* 3. 支持按时间/类型/会话查询
* 4. v0.2.0: 链式哈希防篡改 — 每条日志的 current_hash = SHA256(prev_hash + content)
*
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 日志分层
* @see docs/生产级通用 AI Agent 智能体桌面应用:完整设计与构建指南.html — 第十章
*/
import type Database from 'better-sqlite3';
import { createHash } from 'crypto';
import log from 'electron-log';
/**
* #36 修复: 稳定序列化,递归按 key 字典序排序后序列化
* JSON.stringify 对对象 key 顺序敏感({a:1,b:2} ≠ {b:2,a:1}),
* 导致相同语义的对象产生不同 hash,审计去重失效。
* stableStringify 保证相同内容始终产生相同字符串。
*/
function stableStringify(obj: unknown): string {
if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
if (Array.isArray(obj)) {
return '[' + obj.map(stableStringify).join(',') + ']';
}
const keys = Object.keys(obj as Record<string, unknown>).sort();
return (
'{' +
keys
.map((k) => JSON.stringify(k) + ':' + stableStringify((obj as Record<string, unknown>)[k]))
.join(',') +
'}'
);
}
export type AuditEventType =
| 'tool_call'
| 'permission_check'
| 'error'
| 'llm_request'
| 'llm_response'
| 'session_start'
| 'session_end'
| 'config_change';
export type AuditActor = 'agent' | 'user' | 'system';
export type AuditOutcome = 'success' | 'denied' | 'error';
/** v0.5.0: 审计日志行类型(导出/查询共用) */
interface AuditRecordRow {
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}
export interface AuditEntry {
sessionId: string;
iteration?: number;
eventType: AuditEventType;
actor: AuditActor;
target: string;
details?: Record<string, unknown>;
outcome?: AuditOutcome;
durationMs?: number;
}
export class AuditService {
constructor(private getDB: () => Database.Database) {}
/**
* 计算链式哈希
* current_hash = SHA256(prev_hash + 所有内容字段)
* v0.2.0: 纳入 iteration/outcome/durationMs 字段,使用 JSON.stringify 避免分隔符碰撞
* #36 修复: 改用 stableStringify 替代 JSON.stringify,避免对象 key 顺序不稳定导致 hash 不一致
*/
private computeHash(
prevHash: string,
entry: {
sessionId: string;
iteration: number | null;
eventType: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
durationMs: number | null;
createdAt: number;
},
): string {
// #36 修复: 使用 stableStringify 避免分隔符碰撞且保证 key 顺序稳定
const content = stableStringify({
prevHash,
sessionId: entry.sessionId,
iteration: entry.iteration,
eventType: entry.eventType,
actor: entry.actor,
target: entry.target,
details: entry.details,
outcome: entry.outcome,
durationMs: entry.durationMs,
createdAt: entry.createdAt,
});
return createHash('sha256').update(content, 'utf-8').digest('hex');
}
/**
* 获取最后一条日志的 hash(用于链式哈希计算)
* v0.3.0 修复: 使用内存缓存避免每次 log 都查询数据库
*/
private cachedLastHash: string | null = null;
private getLastHash(): string {
if (this.cachedLastHash !== null) return this.cachedLastHash;
const db = this.getDB();
const row = db.prepare('SELECT current_hash FROM audit_logs ORDER BY id DESC LIMIT 1').get() as
| { current_hash: string | null }
| undefined;
this.cachedLastHash =
row?.current_hash ?? '0000000000000000000000000000000000000000000000000000000000000000';
return this.cachedLastHash;
}
/**
* 记录审计条目(带链式哈希)
*/
log(entry: AuditEntry): void {
try {
const db = this.getDB();
const now = Date.now();
const detailsStr = entry.details ? JSON.stringify(entry.details) : null;
const prevHash = this.getLastHash();
const currentHash = this.computeHash(prevHash, {
sessionId: entry.sessionId,
iteration: entry.iteration ?? null,
eventType: entry.eventType,
actor: entry.actor,
target: entry.target,
details: detailsStr,
outcome: entry.outcome ?? null,
durationMs: entry.durationMs ?? null,
createdAt: now,
});
db.prepare(
`
INSERT INTO audit_logs (session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
entry.sessionId,
entry.iteration ?? null,
entry.eventType,
entry.actor,
entry.target,
detailsStr,
entry.outcome ?? null,
entry.durationMs ?? null,
now,
prevHash,
currentHash,
);
// v0.3.0 修复: 更新缓存的 lastHash,避免下次 log 再查询数据库
this.cachedLastHash = currentHash;
} catch (error) {
// 审计日志写入失败不应影响主流程
log.error('Audit log write failed:', error);
}
}
/**
* 记录工具调用
*/
logToolCall(params: {
sessionId: string;
iteration: number;
toolName: string;
args: Record<string, unknown>;
outcome: AuditOutcome;
result?: unknown;
error?: string;
durationMs: number;
}): void {
this.log({
sessionId: params.sessionId,
iteration: params.iteration,
eventType: 'tool_call',
actor: 'agent',
target: params.toolName,
details: {
args: params.args,
result: typeof params.result === 'string' ? params.result.slice(0, 1000) : params.result,
error: params.error,
},
outcome: params.outcome,
durationMs: params.durationMs,
});
}
/**
* 记录 LLM 请求
*/
logLLMRequest(params: {
sessionId: string;
iteration: number;
provider: string;
model: string;
tokenCount: number;
}): void {
this.log({
sessionId: params.sessionId,
iteration: params.iteration,
eventType: 'llm_request',
actor: 'agent',
target: `${params.provider}/${params.model}`,
details: { tokenCount: params.tokenCount },
});
}
/**
* 记录 LLM 响应
*/
logLLMResponse(params: {
sessionId: string;
iteration: number;
provider: string;
model: string;
tokenUsage: { input: number; output: number; total: number };
durationMs: number;
}): void {
this.log({
sessionId: params.sessionId,
iteration: params.iteration,
eventType: 'llm_response',
actor: 'agent',
target: `${params.provider}/${params.model}`,
details: { tokenUsage: params.tokenUsage },
outcome: 'success',
durationMs: params.durationMs,
});
}
/**
* 记录会话开始
*/
logSessionStart(sessionId: string): void {
this.log({
sessionId,
eventType: 'session_start',
actor: 'user',
target: 'session',
});
}
/**
* 记录会话结束
*/
logSessionEnd(params: {
sessionId: string;
totalIterations: number;
totalTokens: number;
durationMs: number;
terminationReason: string;
}): void {
this.log({
sessionId: params.sessionId,
eventType: 'session_end',
actor: 'agent',
target: 'session',
details: {
totalIterations: params.totalIterations,
totalTokens: params.totalTokens,
terminationReason: params.terminationReason,
},
outcome: 'success',
durationMs: params.durationMs,
});
}
/**
* 查询审计日志
*/
query(filters?: { sessionId?: string; eventType?: AuditEventType; limit?: number }): Array<{
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}> {
const db = this.getDB();
let sql = 'SELECT * FROM audit_logs WHERE 1=1';
const params: unknown[] = [];
if (filters?.sessionId) {
sql += ' AND session_id = ?';
params.push(filters.sessionId);
}
if (filters?.eventType) {
sql += ' AND event_type = ?';
params.push(filters.eventType);
}
sql += ' ORDER BY created_at DESC';
if (filters?.limit) {
sql += ' LIMIT ?';
params.push(filters.limit);
}
return db.prepare(sql).all(...params) as Array<{
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}>;
}
/**
* v0.5.0: 导出审计日志为 JSONL 文本(每行一条 JSON 记录)
*
* @returns JSONL 格式字符串(含全部记录,按 id 升序)
*/
exportJSONL(): string {
const db = this.getDB();
const rows = db
.prepare(
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
)
.all() as AuditRecordRow[];
return rows.map((r) => JSON.stringify(r)).join('\n') + (rows.length > 0 ? '\n' : '');
}
/**
* v0.5.0: 导出审计日志为 CSV 文本
*
* 字段含逗号/引号/换行时按 RFC 4180 转义(双引号包裹 + 内部双引号翻倍)。
*
* @returns CSV 格式字符串(含表头,按 id 升序)
*/
exportCSV(): string {
const db = this.getDB();
const rows = db
.prepare(
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
)
.all() as AuditRecordRow[];
const headers = [
'id',
'session_id',
'iteration',
'event_type',
'actor',
'target',
'details',
'outcome',
'duration_ms',
'created_at',
'prev_hash',
'current_hash',
];
const escapeCsv = (value: string | number | null): string => {
const s = value === null ? '' : String(value);
if (/[",\r\n]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
};
const lines = [headers.join(',')];
for (const r of rows) {
lines.push(
headers
.map((h) => escapeCsv((r as unknown as Record<string, string | number | null>)[h]))
.join(','),
);
}
return lines.join('\n') + '\n';
}
/**
* v0.2.0: 验证链式哈希完整性
*
* 遍历所有审计日志,重新计算每条记录的 hash,与存储的 current_hash 对比。
* 如果任何一条记录的 hash 不匹配,说明日志已被篡改。
*
* @returns 验证结果,包括是否通过、首个篡改位置的 ID
*/
verifyChain(): {
valid: boolean;
totalRecords: number;
verifiedRecords: number;
tamperedId: number | null;
} {
const db = this.getDB();
const rows = db
.prepare(
'SELECT id, session_id, iteration, event_type, actor, target, details, outcome, duration_ms, created_at, prev_hash, current_hash FROM audit_logs ORDER BY id ASC',
)
.all() as Array<{
id: number;
session_id: string;
iteration: number | null;
event_type: string;
actor: string;
target: string;
details: string | null;
outcome: string | null;
duration_ms: number | null;
created_at: number;
prev_hash: string | null;
current_hash: string | null;
}>;
let prevHash = '0000000000000000000000000000000000000000000000000000000000000000';
let verified = 0;
for (const row of rows) {
// 跳过旧数据(current_hash 为 NULL,未回填哈希)
if (row.current_hash === null) {
prevHash = '0'.repeat(64);
continue;
}
const expectedHash = this.computeHash(prevHash, {
sessionId: row.session_id,
iteration: row.iteration,
eventType: row.event_type,
actor: row.actor,
target: row.target,
details: row.details,
outcome: row.outcome,
durationMs: row.duration_ms,
createdAt: row.created_at,
});
if (row.current_hash !== expectedHash) {
// 审查修复: #36 将 JSON.stringify 改为 stableStringify 后,旧记录的 hash 用旧算法生成
// 尝试用旧算法(JSON.stringify)重新计算,如果匹配则跳过(兼容旧数据)
const legacyContent = JSON.stringify({
prevHash,
sessionId: row.session_id,
iteration: row.iteration,
eventType: row.event_type,
actor: row.actor,
target: row.target,
details: row.details,
outcome: row.outcome,
durationMs: row.duration_ms,
createdAt: row.created_at,
});
const legacyHash = createHash('sha256').update(legacyContent, 'utf-8').digest('hex');
if (row.current_hash === legacyHash) {
prevHash = row.current_hash;
continue;
}
return {
valid: false,
totalRecords: rows.length,
verifiedRecords: verified,
tamperedId: row.id,
};
}
prevHash = row.current_hash;
verified++;
}
return { valid: true, totalRecords: rows.length, verifiedRecords: verified, tamperedId: null };
}
}