Files
AskaEth 76263d42ed fix(upstream): M51 复审 p2 三条 —— light.go 同形出口 / MCP SDK 日志面 / ErrorToken 口径
① light.go 的 2 处 "light judge: " + d.Reason 与 engage.go 同形(model reason →
返回值 → coalesce_prepare 的 Debug)。把上一轮的 engageReason 提成包内共用的
judgeReason(judge_reason.go,RedactTextLimited + strings.Fields,60 rune),
engage.go 7 处 + light.go 2 处 + notice.go 的 "notice review failed: "+err.Error()
统一走它。控制流零改动:只换「进去的那份文本」,判定条件与返回值拼接结构未动。

② MCP 的 Logger 面先探针后修:go-sdk v1.8.0 的 keepalive 会把对端 JSON-RPC error
原样打进日志(实测泄漏,见 sdklog_probe_test.go 的输出),tools/call 路径不打日志。
新增 sdklog.go:redactingLogger 交给 SDK,wire 错误(链上有 *jsonrpc.Error)只留
code + message_bytes(复用 upstreamError),本地错误保留原因,消息/属性压平+打码+300 rune。

③ errortoken.go 的口径注释纠正:从「包外不得调用」改为按字段类别判定(协议枚举可守、
自由文本默认不带),并把 cdp.go 这条 M51 用法作为例子写进去。
2026-10-26 20:00:57 +08:00

115 lines
4.8 KiB
Go

package channelbridge
// 本文件钉住 judgeReason 的三个使用者(engage.go / light.go / notice.go)都真的走它。
// engage.go 那 7 处由 engage_reason_test.go 覆盖;这里覆盖后两处与 helper 本身。
//
// 变异判据(每条都对应一个会红的实现):
// - 把 judgeReason 换成恒等返回(摘掉 trace.RedactTextLimited)⇒ 三条用例都红;
// - 只做 RedactText 不做 strings.Fields 压平 ⇒ helper 那条与 light 那条红(换行仍在);
// - 把 light.go / notice.go 的调用点改回原文(`d.Reason` / `err.Error()`)⇒ 对应用例红。
import (
"context"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"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"
)
// judgeReasonSecret 是模型/上游「回显」进 reason 的凭据形状。
const judgeReasonSecret = "sk-live-0123456789abcdef"
// judgeReasonRaw 是原始 reason:短语 + 回显的凭据 + 换行(想在按行聚合的日志里伪造
// 一条新记录)。
const judgeReasonRaw = "第一行:群里有 " + judgeReasonSecret + "\n第二行:伪造一条日志"
// judgeReasonWant 是 judgeReason 的期望产物。
const judgeReasonWant = "第一行:群里有 [redacted] 第二行:伪造一条日志"
// assertSanitizedReason 断言一段出镜文本既不含凭据原文,也不含换行。
func assertSanitizedReason(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 里没有打码标记:%q", what, got)
}
if strings.Contains(got, "\n") {
t.Fatalf("%s 里出现了换行(应被压平):%q", what, got)
}
}
// TestJudgeReason_RedactsFlattensAndBounds 钉住 helper 本身的三件事。
func TestJudgeReason_RedactsFlattensAndBounds(t *testing.T) {
t.Parallel()
if got := judgeReason(judgeReasonRaw); got != judgeReasonWant {
t.Fatalf("judgeReason = %q,期望 %q", got, judgeReasonWant)
}
// 上限按 rune 计:60 个汉字必须原样保留(不能被截),61 个才截断。
if got := judgeReason(strings.Repeat("中", judgeReasonRunes)); got != strings.Repeat("中", judgeReasonRunes) {
t.Fatalf("恰好 %d rune 不该被截断:%q", judgeReasonRunes, got)
}
if got := judgeReason(strings.Repeat("中", judgeReasonRunes+1)); len([]rune(got)) != judgeReasonRunes+1 {
t.Fatalf("超限时应保留 %d rune + 省略号,实际 %q", judgeReasonRunes, got)
}
}
// TestLegacyLightJudge_ReasonSanitized 覆盖 light.go 的两处返回值(旧轻量判定车道):
// reason 出镜前必须打码压平(它随后被 coalesce_prepare 的 Debug 落日志)。
func TestLegacyLightJudge_ReasonSanitized(t *testing.T) {
t.Parallel()
for _, respond := range []bool{true, false} {
judge := &stubLightJudge{resp: LightDecision{Respond: respond, Reason: judgeReasonRaw}}
b, err := New(Options{
Log: logger.New(logger.Options{Level: "debug", Out: &strings.Builder{}}),
Loop: &stubChatter{res: &loop.Result{Text: "x"}},
Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{},
Light: judge,
TriggerWords: []string{"昔涟"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
msg := nonOwnerGroupMsg("提一句昔涟")
got, reason := b.legacyLightJudge(context.Background(), msg, msg.Sender, false)
b.Stop(context.Background())
if got != respond {
t.Fatalf("legacyLightJudge respond = %v, 期望 %v", got, respond)
}
assertSanitizedReason(t, "light judge 返回值", reason)
if !strings.Contains(reason, "light judge: "+judgeReasonWant) {
t.Fatalf("返回值 = %q,期望含 %q", reason, "light judge: "+judgeReasonWant)
}
}
}
// TestNoticeReviewFailureReasonSanitized 覆盖 notice.go 的失败原因:
// 审查模型(或它背后的上游)的 error 文本进 noticeOutcome.Reason 前同样要打码压平。
func TestNoticeReviewFailureReasonSanitized(t *testing.T) {
t.Parallel()
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) {
return nil, errors.New(judgeReasonRaw)
}
ad := &noticeAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件"}, ok: true}
b, _, _, _ := noticeFixture(t, ad, review)
msg := groupNoticeMessage()
out := b.preEntryReview(context.Background(), &msg, ad)
if out.Proceed {
t.Fatalf("审查失败必须走 fail-safe 静默,实际放行了:reason=%q", out.Reason)
}
if !strings.HasPrefix(out.Reason, "notice review failed: ") {
t.Fatalf("原因前缀被改动了:%q", out.Reason)
}
assertSanitizedReason(t, "notice 审查失败原因", out.Reason)
}