feat(harness): 输出脱敏增强 —— 跨分片 StreamRedactor + PII,杜绝密钥碎片泄漏

原逐片脱敏有两个漏:①密钥被切成两片("sk-912cf85b"|"16d0...")逐片都不命中正则而漏检;
②贪婪正则在缓冲末尾凑够最短长度就把半截密钥提前脱敏发走、剩余字符随后明文流出(碎片泄漏)。

有状态 StreamRedactor 跨分片缓冲,切点在「原文」上定且绝不切断任何完整匹配:
- opener 暂留末尾仍在增长的疑似密钥(sk/AKIA/JWT/Bearer/手机/邮箱/长数字)
- 始终留 16B 尾窗兜底 opener 未覆盖的短模式;勿切断完整匹配(循环至稳定)
- rune 边界安全:cut 退到最近 rune 起点,中文不被切成半个发出乱码
- 暂留封顶 256B,防对抗性长串无限暂留 / O(n²)
- 新增 PII:手机号 / 邮箱 / 身份证(18 位)

3 个流式点(graph/react_agent/compose_graph)统一接入,逐片 Push + 收尾 Flush。
7 单测(跨片/逐字符 JWT/碎片回归/尾窗内匹配/干净重建/PII/无误伤),-race 干净。
live 实测:26 位密钥(曾泄漏 ijkl90mnop 碎片)与邮箱整条 [已脱敏]。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-25 16:46:08 +08:00
parent 93e9d3b195
commit 9506a82be9
6 changed files with 278 additions and 27 deletions
@@ -214,6 +214,15 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar
chunks := 0
var produced strings.Builder // 本节点产出(供下游 agent 接力)
red := harness.NewStreamRedactor() // 输出护栏:跨分片脱敏,杜绝密钥被切断而漏检
emit := func(safe string) {
if safe == "" {
return
}
_ = o.sink.PublishToken(taskID, []byte(safe))
produced.WriteString(safe)
chunks++
}
for {
chunk, rerr := sr.Recv()
if rerr == io.EOF {
@@ -226,11 +235,9 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar
if chunk.Content == "" {
continue // 工具调用片段无正文,跳过;正文只来自模型答复
}
safe, _ := harness.RedactSecrets(chunk.Content)
_ = o.sink.PublishToken(taskID, []byte(safe))
produced.WriteString(safe)
chunks++
emit(red.Push(chunk.Content))
}
emit(red.Flush()) // 吐出暂留尾部
o.recordAgentOutput(b, produced.String())
tr.emit(node, "model", "end", "ReAct 智能体",
fmt.Sprintf("%d 段输出 / %d 字", chunks, len([]rune(produced.String()))), time.Since(t0).Milliseconds())