// Package audit implementa el logging por niveles y el registro de auditoría // (agent.log) del agente. // // Seguridad: todo texto pasa por un redactor que sustituye los secretos // registrados (p.ej. API_KEY) por su versión enmascarada. Es una defensa en // profundidad: el código no debería intentar loguear secretos, pero si un // mensaje de error los incluye por accidente, no llegan al disco. package audit import ( "fmt" "io" "os" "strings" "sync" "time" ) // Level es la severidad de un mensaje. type Level int const ( Debug Level = iota Info Warn Error ) func (l Level) String() string { switch l { case Debug: return "DEBUG" case Info: return "INFO" case Warn: return "WARN" default: return "ERROR" } } // Logger escribe mensajes en la consola y en el archivo de auditoría. // // Consola: WARN y ERROR siempre; DEBUG e INFO solo con --verbose. // Archivo: INFO en adelante siempre; DEBUG solo con --verbose. type Logger struct { mu sync.Mutex console io.Writer file *os.File verbose bool replacer *strings.Replacer secrets []string now func() time.Time } // New crea un Logger. Si path está vacío no se escribe archivo de auditoría. // El archivo se crea con permisos 0600 porque puede contener rutas y // comandos sensibles del sistema administrado. func New(console io.Writer, path string, verbose bool) (*Logger, error) { l := &Logger{console: console, verbose: verbose, now: time.Now} if path != "" { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) if err != nil { return nil, fmt.Errorf("failed to open audit log %s: %w", path, err) } l.file = f } return l, nil } // AddSecret registra un valor que nunca debe aparecer en claro. // Se ignoran valores muy cortos para no destrozar texto normal. func (l *Logger) AddSecret(secret, masked string) { if len(secret) < 4 { return } l.mu.Lock() defer l.mu.Unlock() l.secrets = append(l.secrets, secret, masked) l.replacer = strings.NewReplacer(l.secrets...) } // Redact aplica el redactor a un texto arbitrario. func (l *Logger) Redact(s string) string { l.mu.Lock() defer l.mu.Unlock() return l.redactLocked(s) } func (l *Logger) redactLocked(s string) string { if l.replacer == nil { return s } return l.replacer.Replace(s) } // Debugf, Infof, Warnf y Errorf registran mensajes por nivel. func (l *Logger) Debugf(format string, args ...any) { l.log(Debug, format, args...) } func (l *Logger) Infof(format string, args ...any) { l.log(Info, format, args...) } func (l *Logger) Warnf(format string, args ...any) { l.log(Warn, format, args...) } func (l *Logger) Errorf(format string, args ...any) { l.log(Error, format, args...) } func (l *Logger) log(level Level, format string, args ...any) { l.mu.Lock() defer l.mu.Unlock() msg := l.redactLocked(fmt.Sprintf(format, args...)) ts := l.now().Format(time.RFC3339) if level >= Warn || l.verbose { fmt.Fprintf(l.console, "%s %s\n", level, msg) } if l.file != nil && (level >= Info || l.verbose) { fmt.Fprintf(l.file, "%s %s %s\n", ts, level, msg) } } // Action registra una entrada de auditoría estructurada en agent.log. // fields se escribe como key="value" en el orden recibido (pares clave/valor). // // Ejemplo: // // 2026-09-27T10:35:17-06:00 ACTION task="01-nginx.md" action="llm_chat" result="ok" func (l *Logger) Action(fields ...string) { l.mu.Lock() defer l.mu.Unlock() if l.file == nil { return } var b strings.Builder b.WriteString(l.now().Format(time.RFC3339)) b.WriteString(" ACTION") for i := 0; i+1 < len(fields); i += 2 { fmt.Fprintf(&b, " %s=%q", fields[i], l.redactLocked(fields[i+1])) } b.WriteByte('\n') l.file.WriteString(b.String()) } // Close cierra el archivo de auditoría tras sincronizarlo a disco. func (l *Logger) Close() error { l.mu.Lock() defer l.mu.Unlock() if l.file == nil { return nil } l.file.Sync() err := l.file.Close() l.file = nil return err }