Files
metona-ai-desktop/electron/services/audit.service.ts
T

382 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.
/**
* 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';
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.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 };
}
}