diff --git a/internal/runtime/loop/span_write_redaction_test.go b/internal/runtime/loop/span_write_redaction_test.go new file mode 100644 index 0000000..7dfd429 --- /dev/null +++ b/internal/runtime/loop/span_write_redaction_test.go @@ -0,0 +1,199 @@ +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) + } + // 归属标识反而必须原文可读(主人 2026-10-26 裁定:session 是业务标识,不是 + // 正文;排障要把一条 stderr 日志对回某个会话)。它可读与正文不可见并不矛盾, + // 这一条与上一条同时成立才是这一片的验收口径。 + if !strings.Contains(raw, `"session":"sess"`) { + t.Fatalf("JSONL 里 session 不可读:%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) +} diff --git a/internal/runtime/loop/spanlog.go b/internal/runtime/loop/spanlog.go index cabfa7b..21af8f0 100644 --- a/internal/runtime/loop/spanlog.go +++ b/internal/runtime/loop/spanlog.go @@ -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)),因为容器里可能藏着正文或凭据。键按字典序处理, diff --git a/internal/runtime/loop/tool_frame_summary.go b/internal/runtime/loop/tool_frame_summary.go index ce133b2..4786eaa 100644 --- a/internal/runtime/loop/tool_frame_summary.go +++ b/internal/runtime/loop/tool_frame_summary.go @@ -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)) } diff --git a/internal/runtime/loop/tools.go b/internal/runtime/loop/tools.go index d4ae5f2..902f328 100644 --- a/internal/runtime/loop/tools.go +++ b/internal/runtime/loop/tools.go @@ -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 diff --git a/internal/runtime/orchestration/scheduler.go b/internal/runtime/orchestration/scheduler.go index 38aa817..30e3f2f 100644 --- a/internal/runtime/orchestration/scheduler.go +++ b/internal/runtime/orchestration/scheduler.go @@ -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) diff --git a/internal/runtime/orchestration/span_write_redaction_test.go b/internal/runtime/orchestration/span_write_redaction_test.go new file mode 100644 index 0000000..ecf65fe --- /dev/null +++ b/internal/runtime/orchestration/span_write_redaction_test.go @@ -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 } diff --git a/internal/runtime/trace/digest.go b/internal/runtime/trace/digest.go new file mode 100644 index 0000000..d4429e5 --- /dev/null +++ b/internal/runtime/trace/digest.go @@ -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 } —— 「真的没有内容」与「有内容但很小」因此可分辨。 +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 + } +} diff --git a/internal/runtime/trace/graph_props.go b/internal/runtime/trace/graph_props.go index 68f3c9a..ff5c5a9 100644 --- a/internal/runtime/trace/graph_props.go +++ b/internal/runtime/trace/graph_props.go @@ -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) { diff --git a/internal/runtime/trace/json_recorder.go b/internal/runtime/trace/json_recorder.go index 3a5ddc3..f42ec12 100644 --- a/internal/runtime/trace/json_recorder.go +++ b/internal/runtime/trace/json_recorder.go @@ -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 +} diff --git a/internal/runtime/trace/redact_test.go b/internal/runtime/trace/redact_test.go index 329bfbb..928b10e 100644 --- a/internal/runtime/trace/redact_test.go +++ b/internal/runtime/trace/redact_test.go @@ -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") { diff --git a/internal/runtime/trace/span_props.go b/internal/runtime/trace/span_props.go new file mode 100644 index 0000000..03db52a --- /dev/null +++ b/internal/runtime/trace/span_props.go @@ -0,0 +1,156 @@ +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); +// - 归属:session —— span 所属的 flow 图/会话标识(`{会话}[#{路}]`)。 +// +// 表外的键**一律不出图、也不进 JSONL**。确实需要保留诊断线索的,用 Digest +// (digest.go)作为 prop 值 —— 它由本包构造,构造上不含正文,两条投影都会放行。 +// +// session 是**业务标识,不是正文**(主人 2026-10-26 裁定放行):它是排障时把 +// 一条 stderr 日志对回某个会话的唯一线索,而 `/admin/flow/graph` 本来就按会话 +// 返回、连 sessionId 都给了,因此放行它不构成新的暴露。它走 redactValue 的普通 +// 清洗(自身没有凭据形状),不做摘要 —— 变成 Digest 就失去了「对回会话」的用途。 +// +// 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", "session", +} + +// 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 +} diff --git a/internal/runtime/trace/trace_test.go b/internal/runtime/trace/trace_test.go index 506b2b6..1b30ceb 100644 --- a/internal/runtime/trace/trace_test.go +++ b/internal/runtime/trace/trace_test.go @@ -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) diff --git a/internal/runtime/trace/upstream.go b/internal/runtime/trace/upstream.go new file mode 100644 index 0000000..f8150ab --- /dev/null +++ b/internal/runtime/trace/upstream.go @@ -0,0 +1,291 @@ +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;**error.message 默认不带**,只有显式 +// opt-in(WithUpstreamMessage)才保留,且必须过 RedactText 再截到 +// maxUpstreamMessageRunes; +// - 其余**一律丢弃**:响应体原文、请求参数、请求头、provider 自带的 +// param/input/code_id 之流,绝不进入 error 的文本,也绝不进入日志; +// - Error() 与 Attrs() 是**唯二**的出口,两者都只渲染上面那些字段, +// 因此把 err 直接丢进 slog(或任何日志)是安全的。 +// +// 🔴 为什么 message 默认不带(这条取舍是**构造得出的**,不是理论担心): +// 上游把用户内容拼进 message 是真实存在的写法 —— +// `{"error":{"code":"invalid_request_error","message":"Invalid value: <用户原文>"}}` +// 若默认保留前 128 rune,那 128 rune 就会**原样**出现在 error 与日志里。M41 的 +// embedding input 是**她的记忆正文**、DashScope TTS 的 input 是**她要念的话**, +// M42 的渠道响应体同样可能回显消息内容 —— 一个「默认带 message」的模板会被复制 +// 到十几处调用点,等于把同一个洞铺开。 +// +// 因此本文件的默认口径是:**宁可少一条人话提示,也不让她的原话进日志**。 +// 排障通常不需要那句话:`status` + `code` + `type` + `body_len` + `body_sha256` +// 足够分类(400 还是 429、是 model_not_found 还是 context_length_exceeded、 +// 是不是同一份报文),而真正要看细节时,报文在**调用点**的上下文里还在。 + +// 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,**只有显式 opt-in(WithUpstreamMessage) + // 才非空**:那一刻它已压平空白、清洗凭据形状并截断到 + // maxUpstreamMessageRunes。默认构造下恒为空 —— 上游把用户内容拼进 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 +} + +// upstreamOptions 是 UpstreamError 的可选开关集合。零值 = **全部关闭**, +// 也就是「默认安全」:不带 message。 +type upstreamOptions struct { + keepMessage bool +} + +// UpstreamOption 是 UpstreamError 的可选开关。函数式选项的形状让 +// `UpstreamError(a, b, c, d)` 这个最常用的调用**天然是最安全的那个**—— +// 想放宽任何一项,都必须在这里显式写出来,代码评审时一眼可见。 +type UpstreamOption func(*upstreamOptions) + +// WithUpstreamMessage 显式要求保留上游 JSON 的 error.message(过 RedactText +// 清洗凭据形状、压平空白,再截断到 maxUpstreamMessageRunes)。 +// +// 🔴 只有在你**确认该上游不会把用户内容拼进 message** 时才可用。默认不带它是 +// 刻意的:`{"error":{"message":"Invalid value: <用户原文>"}}` 这种写法真实存在, +// 128 rune 会原样进 error 与日志;M41(embedding input = 她的记忆正文、 +// DashScope TTS input = 她要念的话)与 M42(渠道响应体)尤其不能默认打开。 +// +// 用法: +// +// err := trace.UpstreamError(host, status, ct, body, trace.WithUpstreamMessage()) +func WithUpstreamMessage() UpstreamOption { + return func(o *upstreamOptions) { o.keepMessage = true } +} + +// 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 +// **不抽**,除非显式 WithUpstreamMessage)。其余字段、以及 body 的任何一个字节 +// 都不会进入返回的错误。 +// +// 🔴 默认口径:**不带 error.message**。见文件头「为什么 message 默认不带」—— +// 宁可少一条人话提示,也不让她的原话进日志;status/code/type/bodyLen/sha256 +// 足够分类与相关性比对。确实要看那句话时,在**确认该上游不回显用户内容**的调用点 +// 加 WithUpstreamMessage(那时它仍会被 RedactText 清洗并截到 128 rune)。 +func UpstreamError(upstream string, status int, contentType string, body []byte, opts ...UpstreamOption) error { + var cfg upstreamOptions + for _, opt := range opts { + if opt != nil { + opt(&cfg) + } + } + 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 + if cfg.keepMessage { + 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 —— 正文一个字节都不留。 +// +// message 虽然在这里被解析出来,但**只有 opt-in(WithUpstreamMessage)才会往 +// 外走**;默认构造把它原样丢掉(见 UpstreamError 的默认口径)。 +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) +} diff --git a/internal/runtime/trace/urlmask.go b/internal/runtime/trace/urlmask.go new file mode 100644 index 0000000..53cbf3e --- /dev/null +++ b/internal/runtime/trace/urlmask.go @@ -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 +} diff --git a/internal/runtime/trace/write_side_redaction_test.go b/internal/runtime/trace/write_side_redaction_test.go new file mode 100644 index 0000000..d1ceeb0 --- /dev/null +++ b/internal/runtime/trace/write_side_redaction_test.go @@ -0,0 +1,497 @@ +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_KeepsSessionReadableDropsBody 是「日志里 session 可读、正文仍 +// 不可见」**同一条断言**(主人 2026-10-26 裁定放行 session): +// +// - session 是业务标识、不是正文,进白名单后必须**原文可读**(排障要把一条 +// stderr 日志对回某个会话;变成 Digest 就失去用途); +// - 同一 span 里的正文(result / text)仍必须落不到盘上 —— 这一条防的是 +// 「顺手放宽了别的东西」:白名单加一个键,正文的边界不能跟着动。 +func TestJSONRecorder_KeepsSessionReadableDropsBody(t *testing.T) { + t.Parallel() + const session = "web#lane-2" + rec := spanRec("sess1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{ + "session": session, + "result": sentinelToolBody, + "text": sentinelToolBody, + }) + + recs, raw := writeRecords(t, rec) + if !strings.Contains(raw, `"session":"`+session+`"`) { + t.Fatalf("JSONL 里 session 不可读(它是排障对回会话的唯一线索):%s", raw) + } + if strings.Contains(raw, sentinelToolBody) { + t.Fatalf("放行 session 的同时正文也落盘了:%s", raw) + } + + got := byID(recs, "sess1") + if got.Props["session"] != session { + t.Fatalf("session prop = %v, want 原文 %q", got.Props["session"], session) + } + for _, key := range []string{"result", "text"} { + if _, ok := got.Props[key]; ok { + t.Fatalf("表外 prop %q 落盘了:%+v", key, got.Props) + } + if d := omittedEntry(t, got, key); d.SHA256 == "" || d.Bytes == 0 { + t.Fatalf("omitted 里没有 %q 的体积/指纹:%+v", key, d) + } + } +} + +// 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) + } + // 默认构造连上游那句人话(error.message)也不带:它的措辞里就可能嵌着用户内容 + // (这个报文里是 'messages[0].content'),默认安全口径见上游文件头注释。 + if info.Message != "" { + t.Fatalf("默认构造的 Message = %q,want 空", info.Message) + } + // 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("upstream said: " + echoed + "") + 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()) + } +} + +// TestUpstreamError_DefaultDropsMessage 是 P2 收口的行为钉(M41/M42 的模板): +// 上游把**用户内容**拼进 error.message 时,**默认构造**必须一个字都不带出来。 +// 这不是理论担心 —— `{"error":{"message":"Invalid value: <用户原文>"}}` 是真实 +// 存在的写法,默认保留前 128 rune 就够把一段原话抄进日志。 +// +// 断言同时要求「诊断仍可辨」:status / code / type / bodyLen / bodySHA256 都还在, +// 它们才是分类与降级的依据。 +func TestUpstreamError_DefaultDropsMessage(t *testing.T) { + t.Parallel() + const sentinel = "SENTINEL-USER-INPUT-IN-MESSAGE-3c9f-她在输入框里写的话" + body := []byte(`{"error":{"code":"invalid_request_error","type":"invalid_request",` + + `"message":"Invalid value: ` + sentinel + `"}}`) + + err := UpstreamError("api.example.com", 400, "application/json", body) + var logBuf bytes.Buffer + log := slog.New(slog.NewJSONHandler(&logBuf, nil)) + log.Error("upstream call failed", "err", err) + + for _, surface := range []string{err.Error(), logBuf.String()} { + if strings.Contains(surface, sentinel) { + t.Fatalf("默认构造把 error.message 里的用户内容带出来了:%s", surface) + } + } + info := err.(*UpstreamErrorInfo) + if info.Message != "" { + t.Fatalf("默认构造的 Message = %q,want 空(message 必须显式 opt-in)", info.Message) + } + for _, want := range []string{"status=400", "invalid_request_error", "type=invalid_request", "body_bytes="} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %q,want 含 %q(丢掉 message 不等于丢掉诊断)", err.Error(), want) + } + } + if info.BodyLen != len(body) || info.BodySHA256 == "" { + t.Fatalf("info = %+v,want bodyLen/bodySHA256 仍用于相关性比对", info) + } + // Attrs() 同样不得旁路带出 message。 + if joined := strings.Join(anyToStrings(info.Attrs()), " "); strings.Contains(joined, sentinel) { + t.Fatalf("Attrs() 带出了 message 里的用户内容:%s", joined) + } +} + +// TestUpstreamError_OptInMessageIsSanitized 钉住 opt-in 侧的边界:显式 +// WithUpstreamMessage 之后拿到的 message 仍**必须**过 RedactText(凭据打码)并 +// 截断到 maxUpstreamMessageRunes —— opt-in 放宽的是「带不带人话」,不是「带多脏」。 +func TestUpstreamError_OptInMessageIsSanitized(t *testing.T) { + t.Parallel() + const secret = "abcdef1234567890" + // 凭据放在开头(截断窗口内),后面接一大段正文,验证「打码 + 截断」两件事。 + message := "lookup failed: token=" + secret + " " + strings.Repeat("详", 400) + body := []byte(`{"error":{"code":"bad","type":"invalid_request","message":"` + message + `"}}`) + + err := UpstreamError("p", 400, "application/json", body, WithUpstreamMessage()) + info := err.(*UpstreamErrorInfo) + if info.Message == "" { + t.Fatal("opt-in 之后 Message 仍为空") + } + if strings.Contains(info.Message, secret) { + t.Fatalf("opt-in 的 message 带着凭据原文:%q", info.Message) + } + if !strings.Contains(info.Message, redactedMarker) { + t.Fatalf("opt-in 的 message = %q,want 含 %s(必须走 RedactText)", info.Message, redactedMarker) + } + if n := len([]rune(info.Message)); n > maxUpstreamMessageRunes+1 { + t.Fatalf("message 长度 = %d rune,超过上限 %d", n, maxUpstreamMessageRunes) + } + if !strings.HasPrefix(info.Message, "lookup failed:") { + t.Fatalf("message = %q,want 保留上游诊断语的前缀", info.Message) + } + // opt-in 只放宽 message:凭据不许旁路进 Error() 的其它字段。 + if strings.Contains(err.Error(), secret) { + t.Fatalf("opt-in 后 Error() 带着凭据原文:%s", err.Error()) + } +} + +// 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) + } +} diff --git a/internal/services/automations/automations.go b/internal/services/automations/automations.go index 2c2b508..8697656 100644 --- a/internal/services/automations/automations.go +++ b/internal/services/automations/automations.go @@ -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) +} diff --git a/internal/services/automations/service.go b/internal/services/automations/service.go index 7a70375..dd6df16 100644 --- a/internal/services/automations/service.go +++ b/internal/services/automations/service.go @@ -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 { diff --git a/internal/services/automations/service_log_redaction_test.go b/internal/services/automations/service_log_redaction_test.go new file mode 100644 index 0000000..f5b9f82 --- /dev/null +++ b/internal/services/automations/service_log_redaction_test.go @@ -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 +}