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
+9 -8
View File
@@ -275,19 +275,19 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy
msgs, _ := buildMessages(ctx, rc)
tr.emit(node, "model", "start", "模型流式推理", "", 0)
t0 := time.Now()
n, redacted := 0, 0
n := 0
var produced strings.Builder // 本节点自身产出(用于沿图向下游传递)
send := func(s string) {
if s == "" {
// 输出护栏:有状态流式脱敏,跨分片缓冲,杜绝密钥被切断而漏检(逐片正则会漏跨片密钥)。
red := harness.NewStreamRedactor()
emit := func(safe string) {
if safe == "" {
return
}
// 输出护栏:发射前逐片脱敏疑似密钥/令牌(流式无法回收已发,故逐片处理)。
safe, hit := harness.RedactSecrets(s)
redacted += hit
_ = o.sink.PublishToken(taskID, []byte(safe))
produced.WriteString(safe)
n++
}
send := func(s string) { emit(red.Push(s)) }
var err error
if o.pool.Ready() {
err = o.pool.ChatStream(ctx, toChatMessages(msgs), send)
@@ -303,8 +303,9 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy
}
return
}
if redacted > 0 {
tr.info(node, "system", "输出护栏", fmt.Sprintf("已脱敏 %d 处疑似密钥/令牌", redacted))
emit(red.Flush()) // 吐出暂留的尾部(最后一段疑似密钥的判定)
if red.Hits() > 0 {
tr.info(node, "system", "输出护栏", fmt.Sprintf("已脱敏 %d 处疑似密钥/PII", red.Hits()))
}
o.recordAgentOutput(b, produced.String()) // 产出入黑板:成当前成稿 + 供下游接力
tr.emit(node, "model", "end", "模型流式推理",