// Package audit provides an audit event writer for compliance and review. // Events are written to a JSONL file and/or sent to a Matrix room. // This is fully impure (I/O): belongs in shell/. package audit import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "sync" "time" "github.com/enmanuel/agents/internal/config" ) // Event types emitted by the audit trail. const ( EventMessageReceived = "message_received" EventCommandExec = "command_exec" EventToolExec = "tool_exec" EventLLMRequest = "llm_request" EventLLMError = "llm_error" ) // Event represents a single audit trail entry. type Event struct { Time time.Time `json:"time"` AgentID string `json:"agent_id"` EventType string `json:"event_type"` SenderID string `json:"sender_id,omitempty"` RoomID string `json:"room_id,omitempty"` Detail string `json:"detail,omitempty"` } // MatrixSender is a function that sends a message to a Matrix room. // Decouples audit from the Matrix client. type MatrixSender func(roomID, msg string) // Writer writes audit events to a JSONL file and/or a Matrix room. type Writer struct { cfg config.AuditCfg sender MatrixSender // may be nil logger *slog.Logger include map[string]bool // allowlist of event types; empty = all mu sync.Mutex file *os.File } // New creates an AuditWriter from the given config. // matrixSender may be nil if LogToRoom is not configured. func New(cfg config.AuditCfg, sender MatrixSender, logger *slog.Logger) (*Writer, error) { w := &Writer{ cfg: cfg, sender: sender, logger: logger.With("component", "audit"), } // Build include allowlist if len(cfg.Include) > 0 { w.include = make(map[string]bool, len(cfg.Include)) for _, t := range cfg.Include { w.include[t] = true } } // Open log file if configured if cfg.LogFile != "" { dir := filepath.Dir(cfg.LogFile) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create audit log dir %s: %w", dir, err) } f, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return nil, fmt.Errorf("open audit log %s: %w", cfg.LogFile, err) } w.file = f } return w, nil } // Emit writes an audit event. If the event type is not in the include list // (when non-empty), the event is silently dropped. Thread-safe. func (w *Writer) Emit(evt Event) { // Filter by include allowlist (empty = pass all) if len(w.include) > 0 && !w.include[evt.EventType] { return } // Ensure time is set if evt.Time.IsZero() { evt.Time = time.Now().UTC() } // Write to JSONL file if w.file != nil { data, err := json.Marshal(evt) if err != nil { w.logger.Error("audit_marshal_error", "err", err) return } data = append(data, '\n') w.mu.Lock() _, writeErr := w.file.Write(data) w.mu.Unlock() if writeErr != nil { w.logger.Error("audit_write_error", "err", writeErr) } } // Send to Matrix room if w.sender != nil && w.cfg.LogToRoom != "" { msg := fmt.Sprintf("**[audit]** `%s` | agent=%s sender=%s room=%s | %s", evt.EventType, evt.AgentID, evt.SenderID, evt.RoomID, evt.Detail) w.sender(w.cfg.LogToRoom, msg) } } // Close closes the underlying log file. func (w *Writer) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.file != nil { return w.file.Close() } return nil }