package server import ( "context" "database/sql" "encoding/json" "errors" "log" "net/http" "slices" "strconv" "strings" "unicode/utf8" "github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/intercept" actool "github.com/Autumn-27/norma/tool" ) // 사용자 노출 HTTP 에러 응답 문구. 아래 네 문구는 startFindingRetest HTTP 핸들러에서만 // 반환되고 에이전트 도구 경로(findingRetestTools 의 roTool/wrTool 설명·파라미터 설명· // actool.Errorf)에는 닿지 않으므로 한국어로 번역한다. 반대로 도구 설명 // (get_finding_retest_context·record_finding_retest_result)·파라미터 설명· // actool.Errorf(153행 회차 미연결 안내)와 seedFindingRetester 의 DB 시드 에이전트 // 이름(漏洞复测)·프로필·note(内置默认)는 에이전트가 읽는 두뇌 입력이거나 시드라 // 원문을 보존한다(각 지점 주석 참조, F16 두뇌 경계 계열). const ( errFindingRetestNotesTooLong = "复测补充说明最多可输入 4000 个字符" errFindingRetestAgentMissing = "漏洞复测 Agent 不存在或已禁用。请在 Agent 管理中设置 retester" errFindingRetestToolRequired = "请为复测 Agent 启用并连接工具: " errFindingRetestServiceStopping = "服务正在关闭" ) func (s *Server) listActiveFindingRetests(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { return } items, err := pg.ListActiveFindingRetests(r.Context()) if err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]any{"retests": items}) } func (s *Server) listFindingRetests(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 finding id") return } f, err := pg.GetFinding(id) if err != nil { writeErr(w, 500, err.Error()) return } if f == nil { writeErr(w, 404, "finding not found") return } items, err := pg.ListFindingRetests(id) if err != nil { writeErr(w, 500, err.Error()) return } writeJSON(w, 200, map[string]any{"retests": items}) } func (s *Server) startFindingRetest(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 finding id") return } var req struct { Notes string `json:"notes"` } if !decodeConversationRequest(w, r, &req) { return } req.Notes = strings.TrimSpace(req.Notes) if utf8.RuneCountInString(req.Notes) > 4000 { writeErr(w, 400, errFindingRetestNotesTooLong) return } f, err := pg.GetFinding(id) if err != nil { writeErr(w, 500, err.Error()) return } if f == nil { writeErr(w, 404, "finding not found") return } a, err := pg.GetAgentByKey(db.FindingRetestAgentKey) if err != nil { writeErr(w, 500, err.Error()) return } if a == nil || !a.Enabled { writeErr(w, 409, errFindingRetestAgentMissing) return } for _, key := range []string{"get_finding_retest_context", "record_finding_retest_result"} { t, err := pg.GetTool(key) if err != nil { writeErr(w, 500, err.Error()) return } if t == nil || !t.Enabled || !slices.Contains(t.Agents, a.Key) { writeErr(w, 409, errFindingRetestToolRequired+key) return } } if s.resolveChatAgent(&db.Conversation{AgentKey: a.Key}) == nil { writeErr(w, 503, s.chatUnavailableReason()) return } if s.ctx.Err() != nil { writeErr(w, 503, errFindingRetestServiceStopping) return } retest, conv, created, err := pg.CreateFindingRetest(r.Context(), id, req.Notes) if errors.Is(err, sql.ErrNoRows) { writeErr(w, 404, "finding not found") return } if err != nil { writeErr(w, 500, err.Error()) return } if created { busyKey := s.convBusyKey(conv.ID) s.chatMu.Lock() s.chatBusy[busyKey] = true s.chatMu.Unlock() s.runConversation(conv, retest.InitialMessage(), busyKey) } code := http.StatusOK if created { code = http.StatusAccepted } writeJSON(w, code, map[string]any{"retest": retest, "created": created}) } // Both tools derive the finding from server-owned conversation context. Tool // arguments cannot redirect a result into a different finding or conversation. func (s *Server) findingRetestTools() []actool.CoreTool { return []actool.CoreTool{ roTool("get_finding_retest_context", "读取当前复测会话关联的漏洞证据快照、复测状态、补充说明与当前任务约束。无参数,只能读取本会话。", objSchema(map[string]any{}), func(ctx context.Context, _ json.RawMessage) (actool.Result, error) { r, err := s.m.pg.FindingRetestForConversation(ctx, intercept.ConvIDFromContext(ctx)) if err != nil { return actool.Errorf(err.Error()), nil } if r == nil { return actool.Errorf("当前会话未关联复测记录,请从漏洞详情发起复测"), nil } var constraints []db.Constraint f, err := s.m.pg.GetFinding(r.FindingID) if err != nil { return actool.Errorf(err.Error()), nil } if f != nil && f.TaskID != nil { if task, ok := s.m.Task(strconv.FormatInt(*f.TaskID, 10)); ok { constraints, err = task.Store.ListConstraints() if err != nil { return actool.Errorf(err.Error()), nil } } } return jsonResult(map[string]any{"retest": r, "current_constraints": constraints}) }), wrTool("record_finding_retest_result", "为当前复测会话保存唯一结论;原漏洞证据与报告保持不变。会话成功结束且结论为 fixed 时,系统自动将漏洞状态改为已修复;其他结论保留原状态。必须提供本次实际检查的证据,无法确认时写明阻塞原因。", objSchema(map[string]any{ "verdict": map[string]any{"type": "string", "enum": []string{"reproduced", "fixed", "inconclusive"}}, "summary": strParam("本次复测结论摘要"), "evidence": strParam("Markdown:本次实际步骤、观察、对照、结论依据;无法确认则列出已检查内容和阻塞原因"), }, "verdict", "summary", "evidence"), func(ctx context.Context, in json.RawMessage) (actool.Result, error) { var a struct { Verdict string `json:"verdict"` Summary string `json:"summary"` Evidence string `json:"evidence"` } if err := json.Unmarshal(in, &a); err != nil { return actool.Errorf(err.Error()), nil } if err := s.m.pg.RecordFindingRetestResult(ctx, intercept.ConvIDFromContext(ctx), a.Verdict, a.Summary, a.Evidence); err != nil { return actool.Errorf(err.Error()), nil } return jsonResult(map[string]any{"saved": true, "verdict": a.Verdict}) }), } } // Seed the editable agent atomically, without an automatic discovery trigger. // Once seeded, user edits/deletion survive restarts; a pre-existing key is kept. func (s *Server) seedFindingRetester() error { for _, t := range s.findingRetestTools() { schema, _ := json.Marshal(t.InputSchema()) bindings, _ := json.Marshal([]string{db.FindingRetestAgentKey}) if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, bindings); err != nil { return err } } const flag = "finding_retester_seed_v1" tx, err := s.m.pg.Begin() if err != nil { return err } defer tx.Rollback() // Lock this migration, including concurrent server initialization. if _, err = tx.Exec(`SELECT pg_advisory_xact_lock(7337741010)`); err != nil { return err } var done string err = tx.QueryRow(`SELECT value FROM settings WHERE key=$1`, flag).Scan(&done) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } if done == "true" { return nil } var id int64 err = tx.QueryRow(`INSERT INTO agents(key,name,description,role,builtin,enabled) VALUES ($1,'漏洞复测','从漏洞详情手动启动,读取原证据并保存独立复测结论。','assistant',false,true) ON CONFLICT (key) DO NOTHING RETURNING id`, db.FindingRetestAgentKey).Scan(&id) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } if id > 0 { var pid int64 if err = tx.QueryRow(`INSERT INTO agent_prompts(agent_id,version,template_text,note,updated_by) VALUES ($1,1,$2,'内置默认','system') RETURNING id`, id, agent.RetesterDefaultPrompt).Scan(&pid); err != nil { return err } if _, err = tx.Exec(`UPDATE agents SET current_prompt_id=$1 WHERE id=$2`, pid, id); err != nil { return err } } if _, err = tx.Exec(`INSERT INTO settings(key,value) VALUES ($1,'true') ON CONFLICT(key) DO UPDATE SET value='true'`, flag); err != nil { return err } return tx.Commit() } func (s *Server) finishRetest(id int64, status, reason string) { if err := s.m.pg.FinishFindingRetest(id, status, reason); err != nil { // Surface failure to the conversation runner rather than inventing a result. log.Printf("[retest %d] finish: %v", id, err) } }