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, 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). 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) } 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)) } } 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) { 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]) + "…" }