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 }