Files
beads/cmd/bd/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

167 lines
4.6 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/audit"
)
var (
auditRecordKind string
auditRecordModel string
auditRecordPrompt string
auditRecordResponse string
auditRecordIssueID string
auditRecordToolName string
auditRecordExitCode int
auditRecordError string
auditRecordStdin bool
auditLabelValue string
auditLabelReason string
)
var auditCmd = &cobra.Command{
Use: "audit",
Short: "Record and label agent interactions (append-only JSONL)",
Long: `Audit log entries are appended to .beads/interactions.jsonl.
Each line is one event. This file is intended to be versioned in git and used for:
- auditing ("why did the agent do that?")
- dataset generation (SFT/RL fine-tuning)
Entries are append-only. Labeling creates a new "label" entry that references a parent entry.`,
}
var auditRecordCmd = &cobra.Command{
Use: "record",
Short: "Append an audit interaction entry",
Run: func(cmd *cobra.Command, _ []string) {
var e audit.Entry
// If stdin is piped and no explicit record fields were provided, assume stdin JSON.
// This matches "or pipe JSON via stdin" without requiring a flag.
fi, _ := os.Stdin.Stat()
stdinPiped := fi != nil && (fi.Mode()&os.ModeCharDevice) == 0
noFieldsProvided := auditRecordKind == "" &&
auditRecordModel == "" &&
auditRecordPrompt == "" &&
auditRecordResponse == "" &&
auditRecordIssueID == "" &&
auditRecordToolName == "" &&
auditRecordExitCode < 0 &&
auditRecordError == ""
if auditRecordStdin || (stdinPiped && noFieldsProvided) {
b, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to read stdin: %v\n", err)
os.Exit(1)
}
if err := json.Unmarshal(b, &e); err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid JSON on stdin: %v\n", err)
os.Exit(1)
}
// Allow --actor to override/augment stdin.
if actor != "" {
e.Actor = actor
}
} else {
if auditRecordKind == "" {
fmt.Fprintf(os.Stderr, "Error: --kind is required\n")
os.Exit(1)
}
e = audit.Entry{
Kind: auditRecordKind,
Actor: actor,
IssueID: auditRecordIssueID,
Model: auditRecordModel,
Prompt: auditRecordPrompt,
Response: auditRecordResponse,
ToolName: auditRecordToolName,
Error: auditRecordError,
}
if auditRecordExitCode >= 0 {
exit := auditRecordExitCode
e.ExitCode = &exit
}
}
id, err := audit.Append(&e)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(map[string]any{
"id": id,
"kind": e.Kind,
})
return
}
fmt.Println(id)
},
}
var auditLabelCmd = &cobra.Command{
Use: "label <entry-id>",
Short: "Append a label entry referencing an existing interaction",
Args: cobra.ExactArgs(1),
Run: func(_ *cobra.Command, args []string) {
parentID := args[0]
if auditLabelValue == "" {
fmt.Fprintf(os.Stderr, "Error: --label is required\n")
os.Exit(1)
}
e := audit.Entry{
Kind: "label",
Actor: actor,
ParentID: parentID,
Label: auditLabelValue,
Reason: auditLabelReason,
}
id, err := audit.Append(&e)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(map[string]any{
"id": id,
"parent_id": parentID,
"label": auditLabelValue,
})
return
}
fmt.Println(id)
},
}
func init() {
auditRecordCmd.Flags().StringVar(&auditRecordKind, "kind", "", "Entry kind (e.g. llm_call, tool_call, label)")
auditRecordCmd.Flags().StringVar(&auditRecordModel, "model", "", "Model name (llm_call)")
auditRecordCmd.Flags().StringVar(&auditRecordPrompt, "prompt", "", "Prompt text (llm_call)")
auditRecordCmd.Flags().StringVar(&auditRecordResponse, "response", "", "Response text (llm_call)")
auditRecordCmd.Flags().StringVar(&auditRecordIssueID, "issue-id", "", "Related issue id (bd-...)")
auditRecordCmd.Flags().StringVar(&auditRecordToolName, "tool-name", "", "Tool name (tool_call)")
auditRecordCmd.Flags().IntVar(&auditRecordExitCode, "exit-code", -1, "Exit code (tool_call)")
auditRecordCmd.Flags().StringVar(&auditRecordError, "error", "", "Error string (llm_call/tool_call)")
auditRecordCmd.Flags().BoolVar(&auditRecordStdin, "stdin", false, "Read a JSON object from stdin (must match audit.Entry schema)")
auditLabelCmd.Flags().StringVar(&auditLabelValue, "label", "", `Label value (e.g. "good" or "bad")`)
auditLabelCmd.Flags().StringVar(&auditLabelReason, "reason", "", "Reason for label")
auditCmd.AddCommand(auditRecordCmd)
auditCmd.AddCommand(auditLabelCmd)
rootCmd.AddCommand(auditCmd)
}