/ internal / audit / audit.go
audit.go
  1  package audit
  2  
  3  import (
  4  	"encoding/json"
  5  	"fmt"
  6  	"os"
  7  	"path/filepath"
  8  	"regexp"
  9  	"sync"
 10  	"time"
 11  )
 12  
 13  // AuditEntry represents a single audited agent event. Most entries
 14  // describe tool calls (ToolName populated); non-tool entries (e.g.
 15  // "force_stop" stops, run-level boundaries) set Event instead and leave
 16  // ToolName empty.
 17  //
 18  // NOTE: Approved intentionally does NOT use omitempty. `approved:false`
 19  // is the explicit marker for a tool call that was denied or rejected —
 20  // security tooling that greps the audit log distinguishes permitted vs
 21  // denied calls by that field, and omitempty would drop the denial signal.
 22  // Non-tool events (Event != "") serialize with approved:false too, which
 23  // is cosmetic noise only — the Event tag disambiguates them.
 24  type AuditEntry struct {
 25  	Timestamp     time.Time `json:"timestamp"`
 26  	SessionID     string    `json:"session_id"`
 27  	Event         string    `json:"event,omitempty"`
 28  	ToolName      string    `json:"tool_name,omitempty"`
 29  	InputSummary  string    `json:"input_summary,omitempty"`
 30  	OutputSummary string    `json:"output_summary,omitempty"`
 31  	Decision      string    `json:"decision,omitempty"`
 32  	Approved      bool      `json:"approved"`
 33  	DurationMs    int64     `json:"duration_ms,omitempty"`
 34  	// Cost fields (populated when tool reports usage, e.g. gateway tools that
 35  	// call xAI Grok / SerpAPI). Omitted when the tool does not return usage.
 36  	// TotalTokens is the aggregate; for tools that only report a flat count
 37  	// (SERP APIs, current Shannon Cloud schema) only TotalTokens is populated.
 38  	// LLM-backed tools that expose input/output split also fill InputTokens/OutputTokens.
 39  	InputTokens         int     `json:"input_tokens,omitempty"`
 40  	OutputTokens        int     `json:"output_tokens,omitempty"`
 41  	TotalTokens         int     `json:"total_tokens,omitempty"`
 42  	CostUSD             float64 `json:"cost_usd,omitempty"`
 43  	Model               string  `json:"model,omitempty"`
 44  	CacheReadTokens     int     `json:"cache_read_tokens,omitempty"`
 45  	CacheCreationTokens int     `json:"cache_creation_tokens,omitempty"`
 46  }
 47  
 48  // AuditLogger writes audit entries as JSON lines to a log file.
 49  type AuditLogger struct {
 50  	mu   sync.Mutex
 51  	file *os.File
 52  }
 53  
 54  const maxSummaryLen = 500
 55  
 56  // NewAuditLogger creates a logger that writes to the given logDir/audit.log.
 57  // Creates the directory if it does not exist.
 58  func NewAuditLogger(logDir string) (*AuditLogger, error) {
 59  	if logDir == "" {
 60  		return nil, fmt.Errorf("logDir must not be empty")
 61  	}
 62  
 63  	if err := os.MkdirAll(logDir, 0700); err != nil {
 64  		return nil, fmt.Errorf("failed to create log directory %s: %w", logDir, err)
 65  	}
 66  
 67  	logPath := filepath.Join(logDir, "audit.log")
 68  	f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
 69  	if err != nil {
 70  		return nil, fmt.Errorf("failed to open audit log %s: %w", logPath, err)
 71  	}
 72  
 73  	return &AuditLogger{file: f}, nil
 74  }
 75  
 76  // Log records a tool call event. Input and output summaries are truncated
 77  // and have secrets redacted before writing.
 78  func (a *AuditLogger) Log(entry AuditEntry) {
 79  	entry.InputSummary = RedactSecrets(truncate(entry.InputSummary, maxSummaryLen))
 80  	entry.OutputSummary = RedactSecrets(truncate(entry.OutputSummary, maxSummaryLen))
 81  
 82  	data, err := json.Marshal(entry)
 83  	if err != nil {
 84  		return
 85  	}
 86  
 87  	a.mu.Lock()
 88  	defer a.mu.Unlock()
 89  
 90  	a.file.Write(data)
 91  	a.file.Write([]byte("\n"))
 92  }
 93  
 94  // Close closes the underlying log file.
 95  func (a *AuditLogger) Close() error {
 96  	a.mu.Lock()
 97  	defer a.mu.Unlock()
 98  	return a.file.Close()
 99  }
100  
101  // redaction patterns compiled once at package init
102  var redactPatterns []*regexp.Regexp
103  
104  func init() {
105  	patterns := []string{
106  		// AWS access key IDs
107  		`AKIA[0-9A-Z]{16}`,
108  		// JWT tokens
109  		`eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`,
110  		// sk- style API keys (OpenAI, Stripe, etc.)
111  		`sk-[a-zA-Z0-9]{20,}`,
112  		// key- style API keys
113  		`key-[a-zA-Z0-9]{20,}`,
114  		// Bearer tokens
115  		`Bearer [A-Za-z0-9_-]+`,
116  		// PEM content markers
117  		`-----BEGIN[A-Z \-]*-----`,
118  		// Env var assignments with secret-like names
119  		`(?i)[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD)\s*=\s*\S+`,
120  	}
121  
122  	for _, p := range patterns {
123  		redactPatterns = append(redactPatterns, regexp.MustCompile(p))
124  	}
125  }
126  
127  // RedactSecrets replaces known secret patterns with [REDACTED].
128  func RedactSecrets(text string) string {
129  	result := text
130  	for _, re := range redactPatterns {
131  		result = re.ReplaceAllString(result, "[REDACTED]")
132  	}
133  	return result
134  }
135  
136  // truncate shortens text to maxLen, appending "..." if truncated.
137  func truncate(s string, maxLen int) string {
138  	r := []rune(s)
139  	if len(r) <= maxLen {
140  		return s
141  	}
142  	return string(r[:maxLen-3]) + "..."
143  }