From dbd00b846efbe3da53d6feb1cce3e803caaf85c3 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:26:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=B1=E6=8A=A5=E5=91=8A=20Agent=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=85=B3=E8=81=94=E6=B5=81=E9=87=8F=E8=AF=81?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent/assembly.go | 2 +- agent/finding_workflow.go | 18 ++--- docs/漏洞流量证据.md | 6 +- server/finding_workflow.go | 21 +++--- server/finding_workflow_test.go | 73 +++++++++++++++++++-- server/orchestration.go | 8 ++- web/src/app/(main)/system/settings/page.tsx | 2 +- 7 files changed, 99 insertions(+), 31 deletions(-) diff --git a/agent/assembly.go b/agent/assembly.go index 28ce000..e198982 100644 --- a/agent/assembly.go +++ b/agent/assembly.go @@ -56,6 +56,6 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool) if ToolResolve != nil { out = ToolResolve(ctx, agentKey, out) } - out, def.FindingGuidance = findingWorkflowTools(out) + out, def.FindingGuidance = findingWorkflowTools(agentKey, out) return out, def, cleanup } diff --git a/agent/finding_workflow.go b/agent/finding_workflow.go index b82ac28..3bc8e7e 100644 --- a/agent/finding_workflow.go +++ b/agent/finding_workflow.go @@ -24,13 +24,16 @@ const findingTrafficDisabled = "Agent 自动绑定流量已关闭;请在系统 // Applied after ToolResolve: user descriptions and prompts remain intact, while // all actual reporters (including Planner and custom chat agents) see the same // API contract. Disabled/unbound tools are never reintroduced here. -func findingWorkflowTools(tools []actool.CoreTool) ([]actool.CoreTool, string) { +func findingWorkflowTools(agentKey string, tools []actool.CoreTool) ([]actool.CoreTool, string) { if !findingTrafficBindingEnabled() { out := make([]actool.CoreTool, 0, len(tools)) for _, tool := range tools { if tool.Name() == "bind_finding_traffic" { continue } + if agentKey == "reporter" && (tool.Name() == "traffic_search" || tool.Name() == "traffic_get" || tool.Name() == "traffic_blob") { + continue + } switch tool.Name() { case "report_finding", "add_hint", "add_task_hint": // Work on a copy: toggling back on must restore the original schema. @@ -52,7 +55,7 @@ func findingWorkflowTools(tools []actool.CoreTool) ([]actool.CoreTool, string) { note := "" switch tool.Name() { case "report_finding": - note = findingTrafficGuidance + "\n已有对应的、经核实的 HTTP 流量时,随本次上报提交 traffic_refs。代为上报时保留交接中的引用,或传 evidence_hint_id 读取本任务指定 hint 的结构化 traffic_refs;不要只复制文字结论。无数据包仍可上报。返回 finding_id 与 finding_node_id 分别表示独立记录和探索节点。" + note = "\n默认由报告 Agent 在编写报告前核对并绑定流量。上报者在 evidence 中保留验证命令、关键输出、已有的真实流量 ID 及其用途,供报告 Agent 对照执行记录核实;无需为绑定额外查包。兼容显式即时绑定:traffic_refs 或 evidence_hint_id 可提交已核实的引用,后者读取本任务指定 hint 的结构化引用;任一无效则本次上报全部失败。TCP/无包不需要这些可选参数。返回 finding_id 与 finding_node_id 分别表示独立记录和探索节点。" case "add_hint", "add_task_hint": note = "\n交接已确认漏洞时,在对应提示的 traffic_refs 中保留已核实流量的 ID、用途、说明和顺序(单条放顶层,批量放对应 hints 元素),并在 text 中说明它证明的具体漏洞。调用方不能只交接文字而丢弃已有流量引用。未核实的候选不能作为证据传递。" case "get_finding_traffic", "bind_finding_traffic", "list_findings", "list_task_findings", "node_detail", "get_task_node_detail", "update_finding_report": @@ -64,19 +67,16 @@ func findingWorkflowTools(tools []actool.CoreTool) ([]actool.CoreTool, string) { } guidance := "" if has["report_finding"] || has["add_task_hint"] || has["add_hint"] { - guidance = findingTrafficGuidance + "\n**代上报与交接**:有已核实 HTTP 证据时,报告保存应同时携带 traffic_refs;用 add_hint / add_task_hint 交接时保留结构化引用。收到交接提示后用 evidence_hint_id 明确选择本任务对应 hint,不能混用其他漏洞的证据。若只收到文字,先查已有执行记录或请原执行者交接证据,不要仅为补包重新探测。只有拿到真实 ID 才绑定,TCP 或无包时正常上报。" - if has["traffic_search"] && has["traffic_get"] { - guidance += "\n可用 traffic_search 筛选,再用 traffic_get 逐条核实请求/响应及真实 ID。" - } + guidance = "\n\n**流量证据交接(可选)**:自动绑定默认由报告 Agent 在漏洞入库后、编写报告前完成。上报者应在 evidence 保留验证命令、关键输出、已有真实流量 ID 及其用途,任务中带 intent_id,便于报告 Agent 追溯;不必为了绑定额外查包。Auto / Planner 代为上报时不要丢弃执行者已有的引用。add_hint / add_task_hint 可用 traffic_refs 交接;显式即时绑定仍兼容 report_finding 的 traffic_refs / evidence_hint_id。TCP 或无包时正常登记,不能猜测 ID,也不能仅为补包重复探测。" if has["add_task_hint"] && !has["add_hint"] { guidance += "\n平台对话没有任务上下文时,不直接调用 report_finding;通过 add_task_hint 向已有对应任务交接,由任务 Agent 登记,并用 list_task_findings 核对结果。" } if has["prove_goal"] || has["goal_met"] { guidance += "\n判定目标完成前,先完成本次已有证据的上报/交接。不要在证据交接尚未完成时仅因文字漏洞已登记就结束任务、取消 Worker;无包不要求等待或强行抓包。" } - if has["bind_finding_traffic"] { - guidance += "\n若漏洞已登记而遗漏了已有的真实流量,用 bind_finding_traffic 补绑,不重复创建漏洞。" - } + } + if has["update_finding_report"] && has["bind_finding_traffic"] && has["get_finding_traffic"] { + guidance += "\n\n**报告前自动关联流量(已开启)**:你负责为本次触发的漏洞核对并绑定流量,再撰写报告。先从 report_finding 返回 JSON 或 get_task_node_detail / list_task_findings 取得明确的 finding_id 与 finding_node_id。读取漏洞详情、对应意图的执行记录及已有证据清单,优先使用上报者交接的真实 ID。若本次验证为 HTTP 且流量工具可用,用 traffic_search 筛选候选,再用 traffic_get 逐条核实请求/响应确实支持该漏洞;域名和时间只用于筛选,不证明归属。将确认的证据按复现顺序用 bind_finding_traffic(finding_id, traffic_refs) 关联,选择 baseline / proof / verification / supporting 并说明用途。只能操作本次漏洞,不重复创建漏洞或重新探测目标。绑定成功后重新调用 get_finding_traffic 获取最新 version,读取所需正文,再将实际读取的 version 作为 evidence_version 传给 update_finding_report(其 finding_id 参数仍用 finding_node_id)。已有绑定不必重复追加。TCP、未采集、工具不可用或没有确切匹配时,跳过自动绑定,依据文字/命令证据正常写报告并说明原因,不得为凑齐流量而猜测。绑定失败不宣称成功;保留已有证据并在报告说明未绑定原因。" } if guidance != "" || has["get_finding_traffic"] || has["update_finding_report"] { guidance += findingIDGuidance diff --git a/docs/漏洞流量证据.md b/docs/漏洞流量证据.md index 7566261..92c5814 100644 --- a/docs/漏洞流量证据.md +++ b/docs/漏洞流量证据.md @@ -4,7 +4,9 @@ 系统设置中的「Agent 自动绑定流量」默认关闭,读取和修改接口为 `/api/settings` 的 `agent_traffic_binding`。开启会增加查阅请求/响应、工具调用及提示词带来的 Token 消耗;下一轮 Agent 使用新设置。关闭时隐藏自动绑定参数和补绑工具,不注入自动绑定指引,并拒绝已运行会话新提交的自动绑定。人工绑定、流量捕获、已保存证据读取和导出不受影响。 -开启后,Agent 在发现漏洞、调用 `report_finding` 时可一并绑定多条流量,报告 Agent 随后读取这些快照撰写报告。绑定可选:TCP 等非 HTTP 漏洞、未采集或找不到确切记录时,省略 `traffic_refs` 或传空数组仍可正常上报;在 `evidence` 保留命令输出、日志等其他可验证证据,建议说明未绑定原因,不新增必填字段。提交的 ID 必须全部有效且正文完整。任何一条失败都会回滚本次业务记录。重复追加同一快照不增加绑定,也不覆盖说明。 +开启后,默认流程是「发现入库 → 自动触发报告 Agent → 核对并绑定流量 → 按最新证据版本写报告」。上报者在 `evidence` 保留验证命令、关键输出、已有真实流量 ID 及用途;报告 Agent 结合漏洞详情和执行记录,用 `traffic_search` / `traffic_get` 核实后调用 `bind_finding_traffic`,再读取最新 `version` 保存报告。关闭开关时,报告 Agent 不额外获得原始流量检索/读取工具,也不自动绑定;仍能读取人工绑定的快照并生成报告。 + +兼容已有调用者:`report_finding` 的 `traffic_refs` / `evidence_hint_id` 仍可显式即时绑定。绑定可选:TCP 等非 HTTP 漏洞、未采集或找不到确切记录时,省略引用仍可正常上报和撰写报告;保留命令输出、日志等其他可验证证据,建议说明未绑定原因,不新增必填字段。提交的 ID 必须全部有效且正文完整。任何一条失败都会回滚本次绑定操作;显式随上报绑定失败时整次上报回滚。重复追加同一快照不增加绑定,也不覆盖说明。 ## Agent 编号与报告版本 @@ -33,7 +35,7 @@ 已有漏洞漏绑时可用 `bind_finding_traffic(finding_id, traffic_refs)` 补绑,无需重复登记。`list_findings` / `list_task_findings` / `node_detail` / `get_task_node_detail` 返回明确的 `finding_id` 和 `finding_node_id`;旧 `id` 保持探索节点语义。 -启动时仅为旧工具 schema 增加可选属性,原始默认流量工具绑定扩展至内置上报角色;自定义绑定列表、提示词、描述及启用状态保留。自动绑定指引在最终工具装配后统一加入,覆盖 Worker、Planner、MainAgent、Auto 及获授权的自定义 Agent。平台对话缺少任务上下文时应通过结构化提示交接给任务 Agent,不直接上报。判定任务完成前应先交接已有证据;无包不强制等待。失败的 `report_finding` 不触发报告 Agent。 +启动时仅为旧工具 schema 增加可选属性,原始默认流量工具绑定扩展至报告 Agent;补绑工具默认交给报告 Agent。自定义绑定列表、提示词、描述及启用状态保留。指引在最终工具装配后统一加入:上报角色负责交接已有证据,报告 Agent 负责核对、绑定和写报告。平台对话缺少任务上下文时应通过结构化提示交接给任务 Agent,不直接上报。判定任务完成前应先交接已有证据;无包不强制等待。失败的 `report_finding` 不触发报告 Agent。 ## API diff --git a/server/finding_workflow.go b/server/finding_workflow.go index 458d1b3..64c90a0 100644 --- a/server/finding_workflow.go +++ b/server/finding_workflow.go @@ -14,7 +14,7 @@ import ( ) func (s *Server) seedFindingWorkflowTools() { - const flag = "finding_workflow_tools_v1" + const flag = "finding_workflow_tools_v2_reporter" if value, _, _ := s.m.pg.GetSetting(flag); value == "true" { return } @@ -72,24 +72,21 @@ func (s *Server) seedFindingWorkflowTools() { } // Upgrade only the original default binding. Customized lists and enabled // flags survive; the one-time flag also preserves future user unbinding. - readers := `["worker","planner","mainagent","auto","pentest"]` + readers := `["worker","reporter"]` for _, key := range []string{"traffic_search", "traffic_get", "traffic_blob"} { - if _, err := s.m.pg.Exec(`UPDATE tools SET agents=$2::jsonb WHERE key=$1 AND system AND agents='["worker"]'::jsonb`, key, readers); err != nil { + if _, err := s.m.pg.Exec(`UPDATE tools SET agents=$2::jsonb WHERE key=$1 AND system AND (agents='["worker"]'::jsonb OR (agents @> '["worker","planner","mainagent","auto","pentest"]'::jsonb AND jsonb_array_length(agents)=5))`, key, readers); err != nil { return } } if _, err := s.m.pg.Exec(`UPDATE tools SET agents=$1::jsonb WHERE key='get_finding_traffic' AND system AND agents @> '["auto","reporter"]'::jsonb AND jsonb_array_length(agents)=2`, `["auto","reporter","worker","planner","mainagent","pentest"]`); err != nil { return } - if report, err := s.m.pg.GetTool("report_finding"); err == nil && report != nil { - binding, _ := s.m.pg.GetTool("bind_finding_traffic") - if binding != nil && binding.System { - for _, key := range report.Agents { - if err := s.m.pg.AddAgentToToolBinding(key, []string{"bind_finding_traffic"}); err != nil { - return - } - } - } + // Replace the previous code default only; preserve customized binding lists. + if _, err := s.m.pg.Exec(`UPDATE tools SET agents='["reporter"]'::jsonb WHERE key='bind_finding_traffic' AND system AND agents @> '["worker","planner","mainagent","auto","pentest"]'::jsonb AND jsonb_array_length(agents)=5`); err != nil { + return + } + if err := s.m.pg.AddAgentToToolBinding("reporter", []string{"bind_finding_traffic"}); err != nil { + return } _ = s.m.pg.SetSetting(flag, "true") } diff --git a/server/finding_workflow_test.go b/server/finding_workflow_test.go index 39911ab..8d2b2ad 100644 --- a/server/finding_workflow_test.go +++ b/server/finding_workflow_test.go @@ -211,7 +211,7 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) { t.Fatal(err) } } - if err := pg.SetSetting("finding_workflow_tools_v1", "false"); err != nil { + if err := pg.SetSetting("finding_workflow_tools_v2_reporter", "false"); err != nil { t.Fatal(err) } s.seedFindingWorkflowTools() @@ -233,15 +233,80 @@ func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) { } search, _ := pg.GetTool("traffic_search") get, _ := pg.GetTool("traffic_get") - if search.Enabled || !contains(search.Agents, "planner") || get.Enabled || len(get.Agents) != 1 || get.Agents[0] != "custom-agent" { + if search.Enabled || !contains(search.Agents, "reporter") || get.Enabled || len(get.Agents) != 1 || get.Agents[0] != "custom-agent" { t.Fatal("default/custom reader binding migration incorrect") } - if err := pg.RemoveAgentFromTool("planner", "traffic_search"); err != nil { + if err := pg.RemoveAgentFromTool("reporter", "traffic_search"); err != nil { t.Fatal(err) } s.seedFindingWorkflowTools() search, _ = pg.GetTool("traffic_search") - if contains(search.Agents, "planner") { + if contains(search.Agents, "reporter") { t.Fatal("one-time migration undid later unbinding") } } + +func TestFindingWorkflowReporterBindsBeforeWritingReport(t *testing.T) { + s, f, request := trafficEvidenceServer(t) + pg := s.m.pg + ctx := context.Background() + if err := pg.SetBool(settingAgentTrafficBinding, true); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { pg.SetBool(settingAgentTrafficBinding, false); s.m.SetTrafficEnabled(false) }) + if err := s.m.SetTrafficEnabled(true); err != nil { + t.Fatal(err) + } + seedServerEvidenceFlow(t, s, "reporter-proof", []byte("local proof payload")) + seedServerEvidenceFlow(t, s, "reporter-baseline", []byte("local normal response")) + tools, def, cleanup := agent.AugmentTools(ctx, "reporter", nil) + defer cleanup() + if !strings.Contains(def.FindingGuidance, "报告前自动关联流量") || !strings.Contains(def.FindingGuidance, "绑定成功后重新调用") { + t.Fatal("reporter did not receive binding workflow") + } + for _, name := range []string{"traffic_search", "traffic_get", "get_task_worker_trace", "get_task_node_detail", "bind_finding_traffic", "get_finding_traffic", "update_finding_report"} { + workflowTool(t, tools, name) + } + finding, _ := pg.GetFinding(f.FindingID) + detail := workflowCall(t, ctx, workflowTool(t, tools, "get_task_node_detail"), map[string]any{"task_id": fmt.Sprint(*finding.TaskID), "id": f.NodeID}, false) + if !strings.Contains(detail, `"finding_id"`) || !strings.Contains(detail, `"finding_node_id"`) { + t.Fatal("reporter lacks explicit ID mapping") + } + workflowCall(t, ctx, workflowTool(t, tools, "traffic_search"), map[string]any{"host": "evidence.local"}, false) + for _, id := range []string{"reporter-baseline", "reporter-proof"} { + packet := workflowCall(t, ctx, workflowTool(t, tools, "traffic_get"), map[string]any{"id": id}, false) + if !strings.Contains(packet, "local") { + t.Fatal("reporter cannot inspect packet", packet) + } + } + refs := []db.TrafficRef{{TrafficID: "reporter-baseline", Role: "baseline"}, {TrafficID: "reporter-proof", Role: "proof", Note: "confirmed from recorded response"}} + workflowCall(t, ctx, workflowTool(t, tools, "bind_finding_traffic"), map[string]any{"finding_id": f.FindingID, "traffic_refs": refs}, false) + list := workflowCall(t, ctx, workflowTool(t, tools, "get_finding_traffic"), map[string]any{"finding_id": f.FindingID}, false) + var summary struct { + Version int64 `json:"version"` + } + if err := json.Unmarshal([]byte(list), &summary); err != nil || summary.Version != 1 { + t.Fatal(list, err) + } + workflowCall(t, ctx, workflowTool(t, tools, "update_finding_report"), map[string]any{"finding_id": f.NodeID, "evidence_version": summary.Version, "report": "## Local report\n\nVerified baseline and proof using saved evidence."}, false) + updated, err := pg.GetFinding(f.FindingID) + if err != nil || updated.ReportEvidenceVersion != summary.Version || updated.EvidenceVersion != summary.Version { + t.Fatal("report did not cover post-binding version", updated, err) + } + // Disabling automatic binding still lets Reporter read manually bound snapshots. + if r := request("PUT", "/api/settings", `{"agent_traffic_binding":false}`); r.Code != 200 { + t.Fatal(r.Code, r.Body) + } + off, offDef, closeOff := agent.AugmentTools(ctx, "reporter", nil) + defer closeOff() + if offDef.FindingGuidance != "" { + t.Fatal("off reporter still receives auto-binding guidance") + } + for _, tool := range off { + if tool.Name() == "traffic_search" || tool.Name() == "traffic_get" || tool.Name() == "traffic_blob" || tool.Name() == "bind_finding_traffic" { + t.Fatal("auto-binding tool exposed while off", tool.Name()) + } + } + workflowCall(t, ctx, workflowTool(t, off, "get_finding_traffic"), map[string]any{"finding_id": f.FindingID}, false) + workflowTool(t, off, "update_finding_report") +} diff --git a/server/orchestration.go b/server/orchestration.go index b532e9f..30ed24a 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -444,7 +444,11 @@ func (s *Server) seedOrchestrationTools() { autoAgents, _ := json.Marshal([]string{"auto"}) for _, t := range s.orchestrationTools() { schema, _ := json.Marshal(t.InputSchema()) - _ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, autoAgents) + bindings := autoAgents + if t.Name() == "bind_finding_traffic" { + bindings = json.RawMessage(`["reporter"]`) + } + _ = s.m.PG().SeedTool(t.Name(), t.Description(), schema, bindings) } for _, t := range s.platformTools() { schema, _ := json.Marshal(t.InputSchema()) @@ -691,7 +695,7 @@ func (s *Server) seedReporterAgent() { OnToolCall: true, ToolNames: []string{"report_finding"}, ToolCallMessage: "上面刚有一个漏洞被 report_finding 登记。请读取返回 JSON 的 finding_id(独立漏洞记录 ID)与 finding_node_id(探索节点 ID)," + - "用 get_finding_traffic(finding_id) 读取证据;节点详情使用 finding_node_id。" + + "若运行指引启用自动绑定,先核实并关联本次漏洞的流量,再用 get_finding_traffic(finding_id) 读取最新证据;节点详情使用 finding_node_id。" + "最后调用 update_finding_report(finding_id=finding_node_id, report, evidence_version=实际读取版本) 保存。不要混用两种编号。", }); err != nil { log.Printf("[reporter] 创建触发器失败: %v", err) diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index a1d41a2..b555343 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -281,7 +281,7 @@ export default function SystemSettingsPage() { Agent 自动绑定流量 - 默认关闭。开启后,Agent 会核对已有 HTTP 请求/响应,并在上报漏洞或交接任务时关联对应流量。 + 默认关闭。开启后,漏洞入库时触发的报告 Agent 会核对已有 HTTP 请求/响应,关联对应流量后再编写报告。 查阅数据包及额外的工具调用会增加 Token 消耗。
TCP、未抓包或没有匹配流量时仍可正常上报。此开关不影响流量捕获、人工绑定及已保存证据的查看。 对下一轮 Agent