feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
This commit is contained in:
1 parent
fa850f9599
commit
20160c66d8
13 files changed
+1479
-83
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// Package approvalreview 实现 design/11 §4「敏感操作代审」的实质判定器(主人
|
||||
// 2026-10-05 裁决的口径 A):只对**子代理发起**的敏感调用,判「这次调用是否与派给
|
||||
// 它的任务一致 / 有没有越界」。
|
||||
//
|
||||
// 它是既有**审查模型范式**(design/12 §6 策略四的轻量审查模型,见
|
||||
// internal/services/channelbridge 的 NewLLMReviewer)在本主题上的复用:同一个
|
||||
// model.Adapter、同一种单轮轻量 prompt、同一套「输出不可解析即失败」的 strict 解析。
|
||||
// 它**不另造第二套 LLM 包装**:模型适配器由组合根注入,本包只负责问句、一次调用与
|
||||
// 解析。
|
||||
//
|
||||
// 边界(项目红线):
|
||||
// - 判定只**收紧**、绝不**放宽**:某次调用是否敏感由 capability/restrict 的结构化
|
||||
// 判据先决;本包无从放宽它。
|
||||
// - 本包**绝不判身份**:谁在动手仍由 channel 层 actor 元数据 + 白名单 + 策略引擎决定;
|
||||
// 问句里的角色名只是陈述。
|
||||
// - **fail-closed**:模型报错、超时、输出不可解析、缺字段 ⇒ 返回错误,由调用方
|
||||
// (audit.Approver)按「判不出来就不放行」处理。
|
||||
package approvalreview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
|
||||
)
|
||||
|
||||
const (
|
||||
// judgeMaxTokens 限定判定模型的输出:答案是「一个布尔 + 一句话」,冗长补全
|
||||
// 只会增加成本与解析失败面(design/12 §6 策略四的成本护栏同款口径)。
|
||||
judgeMaxTokens = 256
|
||||
// judgePurpose 标明这次模型调用属于代审判定,供用量与排查归类。
|
||||
judgePurpose = "approval_review"
|
||||
// judgePrompt 是判定问句的指令段。它只要求**一项**一致性判定,并写死
|
||||
// 「拿不准即不允许」的 fail-closed 方向、以及「不判身份」的红线。
|
||||
judgePrompt = "你是子代理敏感调用的代审判定器。你会收到「派给某个子代理的任务」与「该子代理即将执行的" +
|
||||
"一次敏感工具调用」。只判断一件事:这次调用是否与派给它的任务一致、有没有越界" +
|
||||
"(例如任务只要求整理资料,却要执行 shell 命令 / 改配置文件 / 向外部发消息 / 读密钥)。\n" +
|
||||
"只做这一项一致性判定:不判断说话人是谁、不判断权限档位、不考虑这次调用本身是否危险。\n" +
|
||||
"拿不准、信息不足、看不出任务与调用之间的关联时,判不允许。\n" +
|
||||
"只输出一个 JSON 对象:{\"allow\": true|false, \"reason\": \"一句话理由\"},不要输出其他内容。"
|
||||
)
|
||||
|
||||
// NewLLMJudge 把 model.Adapter 包装成代审判定器(audit.CallJudge)。llm 为 nil 时
|
||||
// 返回 nil——组合根据此让门对子代理的敏感调用走 fail-closed(绝不因为模型不可用就放行)。
|
||||
func NewLLMJudge(llm model.Adapter, log *slog.Logger) audit.CallJudge {
|
||||
if llm == nil {
|
||||
return nil
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
log = logger.WithSource(log, "services/approvalreview")
|
||||
return &llmJudge{llm: llm, log: log}
|
||||
}
|
||||
|
||||
// llmJudge 是 audit.CallJudge 的 LLM 实现。它无可变状态,可安全并发使用。
|
||||
type llmJudge struct {
|
||||
llm model.Adapter
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// JudgeSubagentCall 用一次单轮轻量调用判定 req 是否仍在派给该子代理的任务边界内。
|
||||
// 超时由调用方(audit.Approver)经 ctx 施加;本实现不再套第二层时限。
|
||||
func (j *llmJudge) JudgeSubagentCall(ctx context.Context, req audit.JudgeRequest) (audit.JudgeVerdict, error) {
|
||||
resp, err := j.llm.Chat(ctx, model.Request{
|
||||
Messages: []model.Message{
|
||||
model.TextMessage(model.RoleSystem, judgePrompt),
|
||||
model.TextMessage(model.RoleUser, judgeContent(req)),
|
||||
},
|
||||
Purpose: judgePurpose,
|
||||
MaxTokens: judgeMaxTokens,
|
||||
}, model.Options{})
|
||||
if err != nil {
|
||||
return audit.JudgeVerdict{}, fmt.Errorf("approvalreview: judge model: %w", err)
|
||||
}
|
||||
verdict, perr := parseVerdict(resp.Content)
|
||||
if perr != nil {
|
||||
// 只记「解析失败」这一事实与输出长度:模型输出里可能回显了问句,
|
||||
// 整段记进日志等于把任务/参数摘要搬进日志(不打印敏感内容)。
|
||||
j.log.Warn("代审判定输出不可解析,按 fail-closed 处理",
|
||||
"purpose", judgePurpose, "output_len", len(resp.Content), "error", perr)
|
||||
return audit.JudgeVerdict{}, fmt.Errorf("approvalreview: judge output: %w", perr)
|
||||
}
|
||||
return verdict, nil
|
||||
}
|
||||
|
||||
// judgeContent 把结构化问句渲染成判定者可见的文本:被派的任务目标 + 本次调用的
|
||||
// 类别与摘要 + 动手的角色(仅供陈述)。它**只**含代审链已算好的摘要,绝不带会话
|
||||
// 正文、记忆或提示词。
|
||||
func judgeContent(req audit.JudgeRequest) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("【派给该子代理的任务】\n")
|
||||
b.WriteString(req.Task)
|
||||
b.WriteString("\n【这次敏感调用】\n")
|
||||
b.WriteString(req.Action)
|
||||
if actor := strings.TrimSpace(req.Actor); actor != "" {
|
||||
b.WriteString("\n动手的角色(仅供陈述,不参与判定):")
|
||||
b.WriteString(actor)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// judgeAnswer 是判定输出的线上形状。Allow 用指针以便区分「模型漏了字段」
|
||||
// (nil ⇒ 解析失败 ⇒ fail-closed)与「模型明确判不允许」。
|
||||
type judgeAnswer struct {
|
||||
Allow *bool `json:"allow"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// parseVerdict 解出判定结论。它从输出里取出第一个 `{` 到最后一个 `}` 的片段再解析,
|
||||
// 使被围栏或前后缀包住的 JSON 仍能识别;缺 allow 字段一律算失败(fail-closed)。
|
||||
func parseVerdict(out string) (audit.JudgeVerdict, error) {
|
||||
obj, ok := jsonObject(out)
|
||||
if !ok {
|
||||
return audit.JudgeVerdict{}, fmt.Errorf("no JSON object in output")
|
||||
}
|
||||
var ans judgeAnswer
|
||||
if err := json.Unmarshal([]byte(obj), &ans); err != nil {
|
||||
return audit.JudgeVerdict{}, fmt.Errorf("decode verdict: %w", err)
|
||||
}
|
||||
if ans.Allow == nil {
|
||||
return audit.JudgeVerdict{}, fmt.Errorf("verdict missing the allow field")
|
||||
}
|
||||
return audit.JudgeVerdict{Allowed: *ans.Allow, Reason: ans.Reason}, nil
|
||||
}
|
||||
|
||||
// jsonObject 切出 s 中第一个 `{` 到最后一个 `}` 之间的片段。
|
||||
func jsonObject(s string) (string, bool) {
|
||||
start := strings.IndexByte(s, '{')
|
||||
end := strings.LastIndexByte(s, '}')
|
||||
if start < 0 || end <= start {
|
||||
return "", false
|
||||
}
|
||||
return s[start : end+1], true
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user