Files
beads/internal/audit/audit.go
Steve Yegge 7b758271ed feat(audit): add append-only agent audit trail (.beads/interactions.jsonl)
Implements audit logging for agent interactions to support auditing and
dataset generation (fixes #649).

New features:
- .beads/interactions.jsonl (append-only audit log)
- bd audit record: log LLM calls, tool calls, or pipe JSON via stdin
- bd audit label <id>: append labels (good/bad) for dataset curation
- bd compact --audit: optionally log LLM prompt/response during compaction
- bd init: creates empty interactions.jsonl
- bd sync: includes interactions.jsonl in staging

Audit entries are append-only - labeling creates new entries that
reference parent entries by ID.

Closes #649

Co-authored-by: Dmitry Chichkov <dchichkov@nvidia.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 03:24:51 -08:00

135 lines
3.4 KiB
Go

package audit
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/steveyegge/beads/internal/beads"
)
const (
// FileName is the audit log file name stored under .beads/.
FileName = "interactions.jsonl"
idPrefix = "int-"
)
// Entry is a generic append-only audit event. It is intentionally flexible:
// use Kind + typed fields for common cases, and Extra for everything else.
type Entry struct {
ID string `json:"id"`
Kind string `json:"kind"`
CreatedAt time.Time `json:"created_at"`
// Common metadata
Actor string `json:"actor,omitempty"`
IssueID string `json:"issue_id,omitempty"`
// LLM call
Model string `json:"model,omitempty"`
Prompt string `json:"prompt,omitempty"`
Response string `json:"response,omitempty"`
Error string `json:"error,omitempty"`
// Tool call
ToolName string `json:"tool_name,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
// Labeling (append-only)
ParentID string `json:"parent_id,omitempty"`
Label string `json:"label,omitempty"` // "good" | "bad" | etc
Reason string `json:"reason,omitempty"` // human / pipeline explanation
Extra map[string]any `json:"extra,omitempty"`
}
func Path() (string, error) {
beadsDir := beads.FindBeadsDir()
if beadsDir == "" {
return "", fmt.Errorf("no .beads directory found")
}
return filepath.Join(beadsDir, FileName), nil
}
// EnsureFile creates .beads/interactions.jsonl if it does not exist.
func EnsureFile() (string, error) {
p, err := Path()
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(p), 0750); err != nil {
return "", fmt.Errorf("failed to create .beads directory: %w", err)
}
_, statErr := os.Stat(p)
if statErr == nil {
return p, nil
}
if !os.IsNotExist(statErr) {
return "", fmt.Errorf("failed to stat interactions log: %w", statErr)
}
// nolint:gosec // JSONL is intended to be shared via git across clones/tools.
if err := os.WriteFile(p, []byte{}, 0644); err != nil {
return "", fmt.Errorf("failed to create interactions log: %w", err)
}
return p, nil
}
// Append appends an event to .beads/interactions.jsonl as a single JSON line.
// This is intentionally append-only: callers must not mutate existing lines.
func Append(e *Entry) (string, error) {
if e == nil {
return "", fmt.Errorf("nil entry")
}
if e.Kind == "" {
return "", fmt.Errorf("kind is required")
}
p, err := EnsureFile()
if err != nil {
return "", err
}
if e.ID == "" {
e.ID, err = newID()
if err != nil {
return "", err
}
}
if e.CreatedAt.IsZero() {
e.CreatedAt = time.Now().UTC()
} else {
e.CreatedAt = e.CreatedAt.UTC()
}
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) // nolint:gosec // intended permissions
if err != nil {
return "", fmt.Errorf("failed to open interactions log: %w", err)
}
defer func() { _ = f.Close() }()
bw := bufio.NewWriter(f)
enc := json.NewEncoder(bw)
enc.SetEscapeHTML(false)
if err := enc.Encode(e); err != nil {
return "", fmt.Errorf("failed to write interactions log entry: %w", err)
}
if err := bw.Flush(); err != nil {
return "", fmt.Errorf("failed to flush interactions log: %w", err)
}
return e.ID, nil
}
func newID() (string, error) {
var b [4]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("failed to generate id: %w", err)
}
return idPrefix + hex.EncodeToString(b[:]), nil
}