Files
sundynix-agentix/sundynix-gateway/internal/guardrail/guardrail.go
T
Blizzard 2f78fc565e feat(harness): 输入护栏升级 —— 归一化反绕过(Tier1) + LLM 越狱分类器(Tier2)
原输入护栏纯正则,空格/编码/同形字一改写即漏,且 bannedTerms 空置。升级为两层:

Tier1(网关同步、无 LLM):先归一化再匹配,干掉绕过——
- 小写 + 去零宽字符 + 去变音符 + 同形字折叠(西里尔/希腊→拉丁) +
  拆字间隔还原(i g n o r e / i.g.n.o.r.e → ignore) + base64 解码回扫
- 多视图(原文/归一化/紧凑/解码)匹配高精度注入正则,无需穷举变体
- bannedTerms 经 GUARDRAIL_BANNED_TERMS env 落地
- 软信号(jailbreak/developer mode/无限制…)→ 灰区,放行但打 safety_check 标志

Tier2(dispatcher harness LLM 分类器,escalation):
- 仅对灰区任务执行前调 LLM 裁决 jailbreak+severity,≥0.7 → rejected
- 明确干净/恶意的不付 LLM 成本;模型抖动/解析失败 fail-open 不误锁正常用户

契约新增 MetaSafetyCheck 透传灰区标志;orchestrator 加执行前护栏门控 + SetGuardian。
网关 6 单测 + dispatcher 4 单测,三模块全绿。live:拆字/base64/西里尔同形字均 422 拦,
恶意灰区被 LLM 拒(severity 1)、良性灰区(海盗 roleplay)放行完成。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:38:34 +08:00

199 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package guardrail 实现 Harness 输入护栏的纯检测逻辑(与 HTTP 解耦,便于单测)。
//
// 两层设计:
// - Tier1(本包,网关同步、无 LLM):先把输入「归一化」——小写、去零宽字符、折叠同形字、
// 拆字间隔(i g n o r e)还原、base64 解码——再跑高精度注入正则,干掉编码/空格/同形字绕过;
// env 黑名单命中即拦。命中即 Blocked。
// - Tier2dispatcher 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
}