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

141 lines
6.4 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 实现 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
}