diff --git a/server/finding_case_reviews.go b/server/finding_case_reviews.go index 8716ce4..e0ea191 100644 --- a/server/finding_case_reviews.go +++ b/server/finding_case_reviews.go @@ -34,7 +34,7 @@ func (s *Server) toolCompleteFindingCaseReview() actool.CoreTool { "finding_id": idParam("所选独立漏洞记录 ID"), "verdict": map[string]any{"type": "string", "enum": []string{"independent", "merged", "suggested"}}, "reason": strParam("判断理由,引用已证据化的共同点或不同代码路径;仅类型、标题或资产相同不足以证明共同根因"), - "root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;不确定则说明缺失证据及已保存建议")}, "finding_id", "verdict", "reason", "root_cause_evidence") + "root_cause_evidence": strParam("原始报告/证据中支持实际共同或不同根因的具体材料;suggested 须说明具体共同点、缺失的关键证据及已保存分组;没有具体共同点应判 independent")}, "finding_id", "verdict", "reason", "root_cause_evidence") return wrTool("complete_finding_case_review", "历史整理结束前提交每条所选记录的根因判断并验收:必须已读取全部记录,疑似必须已有待确认分组建议;归并只需已在活动文件夹中,统一报告可稍后补齐。验收失败不能宣布完成。", objSchema(map[string]any{"summary": strParam("整理结果总结"), "decisions": map[string]any{"type": "array", "items": decision}}, "summary", "decisions"), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { var a findingReviewConclusion if err := json.Unmarshal(raw, &a); err != nil { diff --git a/server/finding_case_reviews_test.go b/server/finding_case_reviews_test.go index 5134a78..2b15441 100644 --- a/server/finding_case_reviews_test.go +++ b/server/finding_case_reviews_test.go @@ -4,11 +4,46 @@ import ( "context" "encoding/json" "fmt" - "github.com/Autumn-27/artex/intercept" "strings" "testing" + + "github.com/Autumn-27/artex/intercept" ) +func TestFindingCaseReviewTaskMessagePolicy(t *testing.T) { + message := fmt.Sprintf(findingCaseReviewTaskMessage, `[101,102,103]`) + for _, want := range []string{ + "finding_ids=[101,102,103]", + "根因明确相同则直接归并", + "相同端点、处理器/后端组件、参数或缺失检查", + "缺少确认共同根因的关键证据", + "一个覆盖2..20条同任务疑似成员的分组", + "不要拆成两两建议", + "仅类型或资产相同不足以建议", + "没有具体共同点时保持 independent", + "不同代码路径时可判 independent", + "尽力更新统一报告", + "统一报告暂缺或待更新不阻塞 complete_finding_case_review", + "不归并未选记录", + "不探测目标", + "遵守人工否决", + } { + if !strings.Contains(message, want) { + t.Errorf("task message missing policy: %s", want) + } + } + if strings.Contains(message, "不确定提交建议") { + t.Fatal("task message suggests solely because of uncertainty") + } + schema, err := json.Marshal((&Server{}).toolCompleteFindingCaseReview().InputSchema()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(schema), "没有具体共同点应判 independent") || strings.Contains(string(schema), "不确定则说明缺失证据及已保存建议") { + t.Fatal("completion schema must not require suggestions solely because of uncertainty") + } +} + const completeFindingReviewReport = `# 同一权限缺陷 ## 概述 同一对象权限检查缺失。 diff --git a/server/finding_cases.go b/server/finding_cases.go index 46200cc..aecc436 100644 --- a/server/finding_cases.go +++ b/server/finding_cases.go @@ -397,6 +397,8 @@ func (s *Server) seedFindingCaseTools() { } names := []string{} previous, _, _ := s.m.pg.GetSetting("finding_case_tools_v1") + // v3 refreshes descriptions and schemas without repeating the v2 binding migration. + previousReview, _, _ := s.m.pg.GetSetting("finding_case_tools_v2_review") for _, t := range s.findingCaseTools() { schema, _ := json.Marshal(t.InputSchema()) if err := s.m.pg.RefreshToolDefaults(t.Name(), t.Description(), schema); err != nil { @@ -405,15 +407,27 @@ func (s *Server) seedFindingCaseTools() { if err := s.m.pg.SeedTool(t.Name(), t.Description(), schema, json.RawMessage(`["reporter"]`)); err != nil { return } - if previous != "true" || t.Name() == "complete_finding_case_review" { + needsBinding := previous != "true" + if t.Name() == "complete_finding_case_review" { + needsBinding = previousReview != "true" + } + if needsBinding { names = append(names, t.Name()) } } if err := s.m.pg.AddAgentToToolBinding("reporter", names); err != nil { return } + if previousReview != "true" { + if err := s.m.pg.SetSetting("finding_case_tools_v2_review", "true"); err != nil { + return + } + } _ = s.m.pg.SetSetting(flag, "true") } + +const findingCaseReviewTaskMessage = "人工选择整理(只读原始记录):本任务所选 finding_ids=%s。逐条读取已有报告,仅判断同一实际根因;根因明确相同则直接归并。只有证据指向相同端点、处理器/后端组件、参数或缺失检查等具体共同点,但缺少确认共同根因的关键证据时,才提交一个覆盖2..20条同任务疑似成员的分组,不要拆成两两建议;仅类型或资产相同不足以建议,没有具体共同点时保持 independent,不要因为不确定就提交建议。证据表明属于不同代码路径时可判 independent。已有或新建组应尽力更新统一报告;统一报告暂缺或待更新不阻塞 complete_finding_case_review,可由后续运行或人工补齐。禁止生成或覆盖原始报告、改评级状态、补绑证据,不归并未选记录,不探测目标,遵守人工否决。结束前调用 complete_finding_case_review 提交逐条判断并通过验收。" + func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) { var a findingCaseRequest if !decodeCaseRequest(w, r, &a) { @@ -479,7 +493,7 @@ func (s *Server) reviewFindingCases(w http.ResponseWriter, r *http.Request) { taskID, fids := reservation.TaskID, reservation.FindingIDs taskContext := taskContexts[taskID] raw, _ := json.Marshal(fids) - msg := fmt.Sprintf("人工选择整理(只读原始记录):本任务所选 finding_ids=%s。逐条读取已有报告,仅判断同一实际根因,明确则归并,不确定提交建议;已有组更新统一报告。禁止生成或覆盖原始报告、改评级状态、补绑证据,不归并未选记录,不探测目标。结束前调用 complete_finding_case_review 提交逐条判断并通过验收。", raw) + msg := fmt.Sprintf(findingCaseReviewTaskMessage, raw) item := triggeredRun{agentKey: "reporter", title: "漏洞整理 · task#" + i64s(taskID), message: msg, taskID: taskID, taskDesc: taskContext.taskDesc, taskGoal: taskContext.taskGoal, conversationID: reservation.ConversationID} cfg := s.readTriggerBehavior("reporter") s.queueMu.Lock() diff --git a/server/finding_cases_test.go b/server/finding_cases_test.go index 09037d8..d02917b 100644 --- a/server/finding_cases_test.go +++ b/server/finding_cases_test.go @@ -42,6 +42,115 @@ func testCaseServer(t *testing.T) (*Server, int64, []int64) { t.Cleanup(func() { _, _ = pg.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = pg.DeleteTask(task.ID) }) return &Server{m: &Manager{pg: pg}}, task.ID, ids } + +func TestFindingCaseToolMigrationPreservesBindings(t *testing.T) { + s, _, _ := testCaseServer(t) + pg := s.m.pg + for _, flag := range []string{"finding_case_tools_v1", "finding_case_tools_v2_review", "finding_case_tools_v3_review"} { + value, exists, err := pg.GetSetting(flag) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if exists { + if err := pg.SetSetting(flag, value); err != nil { + t.Error(err) + } + } else if _, err := pg.Exec(`DELETE FROM settings WHERE key=$1`, flag); err != nil { + t.Error(err) + } + }) + } + for _, tool := range s.findingCaseTools() { + key := tool.Name() + old, err := pg.GetTool(key) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if old == nil { + if _, err := pg.Exec(`DELETE FROM tools WHERE key=$1`, key); err != nil { + t.Error(err) + } + return + } + bindings, _ := json.Marshal(old.Agents) + if err := pg.UpdateTool(key, old.Description, old.Schema, bindings, old.Enabled); err != nil { + t.Error(err) + } + }) + if err := pg.SeedTool(key, tool.Description(), nil, nil); err != nil { + t.Fatal(err) + } + } + for _, tc := range []struct { + name string + v1, v2 bool + }{ + {name: "initial"}, + {name: "v1_to_v3", v1: true}, + {name: "v2_to_v3", v1: true, v2: true}, + {name: "v2_without_v1_flag", v2: true}, + } { + t.Run(tc.name, func(t *testing.T) { + for flag, value := range map[string]bool{ + "finding_case_tools_v1": tc.v1, + "finding_case_tools_v2_review": tc.v2, + "finding_case_tools_v3_review": false, + } { + if value { + if err := pg.SetSetting(flag, "true"); err != nil { + t.Fatal(err) + } + } else if _, err := pg.Exec(`DELETE FROM settings WHERE key=$1`, flag); err != nil { + t.Fatal(err) + } + } + for _, tool := range s.findingCaseTools() { + if err := pg.UpdateTool(tool.Name(), "old description", json.RawMessage(`{}`), json.RawMessage(`["custom-agent"]`), false); err != nil { + t.Fatal(err) + } + } + s.seedFindingCaseTools() + for _, tool := range s.findingCaseTools() { + row, err := pg.GetTool(tool.Name()) + if err != nil || row == nil { + t.Fatal("missing tool", tool.Name(), err) + } + wantReporter := !tc.v1 + if tool.Name() == "complete_finding_case_review" { + wantReporter = !tc.v2 + } + if contains(row.Agents, "reporter") != wantReporter || !contains(row.Agents, "custom-agent") || row.Enabled { + t.Fatalf("binding/enabled configuration changed incorrectly: %+v", row) + } + if row.Description != tool.Description() || len(row.Schema) <= 2 { + t.Fatalf("description/schema not refreshed: %+v", row) + } + } + for _, flag := range []string{"finding_case_tools_v2_review", "finding_case_tools_v3_review"} { + if value, _, err := pg.GetSetting(flag); err != nil || value != "true" { + t.Fatalf("migration flag %s not saved: %q, %v", flag, value, err) + } + } + if err := pg.RemoveAgentFromTool("reporter", "complete_finding_case_review"); err != nil { + t.Fatal(err) + } + // A refresh retry must not replay a completed legacy binding migration. + if tc.v1 { + if err := pg.SetSetting("finding_case_tools_v3_review", "false"); err != nil { + t.Fatal(err) + } + } + s.seedFindingCaseTools() + row, err := pg.GetTool("complete_finding_case_review") + if err != nil || row == nil || contains(row.Agents, "reporter") { + t.Fatal("refresh restored an operator-removed binding", row, err) + } + }) + } +} + func TestFindingCaseAPIPaginationAndTools(t *testing.T) { s, task, ids := testCaseServer(t) ctx := context.Background()