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" ) 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, "retest notes are limited to 4000 characters") 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, "the vulnerability retest agent does not exist or is not enabled; configure retester under Agent management") 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, "enable and bind the tool for the retest agent: "+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, "server is shutting down") 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", "Read the vulnerability evidence snapshot, retest status, supplementary notes, and current task constraints associated with the current retest session. No parameters; only the current session can be read.", 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("the current session is not associated with a retest record; start a retest from the finding details"), 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", "Save the single conclusion for the current retest session; the original vulnerability evidence and report stay unchanged. When the session ends successfully with a fixed verdict, the system automatically sets the vulnerability status to Fixed; other verdicts keep the original status. Evidence from this actual check is required; when it cannot be confirmed, state the blocking reason.", objSchema(map[string]any{ "verdict": map[string]any{"type": "string", "enum": []string{"reproduced", "fixed", "inconclusive"}}, "summary": strParam("summary of this retest conclusion"), "evidence": strParam("Markdown: actual steps, observations, comparisons, and basis for the conclusion of this run; if unconfirmable, list what was checked and the blocking reason"), }, "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,'Vulnerability Retest','Started manually from finding details; reads the original evidence and saves an independent retest conclusion.','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,'built-in default','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) } }