// 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 }