Files
agents_and_robots/shell/audit/writer_test.go
T
egutierrez fb96a79feb 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>
2026-04-09 20:22:36 +00:00

272 lines
6.0 KiB
Go

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")
}
}