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>
This commit is contained in:
134
internal/audit/audit.go
Normal file
134
internal/audit/audit.go
Normal file
@@ -0,0 +1,134 @@
|
||||
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
|
||||
}
|
||||
54
internal/audit/audit_test.go
Normal file
54
internal/audit/audit_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppend_CreatesFileAndWritesJSONL(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
beadsDir := filepath.Join(tmp, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0750); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
// beads.FindBeadsDir() validates that the directory contains project files
|
||||
// (db or *.jsonl). Create an empty issues.jsonl so BEADS_DIR is accepted.
|
||||
issuesPath := filepath.Join(beadsDir, "issues.jsonl")
|
||||
if err := os.WriteFile(issuesPath, []byte{}, 0644); err != nil {
|
||||
t.Fatalf("write issues.jsonl: %v", err)
|
||||
}
|
||||
t.Setenv("BEADS_DIR", beadsDir)
|
||||
|
||||
id1, err := Append(&Entry{Kind: "llm_call", Model: "test-model", Prompt: "p", Response: "r"})
|
||||
if err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
if id1 == "" {
|
||||
t.Fatalf("expected id")
|
||||
}
|
||||
_, err = Append(&Entry{Kind: "label", ParentID: id1, Label: "good", Reason: "ok"})
|
||||
if err != nil {
|
||||
t.Fatalf("append label: %v", err)
|
||||
}
|
||||
|
||||
p := filepath.Join(beadsDir, FileName)
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
lines := 0
|
||||
for sc.Scan() {
|
||||
lines++
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if lines != 2 {
|
||||
t.Fatalf("expected 2 lines, got %d", lines)
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,11 @@ const (
|
||||
|
||||
// Config holds configuration for the compaction process.
|
||||
type Config struct {
|
||||
APIKey string
|
||||
Concurrency int
|
||||
DryRun bool
|
||||
APIKey string
|
||||
Concurrency int
|
||||
DryRun bool
|
||||
AuditEnabled bool
|
||||
Actor string
|
||||
}
|
||||
|
||||
// Compactor handles issue compaction using AI summarization.
|
||||
@@ -53,6 +55,10 @@ func New(store *sqlite.SQLiteStorage, apiKey string, config *Config) (*Compactor
|
||||
}
|
||||
}
|
||||
}
|
||||
if haikuClient != nil {
|
||||
haikuClient.auditEnabled = config.AuditEnabled
|
||||
haikuClient.auditActor = config.Actor
|
||||
}
|
||||
|
||||
return &Compactor{
|
||||
store: store,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/anthropics/anthropic-sdk-go"
|
||||
"github.com/anthropics/anthropic-sdk-go/option"
|
||||
"github.com/steveyegge/beads/internal/audit"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,8 @@ type HaikuClient struct {
|
||||
tier1Template *template.Template
|
||||
maxRetries int
|
||||
initialBackoff time.Duration
|
||||
auditEnabled bool
|
||||
auditActor string
|
||||
}
|
||||
|
||||
// NewHaikuClient creates a new Haiku API client. Env var ANTHROPIC_API_KEY takes precedence over explicit apiKey.
|
||||
@@ -67,7 +70,23 @@ func (h *HaikuClient) SummarizeTier1(ctx context.Context, issue *types.Issue) (s
|
||||
return "", fmt.Errorf("failed to render prompt: %w", err)
|
||||
}
|
||||
|
||||
return h.callWithRetry(ctx, prompt)
|
||||
resp, callErr := h.callWithRetry(ctx, prompt)
|
||||
if h.auditEnabled {
|
||||
// Best-effort: never fail compaction because audit logging failed.
|
||||
e := &audit.Entry{
|
||||
Kind: "llm_call",
|
||||
Actor: h.auditActor,
|
||||
IssueID: issue.ID,
|
||||
Model: string(h.model),
|
||||
Prompt: prompt,
|
||||
Response: resp,
|
||||
}
|
||||
if callErr != nil {
|
||||
e.Error = callErr.Error()
|
||||
}
|
||||
_, _ = audit.Append(e)
|
||||
}
|
||||
return resp, callErr
|
||||
}
|
||||
|
||||
func (h *HaikuClient) callWithRetry(ctx context.Context, prompt string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user