Files
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

161 lines
4.3 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"time"
coretypes "github.com/enmanuel/agents/pkg/llm"
"github.com/enmanuel/agents/shell/logger"
)
// AuditFunc is called after each tool execution for audit purposes.
// The registry does not depend on the audit package directly.
type AuditFunc func(toolName string, durationMS int64, err error)
// Registry holds available tools keyed by name.
type Registry struct {
tools map[string]Tool
logger *slog.Logger
rateLimiter *RateLimiter // nil when rate limiting is disabled
auditFn AuditFunc // nil when audit is disabled
}
// NewRegistry creates an empty registry.
func NewRegistry(log *slog.Logger) *Registry {
return &Registry{
tools: make(map[string]Tool),
logger: log.With(logger.FieldComponent, "tools"),
}
}
// Register adds a tool to the registry.
func (r *Registry) Register(t Tool) {
r.tools[t.Def.Name] = t
r.logger.Debug("tool_registered", "name", t.Def.Name)
}
// Get looks up a tool by name.
func (r *Registry) Get(name string) (Tool, bool) {
t, ok := r.tools[name]
return t, ok
}
// Names returns all registered tool names in sorted order.
func (r *Registry) Names() []string {
names := make([]string, 0, len(r.tools))
for k := range r.tools {
names = append(names, k)
}
sort.Strings(names)
return names
}
// Len returns the number of registered tools.
func (r *Registry) Len() int {
return len(r.tools)
}
// SetRateLimiter attaches a rate limiter to the registry.
// When set, ExecuteForRoom checks the limit before running the tool.
func (r *Registry) SetRateLimiter(rl *RateLimiter) {
r.rateLimiter = rl
}
// SetAuditFunc attaches an audit callback to the registry.
// When set, it is called after each tool execution.
func (r *Registry) SetAuditFunc(fn AuditFunc) {
r.auditFn = fn
}
// ExecuteForRoom is like Execute but checks the per-room rate limit first.
// If the rate limit is exceeded, it returns an error result without executing.
func (r *Registry) ExecuteForRoom(ctx context.Context, name, argsJSON, roomID string) Result {
if r.rateLimiter != nil && roomID != "" {
if !r.rateLimiter.Allow(roomID) {
r.logger.Warn("tool_rate_limited", "tool", name, "room", roomID)
return Result{Err: fmt.Errorf("rate limit exceeded for room %s: too many tool calls per minute", roomID)}
}
}
return r.Execute(ctx, name, argsJSON)
}
// Execute looks up a tool by name and runs it. Returns an error result if not found.
func (r *Registry) Execute(ctx context.Context, name string, argsJSON string) Result {
t, ok := r.tools[name]
if !ok {
r.logger.Warn("tool_not_found", "tool", name)
return Result{Err: fmt.Errorf("tool %q not found", name)}
}
var args map[string]any
if argsJSON != "" {
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
r.logger.Warn("tool_args_invalid", "tool", name, "err", err)
return Result{Err: fmt.Errorf("parse args for %q: %w", name, err)}
}
}
r.logger.Info("tool_exec_start", "tool", name)
start := time.Now()
result := t.Exec(ctx, args)
ms := time.Since(start).Milliseconds()
if result.Err != nil {
r.logger.Warn("tool_exec_error", "tool", name, "err", result.Err, logger.FieldDurationMS, ms)
} else {
r.logger.Info("tool_exec_end", "tool", name, logger.FieldDurationMS, ms)
}
// Audit callback
if r.auditFn != nil {
r.auditFn(name, ms, result.Err)
}
return result
}
// ToLLMSpecs converts all registered tools to the LLM-compatible ToolSpec format.
// This is a pure transformation — no side effects.
func (r *Registry) ToLLMSpecs() []coretypes.ToolSpec {
specs := make([]coretypes.ToolSpec, 0, len(r.tools))
for _, name := range r.Names() {
t := r.tools[name]
specs = append(specs, defToLLMSpec(t.Def))
}
return specs
}
// defToLLMSpec converts a pure Def to an LLM ToolSpec with JSON Schema.
func defToLLMSpec(d Def) coretypes.ToolSpec {
properties := make(map[string]any, len(d.Parameters))
required := make([]string, 0)
for _, p := range d.Parameters {
properties[p.Name] = map[string]any{
"type": p.Type,
"description": p.Description,
}
if p.Required {
required = append(required, p.Name)
}
}
schema := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
return coretypes.ToolSpec{
Name: d.Name,
Description: d.Description,
InputSchema: schema,
}
}