package trace import ( "encoding/json" "fmt" ) // maxSummaryRunes bounds how many runes of a user string / tool result are // rendered into a node label or prop summary, so sensitive or long content is // collapsed to a safe, dashboard-friendly prefix. const maxSummaryRunes = 80 // 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 { props := projectSpanProps(rec.Props) if rec.Kind != SpanKindTool { return props } 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 } // summarize renders an arbitrary value as a short text string for a prop summary. func summarize(v any) string { switch x := v.(type) { case nil: return "" case string: return x default: b, err := json.Marshal(x) if err != nil { return fmt.Sprintf("%v", x) } return string(b) } } // truncate cuts s to at most max runes, appending an ellipsis when it overflows. func truncate(s string, max int) string { if max <= 0 { return s } r := []rune(s) if len(r) <= max { return s } return string(r[:max]) + "…" }