Files
AskaEth 6d0df6a825 fix(trace): 白名单放行 session —— 归属标识可读,正文边界不动
主人 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 全绿。
2026-10-26 13:20:45 +08:00

157 lines
7.1 KiB
Go
Raw Permalink 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);
// - 归属: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
}