// Package guardrail 实现 Harness 输入护栏的纯检测逻辑(与 HTTP 解耦,便于单测)。 // // 两层设计: // - Tier1(本包,网关同步、无 LLM):先把输入「归一化」——小写、去零宽字符、折叠同形字、 // 拆字间隔(i g n o r e)还原、base64 解码——再跑高精度注入正则,干掉编码/空格/同形字绕过; // env 黑名单命中即拦。命中即 Blocked。 // - Tier2(dispatcher harness 的 LLM 分类器):Tier1 判为「疑似但不确定」(Suspect) 的灰区输入 // 放行但打标,由 Dispatcher 执行前调 LLM 裁决(见 contract.MetaSafetyCheck)。 package guardrail import ( "encoding/base64" "os" "regexp" "strings" "unicode" ) // MaxJSONBytes 是 JSON 请求体上限(文件上传走 multipart,不经此检查)。 const MaxJSONBytes = 256 * 1024 // injectionPatterns 是高精度的提示词注入 / 越权诱导模式(命中即硬拦截)。 // 在 原文 + 归一化 + 紧凑(去分隔) + base64 解码 多个视图上匹配,故无需把绕过变体逐一写进正则。 var injectionPatterns = []struct { label string re *regexp.Regexp }{ {"忽略既定指令", regexp.MustCompile(`(?i)ignore\s*(all\s*|the\s*)*previous\s*(instructions?|prompts?)`)}, {"忽略既定指令", regexp.MustCompile(`(?i)disregard\s*(the\s*)?(above|previous|prior)`)}, {"忽略既定指令", regexp.MustCompile(`忽略(以上|之前|前面|上述|先前)[^。\n]{0,8}(指令|指示|提示|要求|规则|设定)`)}, {"角色越权", regexp.MustCompile(`(?i)you\s*are\s*now\s*(a|an|the|no\s*longer)`)}, {"诱导泄露提示词", regexp.MustCompile(`(?i)(reveal|show|print|repeat|expose)\s*(me\s*)?(your\s*|the\s*)*(system\s*)?prompt`)}, {"诱导泄露提示词", regexp.MustCompile(`(泄露|显示|输出|告诉我|重复)[^。\n]{0,6}(系统)?(提示词|指令)`)}, } // suspectLexicon 是「软信号」越狱词表:单独命中不足以拦截(误伤风险),但触发灰区升级(交 LLM 裁决)。 var suspectLexicon = []string{ "jailbreak", "developer mode", "dan mode", "do anything now", "no restrictions", "without restrictions", "unfiltered", "ignore safety", "bypass", "sudo mode", "pretend you are", "roleplay as", "act as if", "越狱", "开发者模式", "无限制", } // bannedTerms 是敏感词黑名单,从 env GUARDRAIL_BANNED_TERMS 读(逗号分隔,已小写);命中即拦截。 var bannedTerms = loadBannedTerms() func loadBannedTerms() []string { var out []string for _, w := range strings.Split(os.Getenv("GUARDRAIL_BANNED_TERMS"), ",") { if w = strings.TrimSpace(strings.ToLower(w)); w != "" { out = append(out, w) } } return out } // Result 是一次输入检查结果。 type Result struct { Blocked bool // 硬拦截(注入/黑名单/超体积) Reason string // 人类可读拦截原因 Suspect bool // 灰区:疑似但不确定,放行但标记升级 LLM 裁决(Tier2) Signals []string // 命中的软信号(可观测) } // Inspect 检查输入是否触发护栏。 func Inspect(body []byte) Result { if len(body) > MaxJSONBytes { return Result{Blocked: true, Reason: "请求体过大(超过 256KB)"} } raw := string(body) // 多视图:原文 + 归一化(小写/去零宽/同形字折叠/塌缩空白)+ 紧凑(去所有分隔)+ base64 解码。 norm := normalize(raw) compact := compactize(norm) views := []string{raw, norm, compact} if dec := decodeBase64Blobs(raw); dec != "" { views = append(views, strings.ToLower(dec), compactize(normalize(dec))) } for _, p := range injectionPatterns { for _, v := range views { if p.re.MatchString(v) { return Result{Blocked: true, Reason: "疑似提示词注入(" + p.label + ")"} } } } for _, w := range bannedTerms { for _, v := range views { if strings.Contains(v, w) { return Result{Blocked: true, Reason: "命中敏感词"} } } } // 软信号 → 灰区升级(不拦,交 Tier2 LLM 裁决)。 var signals []string for _, w := range suspectLexicon { if strings.Contains(norm, w) || strings.Contains(compact, compactize(w)) { signals = append(signals, w) } } if len(views) > 3 { // 含可解码的 base64 blob:本身可疑,升级 signals = append(signals, "base64-payload") } return Result{Suspect: len(signals) > 0, Signals: signals} } // zeroWidth 是常见零宽 / 不可见字符(注入者用来打断关键词、骗过正则): // U+200B-200D 零宽空格/连接符、U+2060 word joiner、U+FEFF BOM、U+00AD 软连字符。 func zeroWidth(r rune) bool { switch r { case '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff', '\u00ad': return true } return false } // homoglyphs 把常见西里尔/希腊同形字折回拉丁字母(如 'а'→'a',肉眼一样、码位不同)。 var homoglyphs = map[rune]rune{ 'а': 'a', 'е': 'e', 'о': 'o', 'р': 'p', 'с': 'c', 'х': 'x', 'у': 'y', 'і': 'i', 'ѕ': 's', 'ԁ': 'd', 'ո': 'n', 'ɡ': 'g', 'ⅼ': 'l', 'ο': 'o', 'ρ': 'p', 'ε': 'e', 'α': 'a', 'ι': 'i', 'ν': 'v', } // normalize 归一化:小写 + 去零宽 + 去组合附加符(变音) + 同形字折叠 + 塌缩连续空白为单空格。 func normalize(s string) string { s = strings.ToLower(s) var b strings.Builder b.Grow(len(s)) prevSpace := false for _, r := range s { if zeroWidth(r) || unicode.Is(unicode.Mn, r) { // Mn=组合附加符 continue } if g, ok := homoglyphs[r]; ok { r = g } if unicode.IsSpace(r) { if !prevSpace { b.WriteRune(' ') prevSpace = true } continue } prevSpace = false b.WriteRune(r) } return strings.TrimSpace(b.String()) } // compactize 去掉所有非「字母数字 / CJK」字符:把 "i.g.n.o.r.e" / "i g n o r e" 还原成 "ignore", // 干掉拆字间隔类绕过。注意会粘连多词,故 compact 视图配合的是同样去分隔的紧凑模式。 func compactize(s string) string { var b strings.Builder b.Grow(len(s)) for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) case r >= 0x4e00 && r <= 0x9fff: // CJK b.WriteRune(r) } } return b.String() } // base64BlobRe 匹配较长的 base64 串(短串误报多,限 ≥24 字符)。 var base64BlobRe = regexp.MustCompile(`[A-Za-z0-9+/]{24,}={0,2}`) // decodeBase64Blobs 解码输入里的长 base64 片段,拼成可扫描的明文(解码失败/非文本的丢弃)。 func decodeBase64Blobs(s string) string { var out strings.Builder for _, m := range base64BlobRe.FindAllString(s, -1) { dec, err := base64.StdEncoding.DecodeString(m) if err != nil { if dec, err = base64.RawStdEncoding.DecodeString(m); err != nil { continue } } if isMostlyText(dec) { out.Write(dec) out.WriteByte(' ') } } return out.String() } // isMostlyText 判断解码结果是否大体为可打印文本(过滤二进制/随机字节,降误报)。 func isMostlyText(b []byte) bool { if len(b) == 0 { return false } printable := 0 for _, c := range b { if c == '\n' || c == '\t' || (c >= 0x20 && c < 0x7f) { printable++ } } return printable*100/len(b) >= 85 }