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

This commit is contained in:
AskaEth committed 2026-10-05 12:12:31 +08:00
1 parent fa850f9599
commit 20160c66d8
13 files changed
+1479 -83

No files matched your search

@@ -0,0 +1,155 @@
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)")
}
})
}
}