From 5099c21520d0a7cc41e5b6394fb57c94532748d9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 21:52:44 +0800 Subject: [PATCH] fix(audit): redact judge reason before it reaches logs and the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 判定理理由(verdict.Reason)是模型自由文本,判定器会把它在参数摘要里看到的 凭据原样抄进理由,而这条理由要进审计日志与前端小字两个读面。cleanReason 在收敛空白/截断之后统一过 trace.RedactText(80 rune 档),与其余出图文本 走同一套清洗;maxReasonRunes 退化为「进入清洗前」的上限,避免在无界文本上 跑凭据匹配。 --- internal/runtime/orchestration/audit/judge.go | 19 +++++++--- .../runtime/orchestration/audit/judge_test.go | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/internal/runtime/orchestration/audit/judge.go b/internal/runtime/orchestration/audit/judge.go index 0cfd553..bded393 100644 --- a/internal/runtime/orchestration/audit/judge.go +++ b/internal/runtime/orchestration/audit/judge.go @@ -7,6 +7,8 @@ import ( "time" "unicode" "unicode/utf8" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本文件是 design/11 §4「敏感操作代审」的**实质判定**侧(主人 2026-10-05 裁决的口径 A): @@ -28,8 +30,9 @@ const judgeTimeout = 5 * time.Second // 不需要整段派发文本(成本受控 + 降低把长文本搬进判定问句的机会)。 const maxTaskRunes = 1000 -// maxReasonRunes 是判定理由可进入审计/前端小字的字符上限(同样是为了成本与 -// 不把长文本搬进呈现层)。超出按 rune 截断。 +// maxReasonRunes 是判定理由**进入清洗前**的字符上限:理由是模型自由文本,先按 +// rune 截断,避免在无界文本上跑凭据匹配(成本受控)。最终出日志/小字的长度由 +// trace.RedactText 的 80 rune 档决定——两处都拦,短的那档生效。 const maxReasonRunes = 200 // 放行被拒时的理由口径。判不出来(未装配判定器 / 调用失败 / 超时 / 输出不可解析) @@ -176,9 +179,13 @@ func (a *Approver) logf(msg string, ap *Approval, err error) { a.log.Warn(msg, fields...) } -// cleanReason 把判定器给的理由收敛成可进日志与前端的一行:压掉控制字符与连续空白, -// 截到 maxReasonRunes。全空时回落到 reasonOutOfScope,使拒绝**绝不静默**(人总能看到 -// 一条理由)。 +// cleanReason 把判定器给的理由收敛成可进日志与前端的一行:压掉控制字符与连续空白、 +// 截到 maxReasonRunes,最后过 trace.RedactText(80 rune 档)。 +// +// 打码这一步是**必须**的:理由是**模型自由文本**,判定器完全可能把它在参数摘要里 +// 看到的凭据原样抄进理由(「不该把 Authorization: Bearer … 发出去」),而这条理由 +// 要进审计日志与前端小字两个读面。它与其余出图文本走同一套清洗,绝不原样落日志。 +// 全空时回落到 reasonOutOfScope,使拒绝**绝不静默**(人总能看到一条理由)。 func cleanReason(reason string) string { var b strings.Builder space := false @@ -198,7 +205,7 @@ func cleanReason(reason string) string { if out == "" { return reasonOutOfScope } - return clip(out, maxReasonRunes) + return trace.RedactText(clip(out, maxReasonRunes)) } // clip 把 s 截到至多 max 个 rune,超出时追加省略号(max 非正时原样返回)。 diff --git a/internal/runtime/orchestration/audit/judge_test.go b/internal/runtime/orchestration/audit/judge_test.go index bcfab24..4adce3f 100644 --- a/internal/runtime/orchestration/audit/judge_test.go +++ b/internal/runtime/orchestration/audit/judge_test.go @@ -320,6 +320,44 @@ func TestReviewAndApprove_WritesJudgementReasonToAuditLog(t *testing.T) { } } +// TestJudgeCall_ReasonIsRedactedBeforeLogging 钉住「判定理由出日志前打码」:理由是 +// **模型自由文本**,判定器完全可能把它在参数摘要里看到的凭据原样抄进理由,而这条 +// 理由要进审计日志与前端小字两个读面(M54)。 +// +// 🔴 断言打在**日志输出字节**上(真 slog handler → 缓冲区):删掉 cleanReason 里的 +// trace.RedactText,raw 凭据就会原样出现在审计行里,本用例随即变红。 +func TestJudgeCall_ReasonIsRedactedBeforeLogging(t *testing.T) { + t.Parallel() + + const rawSecret = "sk-live-9f8e7d6c5b4a3210" + var buf bytes.Buffer + ap := NewApprover(logger.New(logger.Options{Level: "debug", Format: "json", Out: &buf}), + WithSubagentOrigin(subagentOrigin("整理导出文件")), + WithCallJudge(&stubJudge{verdict: JudgeVerdict{ + Allowed: false, + Reason: "越界:任务不需要带着 api_key=" + rawSecret + " 去请求上游", + }}), + ) + got, err := ap.ReviewAndApprove(context.Background(), "昔涟", "执行命令:terminal curl -H 'x'", "curl") + if err != nil { + t.Fatalf("ReviewAndApprove: %v", err) + } + // 日志面是主断言:审计行里只能出现打码后的形态。 + out := buf.String() + if strings.Contains(out, rawSecret) { + t.Fatalf("审计日志里出现了原样凭据: %s", out) + } + for _, want := range []string{`"reason":`, "[redacted]", "越界:任务不需要带着"} { + if !strings.Contains(out, want) { + t.Fatalf("审计日志缺少 %q(打码不能把理由打没): %s", want, out) + } + } + // 同一条理由也进前端小字(Approval.Reason),因此读面之外的返回值同样不许带原文。 + if strings.Contains(got.Reason, rawSecret) { + t.Fatalf("Approval.Reason 里带着原样凭据(会进前端小字): %q", got.Reason) + } +} + // TestNewApprover_WarnsWhenJudgeHasNoOrigin:接线错误(装配了判定器却没有来源解析 ⇒ // 判定永不触发)必须显式告警,不能静默空转。 func TestNewApprover_WarnsWhenJudgeHasNoOrigin(t *testing.T) {