Files
Cyrene_v2/cmd/cyrene/wire_guardrail_test.go
AskaEth 51c1672355
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
- 真 bug:文档注入预算(1M 窗口 ⇒ 349525 字符)> 输入护栏硬编码上限(262144)⇒ 带 744KB 附件的 POST /chat 直接 input_length_exceeded/guardrail_input,整个回合作废(主会话活服务实测)。

- 修法:guardrailMaxInputChars → guardrailMinInputChars(256Ki 下限) + 纯函数 deriveGuardrailInputLimit(log, override, budgetTokens, docInjectChars):派生值 = max(下限, 推导预算 tokens)(1M ⇒ 699050);新增 env GUARDRAIL_MAX_INPUT_CHARS(未设/0=派生、>0=覆盖、<0=WARN 回落);预算只在 wire_context 推导一次并经 buildGuardrailPipeline(ctxBudget) 传入(避免「窗口未声明」WARN 记两遍);输出上限 65536 刻意不参与派生(有显式回归钉住)。

- 关系不变量:文档注入预算 ≤ 输入上限,人为破坏 ⇒ 一条 WARN(不静默)。

- 第 5 条 flake(exec/TestTerminal_Timeout_Killed):仅改测试、terminal.go 保持 pristine;根因不是「杀进程失效」而是旧断言的 ~200ms 精度把宿主负载下的进程创建耗时算了进去(重压下 cmd.Start 会直接返回 deadline exceeded,红行却报 process not killed)⇒ 改为「控制组证明同命令不设短超时正常退出」的确定性证明 + 把墙钟断言降级为 10s 语义判别界(子进程自然存活期拉到 30s)。

- 主会话独立验收:隔离 worktree(G2 的 10 个文件,排除 M1 在途的 wire_channels.go)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、Guardrail* 全 PASS、exec -count=20 绿;变异独立复现(把派生值退回硬编码 ⇒ 4 条红,含 budget=699050 → 262144)。

- 残留缺口(已排 G2b):护栏量的是整段装配请求、注入预算是单份 ⇒ ≥2 份各撞注入预算的大附件仍可能被拦。
2026-10-04 19:15:12 +08:00

