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:
@@ -6,7 +6,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -1007,3 +1009,92 @@ func (b *Beads) GetAttachment(pinnedBeadID string) (*AttachmentFields, error) {
|
|||||||
func currentTimestamp() string {
|
func currentTimestamp() string {
|
||||||
return time.Now().UTC().Format(time.RFC3339)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runMoleculeAttach(cmd *cobra.Command, args []string) error {
|
func runMoleculeAttach(cmd *cobra.Command, args []string) error {
|
||||||
@@ -59,8 +60,11 @@ func runMoleculeDetach(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
previousMolecule := attachment.AttachedMolecule
|
previousMolecule := attachment.AttachedMolecule
|
||||||
|
|
||||||
// Detach the molecule
|
// Detach the molecule with audit logging
|
||||||
_, err = b.DetachMolecule(pinnedBeadID)
|
_, err = b.DetachMoleculeWithAudit(pinnedBeadID, beads.DetachOptions{
|
||||||
|
Operation: "detach",
|
||||||
|
Agent: detectCurrentAgent(),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("detaching molecule: %w", err)
|
return fmt.Errorf("detaching molecule: %w", err)
|
||||||
}
|
}
|
||||||
@@ -126,3 +130,20 @@ func runMoleculeAttachment(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectCurrentAgent returns the current agent identity based on working directory.
|
||||||
|
// Returns empty string if identity cannot be determined.
|
||||||
|
func detectCurrentAgent() string {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
townRoot, err := workspace.FindFromCwd()
|
||||||
|
if err != nil || townRoot == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := detectRole(cwd, townRoot)
|
||||||
|
return buildAgentIdentity(ctx)
|
||||||
|
}
|
||||||
|
|||||||
@@ -201,8 +201,12 @@ func runMoleculeBurn(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
moleculeID := attachment.AttachedMolecule
|
moleculeID := attachment.AttachedMolecule
|
||||||
|
|
||||||
// Detach the molecule (this "burns" it by removing the attachment)
|
// Detach the molecule with audit logging (this "burns" it by removing the attachment)
|
||||||
_, err = b.DetachMolecule(handoff.ID)
|
_, err = b.DetachMoleculeWithAudit(handoff.ID, beads.DetachOptions{
|
||||||
|
Operation: "burn",
|
||||||
|
Agent: target,
|
||||||
|
Reason: "molecule burned by agent",
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("detaching molecule: %w", err)
|
return fmt.Errorf("detaching molecule: %w", err)
|
||||||
}
|
}
|
||||||
@@ -334,8 +338,12 @@ squashed_at: %s
|
|||||||
style.Dim.Render("Warning:"), err)
|
style.Dim.Render("Warning:"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detach the molecule from the handoff bead
|
// Detach the molecule from the handoff bead with audit logging
|
||||||
_, err = b.DetachMolecule(handoff.ID)
|
_, err = b.DetachMoleculeWithAudit(handoff.ID, beads.DetachOptions{
|
||||||
|
Operation: "squash",
|
||||||
|
Agent: target,
|
||||||
|
Reason: fmt.Sprintf("molecule squashed to digest %s", digestIssue.ID),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("detaching molecule: %w", err)
|
return fmt.Errorf("detaching molecule: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user