在正则/字符串规则之外新增一套独立的模型兜底层:规则命中直接按规则走; 一条规则都没命中时,才由模型做语义判定(ALLOW/ASK/DENY),人工审批兜底。 - intercept: DefaultJudgePrompt(三态判定提示词)+ ParseVerdict;Reviewer 回调注入; Judge() 读全局 llm_judge_* 配置、超时/失败回退、ask 填审批超时、模型判定加 [模型] 前缀 - guard: applyIntercept 未命中分支调 Judge;模型 allow 不落库,deny/ask 带 reason 入库 - db: intercept_pending 加 reason 列(带 ALTER IF NOT EXISTS 补旧库); create 函数支持 reason 与 ruleID 0→NULL;历史查询带出 reason - server: 用 providerForProfile + 单次 Stream 收集注入裁判;GET/PUT /api/intercept/judge - web: 拦截页拆「拦截规则 / 模型配置」两 tab;拦截范围提为共用信息条; 模型配置双栏布局(提示词直接展开编辑);审批/任务拦截页标注「模型判定」+ 理由 配置默认值:失败回退 allow、审批超时动作 deny、模型 allow 不落库、 提示词默认即内置模板(清空回退模板)。无需 DB 结构性迁移(judge 走 settings KV)。 Claude-Session: https://claude.ai/code/session_019QXSCDmzimwnjntBNCgUD7
399 lines
10 KiB
Go
399 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/guard"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
"github.com/Autumn-27/norma/llm"
|
|
)
|
|
|
|
// chatGuard returns a guard wired with the manager's interceptor, used for chat
|
|
// conversations. Called once per applyLLM so a new LLM config always gets a fresh guard.
|
|
func (s *Server) chatGuard() *guard.Guard {
|
|
return guard.NewWithInterceptor(s.m.interceptor)
|
|
}
|
|
|
|
// wireInterceptReviewer installs the LLM fallback judge into the interceptor. The
|
|
// judge runs only on tool calls that matched no rule (see intercept.Judge). It
|
|
// resolves the configured judge profile (0 → active/default), builds a provider,
|
|
// runs a one-shot single-line classification, and parses ALLOW/ASK/DENY.
|
|
func (s *Server) wireInterceptReviewer() {
|
|
s.m.interceptor.SetReviewer(func(ctx context.Context, profileID int64, prompt, tool, command string) (intercept.Decision, error) {
|
|
if profileID == 0 {
|
|
if p, err := s.m.pg.ActiveProfile(); err == nil && p != nil {
|
|
profileID = p.ID
|
|
}
|
|
}
|
|
if profileID == 0 {
|
|
return intercept.Decision{}, fmt.Errorf("未配置可用的裁判模型")
|
|
}
|
|
prov, _, ok := s.providerForProfile(profileID)
|
|
if !ok {
|
|
return intercept.Decision{}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
|
|
}
|
|
user := fmt.Sprintf("Tool: %s\nArguments:\n%s", tool, command)
|
|
text, err := streamCollectText(ctx, prov, prompt, user)
|
|
if err != nil {
|
|
return intercept.Decision{}, err
|
|
}
|
|
v := intercept.ParseVerdict(text)
|
|
return intercept.Decision{Action: v.Action, Message: v.Reason}, nil
|
|
})
|
|
}
|
|
|
|
// streamCollectText runs a single non-streaming-style completion (thinking off,
|
|
// low temperature, tiny output) and returns the concatenated text. Used by the
|
|
// LLM fallback judge, whose reply is one short line (ALLOW/ASK:.../DENY:...).
|
|
func streamCollectText(ctx context.Context, prov llm.Provider, system, user string) (string, error) {
|
|
temp := 0.0
|
|
req := llm.CompletionRequest{
|
|
System: []string{system},
|
|
Messages: []llm.Message{llm.UserText(user)},
|
|
MaxTokens: 128,
|
|
Temperature: &temp,
|
|
Thinking: "disabled",
|
|
}
|
|
var sb strings.Builder
|
|
for ev, err := range prov.Stream(ctx, req) {
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if ev.Type == llm.SETextDelta {
|
|
sb.WriteString(ev.Text)
|
|
}
|
|
}
|
|
return sb.String(), nil
|
|
}
|
|
|
|
// --- intercept rule CRUD ---
|
|
|
|
func (s *Server) interceptListRules(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
rules, err := pg.ListInterceptRules()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if rules == nil {
|
|
rules = []db.InterceptRule{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"rules": rules})
|
|
}
|
|
|
|
func (s *Server) interceptCreateRule(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var req interceptRuleReq
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := validateInterceptRuleReq(req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
rule, err := pg.CreateInterceptRule(req.Name, req.MatchTarget, req.MatchType, req.Pattern, req.Action, req.Message, req.Priority, req.Enabled, req.TimeoutEnabled, req.TimeoutSeconds, req.TimeoutAction)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.interceptor.Invalidate()
|
|
writeJSON(w, 200, rule)
|
|
}
|
|
|
|
func (s *Server) interceptUpdateRule(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad rule id")
|
|
return
|
|
}
|
|
var req interceptRuleReq
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := validateInterceptRuleReq(req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
rule, err := pg.UpdateInterceptRule(id, req.Name, req.MatchTarget, req.MatchType, req.Pattern, req.Action, req.Message, req.Priority, req.Enabled, req.TimeoutEnabled, req.TimeoutSeconds, req.TimeoutAction)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.interceptor.Invalidate()
|
|
writeJSON(w, 200, rule)
|
|
}
|
|
|
|
func (s *Server) interceptDeleteRule(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad rule id")
|
|
return
|
|
}
|
|
if err := pg.DeleteInterceptRule(id); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.interceptor.Invalidate()
|
|
writeJSON(w, 200, map[string]any{"deleted": id})
|
|
}
|
|
|
|
func (s *Server) interceptToggleRule(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad rule id")
|
|
return
|
|
}
|
|
var req struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := pg.ToggleInterceptRule(id, req.Enabled); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.m.interceptor.Invalidate()
|
|
writeJSON(w, 200, map[string]any{"ok": true, "enabled": req.Enabled})
|
|
}
|
|
|
|
// --- pending (ask) ---
|
|
|
|
func (s *Server) interceptListPending(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
pending, err := pg.ListPendingIntercepts()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if pending == nil {
|
|
pending = []db.InterceptPending{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"pending": pending})
|
|
}
|
|
|
|
func (s *Server) interceptGetOne(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad pending id")
|
|
return
|
|
}
|
|
p, err := pg.GetInterceptPending(id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if p == nil {
|
|
writeErr(w, 404, "not found")
|
|
return
|
|
}
|
|
writeJSON(w, 200, p)
|
|
}
|
|
|
|
func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
taskID := r.PathValue("taskID")
|
|
if taskID == "" {
|
|
writeErr(w, 400, "bad task id")
|
|
return
|
|
}
|
|
items, err := pg.ListTaskIntercepts(taskID)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []db.InterceptApprovalRow{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": items})
|
|
}
|
|
|
|
func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
items, err := pg.ListAllIntercepts(200)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if items == nil {
|
|
items = []db.InterceptApprovalRow{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": items})
|
|
}
|
|
|
|
func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad pending id")
|
|
return
|
|
}
|
|
var req struct {
|
|
Decision string `json:"decision"` // "allowed" | "denied"
|
|
}
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if req.Decision != "allowed" && req.Decision != "denied" {
|
|
writeErr(w, 400, "decision 必须是 allowed 或 denied")
|
|
return
|
|
}
|
|
if err := s.m.interceptor.Decide(id, req.Decision == "allowed"); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// --- tool-config (全局工具拦截范围) ---
|
|
|
|
// interceptGetToolConfig returns the list of tool names that are currently
|
|
// configured to enter the intercept rule system.
|
|
func (s *Server) interceptGetToolConfig(w http.ResponseWriter, r *http.Request) {
|
|
tools, err := s.m.interceptor.GetEnabledTools()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"enabled_tools": tools})
|
|
}
|
|
|
|
// interceptSetToolConfig replaces the list of tool names that should enter
|
|
// the intercept rule system.
|
|
func (s *Server) interceptSetToolConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
EnabledTools []string `json:"enabled_tools"`
|
|
}
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if req.EnabledTools == nil {
|
|
req.EnabledTools = []string{}
|
|
}
|
|
if err := s.m.interceptor.SetEnabledTools(req.EnabledTools); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// --- LLM fallback judge config (全局模型兜底) ---
|
|
|
|
// interceptGetJudgeConfig returns the resolved judge configuration. Prompt is the
|
|
// effective prompt (built-in template when unset), so the UI can prefill it.
|
|
func (s *Server) interceptGetJudgeConfig(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, 200, s.m.interceptor.GetJudgeConfig())
|
|
}
|
|
|
|
// interceptSetJudgeConfig persists the judge configuration.
|
|
func (s *Server) interceptSetJudgeConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req intercept.JudgeConfig
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
switch req.FailAction {
|
|
case "allow", "ask", "deny":
|
|
default:
|
|
writeErr(w, 400, "fail_action 必须是 allow、ask 或 deny")
|
|
return
|
|
}
|
|
switch req.AskTimeoutAction {
|
|
case "allow", "deny":
|
|
default:
|
|
writeErr(w, 400, "ask_timeout_action 必须是 allow 或 deny")
|
|
return
|
|
}
|
|
if err := s.m.interceptor.SetJudgeConfig(req); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
type interceptRuleReq struct {
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Priority int `json:"priority"`
|
|
MatchTarget string `json:"match_target"`
|
|
MatchType string `json:"match_type"`
|
|
Pattern string `json:"pattern"`
|
|
Action string `json:"action"`
|
|
Message string `json:"message"`
|
|
TimeoutEnabled bool `json:"timeout_enabled"`
|
|
TimeoutSeconds int `json:"timeout_seconds"`
|
|
TimeoutAction string `json:"timeout_action"`
|
|
}
|
|
|
|
func validateInterceptRuleReq(req interceptRuleReq) error {
|
|
if req.Name == "" {
|
|
return fmt.Errorf("name 不能为空")
|
|
}
|
|
switch req.MatchTarget {
|
|
case "tool_name", "tool_input":
|
|
default:
|
|
return fmt.Errorf("match_target 必须是 tool_name 或 tool_input")
|
|
}
|
|
switch req.MatchType {
|
|
case "string", "regex":
|
|
default:
|
|
return fmt.Errorf("match_type 必须是 string 或 regex")
|
|
}
|
|
if req.Pattern == "" {
|
|
return fmt.Errorf("pattern 不能为空")
|
|
}
|
|
switch req.Action {
|
|
case "allow", "deny", "ask":
|
|
default:
|
|
return fmt.Errorf("action 必须是 allow、deny 或 ask")
|
|
}
|
|
if req.MatchType == "regex" {
|
|
if _, err := regexp.Compile(req.Pattern); err != nil {
|
|
return fmt.Errorf("pattern 不是有效正则:%w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|