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:
+166
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user