Merge branch 'feat/b2b-judgetext-redact-judge-reason-sensit' into dev
This commit is contained in:
commit
50cc60d019
6 files changed
+172
-9
No files matched your search
@@ -3,6 +3,8 @@ package restrict
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
// SensitiveOp 是 design/11 §4「敏感清单」里的操作类别。它只用于审计与前端
|
||||
@@ -45,7 +47,8 @@ var SensitiveOps = []SensitiveOp{
|
||||
}
|
||||
|
||||
// sensitiveSummaryRunes 截断动作摘要:代审小字与审计只需一眼看出「想干什么」,
|
||||
// 同时限制进入前端/日志的文本块大小(也降低把长参数里的秘密整段搬进日志的机会)。
|
||||
// 同时限制进入前端/日志的文本块大小。它只管**长度** —— 秘密由 summarize 里的
|
||||
// trace.RedactText 负责打码,不靠截断「顺手甩掉」。
|
||||
const sensitiveSummaryRunes = 80
|
||||
|
||||
// SensitiveCall 判定一次工具调用是否属敏感操作(design/11 §4)。
|
||||
@@ -135,8 +138,14 @@ func httpCall(args map[string]any) (SensitiveOp, string, bool) {
|
||||
|
||||
// summarize 拼接「动作 + 目标」摘要并按 sensitiveSummaryRunes 截断(不切开
|
||||
// 多字节字符);目标为空时只返回动作。
|
||||
//
|
||||
// 目标先过 trace.RedactText 打码:这个摘要会进审计与前端小字两个读面,而
|
||||
// terminal 的 command / http 的 url 里常常就带着凭据(`curl -H 'api_key=…'`、
|
||||
// `?token=…`),截断并不能让它们变得安全。RedactText 自身已按同一档(80 rune)
|
||||
// 截断,后面的 clipRunes 保留是为了让长度上限继续由本包的
|
||||
// sensitiveSummaryRunes 掌握 —— 两处同档,叠加不改变结果。
|
||||
func summarize(action, target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
target = trace.RedactText(strings.TrimSpace(target))
|
||||
if target == "" {
|
||||
return action
|
||||
}
|
||||
|
||||
@@ -77,3 +77,41 @@ func TestSensitiveCall_SummaryIsClipped(t *testing.T) {
|
||||
t.Fatalf("被截断的摘要应以省略号结尾: %q", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSensitiveCall_SummaryIsRedacted 锁定「参数摘要进审计与前端小字之前必须
|
||||
// 打码」:terminal 的 command、http 的 url 里常常就带着凭据,截断不是脱敏。
|
||||
func TestSensitiveCall_SummaryIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sk-live-9f8e7d6c5b4a3210"
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
start string // 摘要应保留的开头(打码不能把摘要打没)
|
||||
}{
|
||||
{
|
||||
name: "terminal",
|
||||
args: map[string]any{"command": "curl -H 'api_key=" + secret + "' https://x"},
|
||||
start: "terminal ",
|
||||
},
|
||||
{
|
||||
name: "http",
|
||||
args: map[string]any{"method": "POST", "url": "https://x/hook?token=" + secret},
|
||||
start: "http POST ",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, detail, sensitive := SensitiveCall(tc.name, tc.args)
|
||||
if !sensitive {
|
||||
t.Fatalf("%s 应判定为敏感", tc.name)
|
||||
}
|
||||
if strings.Contains(detail, secret) {
|
||||
t.Fatalf("%s 摘要泄露了凭据原文: %q", tc.name, detail)
|
||||
}
|
||||
if !strings.Contains(detail, "[redacted]") {
|
||||
t.Fatalf("%s 摘要应带打码标记: %q", tc.name, detail)
|
||||
}
|
||||
if !strings.HasPrefix(detail, tc.start) {
|
||||
t.Fatalf("%s 摘要应以 %q 开头(动作不能被一起打掉): %q", tc.name, tc.start, detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 非正时原样返回)。
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -206,6 +207,6 @@ func (rs *ReminderStore) fireDue(ctx context.Context) {
|
||||
rs.log.Error("publish reminder", "id", r.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
rs.log.Info("reminder fired", "id", r.ID, "at", r.At, "message", r.Message)
|
||||
rs.log.Info("reminder fired", "id", r.ID, "at", r.At, "message", trace.RedactText(r.Message))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package automations
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -166,3 +169,70 @@ func TestService_ReminderLifecycle(t *testing.T) {
|
||||
t.Fatalf("CancelReminder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReminder_FireLogIsRedacted 钉住提醒触发日志的出文本口径:Message 是模型/
|
||||
// 用户自由文本,可能顺手带着凭据(「把 api_key=… 发我」)。日志面必须打码,
|
||||
// 而总线上的 payload 是**投递面**,必须原样保留 —— 打码不能改投递内容。
|
||||
func TestReminder_FireLogIsRedacted(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "sk-live-9f8e7d6c5b4a3210"
|
||||
msg := "把 api_key=" + secret + " 发到群里"
|
||||
|
||||
var logBuf bytes.Buffer
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
|
||||
got := make(chan bus.Event, 1)
|
||||
unsub, err := b.Subscribe(context.Background(), SubjectTrigger, func(_ context.Context, ev bus.Event) error {
|
||||
got <- ev
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
rs, err := NewReminder(Config{ReminderInterval: 10 * time.Millisecond}, Deps{
|
||||
Bus: b,
|
||||
Log: slog.New(slog.NewTextHandler(&logBuf, nil)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewReminder: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := rs.Add(ctx, Reminder{ID: "leak", At: time.Now().Add(10 * time.Millisecond), Message: msg}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if err := rs.Start(ctx); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case ev := <-got:
|
||||
rem, ok := ev.Payload.(Reminder)
|
||||
if !ok {
|
||||
t.Fatalf("payload type = %T, want Reminder", ev.Payload)
|
||||
}
|
||||
if rem.Message != msg {
|
||||
t.Fatalf("总线 payload 的 message 被改动: %q, want %q", rem.Message, msg)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("reminder did not fire")
|
||||
}
|
||||
// Stop 等轮询 goroutine 退出,触发日志必然已写完,避免与后台写缓冲竞争。
|
||||
if err := rs.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
out := logBuf.String()
|
||||
if strings.Contains(out, secret) {
|
||||
t.Fatalf("提醒日志里出现了凭据原文:%s", out)
|
||||
}
|
||||
for _, want := range []string{"reminder fired", "[redacted]"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("提醒日志 = %q,want 含 %q(打码不能把日志变哑)", out, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user