Files
Cyrene_v2/internal/runtime/trace/span_props.go
T
AskaEth 003d12a712 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 全绿。
2026-10-26 13:17:42 +08:00

151 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}