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 全绿。
396 lines
15 KiB
Go
396 lines
15 KiB
Go
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)
|
||
}
|
||
}
|