8b9bdc2978
New AuditLogger writes one JSON line per firewall action to <projectRoot>/.gnoma/sessions/<sessionID>/audit.jsonl so a user can grep 'what did the firewall do this session?' after the fact. Records 'block', 'redact', 'warn', and 'unicode_sanitize' events with the matcher name, source (tool_result / message_text / etc.), and token length. Discipline: never the bytes themselves — only the matcher name and the length, matching the README's scope-note promise about audit data. Plumbing: - Firewall gains an audit *AuditLogger field plus SetAudit setter. The firewall is constructed before the session ID exists, so the audit logger is wired post-hoc once main.go has the sessionID. - Honours incognito: Record is a silent no-op when the firewall's IncognitoMode is active, preserving the no-persistence contract. - Tolerant of fs errors: mkdir / open / encode failures log a Warn but never propagate; the scan pipeline must not depend on audit succeeding. - Nil receiver is a valid no-op so callers don't need nil-guards around every Record. Tracks 'Security boundary — per-session audit log' from the v0.3.0 r/SideProject launch thread (u/Secret_Theme3192, 2026-05-24). Per-host egress allowlist remains separately tracked pending the commenter's reply on host-level vs per-tool semantics.
122 lines
3.8 KiB
Go
122 lines
3.8 KiB
Go
package security
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// AuditEvent records a single firewall action (block / redact / sanitize)
|
|
// in a structured form intended for per-session post-mortem grepping.
|
|
//
|
|
// Discipline: this struct must never carry the raw bytes of any matched
|
|
// secret. The Pattern field names the matcher (e.g. "anthropic_api_key",
|
|
// "high_entropy"); TokenLen carries the length of the offending token so
|
|
// the user can recognise it in a transcript without re-leaking it.
|
|
type AuditEvent struct {
|
|
// Timestamp is the wall-clock time of the event in UTC.
|
|
Timestamp time.Time `json:"ts"`
|
|
// Action is one of: "block", "redact", "warn", "unicode_sanitize".
|
|
Action string `json:"action"`
|
|
// Pattern is the human-readable matcher name (regex tag or
|
|
// "high_entropy" / "unicode"). Never the matched bytes themselves.
|
|
Pattern string `json:"pattern,omitempty"`
|
|
// Source describes where in the data flow the event fired —
|
|
// "message_text", "tool_result", "tool_call_args",
|
|
// "system_prompt", etc.
|
|
Source string `json:"source,omitempty"`
|
|
// TokenLen is the length of the offending token (or chars
|
|
// changed for unicode_sanitize). Length only, never the bytes.
|
|
TokenLen int `json:"token_len,omitempty"`
|
|
}
|
|
|
|
// AuditLogger appends AuditEvent records to a per-session JSON Lines
|
|
// file. Safe for concurrent use. Writes are skipped while incognito
|
|
// mode is active so the no-persistence contract is honoured.
|
|
//
|
|
// A nil *AuditLogger is a valid no-op — callers can use the same
|
|
// `audit.Record(...)` shape whether or not auditing is configured.
|
|
type AuditLogger struct {
|
|
path string
|
|
incognito *IncognitoMode
|
|
logger *slog.Logger
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// AuditLoggerConfig controls how AuditLogger is constructed.
|
|
type AuditLoggerConfig struct {
|
|
// Path is the full filesystem path to write JSONL events to.
|
|
// Parent directories are created lazily on first successful Record.
|
|
Path string
|
|
// Incognito gates writes; when active, Record is a no-op.
|
|
// Optional — pass nil to always persist.
|
|
Incognito *IncognitoMode
|
|
// Logger receives one Warn per write failure so the user sees
|
|
// disk-full / permission errors instead of silently losing
|
|
// audit records. Defaults to slog.Default() when nil.
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewAuditLogger builds an AuditLogger. Pass a zero Path to disable
|
|
// auditing (returns nil).
|
|
func NewAuditLogger(cfg AuditLoggerConfig) *AuditLogger {
|
|
if cfg.Path == "" {
|
|
return nil
|
|
}
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &AuditLogger{
|
|
path: cfg.Path,
|
|
incognito: cfg.Incognito,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Record appends an event to the audit log. Safe to call on a nil
|
|
// receiver (no-op). Skipped silently when incognito is active.
|
|
// Write failures are logged at Warn level but do not propagate to
|
|
// the caller — auditing is best-effort and must not crash the
|
|
// scanner pipeline.
|
|
func (a *AuditLogger) Record(ev AuditEvent) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
if a.incognito != nil && a.incognito.Active() {
|
|
return
|
|
}
|
|
if ev.Timestamp.IsZero() {
|
|
ev.Timestamp = time.Now().UTC()
|
|
}
|
|
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
|
|
if err := os.MkdirAll(filepath.Dir(a.path), 0o700); err != nil {
|
|
a.logger.Warn("audit: mkdir failed", "path", a.path, "err", err)
|
|
return
|
|
}
|
|
f, err := os.OpenFile(a.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
a.logger.Warn("audit: open failed", "path", a.path, "err", err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
if err := json.NewEncoder(f).Encode(ev); err != nil {
|
|
a.logger.Warn("audit: encode failed", "path", a.path, "err", err)
|
|
}
|
|
}
|
|
|
|
// Path returns the file path the logger writes to. Empty when the
|
|
// logger is disabled (nil receiver returns "").
|
|
func (a *AuditLogger) Path() string {
|
|
if a == nil {
|
|
return ""
|
|
}
|
|
return a.path
|
|
}
|