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 全绿。
This commit is contained in:
AskaEth committed 2026-10-26 13:17:42 +08:00
1 parent 240b59708d
commit 003d12a712
18 files changed
+1612 -71

No files matched your search

@@ -0,0 +1,193 @@
package loop
import (
"bytes"
"context"
"encoding/json"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住 M40 的工具侧写盘边界,分两层各钉一遍:
//
// - **循环层**:真跑一次循环,按 writer 收到的**原始 SpanRecord**断言 —— tool
// span 里根本没有 `result` 原文(曾经是原样落盘的工具结果全文,M38 排查出的
// 最严重一条),只有 result_summary(已脱敏、已截断,供回看面读)与
// result_digest(字节/rune/sha256 前缀);
// - **写侧**:同一份记录的 stderr JSONL 字节里搜不到正文。
//
// 两层分开断言是刻意的:只测 JSONL 的话,写侧白名单会替循环层的漏做遮挡,把
// 「span 里曾经存着正文」这件事测不出来(反过来也一样)。
//
// 口径:工具结果正文留在会话库,span 只留摘要与指纹 —— 于是「成功还是失败、
// 输出多大、两次是不是同一份」仍可分辨,脱敏不该把 span 变得没用。
// captureWriter 收下每个 span 的**原始**记录(未经写侧投影),供测试按 span 层断言。
type captureWriter struct {
mu sync.Mutex
recs []trace.SpanRecord
}
// WriteSpan 记录一份 span。
func (c *captureWriter) WriteSpan(rec trace.SpanRecord) error {
c.mu.Lock()
defer c.mu.Unlock()
c.recs = append(c.recs, rec)
return nil
}
// snapshot 返回已收集记录的副本。
func (c *captureWriter) snapshot() []trace.SpanRecord {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]trace.SpanRecord, len(c.recs))
copy(out, c.recs)
return out
}
// teeWriter 把同一份 span 同时交给「原始记录捕获器」与 JSONL 记录器,使两层
// 断言落在同一次运行上。
type teeWriter []trace.Writer
// WriteSpan 依次写入每个出口,返回第一个错误。
func (t teeWriter) WriteSpan(rec trace.SpanRecord) error {
var firstErr error
for _, w := range t {
if err := w.WriteSpan(rec); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// runToolTurnTraced 跑一个「一次工具调用 + 收尾」的回合,返回原始 span 记录与
// stderr JSONL 的原始字节。
func runToolTurnTraced(t *testing.T, toolResult any, toolErr error) ([]trace.SpanRecord, string) {
t.Helper()
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "done"}},
}}
td := dispatcherWith(t, &mockTool{name: "echo", result: toolResult, err: toolErr})
a := newTestAgent(t, ad, td)
var buf bytes.Buffer
capture := &captureWriter{}
writer := teeWriter{capture, trace.NewJSONRecorder(&buf)}
l := New(Options{Agent: a, Tracer: trace.New(writer), MaxRounds: 8})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
t.Fatalf("Run: %v", err)
}
return capture.snapshot(), buf.String()
}
// TestRun_ToolResultNeverEntersSpan 是循环层的泄漏钉:工具结果里有一段哨兵正文,
// span 的 props 与 stderr JSONL 里都搜不到它。
//
// 哨兵刻意放在**摘要窗口之外**:结果摘要(result_summary,回看面要读的那份)按
// 设计就是结果的有界前缀(80 rune),它是摘要、不是正文 —— 本测试钉的是「整段
// 结果不再落盘」,因此不能把断言的前提设成「连摘要都不许有」。
func TestRun_ToolResultNeverEntersSpan(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-TOOL-BODY-3b7d-工具结果的后半段"
result := strings.Repeat("拿到的报表行:", 40) + sentinel
recs, raw := runToolTurnTraced(t, result, nil)
span := toolSpanOf(t, recs)
if _, ok := span.Props["result"]; ok {
t.Fatalf("tool span 仍带着 result 原文:%+v", span.Props)
}
if _, ok := span.Props["text"]; ok {
t.Fatalf("tool span 带着 text 原文:%+v", span.Props)
}
propsBlob := mustMarshal(t, span.Props)
if strings.Contains(propsBlob, sentinel) {
t.Fatalf("tool span 的 props 带着正文:%s", propsBlob)
}
digest, ok := span.Props["result_digest"].(trace.Digest)
if !ok {
t.Fatalf("tool span 没有 result_digest:%+v", span.Props)
}
if digest.Bytes != len(result) {
t.Fatalf("result_digest.Bytes = %d, want %d(输出大小必须仍可读)", digest.Bytes, len(result))
}
summary, _ := span.Props["result_summary"].(string)
if summary == "" {
t.Fatalf("result_summary 缺席:回看面会失去结果摘要:%+v", span.Props)
}
if n := len([]rune(summary)); n > 81 {
t.Fatalf("result_summary 长度 = %d rune,want 有界(摘要不是正文)", n)
}
// 写侧:同一份记录落盘后仍搜不到正文,也不含任何结果原文的长尾。
if strings.Contains(raw, sentinel) {
t.Fatalf("工具结果正文落进了 JSONL:%s", raw)
}
if strings.Contains(raw, strings.Repeat("拿到的报表行:", 16)) {
t.Fatalf("JSONL 里出现了超出一份摘要的结果原文:%s", raw)
}
}
// TestRun_ToolSpanStillTellsOKFromError 是「脱敏不能把 span 变得没用」的验收钉:
// 成功与失败在落盘上仍可分辨 —— 终态不同、失败的带已脱敏的 error、输出体积可读。
func TestRun_ToolSpanStillTellsOKFromError(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
small := "ok"
large := strings.Repeat("失", 500)
okRecs, okRaw := runToolTurnTraced(t, small, nil)
badRecs, badRaw := runToolTurnTraced(t, nil, &toolFailErr{msg: "boom: token=" + secret + " " + large})
okSpan, badSpan := toolSpanOf(t, okRecs), toolSpanOf(t, badRecs)
if okSpan.Status != trace.SpanStatusOK || badSpan.Status != trace.SpanStatusError {
t.Fatalf("status = %q / %q,成功与失败必须仍可分辨", okSpan.Status, badSpan.Status)
}
okDigest, _ := okSpan.Props["result_digest"].(trace.Digest)
if okDigest.Bytes != len(small) {
t.Fatalf("成功调用的 result_digest.Bytes = %d, want %d", okDigest.Bytes, len(small))
}
// 失败侧写盘面:错误原因仍可读("boom"),凭据已打码,长度有界。
for _, surface := range []string{okRaw, badRaw} {
if strings.Contains(surface, secret) {
t.Fatalf("JSONL 带着凭据原文:%s", surface)
}
}
if !strings.Contains(badRaw, "boom") {
t.Fatalf("失败落盘里没有可诊断的原因:%s", badRaw)
}
if strings.Contains(badRaw, large) {
t.Fatalf("失败落盘里带着整段错误文本:%s", badRaw)
}
}
// toolFailErr 是一个只提供 Error() 的工具失败(模拟工具自己拼的错误文本)。
type toolFailErr struct{ msg string }
func (e *toolFailErr) Error() string { return e.msg }
// toolSpanOf 在记录里取那条工具 span。
func toolSpanOf(t *testing.T, recs []trace.SpanRecord) trace.SpanRecord {
t.Helper()
for _, rec := range recs {
if rec.Name == "tool.echo" {
return rec
}
}
t.Fatalf("记录里没有 tool.echo span:%+v", recs)
return trace.SpanRecord{}
}
// mustMarshal 把一段东西序列化成 JSON 文本,用于「搜不到正文」的断言。
func mustMarshal(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return string(b)
}
+21
View File
@@ -126,6 +126,27 @@ func (l *Loop) recordToolDone(span *trace.Span, tc agent.ToolCall, out ToolOutco
_ = span.Record(trace.Event{Type: EventToolDone, Props: props})
}
// recordToolResult 把一次**成功**调用的结果落成两样东西,都不是正文:
//
// - result_summary:与 `tool_execution.end` 帧**逐字同源**的已脱敏、已截断
// 摘要(toolFrameResultSummary,同一条 trace.RedactText 路径)—— 回看面
// `toolCalls[].resultSummary` 读的就是它;
// - result_digest:字节数 / rune 数 / 内容 sha256 前缀(trace.DigestOfValue)。
//
// 🔴 原文绝不进 span:工具结果正文本来就留在会话库里,span 与 stderr 的 JSONL
// 都不需要它,而它们正是最容易扩散的两个落盘面(一次 stderr tail 就能把整段
// 工具输出拷走)。排查「这次调用拿到了多大一份东西、两次是不是同一份」靠
// Digest 就够了。
func recordToolResult(span *trace.Span, result any) {
if span == nil {
return
}
if summary := toolFrameResultSummary(result); summary != "" {
_ = span.SetProp("result_summary", summary)
}
_ = span.SetProp("result_digest", trace.DigestOfValue(result))
}
// argSummary 生成工具参数的「值摘要」:只放行 argValueKeys 表内的键,且
// 密钥类键一律不给值(trace.IsSecretKey 与出图清洗同一判据);非标量值只给
// 占位(数组(N)/对象(N)),因为容器里可能藏着正文或凭据。键按字典序处理,
+6 -6
View File
@@ -26,15 +26,15 @@ import (
// toolFrameResultSummary 把工具返回值渲染成 loop 侧的结果摘要文本,口径与图上那份
// `props.result_summary`(读回看面的数据源)**逐字一致**。
//
// 图侧的取值链是:tool span 的 `result` prop → `trace.safeSpanProps` →
// `RedactText(summarize(result))`。`RedactText` 是导出 API(凭据形状清洗 + 截断,
// 连同密钥键名防线都在 trace/redact.go),但 `summarize` 只服务出图、未导出,
// 且 `internal/runtime/trace/**` 不在本切片文件面内 —— 因此这里按同一口径重述那一步
// 渲染(nil ⇒ 空、字符串原样、其余 JSON 编码,编码失败才回落 %v)。
// 图侧的取值链是:tool span 的 `result_summary` prop(由 spanlog.go 的
// recordToolResult 用**本函数**写入)→ `trace.safeSpanProps` 的白名单投影 →
// 节点 `props.result_summary`。因此两侧不再是「各自重述同一步渲染」,而是同一个
// 生产函数:span 里不再存结果原文(那正是 M40 修掉的写侧泄漏),出图也不可能
// 再从原文反推 —— 摘要只在这里生成一次。
//
// 🔴 两边必须逐字一致:由 `tool_frame_summary_test.go` 用**真建图器**
// (`trace.RunGraph` + 真 span → 反读图上那份 `result_summary`)逐字比对来钉住 ——
// 照字面量写断言会与实现一起漂移。若哪天 trace 导出了这一步渲染,这里应改为直接调用。
// 照字面量写断言会与实现一起漂移。
func toolFrameResultSummary(result any) string {
return trace.RedactText(renderToolResultSummary(result))
}
+1 -1
View File
@@ -321,7 +321,7 @@ func (l *Loop) invokeOne(ctx context.Context, d *tool.Dispatcher, tc agent.ToolC
l.spanProp(toolSpan, "error", err.Error())
l.spanProp(toolSpan, "error_kind", out.ErrKind)
} else {
l.spanProp(toolSpan, "result", result)
recordToolResult(toolSpan, result)
}
l.finishSpan(toolSpan, spanStatusForToolPhase(phase))
return out
+15 -7
View File
@@ -394,21 +394,29 @@ func (s *Scheduler) subSpan(ctx context.Context, sub *SubAgent) (context.Context
}
// subagentEventDone 是子代理运行落定时记在其 span 上的日志事件类型,使面板节点带上
// 结果摘要(design/19 §1 节点实时日志)。
// 结果的元数据(状态/步数/回复体积与指纹,绝无正文;design/19 §1 节点实时日志)。
const subagentEventDone = "subagent.done"
// spanOK 把子代理 span 以 ok 收尾,记录结果文本与一条脱敏的结果事件,随后把已关闭的
// span 折回父 flow。
// spanOK 把子代理 span 以 ok 收尾,记录**结果的体积与指纹**(不是正文)与一条同口径
// 的结果事件,随后把已关闭的 span 折回父 flow。
//
// 🔴 子代理的回复正文不进 span:它本来就要留在会话库(那是主人要读的对话),
// span 与 stderr JSONL 只是落盘的诊断面 —— 把整段回复再抄一遍,等于给一次
// stderr tail 就能拷走全部对话开了口子(M40 修掉的最严重一条泄漏)。这里留
// text_digest(字节/rune/sha256 前缀)与事件里的 status/steps/chars/sha256,
// 足以分辨「这次子代理答了多久、答了多少、是不是同一份」,却拿不到一个字。
func (s *Scheduler) spanOK(sub *SubAgent, span *trace.Span, res *loop.Result) {
if span == nil {
return
}
if res != nil {
_ = span.SetProp("text", res.Text)
digest := trace.DigestOf(res.Text)
_ = span.SetProp("text_digest", digest)
_ = span.Record(trace.Event{Type: subagentEventDone, Props: map[string]any{
"status": string(StatusDone),
"steps": res.Steps,
"summary": compactText(res.Text, summaryTextMax),
"status": string(StatusDone),
"steps": res.Steps,
"chars": digest.Runes,
"sha256": digest.SHA256,
}})
}
span.Finish(trace.SpanStatusOK)
@@ -0,0 +1,176 @@
package orchestration
import (
"bytes"
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住 M40 的子代理侧写盘边界:子代理的**整段回复**曾经以 span prop
// (`text`)与 `subagent.done` 事件值(`summary`)两种形态原样落到 stderr 的
// JSONL 上 —— 一次 `tail` 就能拷走整段对话。现在落盘只有元数据:
// text_digest(字节/rune/sha256 前缀)与事件里的 status/steps/chars/sha256。
//
// 🔴 断言打在落盘字节上(真派发 + 真 tracer + 真 JSONRecorder),不打在
// 「我们没调用 SetProp("text")」这种实现细节上:以后谁把正文塞回来,这里就红。
// spannedScheduler 建一个带 tracer 的调度器:span 同时进「原始记录捕获器」与
// stderr JSONL 记录器,使编排层与写侧能各自被断言(只测 JSONL 的话,写侧白名单
// 会替编排层的漏做遮挡)。
func spannedScheduler(t *testing.T) (*Scheduler, *captureWriter, *bytes.Buffer) {
t.Helper()
capture := &captureWriter{}
buf := &bytes.Buffer{}
tr := trace.New(teeWriter{capture, trace.NewJSONRecorder(buf)})
return newScheduler(t, Options{MaxParallel: 1, Tracer: tr}), capture, buf
}
// captureWriter 收下每个 span 的**原始**记录(未经写侧投影)。
type captureWriter struct {
mu sync.Mutex
recs []trace.SpanRecord
}
// WriteSpan 记录一份 span。
func (c *captureWriter) WriteSpan(rec trace.SpanRecord) error {
c.mu.Lock()
defer c.mu.Unlock()
c.recs = append(c.recs, rec)
return nil
}
// subagent 返回已捕获记录里的那条子代理 span。
func (c *captureWriter) subagent(t *testing.T) trace.SpanRecord {
t.Helper()
c.mu.Lock()
defer c.mu.Unlock()
for _, rec := range c.recs {
if rec.Kind == trace.SpanKindSubagent {
return rec
}
}
t.Fatalf("捕获记录里没有 subagent span:%+v", c.recs)
return trace.SpanRecord{}
}
// teeWriter 把同一份 span 交给多个出口,返回第一个错误。
type teeWriter []trace.Writer
// WriteSpan 依次写入每个出口。
func (t teeWriter) WriteSpan(rec trace.SpanRecord) error {
var firstErr error
for _, w := range t {
if err := w.WriteSpan(rec); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// TestSpawn_SubagentReplyNeverLandsInJSONL 是最直接的泄漏钉:编排层收到的原始
// span 里没有回复原文,落盘的 JSONL 里也没有。
func TestSpawn_SubagentReplyNeverLandsInJSONL(t *testing.T) {
t.Parallel()
const reply = "SENTINEL-SUBAGENT-REPLY-2f91-她说的一整段话"
s, capture, buf := spannedScheduler(t)
s.runner = &mockRunner{result: &loop.Result{Text: reply, Steps: 3, StopReason: "stop"}}
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "查一下天气"})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, func() bool { return sub.Status() == StatusDone }, waitTimeout)
raw := buf.String()
if strings.Contains(raw, reply) {
t.Fatalf("子代理回复原文落进了 JSONL:%s", raw)
}
// 编排层:span 的 props 里没有 text 原文,只有 text_digest。
span := capture.subagent(t)
if _, ok := span.Props["text"]; ok {
t.Fatalf("子代理 span 仍带着 text 原文:%+v", span.Props)
}
digest, ok := span.Props["text_digest"].(trace.Digest)
if !ok {
t.Fatalf("子代理 span 没有 text_digest:%+v", span.Props)
}
if digest.Runes != len([]rune(reply)) {
t.Fatalf("text_digest.Runes = %d, want %d(回复体积必须仍可读)", digest.Runes, len([]rune(reply)))
}
if digest.Bytes != len(reply) {
t.Fatalf("text_digest.Bytes = %d, want %d", digest.Bytes, len(reply))
}
}
// TestSpawn_SubagentDoneEventCarriesMetadataOnly 钉住事件侧:`subagent.done` 只带
// 状态/步数/字符数/指纹,不再带一段回复摘要 —— 摘要也是正文。断言打在**编排层收到的
// 原始事件**上(写侧另有自己的投影,不能替这一层担保)。
func TestSpawn_SubagentDoneEventCarriesMetadataOnly(t *testing.T) {
t.Parallel()
const reply = "SENTINEL-SUBAGENT-DONE-77aa"
s, capture, buf := spannedScheduler(t)
s.runner = &mockRunner{result: &loop.Result{Text: reply, Steps: 2}}
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "go"})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, func() bool { return sub.Status() == StatusDone }, waitTimeout)
if raw := buf.String(); strings.Contains(raw, reply) {
t.Fatalf("事件里带着子代理回复:%s", raw)
}
rec := capture.subagent(t)
if len(rec.Events) != 1 || rec.Events[0].Type != subagentEventDone {
t.Fatalf("events = %+v, want one %s", rec.Events, subagentEventDone)
}
props := rec.Events[0].Props
if _, ok := props["summary"]; ok {
t.Fatalf("subagent.done 仍带 summary(摘要也是正文):%+v", props)
}
if _, ok := props["text"]; ok {
t.Fatalf("subagent.done 仍带 text:%+v", props)
}
if props["status"] != string(StatusDone) || props["chars"] == nil || props["sha256"] == nil || props["steps"] == nil {
t.Fatalf("subagent.done props = %+v,want status/steps/chars/sha256", props)
}
if got := props["chars"]; got != len([]rune(reply)) {
t.Fatalf("chars = %v, want %d(回复体积必须仍可读)", got, len([]rune(reply)))
}
}
// TestSpawn_SubagentFailureStillDiagnosable 钉住失败侧仍可诊断:落盘里仍能看出
// 原因("boom"),且不带凭据原文 —— 脱敏不等于把 span 变哑。
func TestSpawn_SubagentFailureStillDiagnosable(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
s, capture, buf := spannedScheduler(t)
s.runner = &mockRunner{err: &runnerErr{msg: "boom: token=" + secret}}
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "go"})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, func() bool { return sub.Status() == StatusFailed }, waitTimeout)
raw := buf.String()
if strings.Contains(raw, secret) {
t.Fatalf("JSONL 带着凭据原文:%s", raw)
}
if !strings.Contains(raw, "boom") {
t.Fatalf("失败落盘里没有可诊断的原因:%s", raw)
}
if span := capture.subagent(t); span.Status != trace.SpanStatusError {
t.Fatalf("子代理 span 终态 = %q, want error", span.Status)
}
}
// runnerErr 是只提供 Error() 的运行失败(模拟子代理内部/上游拼出的错误文本)。
type runnerErr struct{ msg string }
func (e *runnerErr) Error() string { return e.msg }
+80
View File
@@ -0,0 +1,80 @@
package trace
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"unicode/utf8"
)
// maxDigestHex 是 Digest.SHA256 保留的十六进制位数(16 位 = 8 字节 / 64 bit)。
// 它足够把同一次运行里的两段不同内容区分开(相关性排查够用),又短到不可能据以
// 反推正文;再短的碰撞面就不值得了。
const maxDigestHex = 16
// Digest 是一个值的「体积 + 指纹」:字节数、rune 数、内容 sha256 前缀。
//
// 它是本包对「脱敏后还要留下可诊断线索」这一要求的答案 —— 正文被丢弃的地方
// (写侧 JSONL、span 上的结果/回复)留下一个 Digest,于是「这次调用的输出有多大、
// 两次是不是同一份内容」仍可判断,而正文本身不出现。
//
// 🔴 Digest **不含正文**,因此可以原样穿过本包的两条投影(出图投影与写侧投影)。
// 注意指纹的边界:sha256 只能证明「两份不同的内容指纹不同」,对**低熵**取值
// (例如一个短口令)它不构成保密承诺 —— 但它是截断前缀且不与正文同行,攻击者
// 拿不到「指纹 ↔ 明文」的对照面。
type Digest struct {
// Bytes 是取值编码后的字节数。
Bytes int `json:"bytes"`
// Runes 是字符串取值的 rune 数(非字符串取值为 0,随即省略)。
Runes int `json:"runes,omitempty"`
// SHA256 是内容 sha256 的十六进制前缀(maxDigestHex 位)。
SHA256 string `json:"sha256"`
}
// DigestOf 对字符串取摘要:字节数、rune 数与内容 sha256 前缀。空串给出
// {0 0 <sha256("")前缀>} —— 「真的没有内容」与「有内容但很小」因此可分辨。
func DigestOf(s string) Digest {
sum := sha256.Sum256([]byte(s))
return Digest{
Bytes: len(s),
Runes: utf8.RuneCountInString(s),
SHA256: hex.EncodeToString(sum[:])[:maxDigestHex],
}
}
// DigestOfValue 对任意值取摘要。字符串按原文取摘要(不走 JSON 编码,避免引号
// 把体积算大);其余值先 JSON 编码(与出图/摘要渲染同一口径),编码失败才回落
// %v —— 与 renderToolResultSummary 的取舍一致,使「体积」这个词在两侧同一含义。
func DigestOfValue(v any) Digest {
switch x := v.(type) {
case nil:
return Digest{}
case string:
return DigestOf(x)
case []byte:
return DigestOf(string(x))
default:
b, err := json.Marshal(x)
if err != nil {
return DigestOf(fmt.Sprintf("%v", x))
}
return DigestOf(string(b))
}
}
// digestFrom 报告 v 是否已经是一个 Digest —— 即一个「构造上就不含正文」的值。
// 两条投影据此原样放行它(再摘要一次只会得到「摘要的摘要」,纯属噪音)。
func digestFrom(v any) (Digest, bool) {
switch x := v.(type) {
case Digest:
return x, true
case *Digest:
if x == nil {
return Digest{}, false
}
return *x, true
default:
return Digest{}, false
}
}
+28 -49
View File
@@ -10,61 +10,40 @@ import (
// collapsed to a safe, dashboard-friendly prefix.
const maxSummaryRunes = 80
// safeSpanProps carries only a whitelist of span props to the frontend, plus a
// desensitized tool result summary. It never forwards raw arguments or secrets, and
// every string is scrubbed of credential shapes and truncated, so long or sensitive
// content cannot leak a full payload (design/19 §3).
// safeSpanProps carries only a whitelist of span props to the frontend. It never
// forwards raw arguments, raw tool results or secrets, and every string is
// scrubbed of credential shapes and truncated, so long or sensitive content
// cannot leak a full payload (design/19 §3).
//
// 白名单与投影都是**共享**的(span_props.go:safePropKeys + projectSpanProps),
// 与写侧 stderr JSONL 用的是同一份 —— 读侧多放一个键,写侧就会跟着多放一个,
// 不存在「某一边悄悄开始吐正文」的漂移面。
//
// 工具节点的 result_summary 有两条来源,优先级明确:
// 1. **生产侧直接写**的那个 prop(loop/recordToolResult 用 toolFrameResultSummary
// 写入的已脱敏、已截断文本,与 tool_execution.end 帧逐字同源)—— 正常路径;
// 2. 存量退路:某个生产者仍把结果原文放在 `result` 里时,这里只为它合成一份
// **经 RedactText 清洗 + 截断**的摘要。合成出来的只是摘要,原文本身绝不投影,
// 因此这条退路不构成新的泄漏面(写侧投影会把 `result` 整体折成 Digest)。
func safeSpanProps(rec SpanRecord) map[string]any {
var props map[string]any
for _, k := range safePropKeys {
v, ok := rec.Props[k]
if !ok {
continue
}
if props == nil {
props = make(map[string]any)
}
props[k] = redactValue(v)
props := projectSpanProps(rec.Props)
if rec.Kind != SpanKindTool {
return props
}
if rec.Kind == SpanKindTool {
if v, ok := rec.Props["result"]; ok {
if props == nil {
props = make(map[string]any)
}
props["result_summary"] = RedactText(summarize(v))
}
if _, ok := props["result_summary"]; ok {
return props
}
v, ok := rec.Props["result"]
if !ok {
return props
}
if props == nil {
props = make(map[string]any, 1)
}
props["result_summary"] = RedactText(summarize(v))
return props
}
// safePropKeys are the span prop keys forwarded to a node's props.
//
// 键分五类,全部非敏感(值仍会逐个经 redactValue 清洗):
//
// - 既有:stop_reason / step / error / error_kind / tool / persona;
// - tool 参数摘要(裁决 11.4.3):args_keys(全量参数名)、args_summary
// (仅放行键的值,长度已在 loop/spanlog.go 侧截断),密钥类键的值绝不在其中;
// - guardrail(裁决 11.4.2):allow(裁决结果)、action(检查名)、elapsed_ms(耗时);
// - memory(裁决 11.4.2):hits / count / failed(命中、写入与失败条数)、
// bytes(注入块字节数)、scope / action(记忆作用域与操作名)—— 只记元数据,
// 不含任何记忆正文;
// - orchestration(裁决 11.4.2):subagent(被派发的子代理 id)。
//
// persona 是子代理分支根节点上的「目标角色 id」:orchestration/scheduler.go 只在
// subagent_spawn 显式指定角色时才设置它,且该 id 必须先通过角色注册表校验
// (ErrUnknownPersona 会拒绝未注册的 id,orchestration/persona.go),因此它是注册表里
// 的角色标识,不是用户内容、不是提示词、更不是凭据——同一个 id 本来就随 GET /personas
// 与会话材料对外可见。它是 Docs/api-client.md §3.5(v0.47)对客户端的承诺
// (design/22 §8-2:让流程图显示「这个子代理是以谁的身份在替班」),漏出白名单就会被
// safeSpanProps 静默丢掉。未指定角色派发时该键不存在,符合 v0.47「纯增量」的约定。
var safePropKeys = []string{
"stop_reason", "step", "error", "error_kind", "tool", "persona",
"args_keys", "args_summary",
"allow", "action", "elapsed_ms",
"hits", "count", "failed", "bytes", "scope",
"subagent",
}
// summarize renders an arbitrary value as a short text string for a prop summary.
func summarize(v any) string {
switch x := v.(type) {
+27 -2
View File
@@ -18,6 +18,14 @@ type Writer interface {
// JSONRecorder writes each completed span as one line of JSON (JSONL) to an
// underlying io.Writer. It is safe for concurrent use.
//
// 🔴 它是**脱敏边界**,不是搬运工:stderr 的 JSONL 是无主人在场的落盘面(会被
// 日志采集、tail、粘贴进工单),所以 WriteSpan 在落盘前把记录投影一遍 ——
// props 走共享白名单(与出图同一张表,span_props.go),表外键只留 Digest;事件
// 先经 RedactEvent(凭据形状打码 + 截断),正文键的值再折成 Digest。这样即使上游
// 某天又往 span 里塞了正文(工具结果、子代理回复、上游报文),它也到不了磁盘上。
//
// 端口/文件格式不变:一行一个 span 对象,只是这一行里不再有正文。
type JSONRecorder struct {
mu sync.Mutex
enc *json.Encoder
@@ -30,12 +38,29 @@ func NewJSONRecorder(out io.Writer) *JSONRecorder {
}
// WriteSpan encodes rec as JSON followed by a newline. Concurrent calls are
// serialized so a single span object is never interleaved with another.
// serialized so a single span object is never interleaved with another. rec 在
// 编码前先经 SanitizeRecord 投影 —— 落盘的永远是脱敏后的形态。
func (r *JSONRecorder) WriteSpan(rec SpanRecord) error {
r.mu.Lock()
defer r.mu.Unlock()
if err := r.enc.Encode(rec); err != nil {
if err := r.enc.Encode(SanitizeRecord(rec)); err != nil {
return fmt.Errorf("trace: encode span %q: %w", rec.ID, err)
}
return nil
}
// SanitizeRecord 返回 rec 的写侧形态:props 走白名单投影(表外键折成 Digest),
// events 逐条脱敏(凭据形状打码 + 截断 + 正文键折成 Digest)。它不改动调用方持有
// 的 rec 的其它字段(id/name/kind/status/时间),因此脱敏后的记录仍能按链路串起来。
//
// 导出是因为「写出去」的出口不止一个:stderr 的 JSONL 由 *JSONRecorder 负责,
// 而网关的 trace 环形缓冲(admin 可观测面)、离线导出等出口都拿同一份 SpanRecord。
// 它们应当复用**同一个**投影,而不是各写一份 —— 各写一份必然漂移,漂移的方向总是
// 「某个出口开始原样吐正文」。当前 HTTP 响应形状受 M40 红线冻结,那些出口尚未接入;
// 接入时应直接调用本函数。
func SanitizeRecord(rec SpanRecord) SpanRecord {
out := rec
out.Props = writeSpanProps(rec.Props)
out.Events = writeSpanEvents(rec.Events)
return out
}
+14 -3
View File
@@ -84,10 +84,18 @@ func TestRedactEventScrubsNestedValues(t *testing.T) {
// TestNodePropsAndLogAreRedacted asserts the published graph — both a tool node's
// result_summary prop and its log events — never carries a credential. This is the
// design/19 §3 red line at the last point before publication.
//
// M40 之后 result_summary 是**生产侧**写入的已脱敏摘要(loop/recordToolResult),
// 读侧只做白名单投影,不再从结果原文反推。因此这里同时钉住两件事:白名单键
// 的值仍被逐层清洗;而任何「结果原文」形态的 prop(表外的 result)根本不出图,
// 哪怕某个生产者又把它塞了回来。
func TestNodePropsAndLogAreRedacted(t *testing.T) {
t.Parallel()
rec := spanRec("t1", "step1", "tool.http", SpanKindTool, SpanStatusOK,
map[string]any{"result": map[string]any{"body": `{"api_key":"sk-abcdef0123456789"}`}})
map[string]any{
"result_summary": `{"api_key":"sk-abcdef0123456789"}`,
"result": map[string]any{"body": `{"api_key":"sk-abcdef0123456789"}`},
})
rec.Events = []Event{{
Type: "tool.done",
Props: map[string]any{
@@ -112,8 +120,11 @@ func TestNodePropsAndLogAreRedacted(t *testing.T) {
if !strings.Contains(string(blob), redactedMarker) {
t.Fatalf("published graph carries no redaction marker: %s", blob)
}
if got := node.Props["result_summary"]; !strings.Contains(got.(string), redactedMarker) {
t.Fatalf("result_summary prop = %v, want it scrubbed", got)
if got, ok := node.Props["result_summary"].(string); !ok || !strings.Contains(got, redactedMarker) {
t.Fatalf("result_summary prop = %v, want it scrubbed", node.Props["result_summary"])
}
if _, ok := node.Props["result"]; ok {
t.Fatalf("表外 prop result 出图了:%+v(读侧只投影白名单)", node.Props)
}
// The round's own input/output labels are scrubbed as well.
if in := nodeByID(g, "input").Label; strings.Contains(in, "abcdef1234567890") {
+150
View File
@@ -0,0 +1,150 @@
package trace
import "strings"
// 本文件是 span props 的**唯一**白名单与投影口径。
//
// 读侧(出图:graph_props.go 的 safeSpanProps)与写侧(stderr JSONL:
// json_recorder.go 的 WriteSpan)共用同一张 safePropKeys 与同一个
// projectSpanProps。这是刻意的:两处各写一份必然漂移,而漂移的方向总是
// 「其中一侧开始把正文原样吐出去」—— 本仓已经有过一次这样的病害(读侧有
// 白名单,写侧没有,于是工具结果与子代理回复原样落进 stderr 的 JSONL)。
// safePropKeys are the span prop keys forwarded to a node's props.
//
// 键分六类,全部非敏感(值仍会逐个经 redactValue 清洗):
//
// - 既有:stop_reason / step / error / error_kind / tool / persona;
// - tool 参数摘要(裁决 11.4.3):args_keys(全量参数名)、args_summary
// (仅放行键的值,长度已在 loop/spanlog.go 侧截断),密钥类键的值绝不在其中;
// - tool 结果摘要:result_summary —— 由 loop 生产侧(toolFrameResultSummary,
// 与 tool_execution.end 帧逐字同源)写入的**已脱敏、已截断**文本,从不含
// 结果原文;回看面 toolCalls[].resultSummary 读的就是它;
// - guardrail(裁决 11.4.2):allow(裁决结果)、action(检查名)、elapsed_ms(耗时);
// - memory(裁决 11.4.2):hits / count / failed(命中、写入与失败条数)、
// bytes(注入块字节数)、scope / action(记忆作用域与操作名)—— 只记元数据,
// 不含任何记忆正文;
// - orchestration(裁决 11.4.2):subagent(被派发的子代理 id)。
//
// 表外的键**一律不出图、也不进 JSONL**。确实需要保留诊断线索的,用 Digest
// (digest.go)作为 prop 值 —— 它由本包构造,构造上不含正文,两条投影都会放行。
//
// persona 是子代理分支根节点上的「目标角色 id」:orchestration/scheduler.go 只在
// subagent_spawn 显式指定角色时才设置它,且该 id 必须先通过角色注册表校验
// (ErrUnknownPersona 会拒绝未注册的 id,orchestration/persona.go),因此它是注册表里
// 的角色标识,不是用户内容、不是提示词、更不是凭据——同一个 id 本来就随 GET /personas
// 与会话材料对外可见。它是 Docs/api-client.md §3.5(v0.47)对客户端的承诺
// (design/22 §8-2:让流程图显示「这个子代理是以谁的身份在替班」),漏出白名单就会被
// safeSpanProps 静默丢掉。未指定角色派发时该键不存在,符合 v0.47「纯增量」的约定。
var safePropKeys = []string{
"stop_reason", "step", "error", "error_kind", "tool", "persona",
"args_keys", "args_summary", "result_summary",
"allow", "action", "elapsed_ms",
"hits", "count", "failed", "bytes", "scope",
"subagent",
}
// safePropKeySet 是 safePropKeys 的查表形态。它在包初始化时由同一张表构造一次,
// 因此「加了一个键却忘了查表用」不可能发生 —— 改表即改两侧。
var safePropKeySet = buildSafePropKeySet()
// buildSafePropKeySet 把白名单折叠成集合。
func buildSafePropKeySet() map[string]bool {
set := make(map[string]bool, len(safePropKeys))
for _, k := range safePropKeys {
set[k] = true
}
return set
}
// omittedPropKey 是写侧把「被丢弃的 props」折叠成的唯一键名:它的值是一张
// {原键名 → Digest} 的表。写侧宁可这样留线索,也不把原值留下 —— 于是 JSONL 里
// 「这次工具调用成功没有、输出多大」仍看得出来,正文却不在。
const omittedPropKey = "omitted"
// projectSpanProps 把一张 span props 投影成可安全出镜的副本:
//
// - 白名单键(safePropKeys)→ 经 redactValue 清洗(凭据形状打码 + 截断);
// - Digest 值 → 原样保留(构造上不含正文,且再摘要一次只会得到噪音);
// - 其余键 → 丢弃。
//
// 空输入返回 nil,使节点/记录上不会出现空对象。
func projectSpanProps(props map[string]any) map[string]any {
if len(props) == 0 {
return nil
}
out := make(map[string]any, len(props))
for k, v := range props {
if d, ok := digestFrom(v); ok {
out[k] = d
continue
}
if !safePropKeySet[k] {
continue
}
out[k] = redactValue(v)
}
if len(out) == 0 {
return nil
}
return out
}
// contentEventKeys 是「这个键的值按定义就是正文」的名单。事件 props 没有(也不该
// 有)一份像 safePropKeys 那样的白名单 —— 事件是各步自己的日志,键随步骤变;但
// 「正文键」是有限的,写侧把它们**换成 Digest**(留键、留体积、留指纹,去正文),
// 于是即使某个生产者又把整段话塞进事件,它也到不了磁盘上。
//
// 只做精确键名匹配(不做子串):result_summary / args_summary 这类**已脱敏的摘要**
// 必须原样留下,它们是诊断主力。
var contentEventKeys = map[string]bool{
"text": true, "result": true, "reply": true, "answer": true,
"body": true, "content": true, "prompt": true, "summary": true,
"messages": true, "output": true, "delta": true,
}
// writeSpanEvents 是事件侧的写侧投影:先过 RedactEvent(凭据形状打码 + 截断),
// 再把正文键的值换成 Digest。返回 `[]Event` 的副本,绝不改调用方的记录。
func writeSpanEvents(events []Event) []Event {
out := redactEvents(events)
for i := range out {
for k, v := range out[i].Props {
if _, ok := digestFrom(v); ok {
continue
}
if !contentEventKeys[strings.ToLower(strings.TrimSpace(k))] {
continue
}
out[i].Props[k] = DigestOfValue(v)
}
}
return out
}
// writeSpanProps 是写侧(stderr JSONL)的投影:与出图投影同一张白名单,
// 差别只在**表外键的处理**——出图直接丢弃,写侧把它的体积与指纹收进
// omitted,好让事后排查仍能回答「这次调用有多大输出」。
//
// 表外键的名字本身也过一遍凭据形状清洗:键名通常是代码里写死的(result / text /
// prompt),但 props 是 map[string]any,动态键名并非不可能 —— 宁可多洗一次。
func writeSpanProps(props map[string]any) map[string]any {
out := projectSpanProps(props)
for k, v := range props {
if _, ok := digestFrom(v); ok {
continue
}
if safePropKeySet[k] {
continue
}
if out == nil {
out = make(map[string]any)
}
omitted, ok := out[omittedPropKey].(map[string]any)
if !ok {
omitted = make(map[string]any)
out[omittedPropKey] = omitted
}
omitted[redactSecrets(k)] = DigestOfValue(v)
}
return out
}
+9 -2
View File
@@ -256,8 +256,15 @@ func TestJSONRecorder_Output(t *testing.T) {
if want := childRec.EndedAt.Sub(childRec.StartedAt); childRec.Duration != want {
t.Fatalf("childRec.Duration = %v, want EndedAt-StartedAt = %v", childRec.Duration, want)
}
if childRec.Props["model"] != "gpt-test" {
t.Fatalf("childRec.Props = %+v, want model key", childRec.Props)
if _, ok := childRec.Props["model"]; ok {
t.Fatalf("childRec.Props = %+v,表外键 model 被原样落盘了(写侧只投影白名单)", childRec.Props)
}
omitted, ok := childRec.Props[omittedPropKey].(map[string]any)
if !ok {
t.Fatalf("childRec.Props = %+v,表外键没有折进 %q", childRec.Props, omittedPropKey)
}
if _, ok := omitted["model"]; !ok {
t.Fatalf("omitted = %+v,want 一条 model 的 Digest(脱敏后仍要留诊断线索)", omitted)
}
if len(childRec.Events) != 1 || childRec.Events[0].Type != "prompt" {
t.Fatalf("childRec.Events = %+v, want one 'prompt' event", childRec.Events)
+236
View File
@@ -0,0 +1,236 @@
package trace
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
)
// 本文件是**上游错误体的唯一收敛点**(M40 地基)。推理侧(M41/M42 的 provider
// 适配、模型路由降级、工具/渠道的上游调用)不许各写一份「截断一下响应体再
// 拼进 error」的实现 —— 那样的实现会漂移,而漂移的方向总是「某一家 provider
// 开始把响应体原文吐进错误与日志」。
//
// 🔴 契约(改本文件前先读):
// - 只保留:upstream 名 / status / contentType / bodyLen / body sha256 前缀 /
// JSON 里的 error.code / error.type / 截断到 maxUpstreamMessageRunes 的
// error.message;
// - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的
// param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志;
// - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段,
// 因此把 err 直接丢进 slog(或任何日志)是安全的。
// maxUpstreamMessageRunes 是上游 error.message 允许保留的 rune 上限。128 rune
// 够看清「上游为什么拒绝」,又短到不足以把一段用户正文整段带出来;超出部分
// 截断并标省略号(宁可丢信息,不可丢边界)。
const maxUpstreamMessageRunes = 128
// maxUpstreamLabelRunes 是 upstream 名保留的 rune 上限(它是代码里写死的名字
// 或主机名,本来就很短;上界只是防御)。
const maxUpstreamLabelRunes = 80
// ErrUpstream 是「上游返回了错误」这一类错误的哨兵,供调用方
// errors.Is(err, trace.ErrUpstream) 分类降级(主 → 备 → 本地)。
var ErrUpstream = errors.New("trace: upstream error")
// UpstreamErrorInfo 是一次上游失败**可安全出镜**的全部信息。它不含响应体原文,
// 也不含任何请求侧内容 —— 字段本身就是脱敏后的口径。
type UpstreamErrorInfo struct {
// Upstream 是上游标识(provider 名或主机,见 HostOf)。
Upstream string
// Status 是 HTTP 状态码(0 表示没有拿到状态码,例如连接中途断开)。
Status int
// ContentType 是响应的媒体类型(已去掉 charset 等参数)。
ContentType string
// BodyLen 是响应体的字节数(体积可诊断,内容不留)。
BodyLen int
// BodySHA256 是响应体 sha256 的十六进制前缀,用于相关性比对(同一份报文能
// 认出来,正文拿不到)。
BodySHA256 string
// Code 是上游 JSON 里 error.code(字符串或数字;数字按十进制渲染)。
Code string
// Type 是上游 JSON 里 error.type。
Type string
// Message 是上游 JSON 里 error.message,已清洗凭据形状、压平空白并截断。
Message string
}
// Error 渲染一句**只含上述字段**的单行诊断。可诊断性来自 status/code/type/
// bodyLen/bodySHA256,绝不来自响应体原文。
func (e *UpstreamErrorInfo) Error() string {
var b strings.Builder
b.WriteString("upstream error")
if e.Upstream != "" {
b.WriteString(" from ")
b.WriteString(e.Upstream)
}
if e.Status != 0 {
fmt.Fprintf(&b, ": status=%d", e.Status)
} else {
b.WriteString(": status=unknown")
}
if e.ContentType != "" {
b.WriteString(" content_type=")
b.WriteString(e.ContentType)
}
fmt.Fprintf(&b, " body_bytes=%d", e.BodyLen)
if e.BodySHA256 != "" {
b.WriteString(" body_sha256=")
b.WriteString(e.BodySHA256)
}
if e.Code != "" {
b.WriteString(" code=")
b.WriteString(e.Code)
}
if e.Type != "" {
b.WriteString(" type=")
b.WriteString(e.Type)
}
if e.Message != "" {
b.WriteString(" message=")
b.WriteString(strconv.Quote(e.Message))
}
return b.String()
}
// Unwrap 让 errors.Is(err, ErrUpstream) 成立,调用方据此走降级链而不必解析文本。
func (e *UpstreamErrorInfo) Unwrap() error { return ErrUpstream }
// Attrs 把同一份脱敏信息渲染成 slog 的键值对,供结构化日志直接展开
// (s.log.Error("provider call failed", info.Attrs()...))。它与 Error() 同源,
// 因此不存在「文本里干净、日志里脏」的第二条路径。
func (e *UpstreamErrorInfo) Attrs() []any {
attrs := []any{
"upstream", e.Upstream,
"status", e.Status,
"content_type", e.ContentType,
"body_bytes", e.BodyLen,
"body_sha256", e.BodySHA256,
}
if e.Code != "" {
attrs = append(attrs, "code", e.Code)
}
if e.Type != "" {
attrs = append(attrs, "type", e.Type)
}
if e.Message != "" {
attrs = append(attrs, "message", e.Message)
}
return attrs
}
// UpstreamError 把一次上游失败收敛成一个**绝不携带响应体原文**的错误。
//
// 用法(M41/M42 的用法模板):
//
// err := trace.UpstreamError(trace.HostOf(reqURL), resp.StatusCode,
// resp.Header.Get("Content-Type"), body)
// if errors.Is(err, trace.ErrUpstream) { ...降级到备用 provider... }
// s.log.Error("upstream call failed", err) // 安全:Error() 只渲染脱敏字段
//
// body 可以是任意字节(JSON、HTML、代理页、二进制):它只被用来数长度、取
// sha256 前缀,并在看起来是 JSON 时抽取 error.code / error.type / error.message。
// 其余字段、以及 body 的任何一个字节都不会进入返回的错误。
//
// 🔴 边界(调用方必须知道):error.message 是上游自己写的诊断语,本函数最多保留
// 128 rune。上游若把用户正文拼进 message(少数实现会这么做),那 128 rune 会随之
// 出来 —— 因此调用方**不得**在把响应体交给本函数之前再自行拼接任何请求内容,
// 也不得把 message 之外的字段(param / input / code_id …)自行补进错误。
func UpstreamError(upstream string, status int, contentType string, body []byte) error {
info := &UpstreamErrorInfo{
Upstream: sanitizeUpstreamLabel(upstream),
Status: status,
ContentType: sanitizeContentType(contentType),
BodyLen: len(body),
}
if len(body) > 0 {
info.BodySHA256 = DigestOf(string(body)).SHA256
}
if parsed, ok := parseUpstreamErrorBody(body); ok {
info.Code = parsed.Code
info.Type = parsed.Type
info.Message = sanitizeUpstreamMessage(parsed.Message)
}
return info
}
// upstreamErrorBody 是**唯一**从响应体里读出的结构:只有 error 的三个字段,
// 其余字段(param / input / code_id / 请求回显 …)根本不会被解码到任何地方。
type upstreamErrorBody struct {
Code string
Type string
Message string
}
// parseUpstreamErrorBody 尽力从 JSON 响应体里抽出 error.code / error.type /
// error.message。不是 JSON(HTML 错误页、纯文本、被截断的报文)⇒ ok=false,
// 此时错误里只有 status/contentType/bodyLen/bodySHA256 —— 正文一个字节都不留。
func parseUpstreamErrorBody(body []byte) (upstreamErrorBody, bool) {
if len(body) == 0 {
return upstreamErrorBody{}, false
}
var envelope struct {
Error struct {
Code json.RawMessage `json:"code"`
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return upstreamErrorBody{}, false
}
parsed := upstreamErrorBody{
Code: normalizeUpstreamCode(envelope.Error.Code),
Type: sanitizeUpstreamMessage(envelope.Error.Type),
Message: envelope.Error.Message,
}
if parsed.Code == "" && parsed.Type == "" && parsed.Message == "" {
return upstreamErrorBody{}, false
}
return parsed, true
}
// normalizeUpstreamCode 把 error.code 归一成短字符串:JSON 字符串取原值,
// JSON 数字取十进制文本(Google 风格用 int code),其它形态一律丢弃。
func normalizeUpstreamCode(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return sanitizeUpstreamMessage(s)
}
var n json.Number
if err := json.Unmarshal(raw, &n); err == nil {
return sanitizeUpstreamMessage(n.String())
}
return ""
}
// sanitizeUpstreamMessage 是上游**自由文本**的唯一清洗路径:压平空白(一条诊断
// 只占一行,也顺带挡掉换行注入),清洗凭据形状,再截断到
// maxUpstreamMessageRunes。
func sanitizeUpstreamMessage(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.Join(strings.Fields(s), " ")
return truncate(redactSecrets(s), maxUpstreamMessageRunes)
}
// sanitizeUpstreamLabel 清洗 upstream 名/主机:不允许它成为第二正文通道。
func sanitizeUpstreamLabel(s string) string {
return truncate(redactSecrets(strings.TrimSpace(s)), maxUpstreamLabelRunes)
}
// sanitizeContentType 只保留媒体类型本体(丢掉 charset/boundary 等参数),
// 并清洗凭据形状 —— Content-Type 的参数偶尔会带 provider 自造的 token。
func sanitizeContentType(s string) string {
if i := strings.IndexByte(s, ';'); i >= 0 {
s = s[:i]
}
return truncate(redactSecrets(strings.TrimSpace(s)), maxUpstreamLabelRunes)
}
+128
View File
@@ -0,0 +1,128 @@
package trace
import (
"net/url"
"strings"
)
// 本文件是「URL 只以脱敏形态进日志」的两个唯一工具。它们的职责分开且都不回原串:
//
// - HostOf:只回来源(scheme + host),用于「这次请求打到哪」;
// - MaskQuery:回一个可读的 URL,但凭据类 query 值(OAuth token、签名、API key)
// 已被打码,用于排查「带查询参数的请求」。
//
// 为什么要专门有两个函数:平台的资源地址几乎都带签名(预签名 URL、CDN token、
// OAuth 回调),把 URL 原串写进日志等于把凭据写进日志 —— 而日志会被采集、tail、
// 粘进工单,是最容易扩散的面。任何「顺手 log 一下 URL」的地方都必须经过这里。
//
// 与 mediafetch.HostOf 的关系:那个是「入站附件取回」的同口径工具,但它落在
// channel 层(带 HTTP 客户端与来源白名单),trace 不能反向依赖它,否则观测量
// 就会拖着一个渠道下载器。因此这里是**零依赖的那一份**,口径刻意与它一致
// (只回 scheme://host、绝不回路径与查询串);差异只有一处:带 user:pass@ 时
// 本函数把用户信息整段打码成 [redacted],而 mediafetch 直接丢掉用户信息。
// 两者都不回凭据,因此不存在「一份安全一份不安全」的分叉;将来若要把
// mediafetch.HostOf 改成本函数的薄封装,属于 channel 层的事(不在本切片文件面内)。
// maskedQueryMarker 是被打码的 query 值。它刻意**不含**需要转义的字符,因此
// 打码后的 URL 仍是肉眼可读的形状(access_token=***),而不是一堆 %2A。
const maskedQueryMarker = "***"
// maskedUserInfo 替换 URL 里整段 user:pass@ 用户信息。宁可丢掉用户名也不半遮半掩
// ——半遮半掩的实现最容易在某个分支上漏出密码。
const maskedUserInfo = "[redacted]"
// secretQueryMarkers 是「这个 query/片段参数的值是凭据」的判据:键名归一后
// 命中 IsSecretKey,或含这些子串(覆盖 sig/sign/signature/hmac/credential 等
// 签名类参数 —— 预签名 URL 的授权就在这些值里)。
var secretQueryMarkers = []string{"token", "secret", "key", "sig", "sign", "credential", "password", "passwd"}
// HostOf 返回 rawURL 的来源(scheme://host,含端口),用于日志里的「打到哪」。
//
// 三条口径:
// - 绝不回完整 URL:路径与查询串一律不出(它们才带内容与签名);
// - 带 user:pass@ 时**整段用户信息打码**(maskedUserInfo),因此
// "https://user:pass@h/x" 得到 "https://[redacted]@h";
// - 解析不出 host(相对路径、空串、畸形串)时返回空串 —— 绝不回落到原串。
func HostOf(rawURL string) string {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u.Host == "" {
return ""
}
return maskedOrigin(u)
}
// MaskQuery 返回 rawURL 的脱敏形态:凭据类 query / 片段参数的值被替换为
// maskedQueryMarker,其余原样保留(保序、不重新编码,便于肉眼比对)。
//
// 三条口径:
// - 解析不出 host 时返回空串 —— 绝不回落到原串(回落就是泄漏);
// - user:pass@ 同样整段打码(MaskQuery 的回串也是要进日志的);
// - 片段(fragment)按同一套判据打码:OAuth 隐式流把 access_token 放在 # 之后。
func MaskQuery(rawURL string) string {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u.Host == "" {
return ""
}
var b strings.Builder
b.WriteString(maskedOrigin(u))
b.WriteString(u.EscapedPath())
if masked := maskPairs(u.RawQuery); masked != "" {
b.WriteString("?")
b.WriteString(masked)
}
if u.Fragment != "" {
b.WriteString("#")
b.WriteString(maskPairs(u.Fragment))
}
return b.String()
}
// maskedOrigin 渲染 scheme://host,用户信息存在时整段打码。
func maskedOrigin(u *url.URL) string {
base := "//"
if u.Scheme != "" {
base = u.Scheme + "://"
}
if u.User != nil {
return base + maskedUserInfo + "@" + u.Host
}
return base + u.Host
}
// maskPairs 对形如 a=1&b=2 的串逐个参数打码:键归一后命中凭据判据的参数,
// 其值整段替换为 maskedQueryMarker。没有 "=" 的段(裸标记)原样保留。
func maskPairs(raw string) string {
if raw == "" {
return raw
}
parts := strings.Split(raw, "&")
for i, part := range parts {
key, _, ok := strings.Cut(part, "=")
if !ok {
continue
}
if isSecretQueryKey(key) {
parts[i] = key + "=" + maskedQueryMarker
}
}
return strings.Join(parts, "&")
}
// isSecretQueryKey 报告某个 query/fragment 参数名是否命名了凭据。键先做 URL 解码
// 与大小写/分隔符归一,因此 access%5Ftoken 与 ACCESS-TOKEN 同样命中。
func isSecretQueryKey(key string) bool {
decoded, err := url.QueryUnescape(key)
if err != nil {
decoded = key
}
k := strings.NewReplacer("-", "_", ".", "_", "+", "_").Replace(strings.ToLower(strings.TrimSpace(decoded)))
if IsSecretKey(k) {
return true
}
for _, marker := range secretQueryMarkers {
if strings.Contains(k, marker) {
return true
}
}
return false
}
@@ -0,0 +1,395 @@
package trace
import (
"bytes"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
)
// 本文件钉住 M40 的**写侧**(stderr JSONL)边界:写下去的每一行都可以被
// `tail`/采集/粘进工单,因此它只允许携带脱敏后的投影 —— 正文、工具结果原文、
// 子代理回复原文、上游响应体原文,一个字节都不许出现。
//
// 🔴 断言一律打在**落盘输出/序列化结果**上(JSONL 字节、slog 输出),
// 不打在 error.Error() 的措辞上:措辞会随实现变,泄漏不会。
// sentinelToolBody 是「工具结果正文」的哨兵:只要它出现在落盘里,就说明写侧
// 又把正文抄了一遍。
const sentinelToolBody = "SENTINEL-TOOL-RESULT-9f3a2b-一段不该落盘的正文"
// writeRecords 把若干 span 记录经 JSONRecorder 写进缓冲区并解码回来。它直接用
// 记录(而不是跑一次真循环)是为了让「写侧投影」这件事在 trace 包内自证;
// 端到端那条链由 loop/orchestration 两个包的测试钉住。
func writeRecords(t *testing.T, recs ...SpanRecord) ([]SpanRecord, string) {
t.Helper()
var buf bytes.Buffer
rec := NewJSONRecorder(&buf)
for _, r := range recs {
if err := rec.WriteSpan(r); err != nil {
t.Fatalf("WriteSpan: %v", err)
}
}
return decodeLines(t, buf.Bytes()), buf.String()
}
// TestJSONRecorder_DropsRawContentAndKeepsDigest 是写侧投影的主钉:
// - 表外的 `result` / `text`(正文形态)绝不落盘;
// - 白名单键仍落盘(图上与日志里要能看见 tool / error 之类的元数据);
// - 被丢弃的键折进 omitted,留下 bytes/runes/sha256 —— 于是「同一次工具调用
// 成功还是失败、输出多大」仍可分辨(这是「脱敏不能把 span 变得没用」的验收点);
// - 字符串值一律过 RedactText(凭据形状打码)。
func TestJSONRecorder_DropsRawContentAndKeepsDigest(t *testing.T) {
t.Parallel()
okRec := spanRec("s1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{
"tool": "fs.read",
"result": sentinelToolBody,
"result_summary": "SENTINEL-TOOL",
"result_digest": DigestOf(sentinelToolBody),
})
badRec := spanRec("s2", "", "tool.fs.read", SpanKindTool, SpanStatusError, map[string]any{
"tool": "fs.read",
"result": strings.Repeat("错", 400),
"error": "read failed: token=abcdef1234567890",
"text": sentinelToolBody,
"subagent": "sa-1",
})
recs, raw := writeRecords(t, okRec, badRec)
if strings.Contains(raw, sentinelToolBody) {
t.Fatalf("写侧 JSONL 里出现了工具结果/子代理回复原文:%s", raw)
}
if strings.Contains(raw, "abcdef1234567890") {
t.Fatalf("写侧 JSONL 里出现了凭据原文(字符串值没走 RedactText):%s", raw)
}
if n := strings.Count(strings.TrimSpace(raw), "\n") + 1; n != 2 {
t.Fatalf("落盘行数 = %d, want 2(一行一个 span): %s", n, raw)
}
okSpan, badSpan := byID(recs, "s1"), byID(recs, "s2")
// 表外键不得以原值出现。
for _, key := range []string{"result", "text"} {
if _, ok := badSpan.Props[key]; ok {
t.Fatalf("表外 prop %q 落盘了:%+v", key, badSpan.Props)
}
}
// 白名单键照旧落盘:tool / result_summary / error / subagent。
if okSpan.Props["tool"] != "fs.read" || okSpan.Props["result_summary"] != "SENTINEL-TOOL" {
t.Fatalf("白名单键没落盘:%+v", okSpan.Props)
}
if _, ok := badSpan.Props["subagent"]; !ok {
t.Fatalf("白名单键 subagent 没落盘:%+v", badSpan.Props)
}
if got, _ := badSpan.Props["error"].(string); !strings.Contains(got, redactedMarker) {
t.Fatalf("error prop = %v,凭据没被打码", badSpan.Props["error"])
}
// 诊断线索:成功/失败可分辨(status + Digest),输出大小可分辨(bytes)。
if okSpan.Status != SpanStatusOK || badSpan.Status != SpanStatusError {
t.Fatalf("status = %q/%q,成功与失败必须仍可分辨", okSpan.Status, badSpan.Status)
}
okDropped := omittedEntry(t, okSpan, "result")
badDropped := omittedEntry(t, badSpan, "result")
if okDropped.Bytes != len(sentinelToolBody) {
t.Fatalf("omitted.result.bytes = %d, want %d(输出大小必须仍可读)", okDropped.Bytes, len(sentinelToolBody))
}
if badDropped.Bytes != len(strings.Repeat("错", 400)) {
t.Fatalf("omitted.result.bytes = %d, want %d", badDropped.Bytes, len(strings.Repeat("错", 400)))
}
if okDropped.SHA256 == "" || okDropped.SHA256 == badDropped.SHA256 {
t.Fatalf("omitted.result.sha256 = %q/%q,want 两份不同内容的指纹不同", okDropped.SHA256, badDropped.SHA256)
}
// Digest 型 prop(生产侧明确声明「这不是正文」)原样落盘,且不再被再摘要一次。
if got, ok := okSpan.Props["result_digest"].(map[string]any); !ok {
t.Fatalf("result_digest 没落盘:%+v", okSpan.Props)
} else if int(got["bytes"].(float64)) != len(sentinelToolBody) {
t.Fatalf("result_digest.bytes = %v, want %d", got["bytes"], len(sentinelToolBody))
}
}
// TestJSONRecorder_SubagentReplyNeverLands 钉住子代理回复这条路径:整段回复
// 曾经以 `text` prop 与 `summary` 事件值的形式原样落盘(M40 修掉的最严重一条)。
// 现在 span 上只有 text_digest,事件上只有 chars/sha256。
func TestJSONRecorder_SubagentReplyNeverLands(t *testing.T) {
t.Parallel()
const reply = "SENTINEL-SUBAGENT-REPLY-77c1-她说的整段话"
rec := spanRec("sa", "", "subagent.sa-1", SpanKindSubagent, SpanStatusOK, map[string]any{
"text": reply,
"text_digest": DigestOf(reply),
})
rec.Events = []Event{{Type: "subagent.done", Props: map[string]any{
"status": "done",
"steps": 2,
"chars": len([]rune(reply)),
"sha256": DigestOf(reply).SHA256,
"text": reply,
}}}
recs, raw := writeRecords(t, rec)
if strings.Contains(raw, reply) {
t.Fatalf("写侧 JSONL 里出现了子代理回复原文:%s", raw)
}
got := byID(recs, "sa")
if _, ok := got.Props["text"]; ok {
t.Fatalf("子代理 span 的 text prop 落盘了:%+v", got.Props)
}
if d, ok := got.Props["text_digest"].(map[string]any); !ok || d["sha256"] == "" {
t.Fatalf("text_digest 缺失:%+v", got.Props)
}
if n := len(got.Events); n != 1 {
t.Fatalf("events = %d, want 1", n)
}
if ev := got.Events[0]; ev.Props["text"] != nil {
// 正文键的值必须已被换成 Digest(键留在原处,值不含正文)。
d, ok := ev.Props["text"].(map[string]any)
if !ok || d["sha256"] == "" {
t.Fatalf("事件里的正文键没折成 Digest:%+v", ev.Props)
}
}
}
// TestUpstreamError_DropsEchoedBody 是共享 helper 的行为钉(M41/M42 的用法模板):
// 一个**回显请求正文**的 422 报文,经 UpstreamError 收敛后,错误文本与日志里都
// 搜不到那段回显,但 status / code / type 仍可辨。
func TestUpstreamError_DropsEchoedBody(t *testing.T) {
t.Parallel()
const echoedInput = "SENTINEL-USER-INPUT-5d2e-用户在聊天框里说的话"
body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` +
`"message":"Invalid value for 'messages[0].content'."},` +
`"param":"messages[0].content","input":"` + echoedInput + `",` +
`"echo":{"messages":[{"role":"user","content":"` + echoedInput + `"}]}}`)
err := UpstreamError("api.openai.example", 422, "application/json; charset=utf-8", body)
var buf bytes.Buffer
log := slog.New(slog.NewJSONHandler(&buf, nil))
log.Error("upstream call failed", "err", err)
text := err.Error()
for _, surface := range []string{text, buf.String()} {
if strings.Contains(surface, echoedInput) {
t.Fatalf("响应体回显的输入原样出现了:%s", surface)
}
if strings.Contains(surface, "messages[0].content") && strings.Contains(surface, "role") {
t.Fatalf("响应体的非白名单字段被带出来了:%s", surface)
}
}
for _, want := range []string{"status=422", "invalid_request_error", "type=invalid_request", "api.openai.example"} {
if !strings.Contains(text, want) {
t.Fatalf("error = %q,want 含 %q(诊断信息不能一起丢掉)", text, want)
}
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("errors.Is(err, ErrUpstream) = false;降级链无法按类别判断")
}
info, ok := err.(*UpstreamErrorInfo)
if !ok {
t.Fatalf("err 类型 = %T, want *UpstreamErrorInfo", err)
}
if info.BodyLen != len(body) || info.ContentType != "application/json" || info.BodySHA256 == "" {
t.Fatalf("info = %+v,want bodyLen/contentType/sha256 作为诊断线索", info)
}
// Attrs() 是日志侧的第二出口,必须与 Error() 同源(同样不含正文)。
attrs := info.Attrs()
if joined := strings.TrimSpace(strings.Join(anyToStrings(attrs), " ")); strings.Contains(joined, echoedInput) {
t.Fatalf("Attrs() 带出了回显正文:%s", joined)
}
}
// TestUpstreamError_NonJSONBodyKeepsOnlyMeta 钉住「非 JSON 也绝不回落原文」:
// HTML 错误页 / 代理错误页 / 被截断的报文只留 status 与体积指纹。
func TestUpstreamError_NonJSONBodyKeepsOnlyMeta(t *testing.T) {
t.Parallel()
const echoed = "SENTINEL-PROXY-ECHO-0b71"
body := []byte("<html><body>upstream said: " + echoed + "</body></html>")
err := UpstreamError("", 502, "text/html", body)
if strings.Contains(err.Error(), echoed) {
t.Fatalf("非 JSON 响应体原文进了错误:%s", err.Error())
}
if !strings.Contains(err.Error(), "status=502") || !strings.Contains(err.Error(), "body_bytes=") {
t.Fatalf("error = %q,want status 与 body_bytes 仍在", err.Error())
}
}
// TestUpstreamErrorMessageIsBounded 钉住 message 的上限:上游把一大段东西写进
// error.message 时,进错误的只有前 128 rune。
func TestUpstreamErrorMessageIsBounded(t *testing.T) {
t.Parallel()
body := []byte(`{"error":{"code":"bad","message":"` + strings.Repeat("详", 400) + `"}}`)
err := UpstreamError("p", 400, "application/json", body)
info := err.(*UpstreamErrorInfo)
if n := len([]rune(info.Message)); n > maxUpstreamMessageRunes+1 {
t.Fatalf("message 长度 = %d rune,超过上限 %d", n, maxUpstreamMessageRunes)
}
if !strings.HasPrefix(info.Message, "详") {
t.Fatalf("message = %q,want 保留上游诊断语的前缀", info.Message)
}
}
// TestHostOf_DropsPathQueryAndUserInfo 钉住「日志里的 URL 只有来源」。
func TestHostOf_DropsPathQueryAndUserInfo(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{"普通 URL", "https://cdn.example.com/a/b?token=SECRET&x=1", "https://cdn.example.com"},
{"带端口", "http://api.example.com:8443/v1/chat", "http://api.example.com:8443"},
{"带 user:pass", "https://user:pass@h/x", "https://[redacted]@h"},
{"相对路径", "/a/b?token=SECRET", ""},
{"畸形串", "http://[::1", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := HostOf(tc.in)
if got != tc.want {
t.Fatalf("HostOf(%q) = %q, want %q", tc.in, got, tc.want)
}
for _, leak := range []string{"SECRET", "pass", "user", "/a/"} {
if strings.Contains(got, leak) {
t.Fatalf("HostOf(%q) = %q 泄漏了 %q", tc.in, got, leak)
}
}
})
}
}
// TestMaskQuery_MasksCredentialParams 钉住「查询串里的凭据值被打码」:OAuth token、
// 预签名 URL 的签名、API key 一律不明文出现,其余参数保持可读。
func TestMaskQuery_MasksCredentialParams(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wantHas []string
leaks []string
}{
{
name: "access_token 打码",
in: "https://api.example.com/v1/x?access_token=xxxSECRETxxx&page=2",
wantHas: []string{"access_token=***", "page=2"},
leaks: []string{"xxxSECRETxxx"},
},
{
name: "user:pass 整体打码",
in: "https://user:pass@h/x?api_key=k3ySECRET&sig=SIGSECRET",
wantHas: []string{"[redacted]@h", "api_key=***"},
leaks: []string{"pass", "k3ySECRET", "SIGSECRET"},
},
{
name: "片段里的 token 同样打码",
in: "https://h/cb#access_token=FRAGSECRET&state=ok",
wantHas: []string{"access_token=***", "state=ok"},
leaks: []string{"FRAGSECRET"},
},
{
name: "非 URL 不回原串",
in: "token=RAW&x=1",
leaks: []string{"RAW"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := MaskQuery(tc.in)
for _, want := range tc.wantHas {
if !strings.Contains(got, want) {
t.Fatalf("MaskQuery(%q) = %q, want 含 %q", tc.in, got, want)
}
}
for _, leak := range tc.leaks {
if strings.Contains(got, leak) {
t.Fatalf("MaskQuery(%q) = %q 泄漏了 %q", tc.in, got, leak)
}
}
})
}
}
// TestJSONRecorder_DoesNotMutateCallerRecord 钉住投影是「拷贝」而非「就地改」:
// 调用方(例如 gateway 的 trace 环形缓冲出口)仍拿得到完整记录。
func TestJSONRecorder_DoesNotMutateCallerRecord(t *testing.T) {
t.Parallel()
rec := spanRec("m1", "", "tool.x", SpanKindTool, SpanStatusOK, map[string]any{"result": sentinelToolBody})
if _, raw := writeRecords(t, rec); strings.Contains(raw, sentinelToolBody) {
t.Fatalf("写侧泄漏了原文:%s", raw)
}
if got, _ := rec.Props["result"].(string); got != sentinelToolBody {
t.Fatalf("调用方记录被就地改写了:%+v", rec.Props)
}
}
// TestSanitizeRecordKeepsIdentity 钉住「脱敏不动身份」:id/parent/name/kind/status
// 与时间戳原样保留,否则落盘的链路就串不起来了。
func TestSanitizeRecord_KeepsIdentity(t *testing.T) {
t.Parallel()
rec := spanRec("c1", "p1", "llm.step2", SpanKindLLM, SpanStatusError, map[string]any{"prompt": "正文"})
out := SanitizeRecord(rec)
if out.ID != rec.ID || out.ParentID != rec.ParentID || out.Name != rec.Name ||
out.Kind != rec.Kind || out.Status != rec.Status ||
!out.StartedAt.Equal(rec.StartedAt) || !out.EndedAt.Equal(rec.EndedAt) {
t.Fatalf("sanitizeRecord 改动了身份字段:%+v vs %+v", out, rec)
}
if _, ok := out.Props["prompt"]; ok {
t.Fatalf("表外 prop prompt 落盘了:%+v", out.Props)
}
if _, err := json.Marshal(out); err != nil {
t.Fatalf("脱敏后的记录不可编码:%v", err)
}
}
// byID 在解码后的记录里按 id 找一条,找不到即失败。
func byID(recs []SpanRecord, id string) SpanRecord {
for _, r := range recs {
if r.ID == id {
return r
}
}
panic("span record not found: " + id)
}
// omittedEntry 反读某条记录 omitted 表里被丢弃的键的 Digest。
func omittedEntry(t *testing.T, rec SpanRecord, key string) Digest {
t.Helper()
omitted, ok := rec.Props[omittedPropKey].(map[string]any)
if !ok {
t.Fatalf("props 里没有 %q 表:%+v", omittedPropKey, rec.Props)
}
entry, ok := omitted[key].(map[string]any)
if !ok {
t.Fatalf("%s 表里没有 %q 的 Digest:%+v", omittedPropKey, key, omitted)
}
return Digest{
Bytes: int(entry["bytes"].(float64)),
Runes: int(entry["runes"].(float64)),
SHA256: entry["sha256"].(string),
}
}
// anyToStrings 把 Attrs() 的键值对压成字符串切片,便于做「搜不到」断言。
func anyToStrings(attrs []any) []string {
out := make([]string, 0, len(attrs))
for _, a := range attrs {
out = append(out, strings.Join(strings.Fields(toString(a)), " "))
}
return out
}
// toString 渲染一个 Attr 值(只用于测试断言)。
func toString(v any) string {
switch x := v.(type) {
case string:
return x
case nil:
return ""
default:
b, err := json.Marshal(x)
if err != nil {
return ""
}
return string(b)
}
}
@@ -188,3 +188,15 @@ func resolveNow(f func() time.Time) func() time.Time {
}
return f
}
// payloadKind 返回触发 payload 的 Go 类型名,用于日志里替代 payload 本身。
// 类型名是代码事实(*gateway.ChatEvent 之类),正文才是敏感物 —— 排查「这条路
// 上跑的是什么消息」靠类型名与体积够了,不需要内容。
//
// 它**不**调用 payload 的 String()/Error():那正是把正文请进日志的常见口子。
func payloadKind(payload any) string {
if payload == nil {
return "nil"
}
return fmt.Sprintf("%T", payload)
}
+15 -1
View File
@@ -7,6 +7,8 @@ import (
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Service composes the Scheduler (cron), the Engine (event rules) and the
@@ -177,9 +179,21 @@ func (s *Service) CancelReminder(ctx context.Context, id string) error {
// for cron rules or a bus subject pattern for event rules. A malformed rule or
// expression returns an error wrapping ErrInvalidRule / ErrInvalidCron so the
// admin handler can answer honestly instead of fabricating a success.
//
// 🔴 日志只记触发面的元数据(规则名 / 订阅的 subject / payload 的类型、体积与
// 指纹),绝不记 payload 本身:绑到 `ws.chat`、`subagent.result` 这类 subject
// 上时,payload 里就是**她的正文**。原先这里 `%v` 整个 payload 打日志,等于把
// 一次对话原样抄进日志(M40 修掉的泄漏之一)。
func (s *Service) AddRuleByName(ctx context.Context, name, kind, expression string) (string, error) {
act, err := NewFuncAction(name, func(_ context.Context, payload any) (any, error) {
s.log.Info("admin automation fired", "name", name, "payload", payload)
digest := trace.DigestOfValue(payload)
s.log.Info("admin automation fired",
"name", name,
"subject", expression,
"payload_type", payloadKind(payload),
"payload_bytes", digest.Bytes,
"payload_sha256", digest.SHA256,
)
return map[string]any{"fired": name, "payload": payload}, nil
})
if err != nil {
@@ -0,0 +1,106 @@
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
}