Files
flynn/src/audit/logger.ts
T
William Valentin d62e836b5d feat(audit): Add core audit logging infrastructure
- Add AuditLogger class with rotation support
- Add audit configuration to config schema
- Instrument tool execution with full audit logging
- Instrument session lifecycle (create, message, delete, transfer, compact)
- Add audit logger initialization in daemon
- Add cron scheduler audit logging

Audit events captured:
- tool.start/success/error/denied
- session.create/message/delete/transfer/compact
- cron.trigger/add/remove

All logs go to ~/.local/share/flynn/audit.log (JSON lines)
with rotation (10MB files, 30-day retention)
2026-02-11 15:58:07 -08:00

251 lines
8.5 KiB
TypeScript

import { createWriteStream, existsSync, mkdirSync, promises as fs } from 'fs';
import { dirname } from 'path';
import type {
AuditEvent,
AuditConfig,
ToolStartEvent,
ToolSuccessEvent,
ToolErrorEvent,
ToolDeniedEvent,
SessionCreateEvent,
SessionMessageEvent,
SessionDeleteEvent,
SessionCompactEvent,
CronTriggerEvent,
WebhookReceiveEvent,
HeartbeatCycleEvent,
HeartbeatCheckEvent,
HeartbeatFailEvent,
HeartbeatRecoverEvent,
GmailPollEvent,
GmailNewEmailEvent,
} from './types.js';
import { AuditRotator } from './rotation.js';
export class AuditLogger {
private writeStream: import('fs').WriteStream | null = null;
private config: AuditConfig;
private rotator: AuditRotator;
constructor(config: AuditConfig) {
this.config = config;
this.rotator = new AuditRotator(config);
if (!this.config.enabled) {
return;
}
this.ensureLogDirectory();
this.rotator.checkRotation();
this.writeStream = createWriteStream(config.path, { flags: 'a' });
}
private ensureLogDirectory(): void {
const logDir = dirname(this.config.path);
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true });
}
}
private write(event: Omit<AuditEvent, 'timestamp'>): void {
if (!this.config.enabled || !this.writeStream) {
return;
}
this.rotator.checkRotation();
const fullEvent: AuditEvent = { ...event, timestamp: Date.now() };
this.writeStream!.write(JSON.stringify(fullEvent) + '\n');
}
private shouldLog(category: 'tools' | 'sessions' | 'automation', level: string): boolean {
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 };
const configLevel = this.config.levels[category];
return levelOrder[level as keyof typeof levelOrder] >= levelOrder[configLevel];
}
// ── Tool Events ───────────────────────────────────────────────
toolStart(event: ToolStartEvent): void {
if (!this.shouldLog('tools', 'debug')) return;
this.write({ level: 'debug', event_type: 'tool.start', event: event as unknown as Record<string, unknown> });
}
toolSuccess(event: ToolSuccessEvent): void {
if (!this.shouldLog('tools', 'debug')) return;
this.write({ level: 'debug', event_type: 'tool.success', event: event as unknown as Record<string, unknown> });
}
toolError(event: ToolErrorEvent): void {
if (!this.shouldLog('tools', 'error')) return;
this.write({ level: 'error', event_type: 'tool.error', event: event as unknown as Record<string, unknown> });
}
toolDenied(event: ToolDeniedEvent): void {
if (!this.shouldLog('tools', 'warn')) return;
this.write({ level: 'warn', event_type: 'tool.denied', event: event as unknown as Record<string, unknown> });
}
// ── Session Events ───────────────────────────────────────────
sessionCreate(event: SessionCreateEvent): void {
if (!this.shouldLog('sessions', 'debug')) return;
this.write({ level: 'debug', event_type: 'session.create', event: event as unknown as Record<string, unknown> });
}
sessionMessage(event: SessionMessageEvent): void {
if (!this.shouldLog('sessions', 'debug')) return;
this.write({ level: 'debug', event_type: 'session.message', event: event as unknown as Record<string, unknown> });
}
sessionDelete(event: SessionDeleteEvent): void {
if (!this.shouldLog('sessions', 'debug')) return;
this.write({ level: 'debug', event_type: 'session.delete', event: event as unknown as Record<string, unknown> });
}
sessionCompact(event: SessionCompactEvent): void {
if (!this.shouldLog('sessions', 'debug')) return;
this.write({ level: 'debug', event_type: 'session.compact', event: event as unknown as Record<string, unknown> });
}
sessionTransfer(from: string, to: string, messageCount: number): void {
if (!this.shouldLog('sessions', 'debug')) return;
this.write({
level: 'debug',
event_type: 'session.transfer',
event: { from_session: from, to_session: to, message_count: messageCount },
});
}
// ── Automation Events ───────────────────────────────────────
// Cron
cronTrigger(event: CronTriggerEvent): void {
if (!this.shouldLog('automation', 'debug')) return;
this.write({ level: 'debug', event_type: 'cron.trigger', event: event as unknown as Record<string, unknown> });
}
cronAdd(jobName: string, schedule: string): void {
if (!this.shouldLog('automation', 'info')) return;
this.write({
level: 'info',
event_type: 'cron.add',
event: { job_name: jobName, schedule },
});
}
cronRemove(jobName: string): void {
if (!this.shouldLog('automation', 'info')) return;
this.write({
level: 'info',
event_type: 'cron.remove',
event: { job_name: jobName },
});
}
// Webhook
webhookReceive(event: WebhookReceiveEvent): void {
if (!this.shouldLog('automation', 'debug')) return;
this.write({ level: 'debug', event_type: 'webhook.receive', event: event as unknown as Record<string, unknown> });
}
webhookNotFound(webhookName: string): void {
if (!this.shouldLog('automation', 'warn')) return;
this.write({
level: 'warn',
event_type: 'webhook.not_found',
event: { webhook_name: webhookName },
});
}
webhookDenied(webhookName: string, reason: string): void {
if (!this.shouldLog('automation', 'warn')) return;
this.write({
level: 'warn',
event_type: 'webhook.denied',
event: { webhook_name: webhookName, reason },
});
}
// Heartbeat
heartbeatCycle(event: HeartbeatCycleEvent): void {
if (!this.shouldLog('automation', 'debug')) return;
this.write({ level: 'debug', event_type: 'heartbeat.cycle', event: event as unknown as Record<string, unknown> });
}
heartbeatCheck(event: HeartbeatCheckEvent): void {
if (!this.shouldLog('automation', 'debug')) return;
this.write({ level: 'debug', event_type: 'heartbeat.check', event: event as unknown as Record<string, unknown> });
}
heartbeatFail(event: HeartbeatFailEvent): void {
if (!this.shouldLog('automation', 'warn')) return;
this.write({ level: 'warn', event_type: 'heartbeat.fail', event: event as unknown as Record<string, unknown> });
}
heartbeatRecover(event: HeartbeatRecoverEvent): void {
if (!this.shouldLog('automation', 'info')) return;
this.write({ level: 'info', event_type: 'heartbeat.recover', event: event as unknown as Record<string, unknown> });
}
// Gmail
gmailPoll(event: GmailPollEvent): void {
if (!this.shouldLog('automation', 'debug')) return;
this.write({ level: 'debug', event_type: 'gmail.poll', event: event as unknown as Record<string, unknown> });
}
gmailNewEmail(event: GmailNewEmailEvent): void {
if (!this.shouldLog('automation', 'debug')) return;
this.write({ level: 'debug', event_type: 'gmail.new_email', event: event as unknown as Record<string, unknown> });
}
gmailError(error: string, context?: string): void {
if (!this.shouldLog('automation', 'error')) return;
this.write({
level: 'error',
event_type: 'gmail.error',
event: { error, context },
});
}
// ── System Events ────────────────────────────────────────────
systemStart(component: string, config?: Record<string, unknown>): void {
if (!this.config.enabled) return;
this.write({
level: 'info',
event_type: 'system.start',
event: { component, config },
});
}
systemStop(component: string, reason?: string): void {
if (!this.config.enabled) return;
this.write({
level: 'info',
event_type: 'system.stop',
event: { component, reason },
});
}
systemConfig(component: string, action: string, config: Record<string, unknown>): void {
if (!this.config.enabled) return;
this.write({
level: 'info',
event_type: 'system.config',
event: { component, action, config },
});
}
// ── Lifecycle ───────────────────────────────────────────────
async close(): Promise<void> {
if (this.writeStream) {
await new Promise<void>((resolve) => {
this.writeStream!.end(() => resolve());
});
this.writeStream = null;
}
}
}