// Package filter provides a safety filter pipeline that runs over extracted content. // // This is defense-in-depth, not the primary security boundary. // Primary security is the typed extraction itself — raw content never reaches // the agent. The filter layer catches patterns that slipped through the // structured extraction (e.g., code injection in prose paragraphs). // // Filters are deterministic regex/string operations. No LLM involvement. package filter import ( "regexp" "strings" "github.com/sscoble/specstream/pkg/types" ) // FilterFunc transforms an ExtractedContent in place. Returns warnings. type FilterFunc func(c *types.ExtractedContent) []string // StandardFilters returns the default filter pipeline. func StandardFilters() []FilterFunc { return []FilterFunc{ StripNullBytes, StripControlChars, TruncateParagraphs(5000), StripCommonInjectionPatterns, } } // StripNullBytes removes null bytes from all text fields. func StripNullBytes(c *types.ExtractedContent) []string { var warnings []string for i := range c.Sections { c.Sections[i].Heading = strings.ReplaceAll(c.Sections[i].Heading, "\x00", "") for j := range c.Sections[i].Paragraphs { if strings.Contains(c.Sections[i].Paragraphs[j], "\x00") { c.Sections[i].Paragraphs[j] = strings.ReplaceAll(c.Sections[i].Paragraphs[j], "\x00", "") } } } return warnings } // StripControlChars removes ASCII control characters (except \n, \t, \r). func StripControlChars(c *types.ExtractedContent) []string { var warnings []string re := regexp.MustCompile(`[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]`) for i := range c.Sections { c.Sections[i].Heading = re.ReplaceAllString(c.Sections[i].Heading, "") for j := range c.Sections[i].Paragraphs { if re.MatchString(c.Sections[i].Paragraphs[j]) { c.Sections[i].Paragraphs[j] = re.ReplaceAllString(c.Sections[i].Paragraphs[j], "") } } } return warnings } // TruncateParagraphs caps paragraph length. func TruncateParagraphs(maxLen int) FilterFunc { return func(c *types.ExtractedContent) []string { var warnings []string for i := range c.Sections { for j := range c.Sections[i].Paragraphs { if len(c.Sections[i].Paragraphs[j]) > maxLen { c.Sections[i].Paragraphs[j] = c.Sections[i].Paragraphs[j][:maxLen] } } } return warnings } } // StripCommonInjectionPatterns removes known prompt injection markers // from paragraph text. This is a best-effort blocklist — the primary // defense is the structured extraction itself. func StripCommonInjectionPatterns(c *types.ExtractedContent) []string { var warnings []string patterns := []struct { name string pattern *regexp.Regexp }{ { "ignore-previous", regexp.MustCompile(`(?i)(ignore|forget|disregard|override)\s+(all\s+)?(previous|prior|above)\s+(instructions|commands|directions|prompts?)`), }, { "new-instructions", regexp.MustCompile(`(?i)(now|instead|from\s+now\s+on)\s+(you\s+are|you\'?re|act\s+as|behave\s+as|pretend)\s+`), }, { "system-override", regexp.MustCompile(`(?i)(system\s+prompt|system\s+message|SYSTEM:|system_message)\s*[:=]`), }, { "role-switch", regexp.MustCompile(`(?i)^\s*(user|assistant|system)\s*[:]\s*`), }, { "dangerous-command", regexp.MustCompile(`(?i)(rm\s+-(rf|r)\s+/|format\s+(drive|disk|volume)|dd\s+if=)\s*`), }, } for _, p := range patterns { for i := range c.Sections { orig := c.Sections[i].Heading c.Sections[i].Heading = p.pattern.ReplaceAllString(c.Sections[i].Heading, "[REDACTED]") if orig != c.Sections[i].Heading { c.Metadata.StrippedPatterns[p.name]++ warnings = append(warnings, p.name) } for j := range c.Sections[i].Paragraphs { orig = c.Sections[i].Paragraphs[j] c.Sections[i].Paragraphs[j] = p.pattern.ReplaceAllString(c.Sections[i].Paragraphs[j], "[REDACTED]") if orig != c.Sections[i].Paragraphs[j] { c.Metadata.StrippedPatterns[p.name]++ warnings = append(warnings, p.name) } } } } if len(warnings) > 0 { // Deduplicate seen := make(map[string]bool) unique := []string{} for _, w := range warnings { if !seen[w] { seen[w] = true unique = append(unique, w) } } return unique } return nil }