diff --git a/src/audit/export.ts b/src/audit/export.ts new file mode 100644 index 0000000..6c5e5fe --- /dev/null +++ b/src/audit/export.ts @@ -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 { + 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).session_id as string | undefined; + if (eventSessionId !== query.session_id) { + return false; + } + } + if (query.tool_name) { + const eventToolName = (event.event as Record).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 { + 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'); +} diff --git a/src/audit/index.ts b/src/audit/index.ts new file mode 100644 index 0000000..e6b471a --- /dev/null +++ b/src/audit/index.ts @@ -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; +} diff --git a/src/audit/logger.ts b/src/audit/logger.ts new file mode 100644 index 0000000..8753525 --- /dev/null +++ b/src/audit/logger.ts @@ -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): 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 }); + } + + toolSuccess(event: ToolSuccessEvent): void { + if (!this.shouldLog('tools', 'debug')) return; + this.write({ level: 'debug', event_type: 'tool.success', event: event as unknown as Record }); + } + + toolError(event: ToolErrorEvent): void { + if (!this.shouldLog('tools', 'error')) return; + this.write({ level: 'error', event_type: 'tool.error', event: event as unknown as Record }); + } + + toolDenied(event: ToolDeniedEvent): void { + if (!this.shouldLog('tools', 'warn')) return; + this.write({ level: 'warn', event_type: 'tool.denied', event: event as unknown as Record }); + } + + // ── 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 }); + } + + sessionMessage(event: SessionMessageEvent): void { + if (!this.shouldLog('sessions', 'debug')) return; + this.write({ level: 'debug', event_type: 'session.message', event: event as unknown as Record }); + } + + sessionDelete(event: SessionDeleteEvent): void { + if (!this.shouldLog('sessions', 'debug')) return; + this.write({ level: 'debug', event_type: 'session.delete', event: event as unknown as Record }); + } + + sessionCompact(event: SessionCompactEvent): void { + if (!this.shouldLog('sessions', 'debug')) return; + this.write({ level: 'debug', event_type: 'session.compact', event: event as unknown as Record }); + } + + 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 }); + } + + 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 }); + } + + 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 }); + } + + heartbeatCheck(event: HeartbeatCheckEvent): void { + if (!this.shouldLog('automation', 'debug')) return; + this.write({ level: 'debug', event_type: 'heartbeat.check', event: event as unknown as Record }); + } + + heartbeatFail(event: HeartbeatFailEvent): void { + if (!this.shouldLog('automation', 'warn')) return; + this.write({ level: 'warn', event_type: 'heartbeat.fail', event: event as unknown as Record }); + } + + heartbeatRecover(event: HeartbeatRecoverEvent): void { + if (!this.shouldLog('automation', 'info')) return; + this.write({ level: 'info', event_type: 'heartbeat.recover', event: event as unknown as Record }); + } + + // 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 }); + } + + 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 }); + } + + 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): 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): void { + if (!this.config.enabled) return; + this.write({ + level: 'info', + event_type: 'system.config', + event: { component, action, config }, + }); + } + + // ── Lifecycle ─────────────────────────────────────────────── + + async close(): Promise { + if (this.writeStream) { + await new Promise((resolve) => { + this.writeStream!.end(() => resolve()); + }); + this.writeStream = null; + } + } +} diff --git a/src/audit/rotation.ts b/src/audit/rotation.ts new file mode 100644 index 0000000..2ac302b --- /dev/null +++ b/src/audit/rotation.ts @@ -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 { + 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 { + this.rotate(); + } +} diff --git a/src/audit/types.ts b/src/audit/types.ts new file mode 100644 index 0000000..72b43fd --- /dev/null +++ b/src/audit/types.ts @@ -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; +} + +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[]; +} diff --git a/src/automation/cron.ts b/src/automation/cron.ts index 5670a13..8097956 100644 --- a/src/automation/cron.ts +++ b/src/automation/cron.ts @@ -1,6 +1,7 @@ import { Cron } from 'croner'; import type { CronJobConfig } from '../config/schema.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. */ interface ChannelLookup { @@ -46,6 +47,7 @@ export class CronScheduler implements ChannelAdapter { const enabledCount = this.jobConfigs.filter(j => j.enabled).length; if (enabledCount > 0) { 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._status = 'disconnected'; + auditLogger?.systemStop('CronScheduler'); } async send(peerId: string, message: OutboundMessage): Promise { @@ -93,6 +96,14 @@ export class CronScheduler implements ChannelAdapter { 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); } @@ -118,6 +129,8 @@ export class CronScheduler implements ChannelAdapter { this.jobs.set(config.name, config); + auditLogger?.cronAdd(config.name, config.schedule); + if (config.enabled && this._status === 'connected') { const cronInstance = new Cron(config.schedule, { timezone: config.timezone, @@ -148,6 +161,9 @@ export class CronScheduler implements ChannelAdapter { } this.jobs.delete(name); + + auditLogger?.cronRemove(name); + return true; } } diff --git a/src/backends/native/orchestrator.ts b/src/backends/native/orchestrator.ts index e7db3d0..7108c81 100644 --- a/src/backends/native/orchestrator.ts +++ b/src/backends/native/orchestrator.ts @@ -12,6 +12,7 @@ import type { OutboundAttachmentCollector } from './attachments.js'; import { shouldCompact } from '../../context/tokens.js'; import { compactHistory, type CompactionConfig, type CompactionResult, DEFAULT_COMPACTION_CONFIG } from '../../context/compaction.js'; import { estimateCost } from '../../models/costs.js'; +import { auditLogger } from '../../audit/index.js'; // ── Public types ────────────────────────────────────────────────────── @@ -255,6 +256,16 @@ export class AgentOrchestrator { `${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; } diff --git a/src/config/schema.ts b/src/config/schema.ts index 052aa56..a043e62 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -384,8 +384,23 @@ const sessionsSchema = z.object({ 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({ log_level: logLevelSchema, + audit: auditSchema, telegram: telegramSchema.optional(), discord: discordSchema, slack: slackSchema, @@ -451,3 +466,5 @@ export type GdriveConfig = z.infer; export type GtasksConfig = z.infer; export type PairingCodeConfig = z.infer; export type LogLevel = z.infer; +export type AuditConfig = z.infer; +export type AuditLevel = z.infer; diff --git a/src/daemon/index.ts b/src/daemon/index.ts index aa5a04e..8737b06 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -30,6 +30,7 @@ import { ChannelRegistry } from '../channels/index.js'; import type { McpManager } from '../mcp/index.js'; import type { SkillRegistry, SkillInstaller } from '../skills/index.js'; import type { GatewayServer } from '../gateway/index.js'; +import { AuditLogger, initAuditLogger } from '../audit/index.js'; export interface DaemonContext { config: Config; @@ -61,6 +62,15 @@ export async function startDaemon(config: Config): Promise { const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn'); 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 sessionManager = new SessionManager(sessionStore); diff --git a/src/session/manager.ts b/src/session/manager.ts index 3b8aad3..771fb34 100644 --- a/src/session/manager.ts +++ b/src/session/manager.ts @@ -1,5 +1,6 @@ import type { Message } from '../models/types.js'; import type { SessionStore } from './store.js'; +import { auditLogger } from '../audit/index.js'; export interface Session { id: string; @@ -23,6 +24,15 @@ export class ManagedSession implements Session { }; this.history.push(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; } @@ -31,8 +41,14 @@ export class ManagedSession implements Session { } clear(): void { + const messageCount = this.history.length; this.history = []; 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); session = new ManagedSession(id, this.store, history); this.sessions.set(id, session); + + auditLogger?.sessionCreate({ + session_id: id, + frontend, + user_id: userId, + }); } return session; @@ -88,6 +110,8 @@ export class SessionManager { for (const message of history) { toSession.addMessage(message); } + + auditLogger?.sessionTransfer(fromSession.id, toSession.id, history.length); } listSessions(): string[] { diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 046a344..e8f3136 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -2,6 +2,7 @@ import type { ToolResult } from './types.js'; import type { ToolRegistry } from './registry.js'; import type { HookEngine } from '../hooks/engine.js'; import type { ToolPolicyContext } from './policy.js'; +import { auditLogger } from '../audit/index.js'; export interface ToolExecutorConfig { defaultTimeoutMs?: number; @@ -24,6 +25,12 @@ export class ToolExecutor { async execute(toolName: string, args: unknown, context?: ToolPolicyContext): Promise { const tool = this.registry.getByApiName(toolName); 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` }; } @@ -32,6 +39,12 @@ export class ToolExecutor { if (policy) { const allNames = this.registry.list().map(t => t.name); 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 { success: false, output: '', @@ -48,6 +61,12 @@ export class ToolExecutor { args as Record, ); if (!hookResult.approved) { + auditLogger?.toolDenied({ + tool_name: toolName, + reason: hookResult.reason ?? 'no reason', + denial_type: 'hook', + session_id: context?.sessionId, + }); return { success: false, output: '', @@ -57,6 +76,17 @@ export class ToolExecutor { } // 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 { const result = await Promise.race([ tool.execute(args), @@ -65,17 +95,36 @@ export class ToolExecutor { ), ]); + const duration = Date.now() - startTime; + // Truncate output if too large if (result.output.length > this.maxOutputBytes) { 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; } 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 { success: false, output: '', - error: error instanceof Error ? error.message : String(error), + error: errorMessage, }; } } diff --git a/src/tools/policy.ts b/src/tools/policy.ts index 82410d8..b22ff94 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -132,6 +132,14 @@ export interface ToolPolicyContext { agent?: string; /** Provider name (e.g. 'ollama', 'anthropic'). */ 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 ───────────────────────────────────────────────