Files
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

140 lines
3.9 KiB
Go

package security
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func readAuditLines(t *testing.T, path string) []AuditEvent {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open audit log: %v", err)
}
defer f.Close()
var events []AuditEvent
sc := bufio.NewScanner(f)
for sc.Scan() {
var ev AuditEvent
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
t.Fatalf("decode line %q: %v", sc.Text(), err)
}
events = append(events, ev)
}
if err := sc.Err(); err != nil {
t.Fatalf("scan audit log: %v", err)
}
return events
}
func TestAuditLogger_NilReceiverIsNoop(t *testing.T) {
var a *AuditLogger
// Must not panic.
a.Record(AuditEvent{Action: "block"})
}
func TestAuditLogger_DisabledWhenPathEmpty(t *testing.T) {
a := NewAuditLogger(AuditLoggerConfig{})
if a != nil {
t.Errorf("expected nil logger for empty path, got %v", a)
}
}
func TestAuditLogger_AppendsJSONLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
a := NewAuditLogger(AuditLoggerConfig{Path: path})
if a == nil {
t.Fatal("expected non-nil logger")
}
a.Record(AuditEvent{Action: "block", Pattern: "anthropic_api_key", Source: "tool_result", TokenLen: 51})
a.Record(AuditEvent{Action: "redact", Pattern: "high_entropy", Source: "message_text", TokenLen: 42})
events := readAuditLines(t, path)
if len(events) != 2 {
t.Fatalf("expected 2 events, got %d", len(events))
}
if events[0].Action != "block" || events[0].Pattern != "anthropic_api_key" {
t.Errorf("event 0 = %+v", events[0])
}
if events[0].Timestamp.IsZero() {
t.Error("event 0 missing timestamp")
}
if events[1].Action != "redact" || events[1].TokenLen != 42 {
t.Errorf("event 1 = %+v", events[1])
}
}
func TestAuditLogger_SkipsUnderIncognito(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
incog := NewIncognitoMode()
a := NewAuditLogger(AuditLoggerConfig{Path: path, Incognito: incog})
incog.Activate()
a.Record(AuditEvent{Action: "block", Pattern: "x"})
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("expected audit file to not exist under incognito, got err=%v", err)
}
incog.Deactivate()
a.Record(AuditEvent{Action: "block", Pattern: "y"})
events := readAuditLines(t, path)
if len(events) != 1 {
t.Fatalf("expected 1 event after deactivate, got %d", len(events))
}
if events[0].Pattern != "y" {
t.Errorf("expected pattern=y (incognito event dropped), got %q", events[0].Pattern)
}
}
func TestAuditLogger_CreatesParentDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "deeply", "nested", "audit.jsonl")
a := NewAuditLogger(AuditLoggerConfig{Path: path})
a.Record(AuditEvent{Action: "block"})
if _, err := os.Stat(path); err != nil {
t.Errorf("expected audit file at %s, got err=%v", path, err)
}
}
func TestFirewall_RecordsRedactionToAudit(t *testing.T) {
dir := t.TempDir()
auditPath := filepath.Join(dir, "audit.jsonl")
audit := NewAuditLogger(AuditLoggerConfig{Path: auditPath})
fw := NewFirewall(FirewallConfig{
ScanOutgoing: true,
ScanToolResults: true,
Audit: audit,
})
// Anthropic key prefix is a built-in redact pattern; emit it
// through the tool-result scanning path.
cleaned := fw.ScanToolResult("here is the key sk-ant-abcdef1234567890abcdef1234567890abcdef")
if !strings.Contains(cleaned, "[REDACTED]") {
t.Errorf("expected [REDACTED] in cleaned content, got %q", cleaned)
}
events := readAuditLines(t, auditPath)
var sawAnthropicRedact bool
for _, ev := range events {
if ev.Action == "redact" && ev.Pattern == "anthropic_api_key" && ev.Source == "tool_result" {
sawAnthropicRedact = true
if ev.TokenLen == 0 {
t.Errorf("expected non-zero TokenLen on redact event, got %+v", ev)
}
}
}
if !sawAnthropicRedact {
t.Errorf("expected an anthropic_api_key redact event in audit log, got %+v", events)
}
}