Files
AskaEth 20160c66d8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
2026-10-05 12:12:31 +08:00

156 lines
4.9 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 approvalreview
import (
"context"
"errors"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// recordingLLM 是记录请求的模型适配器桩:返回既定内容或既定错误。
type recordingLLM struct {
content string
err error
mu sync.Mutex
reqs []model.Request
}
func (m *recordingLLM) Name() string { return "recording-llm" }
func (m *recordingLLM) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
m.mu.Lock()
m.reqs = append(m.reqs, req)
m.mu.Unlock()
if m.err != nil {
return model.Response{}, m.err
}
return model.Response{Content: m.content, Message: model.TextMessage(model.RoleAssistant, m.content)}, nil
}
func (m *recordingLLM) ChatStream(context.Context, model.Request, func(model.StreamEvent)) (model.Response, error) {
return model.Response{}, nil
}
func (m *recordingLLM) last() model.Request {
m.mu.Lock()
defer m.mu.Unlock()
return m.reqs[len(m.reqs)-1]
}
func (m *recordingLLM) count() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.reqs)
}
// promptText 拼接请求里所有消息的文本,便于断言问句确实带上了任务与调用摘要。
func (m *recordingLLM) promptText() string {
req := m.last()
var b strings.Builder
for _, msg := range req.Messages {
for _, c := range msg.Content {
b.WriteString(c.Text)
b.WriteString("\n")
}
}
return b.String()
}
// TestNewLLMJudge_NilAdapter:无模型适配器时返回 nil ⇒ 门对子代理的敏感调用走
// fail-closed(绝不因为没有判定器就放行)。
func TestNewLLMJudge_NilAdapter(t *testing.T) {
t.Parallel()
if got := NewLLMJudge(nil, nil); got != nil {
t.Fatalf("NewLLMJudge(nil) = %v, want nil", got)
}
}
// TestJudgeSubagentCall_AsksAStructuredQuestion:单轮轻量问句里带上了被派任务与
// 本次调用摘要,且不塞正文(问句只由这两段结构化输入组成)。
func TestJudgeSubagentCall_AsksAStructuredQuestion(t *testing.T) {
t.Parallel()
llm := &recordingLLM{content: `{"allow": true, "reason": ""}`}
judge := NewLLMJudge(llm, nil)
verdict, err := judge.JudgeSubagentCall(context.Background(), audit.JudgeRequest{
Task: "整理 data/ 下的导出文件",
Actor: "昔涟",
Action: "执行命令:terminal rm -rf /",
})
if err != nil {
t.Fatalf("JudgeSubagentCall: %v", err)
}
if !verdict.Allowed {
t.Fatalf("verdict = %+v, want 放行", verdict)
}
prompt := llm.promptText()
for _, want := range []string{"整理 data/ 下的导出文件", "执行命令:terminal rm -rf /", "昔涟"} {
if !strings.Contains(prompt, want) {
t.Fatalf("问句里缺少 %q,got: %s", want, prompt)
}
}
if req := llm.last(); req.Purpose != judgePurpose || req.MaxTokens != judgeMaxTokens {
t.Fatalf("模型请求 = (purpose=%q, maxTokens=%d), want (%q, %d)",
req.Purpose, req.MaxTokens, judgePurpose, judgeMaxTokens)
}
if llm.count() != 1 {
t.Fatalf("模型调用次数 = %d, want 1(单轮轻量)", llm.count())
}
}
// TestJudgeSubagentCall_ParsesDenial:判「越界」时把理由交回调用方(它要进审计与
// 前端小字)。围栏包裹的 JSON 也能识别。
func TestJudgeSubagentCall_ParsesDenial(t *testing.T) {
t.Parallel()
llm := &recordingLLM{content: "```json\n{\"allow\": false, \"reason\": \"任务里没有执行命令这一项\"}\n```"}
verdict, err := NewLLMJudge(llm, nil).JudgeSubagentCall(context.Background(), audit.JudgeRequest{
Task: "整理导出文件", Action: "执行命令:terminal rm -rf /",
})
if err != nil {
t.Fatalf("JudgeSubagentCall: %v", err)
}
if verdict.Allowed {
t.Fatal("verdict.Allowed = true, want false")
}
if verdict.Reason != "任务里没有执行命令这一项" {
t.Fatalf("理由 = %q, want 判定器给出的理由", verdict.Reason)
}
}
// TestJudgeSubagentCall_FailClosedOnBadOutput:模型报错、输出不是 JSON、缺 allow
// 字段 —— 一律报错(由 Approver 按「判不出来就不放行」处理)。
func TestJudgeSubagentCall_FailClosedOnBadOutput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
llm *recordingLLM
}{
{name: "model error", llm: &recordingLLM{err: errors.New("provider down")}},
{name: "not json", llm: &recordingLLM{content: "允许"}},
{name: "broken json", llm: &recordingLLM{content: `{"allow": `}},
{name: "missing allow field", llm: &recordingLLM{content: `{"reason": "看起来没问题"}`}},
{name: "empty output", llm: &recordingLLM{content: ""}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := NewLLMJudge(tt.llm, nil).JudgeSubagentCall(context.Background(), audit.JudgeRequest{
Task: "整理导出文件", Action: "执行命令:terminal rm -rf /",
})
if err == nil {
t.Fatal("err = nil, want 判不出来(fail-closed)")
}
})
}
}