feat: add audit trail for detach operations (gt-h6eq.8)

Add DetachMoleculeWithAudit() function that logs detach operations
to .beads/audit.log in JSONL format. Each entry captures:
- timestamp
- operation type (detach, burn, squash)
- pinned_bead_id
- detached_molecule
- detached_by (agent identity)
- reason (optional)
- previous_state

Updated callers:
- runMoleculeDetach: logs "detach" operation
- runMoleculeBurn: logs "burn" operation with reason
- runMoleculeSquash: logs "squash" operation with digest ID

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-23 11:34:35 -08:00
parent d3657902a6
commit 9fa4a42030
3 changed files with 126 additions and 6 deletions

View File

@@ -6,7 +6,9 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
@@ -1007,3 +1009,92 @@ func (b *Beads) GetAttachment(pinnedBeadID string) (*AttachmentFields, error) {
func currentTimestamp() string {
return time.Now().UTC().Format(time.RFC3339)
}
// DetachAuditEntry represents an audit log entry for a detach operation.
type DetachAuditEntry struct {
Timestamp string `json:"timestamp"`
Operation string `json:"operation"` // "detach", "burn", "squash"
PinnedBeadID string `json:"pinned_bead_id"`
DetachedMolecule string `json:"detached_molecule"`
DetachedBy string `json:"detached_by,omitempty"` // Agent that triggered detach
Reason string `json:"reason,omitempty"` // Optional reason for detach
PreviousState string `json:"previous_state,omitempty"`
}
// DetachOptions specifies optional context for a detach operation.
type DetachOptions struct {
Operation string // "detach", "burn", "squash" - defaults to "detach"
Agent string // Who is performing the detach
Reason string // Optional reason for the detach
}
// DetachMoleculeWithAudit removes molecule attachment from a pinned bead and logs the operation.
// Returns the updated issue.
func (b *Beads) DetachMoleculeWithAudit(pinnedBeadID string, opts DetachOptions) (*Issue, error) {
// Fetch the pinned bead first to get previous state
issue, err := b.Show(pinnedBeadID)
if err != nil {
return nil, fmt.Errorf("fetching pinned bead: %w", err)
}
// Get current attachment info for audit
attachment := ParseAttachmentFields(issue)
if attachment == nil {
return issue, nil // Nothing to detach
}
// Log the detach operation
operation := opts.Operation
if operation == "" {
operation = "detach"
}
entry := DetachAuditEntry{
Timestamp: currentTimestamp(),
Operation: operation,
PinnedBeadID: pinnedBeadID,
DetachedMolecule: attachment.AttachedMolecule,
DetachedBy: opts.Agent,
Reason: opts.Reason,
PreviousState: issue.Status,
}
if err := b.LogDetachAudit(entry); err != nil {
// Log error but don't fail the detach operation
fmt.Fprintf(os.Stderr, "Warning: failed to write audit log: %v\n", err)
}
// Clear attachment fields by passing nil
newDesc := SetAttachmentFields(issue, nil)
// Update the issue
if err := b.Update(pinnedBeadID, UpdateOptions{Description: &newDesc}); err != nil {
return nil, fmt.Errorf("updating pinned bead: %w", err)
}
// Re-fetch to return updated state
return b.Show(pinnedBeadID)
}
// LogDetachAudit appends an audit entry to the audit log file.
// The audit log is stored in .beads/audit.log as JSONL format.
func (b *Beads) LogDetachAudit(entry DetachAuditEntry) error {
auditPath := filepath.Join(b.workDir, ".beads", "audit.log")
// Marshal entry to JSON
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("marshaling audit entry: %w", err)
}
// Append to audit log file
f, err := os.OpenFile(auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("opening audit log: %w", err)
}
defer f.Close()
if _, err := f.Write(append(data, '\n')); err != nil {
return fmt.Errorf("writing audit entry: %w", err)
}
return nil
}