Files
gnoma/internal/security/audit.go
T
vikingowl 8b9bdc2978 feat(security): per-session firewall audit log
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.
2026-05-24 22:47:28 +02:00

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
}