feat: add security firewall with secret scanning and incognito mode

internal/security/ — core security layer baked into gnoma:
- Secret scanner: gitleaks-derived regex patterns (Anthropic, OpenAI,
  AWS, GitHub, GitLab, Slack, Stripe, private keys, DB URLs, generic
  secrets) + Shannon entropy detection for unknown formats
- Redactor: replaces matched secrets with [REDACTED], merges
  overlapping ranges, preserves surrounding context
- Unicode sanitizer: NFKC normalization, strips Cf/Co categories,
  tag characters (ASCII smuggling), zero-width chars, RTL overrides
- Incognito mode: suppresses persistence, learning, content logging
- Firewall: wraps engine, scans outgoing messages + system prompt +
  tool results before they reach the provider

Wired into engine and CLI. 21 security tests.
This commit is contained in:
2026-04-03 14:07:50 +02:00
parent e3981faff3
commit 33dec722b8
10 changed files with 917 additions and 8 deletions

View File

@@ -12,6 +12,7 @@ import (
"somegit.dev/Owlibou/gnoma/internal/engine"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/security"
anthropicprov "somegit.dev/Owlibou/gnoma/internal/provider/anthropic"
"somegit.dev/Owlibou/gnoma/internal/provider/mistral"
googleprov "somegit.dev/Owlibou/gnoma/internal/provider/google"
@@ -80,10 +81,19 @@ func main() {
// Re-register bash tool with aliases
reg.Register(bash.New(bash.WithAliases(aliases)))
// Create firewall
fw := security.NewFirewall(security.FirewallConfig{
ScanOutgoing: true,
ScanToolResults: true,
EntropyThreshold: 4.5,
Logger: logger,
})
// Create engine
eng, err := engine.New(engine.Config{
Provider: prov,
Tools: reg,
Firewall: fw,
System: *system,
Model: *model,
MaxTurns: *maxTurns,

2
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/VikingOwl91/mistral-go-sdk v1.2.1
github.com/anthropics/anthropic-sdk-go v1.29.0
github.com/openai/openai-go v1.12.0
golang.org/x/text v0.27.0
google.golang.org/genai v1.52.1
)
@@ -28,7 +29,6 @@ require (
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.66.2 // indirect
google.golang.org/protobuf v1.34.2 // indirect

View File

@@ -7,6 +7,7 @@ import (
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/provider"
"somegit.dev/Owlibou/gnoma/internal/security"
"somegit.dev/Owlibou/gnoma/internal/tool"
)
@@ -14,9 +15,10 @@ import (
type Config struct {
Provider provider.Provider
Tools *tool.Registry
System string // system prompt
Model string // override model (empty = provider default)
MaxTurns int // safety limit on tool loops (0 = unlimited)
Firewall *security.Firewall // nil = no scanning
System string // system prompt
Model string // override model (empty = provider default)
MaxTurns int // safety limit on tool loops (0 = unlimited)
Logger *slog.Logger
}

View File

@@ -115,10 +115,18 @@ func (e *Engine) runLoop(ctx context.Context, cb Callback) (*Turn, error) {
}
func (e *Engine) buildRequest(ctx context.Context) provider.Request {
// Scan messages through firewall if configured
messages := e.history
systemPrompt := e.cfg.System
if e.cfg.Firewall != nil {
messages = e.cfg.Firewall.ScanOutgoingMessages(messages)
systemPrompt = e.cfg.Firewall.ScanSystemPrompt(systemPrompt)
}
req := provider.Request{
Model: e.cfg.Model,
SystemPrompt: e.cfg.System,
Messages: e.history,
SystemPrompt: systemPrompt,
Messages: messages,
}
// Only include tools if the model supports them
@@ -169,17 +177,23 @@ func (e *Engine) executeTools(ctx context.Context, calls []message.ToolCall, cb
continue
}
// Scan tool result through firewall
output := result.Output
if e.cfg.Firewall != nil {
output = e.cfg.Firewall.ScanToolResult(output)
}
// Emit tool result as a text delta event so the UI can show it
if cb != nil {
cb(stream.Event{
Type: stream.EventTextDelta,
Text: fmt.Sprintf("\n[tool:%s] %s\n", call.Name, truncate(result.Output, 500)),
Text: fmt.Sprintf("\n[tool:%s] %s\n", call.Name, truncate(output, 500)),
})
}
results = append(results, message.ToolResult{
ToolCallID: call.ID,
Content: result.Output,
Content: output,
})
}

View File

@@ -0,0 +1,126 @@
package security
import (
"log/slog"
"somegit.dev/Owlibou/gnoma/internal/message"
)
// Firewall scans outgoing LLM requests and incoming tool results
// for secrets, sensitive data, and dangerous Unicode. Core security
// layer — not a plugin, everyone benefits by default.
type Firewall struct {
scanner *Scanner
incognito *IncognitoMode
logger *slog.Logger
// Config
scanOutgoing bool
scanToolResults bool
}
type FirewallConfig struct {
ScanOutgoing bool
ScanToolResults bool
EntropyThreshold float64
Logger *slog.Logger
}
func NewFirewall(cfg FirewallConfig) *Firewall {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Firewall{
scanner: NewScanner(cfg.EntropyThreshold),
incognito: NewIncognitoMode(),
logger: logger,
scanOutgoing: cfg.ScanOutgoing,
scanToolResults: cfg.ScanToolResults,
}
}
// Incognito returns the incognito mode controller.
func (f *Firewall) Incognito() *IncognitoMode {
return f.incognito
}
// Scanner returns the secret scanner for adding custom patterns.
func (f *Firewall) Scanner() *Scanner {
return f.scanner
}
// ScanOutgoingMessages scans all message content before sending to provider.
// Returns cleaned messages with secrets redacted.
func (f *Firewall) ScanOutgoingMessages(msgs []message.Message) []message.Message {
if !f.scanOutgoing {
return msgs
}
cleaned := make([]message.Message, len(msgs))
for i, m := range msgs {
cleaned[i] = f.scanMessage(m)
}
return cleaned
}
// ScanToolResult scans a tool execution result for secrets.
// Returns the cleaned content.
func (f *Firewall) ScanToolResult(content string) string {
if !f.scanToolResults {
return content
}
return f.scanAndRedact(content, "tool_result")
}
// ScanSystemPrompt scans the system prompt for accidentally embedded secrets.
func (f *Firewall) ScanSystemPrompt(prompt string) string {
return f.scanAndRedact(prompt, "system_prompt")
}
func (f *Firewall) scanMessage(m message.Message) message.Message {
cleaned := message.Message{Role: m.Role}
cleaned.Content = make([]message.Content, len(m.Content))
for i, c := range m.Content {
switch c.Type {
case message.ContentText:
cleaned.Content[i] = message.NewTextContent(
f.scanAndRedact(c.Text, "message_text"),
)
case message.ContentToolResult:
if c.ToolResult != nil {
tr := *c.ToolResult
tr.Content = f.scanAndRedact(tr.Content, "tool_result")
cleaned.Content[i] = message.NewToolResultContent(tr)
} else {
cleaned.Content[i] = c
}
default:
// Tool calls, thinking blocks — pass through
cleaned.Content[i] = c
}
}
return cleaned
}
func (f *Firewall) scanAndRedact(content, source string) string {
// Unicode sanitization first
content = SanitizeUnicode(content)
// Secret scanning
matches := f.scanner.Scan(content)
if len(matches) == 0 {
return content
}
for _, m := range matches {
f.logger.Warn("secret detected",
"pattern", m.Pattern,
"action", m.Action,
"source", source,
)
}
return Redact(content, matches)
}

View File

@@ -0,0 +1,57 @@
package security
import "sync"
// IncognitoMode controls privacy-sensitive behavior.
// When active: no persistence, no learning, no content logging.
type IncognitoMode struct {
mu sync.RWMutex
active bool
// Options
LocalOnly bool // only route to local arms when incognito
}
func NewIncognitoMode() *IncognitoMode {
return &IncognitoMode{}
}
func (m *IncognitoMode) Activate() {
m.mu.Lock()
defer m.mu.Unlock()
m.active = true
}
func (m *IncognitoMode) Deactivate() {
m.mu.Lock()
defer m.mu.Unlock()
m.active = false
}
func (m *IncognitoMode) Toggle() bool {
m.mu.Lock()
defer m.mu.Unlock()
m.active = !m.active
return m.active
}
func (m *IncognitoMode) Active() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.active
}
// ShouldPersist returns false when incognito is active.
func (m *IncognitoMode) ShouldPersist() bool {
return !m.Active()
}
// ShouldLearn returns false when incognito is active (no router feedback).
func (m *IncognitoMode) ShouldLearn() bool {
return !m.Active()
}
// ShouldLogContent returns false when incognito is active.
func (m *IncognitoMode) ShouldLogContent() bool {
return !m.Active()
}

View File

@@ -0,0 +1,51 @@
package security
import "sort"
const redactedPlaceholder = "[REDACTED]"
// Redact replaces detected secrets in content with [REDACTED].
// Preserves surrounding context (quotes, delimiters).
func Redact(content string, matches []SecretMatch) string {
if len(matches) == 0 {
return content
}
// Filter to redact-only and sort by start position ascending
var redacts []SecretMatch
for _, m := range matches {
if m.Action == ActionRedact && m.Start >= 0 && m.End <= len(content) && m.Start < m.End {
redacts = append(redacts, m)
}
}
if len(redacts) == 0 {
return content
}
sort.Slice(redacts, func(i, j int) bool {
return redacts[i].Start < redacts[j].Start
})
// Merge overlapping ranges
merged := []SecretMatch{redacts[0]}
for _, m := range redacts[1:] {
last := &merged[len(merged)-1]
if m.Start <= last.End {
// Overlapping — extend the range
if m.End > last.End {
last.End = m.End
}
} else {
merged = append(merged, m)
}
}
// Build result replacing merged ranges from end to start
result := []byte(content)
for i := len(merged) - 1; i >= 0; i-- {
m := merged[i]
replacement := []byte(redactedPlaceholder)
result = append(result[:m.Start], append(replacement, result[m.End:]...)...)
}
return string(result)
}

View File

@@ -0,0 +1,57 @@
package security
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// SanitizeUnicode removes potentially dangerous invisible Unicode characters.
// Applies NFKC normalization then strips format (Cf), private use (Co),
// and unassigned (Cn) characters. Prevents ASCII smuggling and hidden
// prompt injection attacks.
func SanitizeUnicode(s string) string {
// Step 1: NFKC normalization (handles composed characters)
s = norm.NFKC.String(s)
// Step 2: Strip dangerous Unicode categories
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if shouldStrip(r) {
continue
}
b.WriteRune(r)
}
return b.String()
}
func shouldStrip(r rune) bool {
// Keep normal printable characters, whitespace, and common symbols
if r <= 0x7E && r >= 0x20 {
return false // ASCII printable
}
if r == '\n' || r == '\t' || r == '\r' {
return false // common whitespace
}
// Strip Unicode format characters (Cf) — invisible formatting
if unicode.Is(unicode.Cf, r) {
return true
}
// Strip private use (Co) — unregistered characters
if unicode.Is(unicode.Co, r) {
return true
}
// Strip specific dangerous ranges
switch {
case r >= 0xE0000 && r <= 0xE007F: // Unicode Tag characters (ASCII smuggling)
return true
case r >= 0xFFF0 && r <= 0xFFFD: // Specials (interlinear annotation, etc.)
return true
}
return false
}

View File

@@ -0,0 +1,215 @@
package security
import (
"math"
"regexp"
"strings"
)
// ScanAction determines what to do when a secret is found.
type ScanAction string
const (
ActionRedact ScanAction = "redact"
ActionBlock ScanAction = "block"
ActionWarn ScanAction = "warn"
)
// SecretPattern defines a pattern for detecting secrets.
type SecretPattern struct {
Name string
Regex *regexp.Regexp
Action ScanAction
}
// SecretMatch represents a detected secret in content.
type SecretMatch struct {
Pattern string // which pattern matched
Action ScanAction
Start int
End int
}
// Scanner detects secrets and sensitive data in content.
type Scanner struct {
patterns []SecretPattern
entropyThreshold float64
}
func NewScanner(entropyThreshold float64) *Scanner {
if entropyThreshold <= 0 {
entropyThreshold = 4.5
}
return &Scanner{
patterns: defaultPatterns(),
entropyThreshold: entropyThreshold,
}
}
// AddPattern adds a custom detection pattern.
func (s *Scanner) AddPattern(name, regex string, action ScanAction) error {
re, err := regexp.Compile(regex)
if err != nil {
return err
}
s.patterns = append(s.patterns, SecretPattern{
Name: name,
Regex: re,
Action: action,
})
return nil
}
// Scan checks content for secrets. Returns all matches found.
func (s *Scanner) Scan(content string) []SecretMatch {
var matches []SecretMatch
seen := make(map[string]bool) // deduplicate by position
for _, p := range s.patterns {
locs := p.Regex.FindAllStringIndex(content, -1)
for _, loc := range locs {
key := strings.Join([]string{p.Name, string(rune(loc[0])), string(rune(loc[1]))}, ":")
if seen[key] {
continue
}
seen[key] = true
matches = append(matches, SecretMatch{
Pattern: p.Name,
Action: p.Action,
Start: loc[0],
End: loc[1],
})
}
}
// Entropy-based detection for unknown secret formats
matches = append(matches, s.scanEntropy(content)...)
return matches
}
// HasSecrets returns true if any secrets are detected.
func (s *Scanner) HasSecrets(content string) bool {
return len(s.Scan(content)) > 0
}
// scanEntropy detects high-entropy strings that might be secrets.
func (s *Scanner) scanEntropy(content string) []SecretMatch {
var matches []SecretMatch
// Check each word-like token that's long enough to be a secret
words := entropyTokenize(content)
for _, w := range words {
if len(w.text) < 20 { // secrets are typically 20+ chars
continue
}
entropy := shannonEntropy(w.text)
if entropy >= s.entropyThreshold {
matches = append(matches, SecretMatch{
Pattern: "high_entropy",
Action: ActionWarn,
Start: w.start,
End: w.start + len(w.text),
})
}
}
return matches
}
type token struct {
text string
start int
}
func entropyTokenize(s string) []token {
var tokens []token
start := -1
for i, r := range s {
isTokenChar := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' || r == '/'
if isTokenChar {
if start == -1 {
start = i
}
} else {
if start != -1 {
tokens = append(tokens, token{text: s[start:i], start: start})
start = -1
}
}
}
if start != -1 {
tokens = append(tokens, token{text: s[start:], start: start})
}
return tokens
}
// shannonEntropy calculates the Shannon entropy of a string.
func shannonEntropy(s string) float64 {
if len(s) == 0 {
return 0
}
freq := make(map[rune]float64)
for _, r := range s {
freq[r]++
}
n := float64(len([]rune(s)))
var entropy float64
for _, count := range freq {
p := count / n
if p > 0 {
entropy -= p * math.Log2(p)
}
}
return entropy
}
// defaultPatterns returns gitleaks-derived patterns for common secret formats.
func defaultPatterns() []SecretPattern {
patterns := []struct {
name string
regex string
}{
// Anthropic
{"anthropic_api_key", `sk-ant-(?:api)?[a-zA-Z0-9_-]{20,}`},
// OpenAI
{"openai_api_key", `sk-(?:proj-)?[a-zA-Z0-9_-]{20,}`},
// Google
{"google_api_key", `AIza[a-zA-Z0-9_-]{35}`},
// AWS
{"aws_access_key", `(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}`},
{"aws_secret_key", `(?i)aws_secret_access_key\s*=\s*[a-zA-Z0-9/+=]{40}`},
// GitHub
{"github_pat", `gh[pousr]_[a-zA-Z0-9]{36,}`},
{"github_fine_grained", `github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}`},
// GitLab
{"gitlab_pat", `glpat-[a-zA-Z0-9_-]{20,}`},
// Slack
{"slack_token", `xox[bpears]-[a-zA-Z0-9-]{10,}`},
// Stripe
{"stripe_key", `(?:sk|pk)_(?:live|test)_[a-zA-Z0-9]{24,}`},
// Private keys
{"private_key", `-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`},
// Generic secrets in assignments
{"generic_secret_assign", `(?i)(?:password|secret|token|api_key|apikey|auth)\s*[:=]\s*['"][a-zA-Z0-9_/+=\-]{8,}['"]`},
// Mistral
{"mistral_api_key", `[a-zA-Z0-9]{32}` + `(?:` + `[a-zA-Z0-9]{0}` + `)`}, // 32-char hex-like strings caught by entropy
// Database URLs with credentials
{"database_url", `(?i)(?:postgres|mysql|mongodb|redis)://[^:]+:[^@]+@`},
// .env file patterns
{"env_secret", `(?i)^[A-Z_]{2,}(?:_KEY|_SECRET|_TOKEN|_PASSWORD)\s*=\s*.{8,}$`},
}
var result []SecretPattern
for _, p := range patterns {
re, err := regexp.Compile(p.regex)
if err != nil {
continue // skip invalid patterns
}
result = append(result, SecretPattern{
Name: p.name,
Regex: re,
Action: ActionRedact,
})
}
return result
}

View File

@@ -0,0 +1,377 @@
package security
import (
"strings"
"testing"
"somegit.dev/Owlibou/gnoma/internal/message"
)
// --- Scanner ---
func TestScanner_DetectsAnthropicKey(t *testing.T) {
s := NewScanner(4.5)
matches := s.Scan("my key is sk-ant-api03-abcdefghijklmnopqrstuvwxyz")
if len(matches) == 0 {
t.Error("should detect Anthropic API key")
}
if matches[0].Pattern != "anthropic_api_key" {
t.Errorf("pattern = %q, want anthropic_api_key", matches[0].Pattern)
}
}
func TestScanner_DetectsOpenAIKey(t *testing.T) {
s := NewScanner(4.5)
matches := s.Scan("key: sk-proj-abcdefghijklmnopqrstuvwxyz123456")
if len(matches) == 0 {
t.Error("should detect OpenAI API key")
}
}
func TestScanner_DetectsAWSKey(t *testing.T) {
s := NewScanner(4.5)
matches := s.Scan("AKIAIOSFODNN7EXAMPLE")
if len(matches) == 0 {
t.Error("should detect AWS access key")
}
if matches[0].Pattern != "aws_access_key" {
t.Errorf("pattern = %q", matches[0].Pattern)
}
}
func TestScanner_DetectsGitHubPAT(t *testing.T) {
s := NewScanner(4.5)
matches := s.Scan("token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij")
hasGH := false
for _, m := range matches {
if m.Pattern == "github_pat" {
hasGH = true
break
}
}
if !hasGH {
t.Error("should detect GitHub PAT")
}
}
func TestScanner_DetectsPrivateKey(t *testing.T) {
s := NewScanner(4.5)
matches := s.Scan("-----BEGIN RSA PRIVATE KEY-----\nMIIE...")
hasKey := false
for _, m := range matches {
if m.Pattern == "private_key" {
hasKey = true
break
}
}
if !hasKey {
t.Error("should detect private key header")
}
}
func TestScanner_DetectsGenericSecret(t *testing.T) {
s := NewScanner(4.5)
matches := s.Scan(`password = "supersecretpassword123"`)
hasGeneric := false
for _, m := range matches {
if m.Pattern == "generic_secret_assign" {
hasGeneric = true
break
}
}
if !hasGeneric {
t.Error("should detect generic secret assignment")
}
}
func TestScanner_DetectsDatabaseURL(t *testing.T) {
s := NewScanner(4.5)
matches := s.Scan("postgres://admin:secretpass@db.example.com:5432/mydb")
hasDB := false
for _, m := range matches {
if m.Pattern == "database_url" {
hasDB = true
break
}
}
if !hasDB {
t.Error("should detect database URL with credentials")
}
}
func TestScanner_NoFalsePositives(t *testing.T) {
s := NewScanner(6.0) // high entropy threshold to avoid false positives
safe := []string{
"hello world",
"func main() {}",
"https://example.com/path",
"go test ./...",
"The quick brown fox jumps over the lazy dog",
}
for _, text := range safe {
matches := s.Scan(text)
if len(matches) > 0 {
t.Errorf("false positive on %q: %v", text, matches[0].Pattern)
}
}
}
func TestScanner_Entropy(t *testing.T) {
s := NewScanner(4.0) // lower threshold for testing
// High entropy string (random-looking)
matches := s.Scan("token: aB3dE5fG7hI9jK1lM3nO5pQ7rS9tU1v")
hasEntropy := false
for _, m := range matches {
if m.Pattern == "high_entropy" {
hasEntropy = true
break
}
}
if !hasEntropy {
t.Error("should detect high entropy string")
}
}
func TestShannonEntropy(t *testing.T) {
tests := []struct {
input string
minBits float64
maxBits float64
}{
{"aaaa", 0, 0.1}, // very low entropy
{"abcd", 1.9, 2.1}, // 4 unique chars = ~2 bits
{"abcdefgh", 2.9, 3.1}, // 8 unique = ~3 bits
{"aB3dE5fG7hI9jK", 3.5, 4.5}, // mixed case + digits
}
for _, tt := range tests {
e := shannonEntropy(tt.input)
if e < tt.minBits || e > tt.maxBits {
t.Errorf("shannonEntropy(%q) = %.2f, want [%.1f, %.1f]", tt.input, e, tt.minBits, tt.maxBits)
}
}
}
// --- Redactor ---
func TestRedact_SingleMatch(t *testing.T) {
content := `AKIAIOSFODNN7EXAMPLE is my key`
s := NewScanner(6.0)
matches := s.Scan(content)
result := Redact(content, matches)
if strings.Contains(result, "AKIA") {
t.Error("should have redacted the key")
}
if !strings.Contains(result, "[REDACTED]") {
t.Error("should contain [REDACTED] placeholder")
}
}
func TestRedact_MultipleMatches(t *testing.T) {
content := "aws: AKIAIOSFODNN7EXAMPLE github: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij"
s := NewScanner(6.0)
matches := s.Scan(content)
result := Redact(content, matches)
if strings.Contains(result, "AKIA") {
t.Error("should redact AWS key")
}
if strings.Contains(result, "ghp_") {
t.Error("should redact GitHub PAT")
}
count := strings.Count(result, "[REDACTED]")
if count < 2 {
t.Errorf("expected at least 2 redactions, got %d in: %q", count, result)
}
}
func TestRedact_NoMatches(t *testing.T) {
content := "hello world"
result := Redact(content, nil)
if result != content {
t.Errorf("should return unchanged content, got %q", result)
}
}
func TestRedact_SkipsWarnAction(t *testing.T) {
matches := []SecretMatch{
{Pattern: "test", Action: ActionWarn, Start: 0, End: 5},
}
result := Redact("hello world", matches)
if result != "hello world" {
t.Errorf("warn-only matches should not be redacted, got %q", result)
}
}
// --- Unicode Sanitization ---
func TestSanitizeUnicode_Normal(t *testing.T) {
normal := "Hello, world! 123"
result := SanitizeUnicode(normal)
if result != normal {
t.Errorf("normal text should be unchanged, got %q", result)
}
}
func TestSanitizeUnicode_StripsTags(t *testing.T) {
// Unicode tag characters (U+E0000-U+E007F) used for ASCII smuggling
tagged := "Hello" + string([]rune{0xE0048, 0xE0065, 0xE006C, 0xE006C, 0xE006F}) + " world"
result := SanitizeUnicode(tagged)
if result != "Hello world" {
t.Errorf("should strip tag characters, got %q (len=%d)", result, len(result))
}
}
func TestSanitizeUnicode_StripsZeroWidth(t *testing.T) {
// Zero-width space (U+200B), zero-width joiner (U+200D)
zwsp := "Hello\u200B\u200Dworld"
result := SanitizeUnicode(zwsp)
if result != "Helloworld" {
t.Errorf("should strip zero-width characters, got %q", result)
}
}
func TestSanitizeUnicode_StripsRTL(t *testing.T) {
// RTL override (U+202E) used for visual spoofing
rtl := "Hello\u202Eworld"
result := SanitizeUnicode(rtl)
if strings.ContainsRune(result, 0x202E) {
t.Error("should strip RTL override character")
}
}
func TestSanitizeUnicode_PreservesNewlines(t *testing.T) {
multiline := "line1\nline2\ttab"
result := SanitizeUnicode(multiline)
if result != multiline {
t.Errorf("should preserve newlines and tabs, got %q", result)
}
}
func TestSanitizeUnicode_PreservesEmoji(t *testing.T) {
emoji := "Hello 😊 world"
result := SanitizeUnicode(emoji)
if result != emoji {
t.Errorf("should preserve emoji, got %q", result)
}
}
// --- Incognito ---
func TestIncognito_DefaultOff(t *testing.T) {
m := NewIncognitoMode()
if m.Active() {
t.Error("should default to inactive")
}
if !m.ShouldPersist() {
t.Error("should allow persistence when not incognito")
}
if !m.ShouldLearn() {
t.Error("should allow learning when not incognito")
}
}
func TestIncognito_Activate(t *testing.T) {
m := NewIncognitoMode()
m.Activate()
if !m.Active() {
t.Error("should be active")
}
if m.ShouldPersist() {
t.Error("should not persist in incognito")
}
if m.ShouldLearn() {
t.Error("should not learn in incognito")
}
if m.ShouldLogContent() {
t.Error("should not log content in incognito")
}
}
func TestIncognito_Toggle(t *testing.T) {
m := NewIncognitoMode()
active := m.Toggle()
if !active {
t.Error("first toggle should activate")
}
active = m.Toggle()
if active {
t.Error("second toggle should deactivate")
}
}
// --- Firewall ---
func TestFirewall_ScanOutgoing(t *testing.T) {
fw := NewFirewall(FirewallConfig{
ScanOutgoing: true,
EntropyThreshold: 6.0,
})
msgs := []message.Message{
message.NewUserText("my key is sk-ant-api03-abcdefghijklmnopqrstuvwxyz"),
}
cleaned := fw.ScanOutgoingMessages(msgs)
text := cleaned[0].TextContent()
if strings.Contains(text, "sk-ant-") {
t.Error("should redact Anthropic key from outgoing message")
}
if !strings.Contains(text, "[REDACTED]") {
t.Errorf("should contain [REDACTED], got %q", text)
}
}
func TestFirewall_ScanToolResult(t *testing.T) {
fw := NewFirewall(FirewallConfig{
ScanToolResults: true,
EntropyThreshold: 6.0,
})
result := fw.ScanToolResult("contents of .env:\nOPENAI_API_KEY=sk-proj-testkey1234567890abcdef12345")
if strings.Contains(result, "sk-proj-") {
t.Error("should redact key from tool result")
}
}
func TestFirewall_DisabledScanning(t *testing.T) {
fw := NewFirewall(FirewallConfig{
ScanOutgoing: false,
ScanToolResults: false,
})
original := "sk-ant-api03-abcdefghijklmnopqrstuvwxyz"
msgs := []message.Message{message.NewUserText(original)}
cleaned := fw.ScanOutgoingMessages(msgs)
if cleaned[0].TextContent() != original {
t.Error("disabled scanning should pass through unchanged")
}
result := fw.ScanToolResult(original)
if result != original {
t.Error("disabled scanning should pass through tool results unchanged")
}
}
func TestFirewall_UnicodeCleanedBeforeSecretScan(t *testing.T) {
fw := NewFirewall(FirewallConfig{
ScanOutgoing: true,
EntropyThreshold: 6.0,
})
// Unicode tags embedded in text
tagged := "normal text" + string([]rune{0xE0048, 0xE0065}) + " more text"
msgs := []message.Message{message.NewUserText(tagged)}
cleaned := fw.ScanOutgoingMessages(msgs)
text := cleaned[0].TextContent()
if strings.ContainsRune(text, 0xE0048) {
t.Error("unicode tags should be stripped")
}
}