主人 2026-10-26 裁定:session 是**业务标识**,不是正文。它是排障时把一条 stderr 日志对回某个会话的唯一线索;而 /admin/flow/graph 本来就按会话返回、连 sessionId 都给了,图里多一个 session 不构成新的暴露。因此把 session 加进共享白名单 (safePropKeys,走 redactValue 的普通清洗),不再被写侧折成 Digest。 「不放宽白名单」仍是原则:本次只放行这一个键,正文的边界一个字没动 —— - trace 侧新增 TestJSONRecorder_KeepsSessionReadableDropsBody,**一条用例同时钉 两件相反的事**:JSONL 里 `"session":"web#lane-2"` 原文可读;同一 span 里的 result / text 哨兵正文仍然搜不到,且只在 omitted 里留下体积与指纹。 - loop 侧真循环用例补一句断言:落盘里 `"session":"sess"` 可读,而工具结果哨兵 同时仍不可见。 - 变异验证:把 session 从白名单摘掉,上述两处都会变红(session 落到 omitted)。 回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿; trace/loop/orchestration 另跑 -race 全绿。
157 lines
7.1 KiB
Go
157 lines
7.1 KiB
Go
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
|
||
}
|