feat: implementar audit trail con AuditWriter y emision de eventos
Crea shell/audit/ con Writer que escribe eventos de auditoria a archivo JSONL y opcionalmente a un room Matrix. Integra la emision de eventos en los puntos clave del runtime: - message_received: al recibir cualquier evento Matrix (handler.go) - command_exec: al ejecutar un comando (handler.go) - tool_exec: al ejecutar una tool (tools/registry.go via AuditFunc callback) - llm_request / llm_error: al llamar al LLM (llm.go) El Writer se inicializa en agents/runtime.go si security.audit.enabled=true. Usa patron de inyeccion de dependencias (MatrixSender como funcion, AuditFunc como callback) para evitar acoplamiento entre packages. Incluye tests completos para el Writer: escritura JSONL, filtrado por Include, modo solo-file, modo solo-room, auto-set de timestamp. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/agents/internal/config"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
|
||||
func TestEmit_WritesToFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := filepath.Join(dir, "audit.jsonl")
|
||||
|
||||
cfg := config.AuditCfg{
|
||||
Enabled: true,
|
||||
LogFile: logFile,
|
||||
}
|
||||
|
||||
w, err := New(cfg, nil, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
w.Emit(Event{
|
||||
Time: time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC),
|
||||
AgentID: "test-bot",
|
||||
EventType: EventCommandExec,
|
||||
SenderID: "@user:example.com",
|
||||
RoomID: "!room:example.com",
|
||||
Detail: "command=help",
|
||||
})
|
||||
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("expected 1 line, got %d", len(lines))
|
||||
}
|
||||
|
||||
var evt Event
|
||||
if err := json.Unmarshal([]byte(lines[0]), &evt); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if evt.AgentID != "test-bot" {
|
||||
t.Errorf("AgentID = %q, want %q", evt.AgentID, "test-bot")
|
||||
}
|
||||
if evt.EventType != EventCommandExec {
|
||||
t.Errorf("EventType = %q, want %q", evt.EventType, EventCommandExec)
|
||||
}
|
||||
if evt.SenderID != "@user:example.com" {
|
||||
t.Errorf("SenderID = %q, want %q", evt.SenderID, "@user:example.com")
|
||||
}
|
||||
if evt.Detail != "command=help" {
|
||||
t.Errorf("Detail = %q, want %q", evt.Detail, "command=help")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmit_IncludeFilter(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := filepath.Join(dir, "audit.jsonl")
|
||||
|
||||
cfg := config.AuditCfg{
|
||||
Enabled: true,
|
||||
LogFile: logFile,
|
||||
Include: []string{EventCommandExec, EventToolExec},
|
||||
}
|
||||
|
||||
w, err := New(cfg, nil, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
// Should be written (in include list)
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventCommandExec, Detail: "included"})
|
||||
|
||||
// Should NOT be written (not in include list)
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventLLMRequest, Detail: "excluded"})
|
||||
|
||||
// Should be written
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventToolExec, Detail: "also-included"})
|
||||
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 lines (filtered), got %d: %s", len(lines), string(data))
|
||||
}
|
||||
|
||||
// Verify content
|
||||
var evt0, evt1 Event
|
||||
json.Unmarshal([]byte(lines[0]), &evt0)
|
||||
json.Unmarshal([]byte(lines[1]), &evt1)
|
||||
|
||||
if evt0.EventType != EventCommandExec {
|
||||
t.Errorf("line 0 EventType = %q, want %q", evt0.EventType, EventCommandExec)
|
||||
}
|
||||
if evt1.EventType != EventToolExec {
|
||||
t.Errorf("line 1 EventType = %q, want %q", evt1.EventType, EventToolExec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmit_NoLogFile_OnlyRoom(t *testing.T) {
|
||||
var sent []string
|
||||
var mu sync.Mutex
|
||||
sender := func(roomID, msg string) {
|
||||
mu.Lock()
|
||||
sent = append(sent, roomID+"|"+msg)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
cfg := config.AuditCfg{
|
||||
Enabled: true,
|
||||
LogToRoom: "!audit:example.com",
|
||||
// No LogFile
|
||||
}
|
||||
|
||||
w, err := New(cfg, sender, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventMessageReceived, Detail: "test"})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("expected 1 message sent, got %d", len(sent))
|
||||
}
|
||||
if !strings.HasPrefix(sent[0], "!audit:example.com|") {
|
||||
t.Errorf("message sent to wrong room: %s", sent[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmit_NoRoom_OnlyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := filepath.Join(dir, "audit.jsonl")
|
||||
|
||||
senderCalled := false
|
||||
sender := func(roomID, msg string) {
|
||||
senderCalled = true
|
||||
}
|
||||
|
||||
cfg := config.AuditCfg{
|
||||
Enabled: true,
|
||||
LogFile: logFile,
|
||||
// No LogToRoom
|
||||
}
|
||||
|
||||
w, err := New(cfg, sender, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventCommandExec, Detail: "test"})
|
||||
|
||||
if senderCalled {
|
||||
t.Error("sender was called despite no LogToRoom configured")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
t.Error("expected data in log file, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmit_SetsTimeIfZero(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := filepath.Join(dir, "audit.jsonl")
|
||||
|
||||
cfg := config.AuditCfg{
|
||||
Enabled: true,
|
||||
LogFile: logFile,
|
||||
}
|
||||
|
||||
w, err := New(cfg, nil, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
before := time.Now().UTC()
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventCommandExec})
|
||||
after := time.Now().UTC()
|
||||
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
var evt Event
|
||||
json.Unmarshal([]byte(strings.TrimSpace(string(data))), &evt)
|
||||
|
||||
if evt.Time.Before(before) || evt.Time.After(after) {
|
||||
t.Errorf("Time %v not in range [%v, %v]", evt.Time, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmit_EmptyInclude_PassesAll(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := filepath.Join(dir, "audit.jsonl")
|
||||
|
||||
cfg := config.AuditCfg{
|
||||
Enabled: true,
|
||||
LogFile: logFile,
|
||||
Include: []string{}, // empty = pass all
|
||||
}
|
||||
|
||||
w, err := New(cfg, nil, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventCommandExec})
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventLLMRequest})
|
||||
w.Emit(Event{AgentID: "bot", EventType: EventToolExec})
|
||||
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Errorf("expected 3 lines (all passed), got %d", len(lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_CreatesDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logFile := filepath.Join(dir, "subdir", "nested", "audit.jsonl")
|
||||
|
||||
cfg := config.AuditCfg{
|
||||
Enabled: true,
|
||||
LogFile: logFile,
|
||||
}
|
||||
|
||||
w, err := New(cfg, nil, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
if _, err := os.Stat(logFile); os.IsNotExist(err) {
|
||||
t.Error("log file was not created")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user