Files
Cyrene_v2/cmd/cyrene/wire_guardrail_test.go
T
AskaEth da3a386daf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(cyrene,config,loop): 护栏/压缩/身份知识三条落地(P0-5/P0-10/P0-11)+ 身份注入收窄到主人会话
- P0-5 护栏不再是空壳:挂 WithSensitiveInfo(阻断) + 长度上限(输入 262144 / 输出 65536 字符,依据=
  组装完成的请求体量级与「不误伤正常对话」) + 关键词**仅在配置时**挂;正常对话有用例证明放行
- P0-11 上下文压缩默认启用:CONTEXT_MAX_TOKENS 默认 32768(=loop.DefaultMaxContextTokens,两常量由测试锁死),
  0/负 = 关闭(行为退回接线前);长对话真压缩且摘要消息进请求首位、最近一轮保留
- P0-10 身份说明书(owner.yaml identityFile)注入人格上下文,并且**收窄为仅主人会话**:
  · 新增最小钩子 loop.Options.PersonaKnowledgeFor(env):**按该轮会话环境**解析(不看提示词、不看会话 id),
    块只进每轮视图、**不进 messages**(不进 checkpoint、不进记忆提取面)
  · 开关 OWNER_IDENTITY_INJECTION:owner_only(默认) / always(仅自测) / off;非法值与读不动 → WARN + 回退默认(不静默)
  · **顺手关掉第二条同型泄漏**:channelbridge 群聊参与度判定器的 PersonaPrompt 此前带身份正文 ⇒ 现改为纯人格指令
  · 回归:缺文件/目录/未配置逐字节等于旧输出;静态指令与角色池 Instructions() 全不含正文;
    文件里点名的陌生账号仍 IsOwner==false(身份知识绝不参与权限判定)
- 复核:build/vet 绿 + cmd/config/loop 包测试绿;四类断言(主人含 / **群聊与陌生人不含** / always·off 两档 /
  回归不变)+ 4 条变异反证(含把判定改成恒真 → 提示词里真的出现身份说明书)均已还原
- 未做(登记):knowledge 块启动时读一次(改文件需重启);`.env.example` 的 OWNER_IDENTITY_INJECTION 行随后补;
  `internal/user/owner.go` 注释与 design/01 §2.2、design/11 §0 待同步
2026-10-04 14:30:26 +08:00

300 lines
12 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 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{})
}
// 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()
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()
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 锁定长度上限的
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
t.Parallel()
p := guardrailApp(t, nil).buildGuardrailPipeline()
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", guardrailMaxInputChars+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(), text); !v.Allow {
t.Fatalf("未配置词表却拦下了文本:%+v", v)
}
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline()
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()
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()
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)
}
}