Go MCP server (port 3991) for agent content safety. Deterministic parsers, typed output, no raw text fields. - HTML (goquery), Markdown (goldmark), Text parsers - Injection pattern detection and redaction - SHA256 content fingerprinting
139 lines
4.1 KiB
Go
139 lines
4.1 KiB
Go
// 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
|
|
}
|