Files
Cyrene_v2/internal/services/automations/service_log_redaction_test.go
T
AskaEth 003d12a712 feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper
stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:17:42 +08:00

107 lines
3.6 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 automations
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
// 本文件钉住 M40 的自动化侧写日志边界:`AddRuleByName` 造的规则动作曾经把整个
// 触发 payload 用 %v 打进日志。绑到 `ws.chat` / `subagent.result` 这类 subject
// 上时,payload 里就是**她的正文** —— 于是「一条自动化触发」的日志会原样抄走一次
// 对话。
//
// 现在日志只留触发面的元数据:规则名、订阅的 subject、payload 的类型/体积/指纹。
// 🔴 断言打在**日志输出字节**上(真 slog handler → 缓冲区),不打在调用参数上。
// TestAddRuleByName_DoesNotLogPayloadBody 触发一次事件型自动化,检查日志里
// 没有正文,但仍有可诊断的类型/体积。
func TestAddRuleByName_DoesNotLogPayloadBody(t *testing.T) {
const sentinel = "SENTINEL-CHAT-BODY-88ce-她在聊天框里说的话"
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{}))
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
svc, err := NewService(Config{}, Deps{Bus: b, Log: log})
if err != nil {
t.Fatalf("NewService: %v", err)
}
id, err := svc.AddRuleByName(context.Background(), "chat-mirror", string(TriggerEvent), "ws.chat")
if err != nil {
t.Fatalf("AddRuleByName: %v", err)
}
rule, ok := ruleByID(svc.Engine().Rules(), id)
if !ok {
t.Fatalf("规则 %q 没有注册到引擎上", id)
}
payload := map[string]any{"session_id": "s1", "text": sentinel}
if _, err := rule.Action.Execute(context.Background(), payload); err != nil {
t.Fatalf("Execute: %v", err)
}
out := logBuf.String()
if strings.Contains(out, sentinel) {
t.Fatalf("自动化日志里出现了触发 payload 正文:%s", out)
}
for _, want := range []string{"admin automation fired", "name=chat-mirror", "subject=ws.chat", "payload_bytes=", "payload_sha256="} {
if !strings.Contains(out, want) {
t.Fatalf("日志 = %q,want 含 %q(脱敏不能把日志变哑)", out, want)
}
}
// 类型名同样是可诊断信息(哪条路上跑的是什么消息)。
if !strings.Contains(out, "payload_type=") || !strings.Contains(out, "map") {
t.Fatalf("日志 = %q,want payload_type=map…", out)
}
}
// TestAddRuleByName_NilPayloadStaysQuiet 钉住 cron 侧(payload 为 nil):日志
// 仍成立,不因 nil 而 panic 或退化成 %v 打印。
func TestAddRuleByName_NilPayloadStaysQuiet(t *testing.T) {
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, nil))
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
svc, err := NewService(Config{}, Deps{Bus: b, Log: log})
if err != nil {
t.Fatalf("NewService: %v", err)
}
id, err := svc.AddRuleByName(context.Background(), "nightly", string(TriggerCron), "@daily")
if err != nil {
t.Fatalf("AddRuleByName: %v", err)
}
rule, ok := ruleByID(svc.Engine().Rules(), id)
if !ok {
// cron 规则注册在调度器上,直接按名字从服务侧的动作跑一次即可。
out, err := svc.RunRule(context.Background(), id)
if err != nil {
t.Fatalf("RunRule: %v", err)
}
if out == nil {
t.Fatal("RunRule 没有返回输出")
}
} else if _, err := rule.Action.Execute(context.Background(), nil); err != nil {
t.Fatalf("Execute: %v", err)
}
if out := logBuf.String(); !strings.Contains(out, "payload_type=nil") {
t.Fatalf("日志 = %q,want payload_type=nil", out)
}
}
// ruleByID 在规则快照里按 id 找一条。
func ruleByID(rules []Rule, id string) (Rule, bool) {
for _, r := range rules {
if r.ID == id {
return r, true
}
}
return Rule{}, false
}