From 6b79043d822e116d74bbd7dcc2de45c4288f790c Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:36:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E6=80=BB=E8=A7=88=E9=A1=B5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BB=BB=E5=8A=A1=E6=B5=8B=E8=AF=95=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E7=9A=84=E6=9F=A5=E7=9C=8B/=E5=A2=9E=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db: 新增 DeleteTaskScope;AddAgentScope 加 source 参数区分 agent/manual - server: 新增 POST/DELETE /api/tasks/{id}/scope 端点 - web: 总览页新增「测试范围」卡片,可按类型手动增删,继承范围只读 Claude-Session: https://claude.ai/code/session_014TfZWeVGhjXqZD3vcj44mz --- agent/tools_insert.go | 2 +- db/exploration_sources_test.go | 6 +- db/task_scope.go | 21 +- server/server.go | 60 ++++ .../tasks/detail/_tabs/overview-tab.tsx | 274 ++++++++++------ web/src/lib/api.ts | 300 ++++++++++++------ web/src/lib/mock/handler.ts | 6 + web/src/lib/types.ts | 12 + 8 files changed, 488 insertions(+), 193 deletions(-) diff --git a/agent/tools_insert.go b/agent/tools_insert.go index 65f7cf2..f2b943c 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -382,7 +382,7 @@ func (t *ToolSet) addTaskScope() actool.CoreTool { var added []map[string]any errs := map[string]string{} for i, e := range items { - ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason) + ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason, "agent") if err != nil { errs[strconv.Itoa(i)] = err.Error() continue diff --git a/db/exploration_sources_test.go b/db/exploration_sources_test.go index 7c66fae..07ceb29 100644 --- a/db/exploration_sources_test.go +++ b/db/exploration_sources_test.go @@ -356,13 +356,13 @@ func TestTaskAssetContextUsesDirectSourceScopeAndAnchors(t *testing.T) { _ = d.DeleteTask(grand.ID) _, _ = assets.DeleteByIDs([]int64{sourceTestedID, sourceUntestedID, sourceAnchoredOnlyID, grandID}) }) - if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test"); err != nil { + if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test", "agent"); err != nil { t.Fatal(err) } - if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test"); err != nil { + if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test", "agent"); err != nil { t.Fatal(err) } - if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test"); err != nil { + if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test", "agent"); err != nil { t.Fatal(err) } if _, err := d.Exploration(source.ExplorationID).AddNode(KindFact, map[string]any{"summary": "tested source asset"}, 0, "confirmed", "worker", []int64{sourceTestedID}); err != nil { diff --git a/db/task_scope.go b/db/task_scope.go index 2c69dcd..af1fc4a 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -113,11 +113,15 @@ func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip st return nil } -// AddAgentScope parses an agent-provided (kind,value) and records it (source='agent'). +// AddAgentScope parses a (kind,value) pair and records it as task scope. +// source is "agent" when called from an LLM tool, "manual" from the UI. // company: value = company name or id (must already exist). root_domain/subdomain: // value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128). -func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (TaskScope, error) { - ts := TaskScope{TaskID: taskID, Kind: kind, Source: "agent", Reason: reason} +func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason, source string) (TaskScope, error) { + if source == "" { + source = "agent" + } + ts := TaskScope{TaskID: taskID, Kind: kind, Source: source, Reason: reason} if taskID <= 0 { return ts, fmt.Errorf("需要 task_id") } @@ -184,6 +188,17 @@ func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (Ta return ts, nil } +// DeleteTaskScope removes a single scope row by id, scoped to the given task. +// Returns whether a row was actually deleted. +func (s *AssetStore) DeleteTaskScope(taskID, scopeID int64) (bool, error) { + res, err := s.db.Exec(`DELETE FROM task_scope WHERE id=$1 AND task_id=$2`, scopeID, taskID) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + // ListTaskScope returns all scope rows for a task. func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) { rows, err := s.db.Query(` diff --git a/server/server.go b/server/server.go index 2f44d26..ecaf168 100644 --- a/server/server.go +++ b/server/server.go @@ -647,6 +647,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/workspace/download", s.wsDownload) mux.HandleFunc("POST /api/workspace/upload", s.wsUpload) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) + mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd) + mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete) mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles) mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent) @@ -1634,6 +1636,64 @@ func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"scope": rows}) } +func (s *Server) taskScopeAdd(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + var body struct { + Kind string `json:"kind"` + Value string `json:"value"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, 400, "invalid JSON") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + ts, err := as.AddAgentScope(taskID, body.Kind, body.Value, body.Reason, "manual") + if err != nil { + writeErr(w, 400, err.Error()) + return + } + writeJSON(w, 200, ts) +} + +func (s *Server) taskScopeDelete(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + scopeID, err := strconv.ParseInt(r.PathValue("sid"), 10, 64) + if err != nil { + writeErr(w, 400, "invalid scope id") + return + } + deleted, err := as.DeleteTaskScope(taskID, scopeID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if !deleted { + writeErr(w, 404, "scope row not found") + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + func (s *Server) frontier(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) if t == nil { diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index f5fd9de..49722b7 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -1,27 +1,49 @@ "use client"; import * as React from "react"; + import { ActivityIcon, AlertTriangleIcon, BugIcon, ClockIcon, + PlusIcon, RefreshCwIcon, ShieldCheckIcon, TargetIcon, + Trash2Icon, } from "lucide-react"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; + import { StatusBadge } from "@/components/status-badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { Progress } from "@/components/ui/progress"; import { api } from "@/lib/api"; -import type { Task, Stats, TaskNode, Finding } from "@/lib/types"; +import type { Finding, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types"; + +// 测试范围一条的显示值:域名 / 网段 / 公司。 +function scopeValue(row: TaskScopeRow): string { + if (row.domain) return row.domain; + if (row.net) return row.net; + if (row.company_id) return `company #${row.company_id}`; + return "—"; +} + +const SCOPE_KIND_LABELS: Record = { + company: "公司", + root_domain: "根域名", + subdomain: "子域名", + ip: "IP", + cidr: "网段", +}; + +const SCOPE_SOURCE_LABELS: Record = { + auto: "自动", + agent: "Agent", + manual: "手动", +}; function StatCard({ label, @@ -42,9 +64,7 @@ function StatCard({ {value} - {sub && ( - {sub} - )} + {sub && {sub}} ); } @@ -63,6 +83,46 @@ export function OverviewTab({ taskId }: { taskId: string }) { } | null>(null); // 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。 const [rerunning, setRerunning] = React.useState>(new Set()); + // 测试范围列表 + 新增表单状态。 + const [scope, setScope] = React.useState([]); + const [scopeKind, setScopeKind] = React.useState("root_domain"); + const [scopeValueInput, setScopeValueInput] = React.useState(""); + const [scopeBusy, setScopeBusy] = React.useState(false); + const [scopeErr, setScopeErr] = React.useState(""); + + const loadScope = React.useCallback(async () => { + try { + const resp = await api.taskScope(taskId); + setScope(resp.scope); + } catch { + // 忽略:无 asset store 时接口 503,范围卡片自然为空 + } + }, [taskId]); + + const addScope = async () => { + const value = scopeValueInput.trim(); + if (!value) return; + setScopeBusy(true); + setScopeErr(""); + try { + await api.addTaskScope(taskId, scopeKind, value); + setScopeValueInput(""); + await loadScope(); + } catch (e) { + setScopeErr(e instanceof Error ? e.message : "添加失败"); + } finally { + setScopeBusy(false); + } + }; + + const removeScope = async (row: TaskScopeRow) => { + setScope((prev) => prev.filter((s) => s.id !== row.id)); + try { + await api.deleteTaskScope(taskId, row.id); + } catch { + await loadScope(); // 删除失败:重新拉取还原 + } + }; const markRerun = (key: string, on: boolean) => setRerunning((prev) => { @@ -77,9 +137,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { markRerun(id, true); try { await api.rerunIntent(taskId, id); - setIntents((prev) => - prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)), - ); + setIntents((prev) => prev.map((i) => (i.id === id ? { ...i, state: "open" } : i))); } catch { // 失败忽略:下次轮询仍显示 blocked,用户可再点 } finally { @@ -92,9 +150,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { markRerun("__all__", true); try { await api.rerunBlocked(taskId); - setIntents((prev) => - prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)), - ); + setIntents((prev) => prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i))); } catch { // ignore } finally { @@ -107,13 +163,12 @@ export function OverviewTab({ taskId }: { taskId: string }) { const load = async () => { try { - const [tasksResp, statsResp, intentsResp, findingsResp] = - await Promise.all([ - api.tasks(), - api.stats(taskId), - api.intents(taskId), - api.findings(taskId), - ]); + const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([ + api.tasks(), + api.stats(taskId), + api.intents(taskId), + api.findings(taskId), + ]); if (cancelled) return; setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null); setStats(statsResp); @@ -133,20 +188,19 @@ export function OverviewTab({ taskId }: { taskId: string }) { }; load(); + void loadScope(); const timer = setInterval(load, 3000); return () => { cancelled = true; clearInterval(timer); }; - }, [taskId]); + }, [taskId, loadScope]); const running = intents.filter((i) => i.state === "running"); const open = intents.filter((i) => i.state === "open"); const blocked = intents.filter((i) => i.state === "blocked"); const taskFindings = findings.filter((f) => f.task_id === taskId); - const goalsPct = task?.goals_total - ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) - : 0; + const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0; return (
@@ -160,15 +214,11 @@ export function OverviewTab({ taskId }: { taskId: string }) {
描述
-

- {task?.description?.trim() || "—"} -

+

{task?.description?.trim() || "—"}

目标
-

- {task?.goal?.trim() || "—"} -

+

{task?.goal?.trim() || "—"}

@@ -205,6 +255,86 @@ export function OverviewTab({ taskId }: { taskId: string }) { )} + {/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */} + + + + 测试范围 + + (覆盖度分母 + 授权边界,共 {scope.length} 条) + + + + + {/* 新增表单 */} +
+ setScopeKind(e.target.value as TaskScopeRow["kind"])} + > + 根域名 + 子域名 + IP + 网段 + 公司 + + setScopeValueInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addScope(); + }} + disabled={scopeBusy} + /> + + {scopeErr && {scopeErr}} +
+ {/* 范围列表 */} + {scope.length > 0 ? ( +
+ {scope.map((row) => ( +
+ + {SCOPE_KIND_LABELS[row.kind]} + + {scopeValue(row)} + {SCOPE_SOURCE_LABELS[row.source]} + {row.task_id.toString() === taskId ? ( + + ) : ( + 继承 + )} +
+ ))} +
+ ) : ( +

暂无测试范围,添加后可作为资产覆盖度的分母。

+ )} +
+
{/* Heartbeat */} @@ -224,17 +354,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
运行中 Worker
-
- {running.length} -
+
{running.length}
最近活动
- {task?.last_activity - ? new Date(task.last_activity).toLocaleTimeString("zh-CN") - : "—"} + {task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
@@ -270,9 +396,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { {i.payload}
))} - {running.length === 0 && ( -

暂无进行中意图

- )} + {running.length === 0 &&

暂无进行中意图

} @@ -284,27 +408,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
-
- {taskFindings.length} -
+
{taskFindings.length}
确认漏洞
-
- {running.length} -
+
{running.length}
执行中
-
- {open.length} -
+
{open.length}
frontier 待领
-
- {blocked.length} -
+
{blocked.length}
被拦意图
@@ -323,9 +439,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { {f.summary} ))} - {taskFindings.length === 0 && ( -

暂无发现

- )} + {taskFindings.length === 0 &&

暂无发现

} @@ -337,19 +451,10 @@ export function OverviewTab({ taskId }: { taskId: string }) { 被拦/出错意图 - - (共 {blocked.length} 条,可重跑) - + (共 {blocked.length} 条,可重跑) - @@ -365,9 +470,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { disabled={rerunning.has(i.id)} onClick={() => void rerunOne(i.id)} > - + 重跑 @@ -383,24 +486,9 @@ export function OverviewTab({ taskId }: { taskId: string }) { {/* Stat cards */}
- - - + + +
); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9b8e611..63595d4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,15 +6,59 @@ import { MOCK } from "@/lib/mock/enabled"; import { mockHandle } from "@/lib/mock/handler"; import type { - Task, DeleteTaskOptions, DeleteTaskResult, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus, - FindingsPage, FindingStats, FindingQuery, Activity, - Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, - PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, - Tool, Conversation, AgentTrigger, ChatAttachment, - InterceptRule, InterceptPending, InterceptApprovalRow, - TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs, - WorkspaceListing, WorkspaceFile, - CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, LLMPoolStatus, + Activity, + Agent, + AgentDetail, + AgentTrigger, + Asset, + AssetNode, + Audit, + ChatAttachment, + CommandRecord, + Company, + Conversation, + ConvTokenSummary, + CoverageAssetRefs, + CoverageGraphData, + DailyTokenBucket, + DeleteTaskOptions, + DeleteTaskResult, + Edge, + Finding, + FindingQuery, + FindingStats, + FindingStatus, + FindingsPage, + InterceptApprovalRow, + InterceptPending, + InterceptRule, + LLMPoolStatus, + LLMProfile, + LLMRecordDetail, + LLMRecordItem, + LLMTask, + MCPServer, + MCPTool, + PromptVar, + PromptVersion, + Settings, + Severity, + SkillItem, + SSProject, + SSTask, + Stats, + Task, + TaskAssetView, + TaskNode, + TaskScopeRow, + TokenTotal, + TokenUsage, + Tool, + TrafficDetail, + TrafficHost, + TrafficResp, + WorkspaceFile, + WorkspaceListing, } from "@/lib/types"; function getToken(): string | null { @@ -92,19 +136,21 @@ function mockReport(_task?: string): string { export function sseUrl(path: string): string { const base = process.env.NEXT_PUBLIC_SSE_BASE ?? - (typeof window !== "undefined" - ? `${window.location.protocol}//${window.location.hostname}:8787` - : ""); + (typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8787` : ""); const token = getToken(); const sep = path.includes("?") ? "&" : "?"; return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`; } const get = (p: string) => http(p); -const post = (p: string, body?: unknown) => http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); -const put = (p: string, body?: unknown) => http(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); -const patch = (p: string, body?: unknown) => http(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }); -const del = (p: string, body?: unknown) => http(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); +const post = (p: string, body?: unknown) => + http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); +const put = (p: string, body?: unknown) => + http(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); +const patch = (p: string, body?: unknown) => + http(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }); +const del = (p: string, body?: unknown) => + http(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); // Go serializes nil slices as JSON null — coerce to []. const arr = (x: T[] | null | undefined): T[] => x ?? []; @@ -116,15 +162,14 @@ export const api = { // ---- auth ---- authStatus: () => get<{ initialized: boolean }>("/auth/status"), - login: (username: string, password: string) => - post<{ token: string }>("/auth/login", { username, password }), - initPassword: (password: string) => - post<{ token: string }>("/auth/init", { password }), + login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }), + initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }), changePassword: (oldPassword: string, newPassword: string) => post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }), // ---- tasks ---- - tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), + tasks: () => + get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), createTask: (input: { description: string; goal: string; @@ -155,9 +200,9 @@ export const api = { llm_profile_ids: llmProfileIds, active_llm_profile_id: activeLLMProfileId ?? null, }), - deleteTask: (id: string, options: DeleteTaskOptions) => - del(`/tasks/${id}`, options), - controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), + deleteTask: (id: string, options: DeleteTaskOptions) => del(`/tasks/${id}`, options), + controlTask: (id: string, action: "pause" | "resume") => + post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") => post<{ id: number; @@ -165,7 +210,8 @@ export const api = { deleted?: { intents: number; facts: number; findings: number; activities: number }; }>(`/tasks/${taskId}/intents/${intentId}/control`, { action }), // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 - rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), + rerunIntent: (taskId: string, intentId: string) => + post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), // 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。 rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`), setActive: (id: string) => post<{ active: string }>("/active", { id }), @@ -181,23 +227,24 @@ export const api = { by_type: { type: string; total: number; tested: number }[]; }>(`/tasks/${id}/coverage`), // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 - taskCoverageGraph: (id: string) => - get(`/tasks/${id}/coverage-graph`), + taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`), + // 本任务测试范围列表(含继承自来源任务的范围)。 + taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`), + // 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。 + addTaskScope: (id: string, kind: string, value: string, reason?: string) => + post(`/tasks/${id}/scope`, { kind, value, reason }), + // 删除本任务的一条测试范围。 + deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`), // 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。 - taskAssetRefs: (id: string, assetId: number) => - get(`/tasks/${id}/asset-refs?asset_id=${assetId}`), + taskAssetRefs: (id: string, assetId: number) => get(`/tasks/${id}/asset-refs?asset_id=${assetId}`), // ---- workspace file manager (workDir) ---- - workspaceList: (path = "") => - get(`/workspace/list?path=${encodeURIComponent(path)}`), - workspaceRead: (path: string) => - get(`/workspace/read?path=${encodeURIComponent(path)}`), + workspaceList: (path = "") => get(`/workspace/list?path=${encodeURIComponent(path)}`), + workspaceRead: (path: string) => get(`/workspace/read?path=${encodeURIComponent(path)}`), workspaceWrite: (path: string, content: string) => post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }), - workspaceMkdir: (path: string) => - post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }), - workspaceDelete: (path: string) => - del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`), + workspaceMkdir: (path: string) => post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }), + workspaceDelete: (path: string) => del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`), workspaceUpload: async (dir: string, files: File[]) => { if (MOCK) return { uploaded: files.length }; const fd = new FormData(); @@ -236,8 +283,9 @@ export const api = { // ---- assets ---- // Server-side paginated: pass limit/offset, get back the page + full match total. assets: (type = "", limit = 50, offset = 0) => - get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`) - .then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), + get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`).then( + (r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 }), + ), searchAssets: (dsl: string, type = "", limit = 50, offset = 0) => get<{ count: number; total: number; assets: Asset[] }>( `/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`, @@ -257,18 +305,38 @@ export const api = { createCompany: (name: string, logo: string, scope: string) => post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>( "/companies", - { name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] }, + { + name, + logo, + scope: scope.trim() + ? scope + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + : [], + }, ), addCompanyScope: (id: number, scope: string, reason = "") => - post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( - `/companies/${id}/scope`, - { scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason }, - ), + post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, { + scope: scope.trim() + ? scope + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + : [], + reason, + }), updateCompanyScope: (id: number, scope: string, reason = "") => - post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( - `/companies/${id}/scope`, - { scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason, reset: true }, - ), + post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, { + scope: scope.trim() + ? scope + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + : [], + reason, + reset: true, + }), deleteCompany: (id: number, deleteAssets = false) => http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, { method: "DELETE", @@ -293,12 +361,9 @@ export const api = { `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, ), // 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。 - findingLineage: (id: string) => - get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), - setFindingStatus: (id: string, status: FindingStatus) => - patch(`/exploration/findings/${id}`, { status }), - setFindingSeverity: (id: string, severity: Severity) => - patch(`/exploration/findings/${id}`, { severity }), + findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), + setFindingStatus: (id: string, status: FindingStatus) => patch(`/exploration/findings/${id}`, { status }), + setFindingSeverity: (id: string, severity: Severity) => patch(`/exploration/findings/${id}`, { severity }), // 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。 updateFinding: ( id: string, @@ -312,10 +377,11 @@ export const api = { workers: arr(r.workers), total: r.total, })), - tokenDaily: (days = 30) => - get(`/tokens/daily?days=${days}`).then(arr), + tokenDaily: (days = 30) => get(`/tokens/daily?days=${days}`).then(arr), conversationTokens: () => - get("/tokens/conversations").then(arr).catch(() => [] as ConvTokenSummary[]), + get("/tokens/conversations") + .then(arr) + .catch(() => [] as ConvTokenSummary[]), explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`), activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => { const q = new URLSearchParams(); @@ -323,7 +389,10 @@ export const api = { if (opts?.intent) q.set("intent", opts.intent); if (opts?.since) q.set("since", String(opts.since)); if (opts?.limit) q.set("limit", String(opts.limit)); - return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0 })); + return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ + items: arr(r.items), + cursor: r.cursor ?? 0, + })); }, activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`), // Reverse-paginated session history. session = "main" | "plan" | "intent:". @@ -366,20 +435,21 @@ export const api = { (method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") + (q ? `&q=${encodeURIComponent(q)}` : ""), ), - trafficExchange: (id: string) => - get(`/traffic/exchange?id=${encodeURIComponent(id)}`), + trafficExchange: (id: string) => get(`/traffic/exchange?id=${encodeURIComponent(id)}`), trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`), - trafficDeleteHost: (host: string) => - del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), - trafficDeleteHosts: (hosts: string[]) => - del<{ deleted: number }>(`/traffic/hosts`, { hosts }), + trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), + trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }), // ---- app settings (runtime toggles) ---- settings: () => get(`/settings`), setSettings: (patch: Partial) => put(`/settings`, patch), // Run a real "test" search with the given (or saved) config to verify it works. - testWebSearch: (patch: { web_search_backend?: string; web_search_proxy?: string; brave_search_api_key?: string; tavily_search_api_key?: string }) => - post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), + testWebSearch: (patch: { + web_search_backend?: string; + web_search_proxy?: string; + brave_search_api_key?: string; + tavily_search_api_key?: string; + }) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), report: async (task?: string) => { if (MOCK) return mockReport(task); const token = getToken(); @@ -393,7 +463,15 @@ export const api = { post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }), // 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。 chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => { - if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size, abs: `/mock/${f.name}` })) }; + if (MOCK) + return { + attachments: files.map((f) => ({ + name: f.name, + path: `uploads/${f.name}`, + size: f.size, + abs: `/mock/${f.name}`, + })), + }; const fd = new FormData(); for (const f of files) fd.append("file", f); const token = getToken(); @@ -409,9 +487,29 @@ export const api = { gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}), // ---- LLM ---- - getLLM: () => get<{ configured: boolean; provider: string; model: string; base_url: string; proxy?: string; key_set: boolean; rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; reasoning_effort?: string }>("/llm"), - setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) => - post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }), + getLLM: () => + get<{ + configured: boolean; + provider: string; + model: string; + base_url: string; + proxy?: string; + key_set: boolean; + rate_per_second?: number; + rate_per_minute?: number; + context_window_k?: number; + reasoning_effort?: string; + }>("/llm"), + setLLM: ( + provider: string, + model: string, + base_url: string, + api_key: string, + rate_per_second = 0, + rate_per_minute = 0, + proxy = "", + context_window_k = 0, + ) => post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }), testLLM: ( provider: string, model: string, @@ -464,8 +562,7 @@ export const api = { // ---- agents ---- agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)), getAgent: (key: string) => get(`/agents/${key}`), - createAgent: (key: string, name: string, description = "") => - post("/agents", { key, name, description }), + createAgent: (key: string, name: string, description = "") => post("/agents", { key, name, description }), updateAgent: (key: string, name: string, description = "") => patch<{ ok: boolean }>(`/agents/${key}`, { name, description }), deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`), @@ -497,7 +594,8 @@ export const api = { sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) => post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }), stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}), - saveAgentPrompt: (key: string, template: string, note = "") => put<{ version: number }>(`/agents/${key}/prompt`, { template, note }), + saveAgentPrompt: (key: string, template: string, note = "") => + put<{ version: number }>(`/agents/${key}/prompt`, { template, note }), resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}), // 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认; // max_turns 省略则不动、传 0=用内置默认轮数 @@ -509,7 +607,10 @@ export const api = { saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) => put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }), resetAgentTaskTimeoutWrapup: (key: string) => - post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/task-timeout/reset`, {}), + post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>( + `/agents/${key}/wrapup/task-timeout/reset`, + {}, + ), // P3 triggers (仅自定义 agent) agentTriggers: (key: string) => get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)), @@ -531,12 +632,15 @@ export const api = { trigger_max_parallel?: number; }, ) => put<{ ok: boolean }>(`/agents/${key}/config`, patch), - agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)), - agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)), + agentPromptVersions: (key: string) => + get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)), + agentVariables: (key: string) => + get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)), previewAgentPrompt: (key: string, template: string, sample?: Record) => post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }), getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`), - setAgentVisibility: (key: string, mcp: number[], skill: string[]) => put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }), + setAgentVisibility: (key: string, mcp: number[], skill: string[]) => + put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }), // ---- tools (内置工具目录) ---- tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)), @@ -544,10 +648,13 @@ export const api = { put<{ ok: boolean }>(`/tools/${key}`, patch), resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}), // custom tools (自定义工具) - createCustomTool: (t: Pick) => - post<{ key: string }>("/tools/custom", t), - updateCustomTool: (key: string, t: Pick) => - put<{ ok: boolean }>(`/tools/custom/${key}`, t), + createCustomTool: ( + t: Pick, + ) => post<{ key: string }>("/tools/custom", t), + updateCustomTool: ( + key: string, + t: Pick, + ) => put<{ ok: boolean }>(`/tools/custom/${key}`, t), deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`), testCustomTool: (body: { kind: string; exec: Record; params: Record }) => post<{ output: string; is_error: boolean }>("/tools/custom/test", body), @@ -591,8 +698,14 @@ export const api = { // ---- skills (文件系统) ---- skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.skills)), - createSkill: (s: { name: string; description: string; license?: string; compatibility?: string; mcps?: string[]; instructions?: string }) => - post<{ name: string }>("/skills", s), + createSkill: (s: { + name: string; + description: string; + license?: string; + compatibility?: string; + mcps?: string[]; + instructions?: string; + }) => post<{ name: string }>("/skills", s), // uploadSkill installs a skill from a .zip (multipart). Surfaces the backend // error text (e.g. 已存在 / 缺少 SKILL.md) so the UI can show a precise message. uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => { @@ -610,20 +723,21 @@ export const api = { return body; }, deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`), - updateSkillMeta: (name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }) => - put<{ ok: boolean }>(`/skills/${name}/meta`, meta), - createSkillDir: (skill: string, path: string) => - post<{ dir: string }>(`/skills/${skill}/dirs`, { path }), + updateSkillMeta: ( + name: string, + meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }, + ) => put<{ ok: boolean }>(`/skills/${name}/meta`, meta), + createSkillDir: (skill: string, path: string) => post<{ dir: string }>(`/skills/${skill}/dirs`, { path }), skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files), readSkillFile: (name: string, file: string) => get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content), writeSkillFile: (name: string, file: string, content: string) => put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }), - deleteSkillPath: (skill: string, path: string) => - del<{ deleted: string }>(`/skills/${skill}/files/${path}`), + deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`), // ---- visibility (MCP resource side) ---- (agent ids are strings per spec) - resourceVisibility: (kind: string, id: number) => get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)), + resourceVisibility: (kind: string, id: number) => + get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)), toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) => post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }), @@ -648,7 +762,8 @@ export const api = { interceptDecide: (id: number, decision: "allowed" | "denied") => post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }), interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)), - interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), + interceptTask: (taskId: string) => + get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), // ---- intercept tool-config (全局工具拦截范围) ---- interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => { @@ -696,6 +811,5 @@ export const api = { }, llmRecordDetail: (id: number) => get(`/llm/records/${id}`), llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`), - llmRecordsDeleteTask: (task: string) => - del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), + llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), }; diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 1b57783..bd926e5 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -149,6 +149,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (seg[0] === "tasks" && seg[2] === "coverage-graph") return D.coverageGraph; if (seg[0] === "tasks" && seg[2] === "asset-refs") return D.assetRefsFor(Number(q.get("asset_id") ?? 0)); + // ── 任务测试范围(增删查)── + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "GET") return { scope: [] }; + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST") + return { id: Date.now(), task_id: Number(seg[1]), kind: b.kind, domain: b.value, source: "manual" }; + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") return { ok: true }; + // ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})── if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? ""); if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? ""); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ca761f7..cc5edf7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -225,6 +225,18 @@ export interface WorkspaceFile { content?: string; } +// 任务测试范围的一条(覆盖度分母 + 授权边界)。 +export interface TaskScopeRow { + id: number; + task_id: number; + kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr"; + company_id?: number; + domain?: string; + net?: string; + source: "auto" | "agent" | "manual"; + reason?: string; +} + // 公司资产范围规则的一条(归属唯一真值来源)。 export interface ScopeRow { id: number;