Files
Cyrene_v2/internal/services/channelbridge/engage_reason_test.go
T
AskaEth 7fbb8d6232 fix(channelbridge): M51 追加 —— 参与度判定 reason 出镜前打码压平
Debug 日志与返回值共 7 处(engage.go 222/227/228/231/250/256/257 原行号)此前把
判定模型回的 d.Reason 原样写出:模型可以把 Authorization / sk-… 或换行塞进 reason,
前者进日志就是凭据泄漏,后者能按行聚合的日志里伪造一条新记录。

新增 engageReason(trace.RedactTextLimited + strings.Fields,上限 60 rune):
- 控制流零改动:分支条件、状态迁移、返回值拼接结构一个字未动,
  只把「进去的那份文本」从 d.Reason 换成 engageReason(d.Reason);
- 上限 60 rune 的理由写在常量注释里(reason 是一句短语,超过即是在复述群聊原文)。

用例 engage_reason_test.go:PROBING / 跟进 / 回避+无兴趣三条出口,
断言日志与返回值都不含 sk-live…、都含 [redacted]、都不含换行。
变异实测:摘 RedactText ⇒ 三条红;摘压平 ⇒ 三条红。
2026-10-26 19:47:35 +08:00

159 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 channelbridge
// 本文件钉住「参与度判定器的 reason 是模型自由文本,不得原样出镜」(engage.go 的
// engageReason):Debug 日志与返回值必须同时满足「凭据打码」与「压平成一行的短句」。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 engageReason 换成恒等返回(或摘掉 trace.RedactTextLimited)⇒ 日志与返回值里
// 都能搜到 sk-live…,红;
// - 只做 RedactText 不做 strings.Fields 压平 ⇒ 换行仍在,红(见下面两条换行断言);
// - 只改日志、不改返回值(`"engage: " + d.Reason` 保持原样)⇒ 返回值那条断言红。
import (
"bytes"
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// engageReasonSecret 是判定器「回显」进 reason 的凭据形状(sk-… 形状级匹配能认)。
const engageReasonSecret = "sk-live-0123456789abcdef"
// engageReasonRaw 是判定器回的原始 reason:一句正常短语 + 回显的凭据 + 一个换行
// (换行是想在按行聚合的日志里伪造一条新记录)。
const engageReasonRaw = "第一行:群里有 " + engageReasonSecret + "\n第二行:伪造一条日志"
// engageReasonWant 是 engageReason 的期望产物:凭据被打码成 [redacted]、两行被压成
// 一行(空格分隔)、其余原文保留(诊断价值仍在)。
const engageReasonWant = "第一行:群里有 [redacted] 第二行:伪造一条日志"
// engageReasonBridge 构建一条装配了参与度引擎与 LightJudge 的桥接器,
// 并把 Debug 级日志写进 out(Debug 是 reason 的唯一落点,级别必须降到 debug)。
func engageReasonBridge(t *testing.T, judge LightJudge, out *bytes.Buffer) *Bridge {
t.Helper()
b, err := New(Options{
Log: logger.New(logger.Options{Format: string(logger.FormatJSON), Level: "debug", Out: out}),
Loop: &stubChatter{res: &loop.Result{Text: "参与"}},
Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{},
Light: judge,
TriggerWords: []string{"昔涟"},
Engage: DefaultEngageConfig(),
EngageRand: func() float64 { return 0.5 }, // 永不命中概率触发,只走角色名提及
Now: dayNow(),
})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { b.Stop(context.Background()) })
return b
}
// assertNoCredentialNoNewline 断言一段出镜文本既不含凭据原文,也不含换行
// (换行会按行聚合的日志里伪造出「另一条记录」)。
func assertNoCredentialNoNewline(t *testing.T, what, got string) {
t.Helper()
if strings.Contains(got, "sk-live") {
t.Fatalf("%s 里出现了判定器回显的凭据:%q", what, got)
}
if !strings.Contains(got, "[redacted]") {
t.Errorf("%s 里没有打码标记(Reason 应当仍带上限出镜):%q", what, got)
}
if strings.Contains(got, "\n") || strings.Contains(got, "\\n") {
t.Fatalf("%s 里出现了换行(应被压平):%q", what, got)
}
}
// TestEngageReason_ProbeLogAndReturnAreSanitized 覆盖 PROBING 判定(→ ACTIVE):
// 日志与返回值都只带打码压平后的短句。
func TestEngageReason_ProbeLogAndReturnAreSanitized(t *testing.T) {
t.Parallel()
var out bytes.Buffer
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: engageReasonRaw},
}}
b := engageReasonBridge(t, judge, &out)
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
respond, reason := b.engageDecision(context.Background(), msg, msg.Sender, nonOwnerSession(), false)
if !respond {
t.Fatalf("判定器说值得回,engageDecision 却回了 false(reason=%q)", reason)
}
assertNoCredentialNoNewline(t, "PROBING 返回值", reason)
if !strings.Contains(reason, engageReasonWant) {
t.Fatalf("返回值 = %q,期望含 %q(打码 + 压平后的短句)", reason, engageReasonWant)
}
logged := out.String()
if !strings.Contains(logged, engageReasonWant) {
t.Fatalf("Debug 日志里没有打码压平后的 reason(原文换行未压平?):%s", logged)
}
if strings.Contains(logged, "sk-live") {
t.Fatalf("Debug 日志里出现了判定器回显的凭据:%s", logged)
}
}
// TestEngageReason_FollowUpLogAndReturnAreSanitized 覆盖 ACTIVE 跟进复审(→ 继续参与):
// 第二处返回值拼接(`"engage follow-up: " + …`)同样必须打码压平。
func TestEngageReason_FollowUpLogAndReturnAreSanitized(t *testing.T) {
t.Parallel()
var out bytes.Buffer
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: true, WorthReply: true, Reason: "先进入活跃窗口"},
{Interesting: true, WorthReply: true, Reason: engageReasonRaw},
}}
b := engageReasonBridge(t, judge, &out)
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
ctx := context.Background()
if respond, _ := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); !respond {
t.Fatal("第一次探测应当进入 ACTIVE")
}
respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false)
if !respond {
t.Fatalf("跟进判定说继续参与,engageDecision 却回了 false(reason=%q)", reason)
}
assertNoCredentialNoNewline(t, "跟进返回值", reason)
if !strings.Contains(reason, "engage follow-up: "+engageReasonWant) {
t.Fatalf("跟进返回值 = %q,期望含 %q", reason, "engage follow-up: "+engageReasonWant)
}
logged := out.String()
if !strings.Contains(logged, engageReasonWant) {
t.Fatalf("跟进 Debug 日志里没有打码压平后的 reason:%s", logged)
}
if strings.Contains(logged, "sk-live") {
t.Fatalf("跟进 Debug 日志里出现了判定器回显的凭据:%s", logged)
}
}
// TestEngageReason_AvoidTopicAndNoInterestAreSanitized 覆盖另外两条 Debug 出口
// (回避话题 / 无兴趣),它们同样带 reason。
func TestEngageReason_AvoidTopicAndNoInterestAreSanitized(t *testing.T) {
t.Parallel()
var out bytes.Buffer
judge := &engageScriptJudge{responses: []LightDecision{
{Interesting: false, WorthReply: false, AvoidTopic: true, Reason: engageReasonRaw},
{Interesting: false, WorthReply: false, Reason: engageReasonRaw},
}}
b := engageReasonBridge(t, judge, &out)
msg := nonOwnerGroupMsg("大家在聊昔涟的事")
ctx := context.Background()
if respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); respond {
t.Fatalf("回避话题不应回复(reason=%q)", reason)
}
if respond, reason := b.engageDecision(ctx, msg, msg.Sender, nonOwnerSession(), false); respond {
t.Fatalf("无兴趣不应回复(reason=%q)", reason)
}
logged := out.String()
if strings.Contains(logged, "sk-live") {
t.Fatalf("回避/无兴趣的 Debug 日志里出现了凭据:%s", logged)
}
if n := strings.Count(logged, engageReasonWant); n < 2 {
t.Fatalf("回避与无兴趣两条日志各应带一次打码压平后的 reason,实际 %d 次:%s", n, logged)
}
}