Merge branch 'feature/audit-logging'
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
import { createReadStream, promises as fs } from 'fs';
|
||||||
|
import { dirname, basename } from 'path';
|
||||||
|
import type { AuditEvent, AuditQuery } from './types.js';
|
||||||
|
|
||||||
|
export async function queryAuditLogs(logPath: string, query: AuditQuery): Promise<AuditEvent[]> {
|
||||||
|
const logs: AuditEvent[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(logPath, 'utf-8');
|
||||||
|
const lines = content.split('\n').filter(line => line.trim() !== '');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const event: AuditEvent = JSON.parse(line);
|
||||||
|
if (matchesQuery(event, query)) {
|
||||||
|
logs.push(event);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(event: AuditEvent, query: AuditQuery): boolean {
|
||||||
|
if (query.start_time && event.timestamp < query.start_time) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.end_time && event.timestamp > query.end_time) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.event_types && !query.event_types.includes(event.event_type)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.level && event.level !== query.level) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.session_id) {
|
||||||
|
const eventSessionId = (event.event as Record<string, unknown>).session_id as string | undefined;
|
||||||
|
if (eventSessionId !== query.session_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (query.tool_name) {
|
||||||
|
const eventToolName = (event.event as Record<string, unknown>).tool_name as string | undefined;
|
||||||
|
if (eventToolName !== query.tool_name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAuditLogs(logPath: string, query: AuditQuery, format: 'json' | 'csv'): Promise<string> {
|
||||||
|
const logs = await queryAuditLogs(logPath, query);
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
return JSON.stringify(logs, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV format
|
||||||
|
const headers = ['timestamp', 'level', 'event_type', 'event'];
|
||||||
|
const rows = logs.map(log => {
|
||||||
|
const eventStr = JSON.stringify(log.event).replace(/"/g, '""');
|
||||||
|
return `${log.timestamp},${log.level},${log.event_type},"${eventStr}"`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [headers.join(','), ...rows].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { AuditLogger } from './logger.js';
|
||||||
|
export * from './types.js';
|
||||||
|
export { AuditLogger } from './logger.js';
|
||||||
|
|
||||||
|
export let auditLogger: AuditLogger | null = null;
|
||||||
|
|
||||||
|
export function initAuditLogger(logger: AuditLogger): void {
|
||||||
|
auditLogger = logger;
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { createReadStream, createWriteStream, existsSync, promises as fs, statSync } from 'fs';
|
||||||
|
import { basename, dirname } from 'path';
|
||||||
|
import { createGzip } from 'zlib';
|
||||||
|
import { pipeline } from 'stream/promises';
|
||||||
|
import type { AuditConfig } from './types.js';
|
||||||
|
|
||||||
|
export class AuditRotator {
|
||||||
|
private config: AuditConfig;
|
||||||
|
private lastRotationCheck = 0;
|
||||||
|
private readonly CHECK_INTERVAL = 60_000; // Check every minute
|
||||||
|
|
||||||
|
constructor(config: AuditConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkRotation(): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastRotationCheck < this.CHECK_INTERVAL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.lastRotationCheck = now;
|
||||||
|
|
||||||
|
return this.rotateIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private rotateIfNeeded(): boolean {
|
||||||
|
if (!existsSync(this.config.path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statSync(this.config.path);
|
||||||
|
const sizeMb = stats.size / (1024 * 1024);
|
||||||
|
|
||||||
|
if (sizeMb < this.config.max_size_mb) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rotate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rotate(): void {
|
||||||
|
const basePath = this.config.path;
|
||||||
|
const baseName = basename(basePath);
|
||||||
|
const dir = dirname(basePath);
|
||||||
|
const timestamp = new Date().toISOString().split('T')[0];
|
||||||
|
const rotatedPath = `${dir}/${baseName}.${timestamp}`;
|
||||||
|
const compressedPath = `${rotatedPath}.gz`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Move current log to rotated name
|
||||||
|
if (existsSync(rotatedPath)) {
|
||||||
|
fs.unlink(rotatedPath);
|
||||||
|
}
|
||||||
|
if (existsSync(compressedPath)) {
|
||||||
|
fs.unlink(compressedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rename(basePath, rotatedPath);
|
||||||
|
|
||||||
|
// Compress the rotated file
|
||||||
|
const gzip = createGzip();
|
||||||
|
const input = createReadStream(rotatedPath);
|
||||||
|
const output = createWriteStream(compressedPath);
|
||||||
|
|
||||||
|
pipeline(input, gzip, output).then(() => {
|
||||||
|
fs.unlink(rotatedPath);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Audit log compression failed:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up old logs
|
||||||
|
this.cleanupOldLogs();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit log rotation failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupOldLogs(): Promise<void> {
|
||||||
|
const dir = dirname(this.config.path);
|
||||||
|
const baseName = basename(this.config.path);
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - this.config.keep_days);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(dir);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.startsWith(baseName)) continue;
|
||||||
|
|
||||||
|
const filePath = `${dir}/${file}`;
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
const fileDate = new Date(stats.mtime);
|
||||||
|
|
||||||
|
if (fileDate < cutoffDate) {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Audit log cleanup failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async forceRotation(): Promise<void> {
|
||||||
|
this.rotate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
export type AuditLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export type AuditEventType =
|
||||||
|
// Tool execution
|
||||||
|
| 'tool.start' | 'tool.success' | 'tool.error' | 'tool.denied'
|
||||||
|
// Session lifecycle
|
||||||
|
| 'session.create' | 'session.message' | 'session.delete' | 'session.transfer' | 'session.compact'
|
||||||
|
// Automation - Cron
|
||||||
|
| 'cron.trigger' | 'cron.sent' | 'cron.add' | 'cron.remove'
|
||||||
|
// Automation - Webhook
|
||||||
|
| 'webhook.receive' | 'webhook.sent' | 'webhook.not_found' | 'webhook.denied'
|
||||||
|
// Automation - Heartbeat
|
||||||
|
| 'heartbeat.cycle' | 'heartbeat.check' | 'heartbeat.fail' | 'heartbeat.recover'
|
||||||
|
// Automation - Gmail
|
||||||
|
| 'gmail.poll' | 'gmail.new_email' | 'gmail.error'
|
||||||
|
// System events
|
||||||
|
| 'system.start' | 'system.stop' | 'system.config';
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
timestamp: number;
|
||||||
|
level: AuditLevel;
|
||||||
|
event_type: AuditEventType;
|
||||||
|
event: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
path: string;
|
||||||
|
max_size_mb: number;
|
||||||
|
keep_days: number;
|
||||||
|
levels: {
|
||||||
|
tools: AuditLevel;
|
||||||
|
sessions: AuditLevel;
|
||||||
|
automation: AuditLevel;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditQuery {
|
||||||
|
start_time?: number;
|
||||||
|
end_time?: number;
|
||||||
|
event_types?: AuditEventType[];
|
||||||
|
session_id?: string;
|
||||||
|
tool_name?: string;
|
||||||
|
level?: AuditLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolStartEvent {
|
||||||
|
tool_name: string;
|
||||||
|
tool_args: unknown;
|
||||||
|
session_id?: string;
|
||||||
|
channel?: string;
|
||||||
|
sender?: string;
|
||||||
|
agent_tier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolSuccessEvent {
|
||||||
|
tool_name: string;
|
||||||
|
result: { success: boolean; output: string; error?: string };
|
||||||
|
duration_ms: number;
|
||||||
|
session_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolErrorEvent {
|
||||||
|
tool_name: string;
|
||||||
|
error: string;
|
||||||
|
duration_ms: number;
|
||||||
|
reason?: string;
|
||||||
|
session_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolDeniedEvent {
|
||||||
|
tool_name: string;
|
||||||
|
reason: string;
|
||||||
|
session_id?: string;
|
||||||
|
denial_type: 'policy' | 'hook' | 'not_found';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionCreateEvent {
|
||||||
|
session_id: string;
|
||||||
|
frontend: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMessageEvent {
|
||||||
|
session_id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content_length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDeleteEvent {
|
||||||
|
session_id: string;
|
||||||
|
message_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionCompactEvent {
|
||||||
|
session_id: string;
|
||||||
|
messages_before: number;
|
||||||
|
messages_after: number;
|
||||||
|
tokens_before: number;
|
||||||
|
tokens_after: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CronTriggerEvent {
|
||||||
|
job_name: string;
|
||||||
|
schedule: string;
|
||||||
|
message: string;
|
||||||
|
output_channel: string;
|
||||||
|
output_peer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookReceiveEvent {
|
||||||
|
webhook_name: string;
|
||||||
|
body: string;
|
||||||
|
signature_verified: boolean;
|
||||||
|
output_channel: string;
|
||||||
|
output_peer: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatCycleEvent {
|
||||||
|
interval_ms: number;
|
||||||
|
checks: string[];
|
||||||
|
healthy: boolean;
|
||||||
|
consecutive_failures: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatCheckEvent {
|
||||||
|
check_name: string;
|
||||||
|
healthy: boolean;
|
||||||
|
message: string;
|
||||||
|
duration_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatFailEvent {
|
||||||
|
checks_failed: string[];
|
||||||
|
consecutive_failures: number;
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatRecoverEvent {
|
||||||
|
consecutive_failures_before: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailPollEvent {
|
||||||
|
mode: 'watch' | 'poll';
|
||||||
|
interval_ms?: number;
|
||||||
|
emails_processed: number;
|
||||||
|
new_emails: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailNewEmailEvent {
|
||||||
|
email_id: string;
|
||||||
|
from: string;
|
||||||
|
subject: string;
|
||||||
|
labels: string[];
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Cron } from 'croner';
|
import { Cron } from 'croner';
|
||||||
import type { CronJobConfig } from '../config/schema.js';
|
import type { CronJobConfig } from '../config/schema.js';
|
||||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
||||||
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
/** Minimal interface for the parts of ChannelRegistry we need. */
|
/** Minimal interface for the parts of ChannelRegistry we need. */
|
||||||
interface ChannelLookup {
|
interface ChannelLookup {
|
||||||
@@ -46,6 +47,7 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
const enabledCount = this.jobConfigs.filter(j => j.enabled).length;
|
const enabledCount = this.jobConfigs.filter(j => j.enabled).length;
|
||||||
if (enabledCount > 0) {
|
if (enabledCount > 0) {
|
||||||
console.log(`CronScheduler: ${enabledCount} job(s) scheduled`);
|
console.log(`CronScheduler: ${enabledCount} job(s) scheduled`);
|
||||||
|
auditLogger?.systemStart('CronScheduler', { jobs_enabled: enabledCount });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
}
|
}
|
||||||
this.cronInstances.clear();
|
this.cronInstances.clear();
|
||||||
this._status = 'disconnected';
|
this._status = 'disconnected';
|
||||||
|
auditLogger?.systemStop('CronScheduler');
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||||
@@ -93,6 +96,14 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
metadata: { cronJob: jobName, scheduled: true, modelTier: job.model_tier },
|
metadata: { cronJob: jobName, scheduled: true, modelTier: job.model_tier },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogger?.cronTrigger({
|
||||||
|
job_name: jobName,
|
||||||
|
schedule: job.schedule,
|
||||||
|
message: job.message,
|
||||||
|
output_channel: job.output.channel,
|
||||||
|
output_peer: job.output.peer,
|
||||||
|
});
|
||||||
|
|
||||||
this.messageHandler?.(msg);
|
this.messageHandler?.(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +129,8 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
|
|
||||||
this.jobs.set(config.name, config);
|
this.jobs.set(config.name, config);
|
||||||
|
|
||||||
|
auditLogger?.cronAdd(config.name, config.schedule);
|
||||||
|
|
||||||
if (config.enabled && this._status === 'connected') {
|
if (config.enabled && this._status === 'connected') {
|
||||||
const cronInstance = new Cron(config.schedule, {
|
const cronInstance = new Cron(config.schedule, {
|
||||||
timezone: config.timezone,
|
timezone: config.timezone,
|
||||||
@@ -148,6 +161,9 @@ export class CronScheduler implements ChannelAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.jobs.delete(name);
|
this.jobs.delete(name);
|
||||||
|
|
||||||
|
auditLogger?.cronRemove(name);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { GmailConfig } from '../config/schema.js';
|
|||||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
||||||
import { parseInterval } from './heartbeat.js';
|
import { parseInterval } from './heartbeat.js';
|
||||||
import { sanitizeHtml } from '../utils/html.js';
|
import { sanitizeHtml } from '../utils/html.js';
|
||||||
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
/** Minimal interface for the parts of ChannelRegistry we need. */
|
/** Minimal interface for the parts of ChannelRegistry we need. */
|
||||||
interface ChannelLookup {
|
interface ChannelLookup {
|
||||||
@@ -99,6 +100,7 @@ export class GmailWatcher implements ChannelAdapter {
|
|||||||
|
|
||||||
this._status = 'connected';
|
this._status = 'connected';
|
||||||
console.log(`GmailWatcher: Connected (poll_interval=${this.config.poll_interval ?? '300s'})`);
|
console.log(`GmailWatcher: Connected (poll_interval=${this.config.poll_interval ?? '300s'})`);
|
||||||
|
auditLogger?.systemStart('GmailWatcher', { poll_interval: this.config.poll_interval });
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
@@ -107,11 +109,12 @@ export class GmailWatcher implements ChannelAdapter {
|
|||||||
this.pollTimer = undefined;
|
this.pollTimer = undefined;
|
||||||
}
|
}
|
||||||
if (this.watchTimer) {
|
if (this.watchTimer) {
|
||||||
clearInterval(this.watchTimer);
|
clearTimeout(this.watchTimer);
|
||||||
this.watchTimer = undefined;
|
this.watchTimer = undefined;
|
||||||
}
|
}
|
||||||
this.oauth2Client = undefined;
|
this.oauth2Client = undefined;
|
||||||
this._status = 'disconnected';
|
this._status = 'disconnected';
|
||||||
|
auditLogger?.systemStop('GmailWatcher');
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { statfsSync, accessSync, constants as fsConstants } from 'fs';
|
|||||||
import { request } from 'http';
|
import { request } from 'http';
|
||||||
import type { HeartbeatConfig, HeartbeatCheck } from '../config/schema.js';
|
import type { HeartbeatConfig, HeartbeatCheck } from '../config/schema.js';
|
||||||
import type { ChannelAdapter, ChannelStatus, OutboundMessage } from '../channels/types.js';
|
import type { ChannelAdapter, ChannelStatus, OutboundMessage } from '../channels/types.js';
|
||||||
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
/** Result of a single health check. */
|
/** Result of a single health check. */
|
||||||
export interface CheckResult {
|
export interface CheckResult {
|
||||||
@@ -77,6 +78,7 @@ export class HeartbeatMonitor {
|
|||||||
|
|
||||||
const intervalMs = parseInterval(this.deps.config.interval);
|
const intervalMs = parseInterval(this.deps.config.interval);
|
||||||
console.log(`HeartbeatMonitor: starting (interval=${this.deps.config.interval}, checks=[${this.deps.config.checks.join(', ')}])`);
|
console.log(`HeartbeatMonitor: starting (interval=${this.deps.config.interval}, checks=[${this.deps.config.checks.join(', ')}])`);
|
||||||
|
auditLogger?.systemStart('HeartbeatMonitor', { interval: this.deps.config.interval, checks: this.deps.config.checks });
|
||||||
|
|
||||||
this.timer = setInterval(() => {
|
this.timer = setInterval(() => {
|
||||||
this.runChecks().catch((err) => {
|
this.runChecks().catch((err) => {
|
||||||
@@ -96,6 +98,7 @@ export class HeartbeatMonitor {
|
|||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = undefined;
|
this.timer = undefined;
|
||||||
}
|
}
|
||||||
|
auditLogger?.systemStop('HeartbeatMonitor');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run all configured checks and return the result. */
|
/** Run all configured checks and return the result. */
|
||||||
@@ -136,6 +139,12 @@ export class HeartbeatMonitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
checks.push(result);
|
checks.push(result);
|
||||||
|
auditLogger?.heartbeatCheck({
|
||||||
|
check_name: result.name,
|
||||||
|
healthy: result.healthy,
|
||||||
|
message: result.message,
|
||||||
|
duration_ms: result.durationMs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const healthy = checks.every((c) => c.healthy);
|
const healthy = checks.every((c) => c.healthy);
|
||||||
@@ -154,16 +163,33 @@ export class HeartbeatMonitor {
|
|||||||
this.notifiedFailure = true;
|
this.notifiedFailure = true;
|
||||||
const failedChecks = checks.filter((c) => !c.healthy).map((c) => `${c.name}: ${c.message}`);
|
const failedChecks = checks.filter((c) => !c.healthy).map((c) => `${c.name}: ${c.message}`);
|
||||||
await this.notify(`Heartbeat FAILING (${this.consecutiveFailures} consecutive failures):\n${failedChecks.join('\n')}`);
|
await this.notify(`Heartbeat FAILING (${this.consecutiveFailures} consecutive failures):\n${failedChecks.join('\n')}`);
|
||||||
|
|
||||||
|
auditLogger?.heartbeatFail({
|
||||||
|
checks_failed: failedChecks,
|
||||||
|
consecutive_failures: this.consecutiveFailures,
|
||||||
|
threshold: this.deps.config.failure_threshold,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.notifiedFailure) {
|
if (this.notifiedFailure) {
|
||||||
// Recovery notification
|
// Recovery notification
|
||||||
await this.notify(`Heartbeat RECOVERED after ${this.consecutiveFailures} consecutive failure(s). All checks passing.`);
|
await this.notify(`Heartbeat RECOVERED after ${this.consecutiveFailures} consecutive failure(s). All checks passing.`);
|
||||||
|
|
||||||
|
auditLogger?.heartbeatRecover({
|
||||||
|
consecutive_failures_before: this.consecutiveFailures,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.consecutiveFailures = 0;
|
this.consecutiveFailures = 0;
|
||||||
this.notifiedFailure = false;
|
this.notifiedFailure = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogger?.heartbeatCycle({
|
||||||
|
interval_ms: parseInterval(this.deps.config.interval),
|
||||||
|
checks: this.deps.config.checks,
|
||||||
|
healthy,
|
||||||
|
consecutive_failures: this.consecutiveFailures,
|
||||||
|
});
|
||||||
|
|
||||||
return heartbeatResult;
|
return heartbeatResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createHmac, timingSafeEqual } from 'crypto';
|
|||||||
import type { IncomingMessage, ServerResponse } from 'http';
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
import type { WebhookConfig } from '../config/schema.js';
|
import type { WebhookConfig } from '../config/schema.js';
|
||||||
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
|
||||||
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
/** Minimal interface for the parts of ChannelRegistry we need. */
|
/** Minimal interface for the parts of ChannelRegistry we need. */
|
||||||
interface ChannelLookup {
|
interface ChannelLookup {
|
||||||
@@ -83,11 +84,13 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
const enabledCount = this.webhookConfigs.filter(w => w.enabled).length;
|
const enabledCount = this.webhookConfigs.filter(w => w.enabled).length;
|
||||||
if (enabledCount > 0) {
|
if (enabledCount > 0) {
|
||||||
console.log(`WebhookHandler: ${enabledCount} webhook(s) registered`);
|
console.log(`WebhookHandler: ${enabledCount} webhook(s) registered`);
|
||||||
|
auditLogger?.systemStart('WebhookHandler', { webhooks_enabled: enabledCount });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
this._status = 'disconnected';
|
this._status = 'disconnected';
|
||||||
|
auditLogger?.systemStop('WebhookHandler');
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
async send(peerId: string, message: OutboundMessage): Promise<void> {
|
||||||
@@ -118,12 +121,14 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
async handleRequest(webhookName: string, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
async handleRequest(webhookName: string, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||||
const webhook = this.webhooks.get(webhookName);
|
const webhook = this.webhooks.get(webhookName);
|
||||||
if (!webhook) {
|
if (!webhook) {
|
||||||
|
auditLogger?.webhookNotFound(webhookName);
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Unknown webhook' }));
|
res.end(JSON.stringify({ error: 'Unknown webhook' }));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webhook.enabled) {
|
if (!webhook.enabled) {
|
||||||
|
auditLogger?.webhookDenied(webhookName, 'Webhook disabled');
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Webhook disabled' }));
|
res.end(JSON.stringify({ error: 'Webhook disabled' }));
|
||||||
return false;
|
return false;
|
||||||
@@ -132,9 +137,12 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
|
|
||||||
// Verify HMAC if secret is configured
|
// Verify HMAC if secret is configured
|
||||||
|
const signatureVerified = !webhook.secret;
|
||||||
if (webhook.secret) {
|
if (webhook.secret) {
|
||||||
const signature = req.headers['x-webhook-signature'] as string | undefined;
|
const signature = req.headers['x-webhook-signature'] as string | undefined;
|
||||||
if (!signature || !verifyHmac(body, webhook.secret, signature)) {
|
const verified = signature ? verifyHmac(body, webhook.secret, signature) : false;
|
||||||
|
if (!verified) {
|
||||||
|
auditLogger?.webhookDenied(webhookName, 'Invalid HMAC signature');
|
||||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
res.end(JSON.stringify({ error: 'Invalid signature' }));
|
||||||
return false;
|
return false;
|
||||||
@@ -154,6 +162,14 @@ export class WebhookHandler implements ChannelAdapter {
|
|||||||
metadata: { webhookName, body },
|
metadata: { webhookName, body },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
auditLogger?.webhookReceive({
|
||||||
|
webhook_name: webhookName,
|
||||||
|
body,
|
||||||
|
signature_verified: signatureVerified,
|
||||||
|
output_channel: webhook.output.channel,
|
||||||
|
output_peer: webhook.output.peer,
|
||||||
|
});
|
||||||
|
|
||||||
this.messageHandler?.(msg);
|
this.messageHandler?.(msg);
|
||||||
|
|
||||||
res.writeHead(202, { 'Content-Type': 'application/json' });
|
res.writeHead(202, { 'Content-Type': 'application/json' });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { OutboundAttachmentCollector } from './attachments.js';
|
|||||||
import { shouldCompact } from '../../context/tokens.js';
|
import { shouldCompact } from '../../context/tokens.js';
|
||||||
import { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_COMPACTION_CONFIG } from '../../context/compaction.js';
|
import { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_COMPACTION_CONFIG } from '../../context/compaction.js';
|
||||||
import { estimateCost } from '../../models/costs.js';
|
import { estimateCost } from '../../models/costs.js';
|
||||||
|
import { auditLogger } from '../../audit/index.js';
|
||||||
|
|
||||||
// ── Public types ──────────────────────────────────────────────────────
|
// ── Public types ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -255,6 +256,16 @@ export class AgentOrchestrator {
|
|||||||
`${result.tokensBefore} → ${result.tokensAfter} tokens`,
|
`${result.tokensBefore} → ${result.tokensAfter} tokens`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this._session) {
|
||||||
|
auditLogger?.sessionCompact({
|
||||||
|
session_id: this._session.id,
|
||||||
|
messages_before: result.tokensBefore,
|
||||||
|
messages_after: result.tokensAfter,
|
||||||
|
tokens_before: result.tokensBefore,
|
||||||
|
tokens_after: result.tokensAfter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -384,8 +384,23 @@ const sessionsSchema = z.object({
|
|||||||
|
|
||||||
const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error', 'silent']).default('info');
|
const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error', 'silent']).default('info');
|
||||||
|
|
||||||
|
const auditLevelSchema = z.enum(['debug', 'info', 'warn', 'error']).default('debug');
|
||||||
|
|
||||||
|
const auditSchema = z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
path: z.string().default('~/.local/share/flynn/audit.log'),
|
||||||
|
max_size_mb: z.number().min(1).max(1000).default(10),
|
||||||
|
keep_days: z.number().min(1).max(365).default(30),
|
||||||
|
levels: z.object({
|
||||||
|
tools: auditLevelSchema.default('debug'),
|
||||||
|
sessions: auditLevelSchema.default('debug'),
|
||||||
|
automation: auditLevelSchema.default('debug'),
|
||||||
|
}).default({}),
|
||||||
|
}).default({});
|
||||||
|
|
||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
log_level: logLevelSchema,
|
log_level: logLevelSchema,
|
||||||
|
audit: auditSchema,
|
||||||
telegram: telegramSchema.optional(),
|
telegram: telegramSchema.optional(),
|
||||||
discord: discordSchema,
|
discord: discordSchema,
|
||||||
slack: slackSchema,
|
slack: slackSchema,
|
||||||
@@ -451,3 +466,5 @@ export type GdriveConfig = z.infer<typeof gdriveSchema>;
|
|||||||
export type GtasksConfig = z.infer<typeof gtasksSchema>;
|
export type GtasksConfig = z.infer<typeof gtasksSchema>;
|
||||||
export type PairingCodeConfig = z.infer<typeof pairingSchema>;
|
export type PairingCodeConfig = z.infer<typeof pairingSchema>;
|
||||||
export type LogLevel = z.infer<typeof logLevelSchema>;
|
export type LogLevel = z.infer<typeof logLevelSchema>;
|
||||||
|
export type AuditConfig = z.infer<typeof auditSchema>;
|
||||||
|
export type AuditLevel = z.infer<typeof auditLevelSchema>;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { ChannelRegistry } from '../channels/index.js';
|
|||||||
import type { McpManager } from '../mcp/index.js';
|
import type { McpManager } from '../mcp/index.js';
|
||||||
import type { SkillRegistry, SkillInstaller } from '../skills/index.js';
|
import type { SkillRegistry, SkillInstaller } from '../skills/index.js';
|
||||||
import type { GatewayServer } from '../gateway/index.js';
|
import type { GatewayServer } from '../gateway/index.js';
|
||||||
|
import { AuditLogger, initAuditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
export interface DaemonContext {
|
export interface DaemonContext {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -61,6 +62,15 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
|
const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
|
||||||
|
// ── Audit Logger ──
|
||||||
|
const auditLoggerInstance = new AuditLogger(config.audit);
|
||||||
|
initAuditLogger(auditLoggerInstance);
|
||||||
|
|
||||||
|
lifecycle.onShutdown(async () => {
|
||||||
|
await auditLoggerInstance.close();
|
||||||
|
console.log('Audit logger closed');
|
||||||
|
});
|
||||||
|
|
||||||
const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db'));
|
const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db'));
|
||||||
const sessionManager = new SessionManager(sessionStore);
|
const sessionManager = new SessionManager(sessionStore);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Message } from '../models/types.js';
|
import type { Message } from '../models/types.js';
|
||||||
import type { SessionStore } from './store.js';
|
import type { SessionStore } from './store.js';
|
||||||
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -23,6 +24,15 @@ export class ManagedSession implements Session {
|
|||||||
};
|
};
|
||||||
this.history.push(messageWithTimestamp);
|
this.history.push(messageWithTimestamp);
|
||||||
this.store.addMessage(this.id, messageWithTimestamp);
|
this.store.addMessage(this.id, messageWithTimestamp);
|
||||||
|
|
||||||
|
auditLogger?.sessionMessage({
|
||||||
|
session_id: this.id,
|
||||||
|
role: message.role,
|
||||||
|
content_length: typeof message.content === 'string'
|
||||||
|
? message.content.length
|
||||||
|
: JSON.stringify(message.content).length,
|
||||||
|
});
|
||||||
|
|
||||||
return messageWithTimestamp;
|
return messageWithTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,8 +41,14 @@ export class ManagedSession implements Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
|
const messageCount = this.history.length;
|
||||||
this.history = [];
|
this.history = [];
|
||||||
this.store.clearSession(this.id);
|
this.store.clearSession(this.id);
|
||||||
|
|
||||||
|
auditLogger?.sessionDelete({
|
||||||
|
session_id: this.id,
|
||||||
|
message_count: messageCount,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +83,12 @@ export class SessionManager {
|
|||||||
const history = this.store.getMessages(id);
|
const history = this.store.getMessages(id);
|
||||||
session = new ManagedSession(id, this.store, history);
|
session = new ManagedSession(id, this.store, history);
|
||||||
this.sessions.set(id, session);
|
this.sessions.set(id, session);
|
||||||
|
|
||||||
|
auditLogger?.sessionCreate({
|
||||||
|
session_id: id,
|
||||||
|
frontend,
|
||||||
|
user_id: userId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
@@ -88,6 +110,8 @@ export class SessionManager {
|
|||||||
for (const message of history) {
|
for (const message of history) {
|
||||||
toSession.addMessage(message);
|
toSession.addMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogger?.sessionTransfer(fromSession.id, toSession.id, history.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
listSessions(): string[] {
|
listSessions(): string[] {
|
||||||
|
|||||||
+50
-1
@@ -2,6 +2,7 @@ import type { ToolResult } from './types.js';
|
|||||||
import type { ToolRegistry } from './registry.js';
|
import type { ToolRegistry } from './registry.js';
|
||||||
import type { HookEngine } from '../hooks/engine.js';
|
import type { HookEngine } from '../hooks/engine.js';
|
||||||
import type { ToolPolicyContext } from './policy.js';
|
import type { ToolPolicyContext } from './policy.js';
|
||||||
|
import { auditLogger } from '../audit/index.js';
|
||||||
|
|
||||||
export interface ToolExecutorConfig {
|
export interface ToolExecutorConfig {
|
||||||
defaultTimeoutMs?: number;
|
defaultTimeoutMs?: number;
|
||||||
@@ -24,6 +25,12 @@ export class ToolExecutor {
|
|||||||
async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise<ToolResult> {
|
async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise<ToolResult> {
|
||||||
const tool = this.registry.getByApiName(toolName);
|
const tool = this.registry.getByApiName(toolName);
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
|
auditLogger?.toolDenied({
|
||||||
|
tool_name: toolName,
|
||||||
|
reason: 'Tool not found',
|
||||||
|
denial_type: 'not_found',
|
||||||
|
session_id: context?.sessionId,
|
||||||
|
});
|
||||||
return { success: false, output: '', error: `Tool '${toolName}' not found` };
|
return { success: false, output: '', error: `Tool '${toolName}' not found` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +39,12 @@ export class ToolExecutor {
|
|||||||
if (policy) {
|
if (policy) {
|
||||||
const allNames = this.registry.list().map(t => t.name);
|
const allNames = this.registry.list().map(t => t.name);
|
||||||
if (!policy.isAllowed(toolName, allNames, context)) {
|
if (!policy.isAllowed(toolName, allNames, context)) {
|
||||||
|
auditLogger?.toolDenied({
|
||||||
|
tool_name: toolName,
|
||||||
|
reason: 'Tool not allowed by policy',
|
||||||
|
denial_type: 'policy',
|
||||||
|
session_id: context?.sessionId,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: '',
|
output: '',
|
||||||
@@ -48,6 +61,12 @@ export class ToolExecutor {
|
|||||||
args as Record<string, unknown>,
|
args as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
if (!hookResult.approved) {
|
if (!hookResult.approved) {
|
||||||
|
auditLogger?.toolDenied({
|
||||||
|
tool_name: toolName,
|
||||||
|
reason: hookResult.reason ?? 'no reason',
|
||||||
|
denial_type: 'hook',
|
||||||
|
session_id: context?.sessionId,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: '',
|
output: '',
|
||||||
@@ -57,6 +76,17 @@ export class ToolExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute with timeout
|
// Execute with timeout
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
auditLogger?.toolStart({
|
||||||
|
tool_name: toolName,
|
||||||
|
tool_args: args,
|
||||||
|
session_id: context?.sessionId,
|
||||||
|
channel: context?.channel,
|
||||||
|
sender: context?.sender,
|
||||||
|
agent_tier: context?.tier,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
tool.execute(args),
|
tool.execute(args),
|
||||||
@@ -65,17 +95,36 @@ export class ToolExecutor {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
// Truncate output if too large
|
// Truncate output if too large
|
||||||
if (result.output.length > this.maxOutputBytes) {
|
if (result.output.length > this.maxOutputBytes) {
|
||||||
result.output = result.output.slice(0, this.maxOutputBytes) + '\n[truncated]';
|
result.output = result.output.slice(0, this.maxOutputBytes) + '\n[truncated]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auditLogger?.toolSuccess({
|
||||||
|
tool_name: toolName,
|
||||||
|
result: result,
|
||||||
|
duration_ms: duration,
|
||||||
|
session_id: context?.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
auditLogger?.toolError({
|
||||||
|
tool_name: toolName,
|
||||||
|
error: errorMessage,
|
||||||
|
duration_ms: duration,
|
||||||
|
session_id: context?.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
output: '',
|
output: '',
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: errorMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ export interface ToolPolicyContext {
|
|||||||
agent?: string;
|
agent?: string;
|
||||||
/** Provider name (e.g. 'ollama', 'anthropic'). */
|
/** Provider name (e.g. 'ollama', 'anthropic'). */
|
||||||
provider?: string;
|
provider?: string;
|
||||||
|
/** Session ID for audit logging. */
|
||||||
|
sessionId?: string;
|
||||||
|
/** Channel name for audit logging. */
|
||||||
|
channel?: string;
|
||||||
|
/** Sender ID for audit logging. */
|
||||||
|
sender?: string;
|
||||||
|
/** Model tier for audit logging. */
|
||||||
|
tier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ToolPolicy engine ───────────────────────────────────────────────
|
// ── ToolPolicy engine ───────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user