- 真 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 份各撞注入预算的大附件仍可能被拦。
316 lines
13 KiB
Go
316 lines
13 KiB
Go
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)
|
||
}
|
||
}
|