Files
artex/server/finding_retests.go
T
dela 97668dab7e
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
Localize user-facing UI, agent output, and backend messages to Simplified Chinese
2026-10-09 14:08:31 +08:00

263 lines
8.7 KiB
Go

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