Files
ARTEX/server/intercept.go
T
RuoJi6 c0d1aa6f37 feat(intercept): 模型审查补充任务上下文与裁决说明 (#124)
审查模型改为接收版本化 JSON:任务描述/目标/操作约束、工作目录、Worker 意图、当前轮输入,以及按 tool_use_id 配对的近期工具执行记录;末尾是完整的 tool_name 与 arguments。

- 任务约束每次审查从数据库重新读取;读取失败或约束超预算转人工。
- 历史只保留唯一配对的执行事实,排除 Agent 自述、未完成请求与平台拒绝反馈。
- 裁决改为 JSON decision(allow/ask/deny) + comment,三种结果均须说明实际操作、成功后果与命中规则。
- 所有模型裁决(含自动放行)保存实际模型输入与 SHA-256 指纹,详情页可展开。
- 对话审批卡片支持展开完整请求、模型输入、裁决与执行结果。
- 修复父目录 lockfile 导致的 Turbopack 根目录误判。

官方默认拦截规则、工具范围与优先级不变。
2026-09-15 20:08:12 +08:00

481 lines
12 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"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 JSON classification with an explanation for every verdict.
func (s *Server) wireInterceptReviewer() {
s.m.interceptor.SetReviewer(func(ctx context.Context, profileID int64, prompt string, input intercept.ReviewInput) (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{ProfileID: profileID}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
}
text, err := reviewCompletion(ctx, prov, prompt, input)
if err != nil {
return intercept.Decision{ProfileID: profileID}, err
}
v := intercept.ParseVerdict(text)
if v.Action == "" {
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("模型裁决格式无效,必须包含裁决、实际操作、成功后的后果和命中规则")
}
return intercept.Decision{Action: v.Action, Message: v.Reason, ProfileID: profileID}, nil
})
}
func reviewCompletion(ctx context.Context, prov llm.Provider, prompt string, input intercept.ReviewInput) (string, error) {
user, err := json.Marshal(input)
if err != nil {
return "", err
}
return streamCollectText(ctx, prov, prompt, string(user))
}
// streamCollectText runs a single non-streaming-style completion (thinking off,
// low temperature, bounded output) and returns the concatenated text. The
// budget includes the explanation and complete closing JSON delimiters.
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: 1024,
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
}
q := r.URL.Query()
if q.Get("page") == "" && q.Get("size") == "" {
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, "total": len(items)})
return
}
page, size := interceptPageParams(q)
items, total, err := pg.ListTaskInterceptsPage(taskID, page, size)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if items == nil {
items = []db.InterceptApprovalRow{}
}
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
}
func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
q := r.URL.Query()
if q.Get("page") == "" && q.Get("size") == "" {
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, "total": len(items)})
return
}
page, size := interceptPageParams(q)
items, total, err := pg.ListAllInterceptsPage(page, size)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if items == nil {
items = []db.InterceptApprovalRow{}
}
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
}
func interceptPageParams(q url.Values) (int, int) {
page := atoiDefault(q.Get("page"), 1)
size := atoiDefault(q.Get("size"), 20)
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
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 {
if errors.Is(err, intercept.ErrAlreadyDecided) {
writeErr(w, 409, err.Error())
return
}
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
}
func (s *Server) interceptDetail(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
id, ok := pathInt(r, "id")
if !ok || id <= 0 {
writeErr(w, 400, "bad approval id")
return
}
detail, err := pg.GetInterceptDetail(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if detail == nil {
writeErr(w, 404, "not found")
return
}
writeJSON(w, 200, detail)
}