49d80cf847
Add a deterministic pre-extractor that skips known-safe token shapes before they reach the entropy scorer. Targets the false-positive regime that bites under lowered entropy_threshold or redact_high_entropy = true — UUIDs (~3.4 bits), SHA hex digests (~3.9 bits), ISO-8601 timestamps, and HTTP(S) URLs. Config knob lives under the existing security section to match entropy_threshold / redact_high_entropy convention: [security] entropy_safelist = ["uuid", "sha_hex", "iso8601", "url"] Empty / unset preserves pre-F-1 behaviour exactly — users opt in. Per-pattern Debug telemetry fires on every skip (pattern name + token length, never the token bytes). This is the data F-2's go/no-go gate depends on; the plan literally specifies it. NewFirewall validates names at the config boundary and emits a Warn for unknown entries so a typo like "uid" instead of "uuid" surfaces loudly instead of silently disabling FP reduction. Tests cover: UUID/SHA-1/SHA-256 skipped at lowered threshold, mixed payload (safe shape + real secret) preserves the secret, secret-adjacent-to-UUID regression guard, empty safelist preserves pre-F-1 behaviour, unknown name silently dropped at scanner level but warned at firewall level, end-to-end FirewallConfig wiring, and the skip-telemetry log line. F-2 remains gated on real-workload FP-rate observations.
161 lines
4.1 KiB
Go
161 lines
4.1 KiB
Go
package security
|
|
|
|
import (
|
|
"encoding/json"
|
|
"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
|
|
RedactHighEntropy bool
|
|
EntropyThreshold float64
|
|
EntropySafelist []string
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
func NewFirewall(cfg FirewallConfig) *Firewall {
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
scanner := NewScanner(cfg.EntropyThreshold, cfg.RedactHighEntropy)
|
|
scanner.SetLogger(logger)
|
|
// Validate safelist names at the config boundary so a typo surfaces
|
|
// loudly instead of silently disabling FP reduction.
|
|
entries, unknown := splitSafelistNames(cfg.EntropySafelist)
|
|
for _, name := range unknown {
|
|
logger.Warn("ignoring unknown entropy safelist name",
|
|
"name", name,
|
|
"hint", "valid names: uuid, sha_hex, iso8601, url",
|
|
)
|
|
}
|
|
scanner.safelist = entries
|
|
return &Firewall{
|
|
scanner: scanner,
|
|
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
|
|
}
|
|
case message.ContentToolCall:
|
|
// Scan LLM-generated tool arguments for accidentally embedded secrets
|
|
if c.ToolCall != nil {
|
|
tc := *c.ToolCall
|
|
scanned := f.scanAndRedact(string(tc.Arguments), "tool_call_args")
|
|
tc.Arguments = json.RawMessage(scanned)
|
|
cleaned.Content[i] = message.NewToolCallContent(tc)
|
|
} else {
|
|
cleaned.Content[i] = c
|
|
}
|
|
default:
|
|
// 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 {
|
|
switch m.Action {
|
|
case ActionBlock:
|
|
f.logger.Error("blocked: secret detected",
|
|
"pattern", m.Pattern,
|
|
"source", source,
|
|
)
|
|
return "[BLOCKED: content contained a secret]"
|
|
default:
|
|
f.logger.Debug("secret redacted",
|
|
"pattern", m.Pattern,
|
|
"action", m.Action,
|
|
"source", source,
|
|
)
|
|
}
|
|
}
|
|
|
|
return Redact(content, matches)
|
|
}
|