316 lines
13 KiB
Go
Raw Permalink 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 main
import (
"context"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定 P0-5:组合根的护栏管线**不是空壳**。断言分两层 ——
// 管线本身(正常对话过 / 敏感与超长被拦 / 词表只在配置提供时挂),
// 以及端到端(同一个循环:正常消息真被模型回答,敏感消息在模型调用**之前**
// 就被拦下,适配器一次都没被调用)。
// guardProbeAdapter 只回答固定文本并数调用次数,使「护栏是否在模型调用前拦下」
// 变成可断言的事实。
type guardProbeAdapter struct {
reply string
calls int
}
func (a *guardProbeAdapter) Name() string { return "guard-probe" }
func (a *guardProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
a.calls++
return model.Response{Content: a.reply}, nil
}
func (a *guardProbeAdapter) ChatStream(ctx context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: a.reply})
}
return a.Chat(ctx, req, model.Options{})
}
// noWindowBudget 是「没声明窗口的小部署」的预算形状:压缩预算与注入预算两项
// 推导值都是 0,于是派生的输入上限落回下限 guardrailMinInputChars —— 也就是
// 接线前那个硬编码值的等价场景。只关心「护栏里有什么」的用例传它即可。
func noWindowBudget() ctxBudget { return ctxBudget{} }
// guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。
func guardrailApp(t *testing.T, keywords []string) *app {
t.Helper()
if keywords == nil {
return &app{log: testLog()}
}
cfg := &config.Config{}
cfg.Guardrail.Keywords = keywords
return &app{log: testLog(), cfg: cfg}
}
// checkInput / checkOutput 是测试侧的薄封装:把一段文本包成请求或响应再判一次。
func checkInput(t *testing.T, p *guardrail.Pipeline, text string) *guardrail.Verdict {
t.Helper()
req := model.Request{Messages: []model.Message{model.TextMessage(model.RoleUser, text)}}
v, err := p.CheckInput(context.Background(), &req)
if err != nil {
t.Fatalf("CheckInput(%q): %v", truncate(text, 40), err)
}
return v
}
func checkOutput(t *testing.T, p *guardrail.Pipeline, text string) *guardrail.Verdict {
t.Helper()
resp := model.Response{Content: text}
v, err := p.CheckOutput(context.Background(), &resp)
if err != nil {
t.Fatalf("CheckOutput: %v", err)
}
return v
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// TestGuardrailPipeline_NormalConversationPasses 是真断言①:正常对话照常通过。
// 系统提示词 + 日常用户消息 + 一段几千字的正常长回复都必须放行 —— 护栏上线
// 不能以「误伤日常聊天」为代价。
func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"),
model.TextMessage(model.RoleUser, "今天有点累,陪我聊会儿天好不好?"),
model.TextMessage(model.RoleAssistant, "好呀,人家陪着你♪ 今天遇到什么啦?"),
model.TextMessage(model.RoleUser, "就是工作有点多。"),
}}
if v, _ := p.CheckInput(context.Background(), &req); !v.Allow {
t.Fatalf("正常对话被输入护栏拦下:%+v", v)
}
// 一段普通的长回复(约 3000 字)也不该被长度上限碰到。
long := strings.Repeat("今天也要好好吃饭、早点睡觉哦,人家会一直陪着你的♪", 100)
if v := checkOutput(t, p, long); !v.Allow {
t.Fatalf("正常长回复被输出护栏拦下:%+v", v)
}
}
// TestGuardrailPipeline_BlocksSensitiveInfo 是真断言②:敏感信息真被拦(阻断档,
// 不是标记档),且判决里不回流被命中的秘密本身(guardrail 包的既有契约)。
func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789"
cases := []struct {
name string
text string
secret string
}{
{name: "api key 参数", text: "api_key = " + secret, secret: secret},
{name: "私钥头", text: "-----BEGIN RSA PRIVATE KEY-----\nMIIEow...", secret: "MIIEow"},
{name: "bearer token", text: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9", secret: "eyJhbGciOiJIUzI1NiJ9"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
v := checkOutput(t, p, tc.text)
if v.Allow {
t.Fatalf("敏感内容未被拦下:%q → %+v", tc.text, v)
}
if !strings.Contains(v.Reason, "sensitive_information_blocked") {
t.Fatalf("拦截原因 = %q,want 含 sensitive_information_blocked", v.Reason)
}
if strings.Contains(v.Reason, tc.secret) {
t.Fatalf("判决里回流了被命中的秘密:%q", v.Reason)
}
})
}
// 请求侧同样拦(用户把密钥贴进来,不该先消耗一次模型调用)。
if v := checkInput(t, p, "帮我看看 password: hunter2 对不对"); v.Allow {
t.Fatalf("请求侧敏感内容未被拦下:%+v", v)
}
}
// TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
//
// 这里用的是 noWindowBudget(小窗口部署):派生的输入上限落回下限
// guardrailMinInputChars,也就是接线前那个 262144 的行为。
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
if limit := guardrailApp(t, nil).guardrailInputLimit(noWindowBudget()); limit != guardrailMinInputChars {
t.Fatalf("小窗口部署的输入上限 = %d, want 下限 %d", limit, guardrailMinInputChars)
}
// 输出上限是**另一个**数:它防的是模型输出失控,与上下文预算无关,
// 因此本切片只动输入侧,输出侧不得顺手放宽(回归钉住取值本身)。
if guardrailMaxOutputChars != 65536 {
t.Fatalf("输出上限 = %d, want 65536(本切片只动输入侧)", guardrailMaxOutputChars)
}
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow {
t.Fatalf("恰好到上限的输出被拦:%+v", v)
}
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars+1)); v.Allow {
t.Fatal("超出输出上限一个字符却放行")
} else if !strings.Contains(v.Reason, "output_length_exceeded") {
t.Fatalf("超长判决 = %q,want output_length_exceeded", v.Reason)
}
// 输入侧检查跑在**组装完成的请求**上,因此上限按整个上下文定:
// 10 万字符(约 5 万 token 的上下文)仍属正常,必须放行。
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, strings.Repeat("设定。", 1000)),
model.TextMessage(model.RoleUser, strings.Repeat("闲聊内容。", 10000)),
}}
if v, _ := p.CheckInput(context.Background(), &req); !v.Allow {
t.Fatalf("10 万字符的正常上下文被输入上限拦下:%+v", v)
}
// 超过上限则被拦。
over := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMinInputChars+1)),
}}
if v, _ := p.CheckInput(context.Background(), &over); v.Allow {
t.Fatal("超出输入上限的载荷却放行")
} else if !strings.Contains(v.Reason, "input_length_exceeded") {
t.Fatalf("超长输入判决 = %q,want input_length_exceeded", v.Reason)
}
}
// TestGuardrailPipeline_KeywordsOnlyWhenConfigured 锁定策略③:项目不硬编词表。
// 同一段文本,没配置时放行,配置了该词才被拦。
func TestGuardrailPipeline_KeywordsOnlyWhenConfigured(t *testing.T) {
t.Parallel()
text := "这段文本里出现了违禁词。"
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()), text); !v.Allow {
t.Fatalf("未配置词表却拦下了文本:%+v", v)
}
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline(noWindowBudget())
v := checkInput(t, configured, text)
if v.Allow {
t.Fatal("配置了词表却没拦下命中文本")
}
if !strings.Contains(v.Reason, "content_policy_blocked") {
t.Fatalf("关键词判决 = %q,want content_policy_blocked", v.Reason)
}
// 词表只按声明的词命中,不牵连别的正常文本。
if v := checkInput(t, configured, "这句很正常。"); !v.Allow {
t.Fatalf("词表误伤了正常文本:%+v", v)
}
}
// TestGuardrailPipeline_EmptyPipelineIsTheBugThisFixes 是反证(变异):空管线
// (P0-5 的原始形态 `guardrail.New()`)对同一段敏感文本一律放行 —— 证明上面
// 的断言不是恒真的,敏感拦截确实来自组合根挂上的检查。
func TestGuardrailPipeline_EmptyPipelineIsTheBugThisFixes(t *testing.T) {
t.Parallel()
empty := guardrail.New()
if v := checkOutput(t, empty, "api_key = sk-abcdefghijklmnopqrstuvwxyz0123456789"); !v.Allow {
t.Fatalf("空管线本应全部放行(这正是 P0-5 的空壳),却拦下了:%+v", v)
}
if v := checkOutput(t, empty, strings.Repeat("a", guardrailMaxOutputChars+1)); !v.Allow {
t.Fatalf("空管线本应不做长度检查,却拦下了:%+v", v)
}
}
// TestGuardrailPipeline_RealPromptPasses 是一道防误伤的现实检查:生产里真正被
// 检查的输入侧载荷是**组装完成的请求**(真实人格提示词 + 主人身份说明书 + 用户
// 消息)。若这几段里恰好命中某条敏感模式,每一轮对话都会被永久拦死 ——
// 因此这里拿仓库里真实的两份文件走一遍。
func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
t.Parallel()
a, p := identityApp(t, filepath.Join("..", "..", "configs", "users", "owner_identity.md"))
// 身份说明书自 P0-10 收窄后不再进静态指令,而是按会话注入系统上下文
// (persona_identity.go 的 personaKnowledgeFor)。护栏看到的真实载荷仍是
// 「人格提示词 + 身份说明书 + 用户消息」,故这里把它拼回同一段文本。
instructions := a.personaInstructions(p) +
"\n\n" + identityKnowledgeBlock(a.ownerIdentityKnowledge())
if !strings.Contains(instructions, "你叫他「亲爱的」") {
t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions)
}
pipeline := a.buildGuardrailPipeline(noWindowBudget())
req := model.Request{Messages: []model.Message{
model.TextMessage(model.RoleSystem, instructions),
model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"),
}}
v, err := pipeline.CheckInput(context.Background(), &req)
if err != nil {
t.Fatalf("CheckInput: %v", err)
}
if !v.Allow {
t.Fatalf("真实人格提示词 + 身份说明书被护栏拦下(会造成每轮都被拦死):%+v", v)
}
}
// TestGuardrailWiring_EndToEnd 是端到端真断言:同一个循环里,
// - 正常消息被模型正常回答(适配器被调用一次);
// - 敏感消息在模型调用**之前**就被拦下(适配器一次都没被调用),
// 且该回合的 StopReason 是 guardrail_input。
func TestGuardrailWiring_EndToEnd(t *testing.T) {
t.Parallel()
guards := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
adapter := &guardProbeAdapter{reply: "我在呢♪"}
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
Guards: guards,
OnStream: func(model.StreamEvent) {},
})
normal, err := l.Run(context.Background(), "web", []model.Message{
model.TextMessage(model.RoleUser, "晚上好呀"),
})
if err != nil {
t.Fatalf("Run(正常消息): %v", err)
}
if normal.Text != "我在呢♪" {
t.Fatalf("正常消息的回答 = %q,want 模型回复(护栏不该改动它)", normal.Text)
}
if adapter.calls != 1 {
t.Fatalf("正常消息的模型调用次数 = %d,want 1", adapter.calls)
}
blocked, err := l.Run(context.Background(), "web", []model.Message{
model.TextMessage(model.RoleUser, "我的 api_key = sk-abcdefghijklmnopqrstuvwxyz0123456789"),
})
if err != nil {
t.Fatalf("Run(敏感消息): %v", err)
}
if adapter.calls != 1 {
t.Fatalf("敏感消息触发了模型调用(calls=%d)—— 输入护栏没有前置拦下", adapter.calls)
}
if !strings.Contains(blocked.Text, "sensitive_information_blocked") {
t.Fatalf("敏感消息的回答 = %q,want 含拦截原因", blocked.Text)
}
if blocked.StopReason != agent.StopReasonGuardrailInput {
t.Fatalf("敏感消息的 StopReason = %q,want %q", blocked.StopReason, agent.StopReasonGuardrailInput)
}
}