diff --git a/README.md b/README.md index af125c6..e62cd3a 100644 --- a/README.md +++ b/README.md @@ -620,3 +620,11 @@ https://github.com/oritera/Cairn ### 免责声明 本项目按“现状(AS IS)”提供,不附带任何明示或默示的担保。作者及贡献者不对使用本工具(无论使用方式是否得当)所导致的任何直接或间接损失、数据丢失、系统损坏或法律纠纷承担责任。**下载、安装或使用本项目,即表示你已阅读、理解并同意上述全部条款。** + +### 任务情报 + +在「系统配置 → 任务情报」开启 **Agent 自动记录**,即可在任务详情的「任务情报」中集中查看凭据、重要线索和敏感数据。此开关默认关闭;关闭时不向 Agent 注入情报工具、附加字段或提示,不启动额外总结 Agent,已有记录仍可查看。开启会增加提示及上报的 Token 消耗,对后续 Agent 执行生效;已在运行的会话可能仍保留旧工具描述,关闭后服务端立即拒绝新增情报。 + +Worker 可调用 `record_intelligence`,或在 `record_fact` 的单条/批量事实中附带 `intelligence` 数组一起提交。`insert_assets` 本次提交的服务 `auth` 自动登记为未验证凭据,避免重复上报。任务、执行节点由系统绑定,记录需要真实来源证据;获取凭据不等于登录成功。 + +页面支持分类、标题/上报者搜索、状态筛选、分页、状态更新及来源查看。凭据和敏感数据的正文与证据默认不随列表返回,点击「显示敏感内容」后加载。直接关联任务的记录标明继承且只读;在资产页选择一项资产后点击「关联情报」可查看同一份记录。情报随任务归档、恢复和删除;新版归档格式为 v4,仍支持读取 v1–v3。此功能不自动补提取历史会话。 diff --git a/agent/assembly.go b/agent/assembly.go index 115a6db..94e4d7d 100644 --- a/agent/assembly.go +++ b/agent/assembly.go @@ -16,7 +16,8 @@ import ( // UnlockSkill unlocks a named skill's MCPs — hosts call it to rebuild the unlock set // from history on a resumed session (design doc C2). type DeferredInfo struct { - CaseGuidance string // independent of optional traffic binding + IntelligenceGuidance string + CaseGuidance string // independent of optional traffic binding FindingGuidance string // derived from the final permitted tools, including DB overrides Deferred []string // all MCP tool names (schema withheld) @@ -67,6 +68,7 @@ func AugmentTools(ctx context.Context, agentKey string, base []actool.CoreTool) } out, def.FindingGuidance = findingWorkflowTools(agentKey, out) out, def.CaseGuidance = findingCaseWorkflow(agentKey, out) + out, def.IntelligenceGuidance = intelligenceWorkflow(ctx, out) if agentKey == "reporter" && isFindingCaseReview(ctx) { def.FindingGuidance = "" def.CaseGuidance += historicalFindingReviewGuidance diff --git a/agent/deferred.go b/agent/deferred.go index fb2adb0..a285588 100644 --- a/agent/deferred.go +++ b/agent/deferred.go @@ -16,7 +16,7 @@ import ( // skill loads. Returns a single plain segment + boundary 0 when there is no global // block to add. func deferredSystem(sysText string, def DeferredInfo) (system []string, boundary int) { - sysText += def.FindingGuidance + def.CaseGuidance + sysText += def.FindingGuidance + def.CaseGuidance + def.IntelligenceGuidance block := actool.RenderDeferredToolsBlock(def.GlobalNames) if block == "" { return []string{sysText}, 0 diff --git a/agent/intelligence.go b/agent/intelligence.go new file mode 100644 index 0000000..a407475 --- /dev/null +++ b/agent/intelligence.go @@ -0,0 +1,98 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/Autumn-27/artex/db" + actool "github.com/Autumn-27/norma/tool" +) + +// Missing host/setting means off. Rechecked on every write for running sessions. +var IntelligenceEnabled func() bool +var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error) + +func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() } +func intelligenceSchema() map[string]any { + return obj(map[string]any{ + "kind": map[string]any{"type": "string", "enum": []string{"credential", "lead", "sensitive_data"}}, + "title": str("简短标题,禁止包含密码、Token 或敏感数据原文"), + "content": str("实际获取的信息。凭据用 JSON 字符串保存账号/密码/Token 等;敏感数据仅少量样例、数量和文件位置,禁止整库粘贴"), + "evidence": str("真实来源:命令/请求/响应/文件位置及关键观察,禁止编造"), + "confidence": map[string]any{"type": "string", "enum": []string{"observed", "inferred"}}, + "status": str("凭据 unverified/verified/invalid;线索 pending/in_progress/dismissed/converted;敏感数据 suspected/confirmed/dismissed。省略时保守默认,verified 需要真实认证成功证据"), + "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}}, + }, "kind", "title", "content", "evidence", "confidence") +} +func (t *ToolSet) saveIntelligence(ctx context.Context, nodeID int64, in db.IntelligenceInput) (int64, error) { + if !intelligenceEnabled() || IntelligenceRecord == nil { + return 0, fmt.Errorf("任务情报自动记录已关闭") + } + taskID := t.taskID + if taskID <= 0 { + taskID = RunInfoFrom(ctx).TaskID + } + if taskID <= 0 { + return 0, fmt.Errorf("任务情报需要当前任务上下文") + } + if nodeID <= 0 { + nodeID = t.ownerNode + } + if nodeID <= 0 { + nodeID = RunInfoFrom(ctx).IntentID + } + id, err := IntelligenceRecord(ctx, taskID, nodeID, t.worker, in) + if err == nil { + t.writes.Intelligence++ + } + return id, err +} +func (t *ToolSet) recordIntelligence() actool.CoreTool { + return writeTool("record_intelligence", "记录当前任务实际获取的凭据、重要线索或敏感数据。任务和执行来源由系统绑定;凭据发现不等于验证成功。重复记录会去重。已有 insert_assets.auth 自动登记凭据,无需重复调用。", intelligenceSchema(), func(ctx context.Context, raw json.RawMessage) (actool.Result, error) { + var in db.IntelligenceInput + if err := json.Unmarshal(raw, &in); err != nil { + return actool.Errorf("无效情报参数"), nil + } + id, err := t.saveIntelligence(ctx, 0, in) + if err != nil { + return actool.Errorf(err.Error()), nil + } + return actool.Text(fmt.Sprintf("intelligence recorded: %d", id)), nil + }) +} +func intelligenceWorkflow(ctx context.Context, tools []actool.CoreTool) ([]actool.CoreTool, string) { + enabled := intelligenceEnabled() && RunInfoFrom(ctx).TaskID > 0 + out := make([]actool.CoreTool, 0, len(tools)) + hasRecord := false + hasFact := false + for _, tool := range tools { + if tool.Name() == "record_intelligence" { + if !enabled { + continue + } + hasRecord = true + } + if tool.Name() == "record_fact" { + raw, _ := json.Marshal(tool.InputSchema()) + var schema map[string]any + if json.Unmarshal(raw, &schema) == nil { + props, _ := schema["properties"].(map[string]any) + if props != nil { + delete(props, "intelligence") + if enabled { + props["intelligence"] = map[string]any{"type": "array", "maxItems": 20, "description": "随事实一次写回情报,字段与 record_intelligence 相同;无需再单独调用该工具", "items": intelligenceSchema()} + hasFact = true + } + } + tool = DecorateTool(tool, tool.Description(), schema) + } + } + out = append(out, tool) + } + guide := "" + if enabled && (hasRecord || hasFact) { + guide = "\n\n**任务情报记录已开启**:发现凭据、值得跟进的线索或真实敏感数据时立即结构化写回,不等用户追问;结束前检查遗漏。record_fact 可附 intelligence 数组一起写回,每条事实/批量 facts 的元素均支持;单独记录用 record_intelligence(若可用)。insert_assets.auth 已由后端自动登记,勿重复调用。只记录新增、有价值的信息,普通指纹/端口状态仍仅作 fact。title 禁止包含秘密原文;正文带实际内容和来源。凭据默认未验证,只有真实认证成功才标 verified;推断必须 inferred;敏感数据写类型、数量、少量样例、文件位置,勿粘整库。" + } + return out, guide +} diff --git a/agent/intelligence_test.go b/agent/intelligence_test.go new file mode 100644 index 0000000..6a0d4e3 --- /dev/null +++ b/agent/intelligence_test.go @@ -0,0 +1,131 @@ +package agent + +import ( + "context" + "encoding/json" + "github.com/Autumn-27/artex/db" + "strings" + "testing" +) + +func TestIntelligenceGateRemovesTokenOverheadAndRejectsLiveWrites(t *testing.T) { + oldEnabled, oldRecord := IntelligenceEnabled, IntelligenceRecord + oldResolve, oldAugment := ToolResolve, ToolAugment + defer func() { + IntelligenceEnabled, IntelligenceRecord = oldEnabled, oldRecord + ToolResolve, ToolAugment = oldResolve, oldAugment + }() + ToolResolve = nil + ToolAugment = nil + enabled := false + calls := 0 + IntelligenceEnabled = func() bool { return enabled } + IntelligenceRecord = func(_ context.Context, taskID, nodeID int64, worker string, in db.IntelligenceInput) (int64, error) { + calls++ + if taskID != 42 || nodeID != 7 || worker != "qa" { + t.Fatalf("bad attribution: %d %d %s", taskID, nodeID, worker) + } + return 123, nil + } + ts := NewToolSet(nil, "qa") + ts.SetTaskID(42) + ts.SetOwnerNode(7) + ctx := WithRunInfo(context.Background(), RunInfo{TaskID: 42, IntentID: 7}) + tools, def, cleanup := AugmentTools(ctx, "worker", ts.WorkerTools()) + cleanup() + if def.IntelligenceGuidance != "" { + t.Fatal("disabled prompt overhead") + } + for _, tool := range tools { + if tool.Name() == "record_intelligence" { + t.Fatal("disabled tool exposed") + } + if tool.Name() == "record_fact" && strings.Contains(mustJSON(tool.InputSchema()), "intelligence") { + t.Fatal("disabled schema overhead") + } + } + enabled = true + tools, def, cleanup = AugmentTools(ctx, "worker", ts.WorkerTools()) + cleanup() + if def.IntelligenceGuidance == "" { + t.Fatal("missing guidance") + } + input := json.RawMessage(`{"kind":"lead","title":"config path","content":"/tmp/qa","evidence":"fixture","confidence":"observed"}`) + found := false + for _, tool := range tools { + if tool.Name() != "record_intelligence" { + continue + } + found = true + result, err := tool.Call(ctx, input, nil) + if err != nil || result.IsError || calls != 1 || ts.Writes().Total() != 1 { + t.Fatal("record failed", err, result.Flatten()) + } + enabled = false + result, err = tool.Call(ctx, input, nil) + if err != nil || !result.IsError || calls != 1 { + t.Fatal("live disable did not block write") + } + } + if !found { + t.Fatal("missing enabled tool") + } + enabled = true + tools, def, cleanup = AugmentTools(context.Background(), "worker", ts.WorkerTools()) + cleanup() + if def.IntelligenceGuidance != "" { + t.Fatal("taskless guidance") + } + for _, tool := range tools { + if tool.Name() == "record_intelligence" { + t.Fatal("taskless tool") + } + } +} +func mustJSON(v any) string { b, _ := json.Marshal(v); return string(b) } +func TestIntelligenceAssetAndFactWriteback(t *testing.T) { + d := testDB(t) + defer d.Close() + task, err := d.CreateTask("intelligence tool integration", "local fixtures", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(task.ID) + ts := NewToolSet(d.Exploration(task.ExplorationID), "qa") + ts.SetTaskID(task.ID) + ts.SetAssetStore(d.Assets(), d.Companies()) + node, err := d.Exploration(task.ExplorationID).AddIntent(map[string]any{"summary": "fixture"}, 5, nil, "qa") + if err != nil { + t.Fatal(err) + } + ts.SetOwnerNode(node) + oldEnabled, oldRecord := IntelligenceEnabled, IntelligenceRecord + defer func() { IntelligenceEnabled, IntelligenceRecord = oldEnabled, oldRecord }() + IntelligenceEnabled = func() bool { return true } + IntelligenceRecord = func(ctx context.Context, tid, nid int64, w string, in db.IntelligenceInput) (int64, error) { + item, err := d.RecordIntelligence(ctx, tid, nid, w, in) + if err != nil { + return 0, err + } + return item.ID, nil + } + defer d.Exec(`DELETE FROM assets WHERE domain='intelligence-tool.example' OR root_domain='intelligence-tool.example'`) + result := callInsertAssets(t, ts, map[string]any{"assets": []any{map[string]any{"type": "service", "url": "https://intelligence-tool.example", "auth": []any{map[string]any{"username": "qa", "password": "fixture"}}}}}) + if errs, ok := result["errors"].([]any); ok && len(errs) > 0 { + t.Fatal(result) + } + raw := json.RawMessage(`{"summary":"found useful fixture","intelligence":[{"kind":"lead","title":"fixture path","content":"/tmp/config","evidence":"read fixture","confidence":"observed"}]}`) + res, err := ts.recordFact().Call(context.Background(), raw, nil) + if err != nil || res.IsError { + t.Fatal("fact intelligence", err, res.Flatten()) + } + page, err := d.ListIntelligence(t.Context(), db.IntelligenceFilter{TaskID: task.ID}) + if err != nil || page.Total != 2 || page.Counts["credential"] != 1 || page.Counts["lead"] != 1 { + t.Fatalf("writebacks missing: %+v %v", page, err) + } + for _, item := range page.Items { + if item.NodeID == nil { + t.Fatal("lost source node") + } + } +} diff --git a/agent/tools.go b/agent/tools.go index 139a03a..adb09c4 100644 --- a/agent/tools.go +++ b/agent/tools.go @@ -141,18 +141,23 @@ type EnrichTrigger interface { // findings under "facts" (record_fact → Facts, insert_assets → Assets, // report_finding → Findings; each element of a batch counts once). type WriteCounts struct { - Facts int - Assets int - Findings int + Intelligence int + Facts int + Assets int + Findings int } // Total is every node persisted this run, regardless of kind — the // "explored but persisted nothing" signal (Total == 0). -func (w WriteCounts) Total() int { return w.Facts + w.Assets + w.Findings } +func (w WriteCounts) Total() int { return w.Facts + w.Assets + w.Findings + w.Intelligence } // String renders the per-kind breakdown for logs, e.g. "事实1 资产25 漏洞0". func (w WriteCounts) String() string { - return fmt.Sprintf("事实%d 资产%d 漏洞%d", w.Facts, w.Assets, w.Findings) + summary := fmt.Sprintf("事实%d 资产%d 漏洞%d", w.Facts, w.Assets, w.Findings) + if w.Intelligence > 0 { + summary += fmt.Sprintf(" 情报%d", w.Intelligence) + } + return summary } // Writes reports what this run wrote back, split by node kind (so the engine can @@ -1304,12 +1309,13 @@ func (t *ToolSet) addFinding() actool.CoreTool { // must NOT be stuffed into the asset graph via upsert_asset. // factItem 是 record_fact 批量/单条的一条事实。 type factItem struct { - Summary string `json:"summary"` - Detail string `json:"detail"` - Evidence string `json:"evidence"` // 一行关键证据(命令+关键输出行),支撑结论、便于事后核对 - Confidence string `json:"confidence"` // observed(直接看到)| inferred(据现象推断) - IntentID json.RawMessage `json:"intent_id"` - AssetIDs []json.RawMessage `json:"asset_ids"` + Intelligence []db.IntelligenceInput `json:"intelligence"` + Summary string `json:"summary"` + Detail string `json:"detail"` + Evidence string `json:"evidence"` // 一行关键证据(命令+关键输出行),支撑结论、便于事后核对 + Confidence string `json:"confidence"` // observed(直接看到)| inferred(据现象推断) + IntentID json.RawMessage `json:"intent_id"` + AssetIDs []json.RawMessage `json:"asset_ids"` } // recordOneFact 写一条 fact 节点并连到意图(intent→yields→fact)。defaultIntent 为 @@ -1367,7 +1373,7 @@ func (t *ToolSet) recordFact() actool.CoreTool { "confidence": str("observed(输出里直接看到)| inferred(据现象推断)。否定结论务必如实标注。"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}, "description": "相关资产 id(可选,0/1/多个):该事实涉及哪些资产"}, }), - func(_ context.Context, in json.RawMessage) (actool.Result, error) { + func(ctx context.Context, in json.RawMessage) (actool.Result, error) { var a struct { Facts []factItem `json:"facts"` factItem // 单条模式 + 批量默认 intent_id @@ -1389,6 +1395,21 @@ func (t *ToolSet) recordFact() actool.CoreTool { continue } ids[i] = id + if intelligenceEnabled() { + if len(it.Intelligence) > 20 { + errs[strconv.Itoa(i)] = "事实已保存,情报每次最多 20 条" + continue + } + for _, intel := range it.Intelligence { + if len(intel.AssetIDs) == 0 { + intel.AssetIDs = pidList(it.AssetIDs) + } + if _, err := t.saveIntelligence(ctx, id, intel); err != nil { + errs[strconv.Itoa(i)] = fmt.Sprintf("事实 #%d 已保存,情报未保存:%s", id, err) + break + } + } + } } if !batch { // 单条:保持原返回 diff --git a/agent/tools_insert.go b/agent/tools_insert.go index dde9eaf..91366aa 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -200,7 +200,7 @@ func (t *ToolSet) insertAssets() actool.CoreTool { }, "type"), }, }, "assets"), - func(_ context.Context, in json.RawMessage) (actool.Result, error) { + func(ctx context.Context, in json.RawMessage) (actool.Result, error) { if t.as == nil { return actool.Errorf("insert_assets 未启用: AssetStore 未初始化"), nil } @@ -337,6 +337,15 @@ func (t *ToolSet) insertAssets() actool.CoreTool { errs = append(errs, errEntry{Index: i, Error: err.Error()}) continue } + if intelligenceEnabled() && typ == "service" && taskID > 0 { + for _, auth := range item.Auth { + raw, _ := json.Marshal(auth) + input := db.IntelligenceInput{Kind: "credential", Title: fmt.Sprintf("服务 #%d 的认证信息", id), Content: string(raw), Evidence: fmt.Sprintf("insert_assets 上报,服务资产 #%d,来源意图 #%d", id, t.ownerNode), Confidence: "observed", Status: "unverified", AssetIDs: []int64{id}} + if _, err := t.saveIntelligence(ctx, 0, input); err != nil { + errs = append(errs, errEntry{Index: i, Error: "资产已保存,凭据情报未保存:" + err.Error()}) + } + } + } results = append(results, result{Index: i, ID: id, Type: typ}) t.writes.Assets++ t.anchorOwner(id) @@ -645,7 +654,7 @@ func (t *ToolSet) WorkerTools() []actool.CoreTool { return []actool.CoreTool{ // list_findings 保留:报漏洞前先查本任务已确认漏洞,避免重复上报同一漏洞。 t.listFindings(), - t.addFinding(), t.recordFact(), + t.addFinding(), t.recordFact(), t.recordIntelligence(), // asset management (handlers guard nil store internally)。 // add_company_scope 不给 worker:定义企业资产范围属规划/主控/Auto 的职责,worker 只执行探索。 t.insertAssets(), t.listAssets(), @@ -674,7 +683,7 @@ func (t *ToolSet) MainAgentTools() []actool.CoreTool { t.setConstraints(), // asset management (handlers guard nil store internally) t.insertAssets(), t.addCompanyScope(), t.listAssets(), - t.addFinding(), t.recordFact(), + t.addFinding(), t.recordFact(), t.recordIntelligence(), t.addTaskScope(), // list_untested_assets:按需查本任务范围内未测资产(类型+分页),自行决定补测。 t.listUntestedAssets(), diff --git a/db/intelligence.go b/db/intelligence.go new file mode 100644 index 0000000..b0f784f --- /dev/null +++ b/db/intelligence.go @@ -0,0 +1,293 @@ +package db + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + "unicode/utf8" +) + +const SettingTaskIntelligence = "task_intelligence_enabled" + +var ErrIntelligenceInvalid = errors.New("invalid intelligence") +var ErrIntelligenceNotFound = errors.New("intelligence not found") + +type IntelligenceInput struct { + Kind string `json:"kind"` + Title string `json:"title"` + Content string `json:"content"` + Evidence string `json:"evidence"` + Confidence string `json:"confidence"` + Status string `json:"status"` + AssetIDs []int64 `json:"asset_ids"` +} +type Intelligence struct { + IntelligenceInput + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + NodeID *int64 `json:"node_id,omitempty"` + Worker string `json:"worker"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Inherited bool `json:"inherited"` + Redacted bool `json:"redacted"` +} +type IntelligenceFilter struct { + TaskID int64 + AssetID int64 + Kind string + Status string + Query string + Limit int + Offset int +} +type IntelligencePage struct { + Items []*Intelligence `json:"items"` + Total int `json:"total"` + Counts map[string]int `json:"counts"` + Enabled bool `json:"enabled"` +} + +func ValidIntelligenceStatus(kind, status string) bool { + switch kind { + case "credential": + return status == "unverified" || status == "verified" || status == "invalid" + case "lead": + return status == "pending" || status == "in_progress" || status == "dismissed" || status == "converted" + case "sensitive_data": + return status == "suspected" || status == "confirmed" || status == "dismissed" + } + return false +} +func NormalizeIntelligence(in IntelligenceInput) (IntelligenceInput, error) { + in.Kind = strings.TrimSpace(in.Kind) + in.Title = strings.TrimSpace(in.Title) + in.Content = strings.TrimSpace(in.Content) + in.Evidence = strings.TrimSpace(in.Evidence) + if in.Title == "" || in.Content == "" || in.Evidence == "" { + return in, fmt.Errorf("%w: title/content/evidence 不能为空", ErrIntelligenceInvalid) + } + if utf8.RuneCountInString(in.Title) > 200 || len(in.Content) > 32768 || len(in.Evidence) > 8192 || len(in.AssetIDs) > 100 { + return in, fmt.Errorf("%w: 情报内容过长或关联资产过多", ErrIntelligenceInvalid) + } + if in.Confidence != "observed" && in.Confidence != "inferred" { + return in, fmt.Errorf("%w: confidence 必须为 observed/inferred", ErrIntelligenceInvalid) + } + if in.Status == "" { + switch in.Kind { + case "credential": + in.Status = "unverified" + case "lead": + in.Status = "pending" + case "sensitive_data": + in.Status = "suspected" + } + } + if !ValidIntelligenceStatus(in.Kind, in.Status) { + return in, fmt.Errorf("%w: 类别或状态无效", ErrIntelligenceInvalid) + } + if in.Confidence == "inferred" && (in.Status == "verified" || in.Status == "confirmed") { + return in, fmt.Errorf("%w: 推测不能标记为验证成功", ErrIntelligenceInvalid) + } + seen := map[int64]bool{} + ids := []int64{} + for _, id := range in.AssetIDs { + if id <= 0 { + return in, fmt.Errorf("%w: asset_ids 必须为正整数", ErrIntelligenceInvalid) + } + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + in.AssetIDs = ids + return in, nil +} +func intelligenceFingerprint(in IntelligenceInput) string { + content := in.Content + var v any + if json.Unmarshal([]byte(content), &v) == nil { + b, _ := json.Marshal(v) + content = string(b) + } + ids := make([]int64, 0, len(in.AssetIDs)) + seen := map[int64]bool{} + for _, id := range in.AssetIDs { + if !seen[id] { + ids = append(ids, id) + seen[id] = true + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + b, _ := json.Marshal([]any{content, ids}) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +const intelligenceCols = `id,task_id,node_id,kind,title,content,evidence,confidence,status,asset_ids,worker,created_at,updated_at` + +func scanIntelligence(row interface{ Scan(...any) error }) (*Intelligence, error) { + item := &Intelligence{} + var assets []byte + err := row.Scan(&item.ID, &item.TaskID, &item.NodeID, &item.Kind, &item.Title, &item.Content, &item.Evidence, &item.Confidence, &item.Status, &assets, &item.Worker, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return nil, err + } + if err = json.Unmarshal(assets, &item.AssetIDs); err != nil { + return nil, err + } + return item, nil +} + +// Task and source node are supplied by the host, never chosen by the model. +// The row lock serializes writes with deletion/archive, preventing orphaned writes. +func (d *DB) RecordIntelligence(ctx context.Context, taskID, nodeID int64, worker string, input IntelligenceInput) (*Intelligence, error) { + in, err := NormalizeIntelligence(input) + if err != nil { + return nil, err + } + tx, err := d.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + var found int64 + err = tx.QueryRowContext(ctx, `SELECT id FROM tasks WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, taskID).Scan(&found) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrIntelligenceNotFound + } + if err != nil { + return nil, err + } + var node *int64 + if nodeID > 0 { + err = tx.QueryRowContext(ctx, `SELECT n.id FROM exploration_nodes n JOIN tasks t ON t.exploration_id=n.exploration_id WHERE t.id=$1 AND n.id=$2`, taskID, nodeID).Scan(&found) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: 来源节点不属于当前任务", ErrIntelligenceInvalid) + } + if err != nil { + return nil, err + } + node = &nodeID + } + if len(in.AssetIDs) > 0 { + var count int + if err = tx.QueryRowContext(ctx, `SELECT count(*) FROM assets WHERE id=ANY($1::bigint[])`, in.AssetIDs).Scan(&count); err != nil { + return nil, err + } + if count != len(in.AssetIDs) { + return nil, fmt.Errorf("%w: 关联资产不存在", ErrIntelligenceInvalid) + } + } + assets, _ := json.Marshal(in.AssetIDs) + item, err := scanIntelligence(tx.QueryRowContext(ctx, `INSERT INTO task_intelligence(task_id,node_id,kind,title,content,evidence,confidence,status,asset_ids,worker,fingerprint) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ON CONFLICT(task_id,kind,fingerprint) DO UPDATE SET updated_at=now(), + evidence=CASE WHEN task_intelligence.confidence='inferred' AND EXCLUDED.confidence='observed' OR task_intelligence.status IN ('unverified','suspected','pending') AND EXCLUDED.status NOT IN ('unverified','suspected','pending') THEN EXCLUDED.evidence ELSE task_intelligence.evidence END, + confidence=CASE WHEN task_intelligence.confidence='observed' THEN 'observed' ELSE EXCLUDED.confidence END, + status=CASE WHEN task_intelligence.status IN ('unverified','suspected','pending') THEN EXCLUDED.status ELSE task_intelligence.status END + RETURNING `+intelligenceCols, taskID, node, in.Kind, in.Title, in.Content, in.Evidence, in.Confidence, in.Status, string(assets), worker, intelligenceFingerprint(in))) + if err != nil { + return nil, err + } + if err = tx.Commit(); err != nil { + return nil, err + } + return item, nil +} + +// Direct task inheritance is explicit; shared asset membership alone grants no task provenance. +func intelligenceScope() string { + return ` FROM task_intelligence i JOIN tasks t ON t.id=i.task_id AND t.deleted_at IS NULL WHERE + ($1::bigint=0 OR i.task_id=$1 OR EXISTS(SELECT 1 FROM task_relations r WHERE r.task_id=$1 AND r.source_task_id=i.task_id)) + AND ($2::bigint=0 OR i.asset_ids @> jsonb_build_array($2::bigint))` +} +func (d *DB) ListIntelligence(ctx context.Context, f IntelligenceFilter) (*IntelligencePage, error) { + if f.Limit <= 0 || f.Limit > 100 { + f.Limit = 25 + } + if f.Offset < 0 { + f.Offset = 0 + } + page := &IntelligencePage{Items: []*Intelligence{}, Counts: map[string]int{"credential": 0, "lead": 0, "sensitive_data": 0}, Enabled: d.GetBool(SettingTaskIntelligence, false)} + scope := intelligenceScope() + args := []any{f.TaskID, f.AssetID} + rows, err := d.QueryContext(ctx, `SELECT kind,count(*)`+scope+` GROUP BY kind`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var k string + var n int + if err = rows.Scan(&k, &n); err != nil { + rows.Close() + return nil, err + } + page.Counts[k] = n + } + err = rows.Err() + rows.Close() + if err != nil { + return nil, err + } + // Search metadata only: secrets are never searched or echoed through list endpoints. + where := scope + ` AND ($3='' OR kind=$3) AND ($4='' OR i.status=$4) AND ($5='' OR i.title ILIKE '%'||$5||'%' OR i.worker ILIKE '%'||$5||'%')` + args = append(args, f.Kind, f.Status, f.Query) + if err = d.QueryRowContext(ctx, `SELECT count(*)`+where, args...).Scan(&page.Total); err != nil { + return nil, err + } + args = append(args, f.Limit, f.Offset) + // Do not load credential/data bodies into list responses at all. + cols := `i.id,i.task_id,i.node_id,i.kind,i.title,CASE WHEN kind='lead' THEN content ELSE '' END,CASE WHEN kind='lead' THEN evidence ELSE '' END,confidence,i.status,asset_ids,i.worker,i.created_at,i.updated_at` + rows, err = d.QueryContext(ctx, `SELECT `+cols+where+` ORDER BY i.id DESC LIMIT $6 OFFSET $7`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + item, e := scanIntelligence(rows) + if e != nil { + return nil, e + } + item.Inherited = f.TaskID > 0 && item.TaskID != f.TaskID + item.Redacted = item.Kind != "lead" + page.Items = append(page.Items, item) + } + return page, rows.Err() +} +func (d *DB) GetIntelligence(ctx context.Context, id int64) (*Intelligence, error) { + item, err := scanIntelligence(d.QueryRowContext(ctx, `SELECT `+intelligenceCols+` FROM task_intelligence WHERE id=$1 AND EXISTS(SELECT 1 FROM tasks WHERE tasks.id=task_intelligence.task_id AND deleted_at IS NULL)`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrIntelligenceNotFound + } + return item, err +} +func (d *DB) UpdateIntelligenceStatus(ctx context.Context, taskID, id int64, status string) error { + item, err := d.GetIntelligence(ctx, id) + if err != nil { + return err + } + if item.TaskID != taskID { + return ErrIntelligenceNotFound + } + if !ValidIntelligenceStatus(item.Kind, status) { + return fmt.Errorf("%w: 状态无效", ErrIntelligenceInvalid) + } + result, err := d.ExecContext(ctx, `UPDATE task_intelligence SET status=$3,updated_at=now() WHERE id=$1 AND task_id=$2 AND EXISTS(SELECT 1 FROM tasks WHERE tasks.id=$2 AND deleted_at IS NULL)`, id, taskID, status) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return ErrIntelligenceNotFound + } + return nil +} diff --git a/db/intelligence_test.go b/db/intelligence_test.go new file mode 100644 index 0000000..2d8950f --- /dev/null +++ b/db/intelligence_test.go @@ -0,0 +1,121 @@ +package db + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func testIntelligenceInput() IntelligenceInput { + return IntelligenceInput{Kind: "credential", Title: "测试数据库账号", Content: `{"username":"qa","password":"local-fixture-secret"}`, Evidence: "本机测试配置文件 /tmp/fixture", Confidence: "observed"} +} +func TestIntelligenceValidation(t *testing.T) { + for _, change := range []func(*IntelligenceInput){ + func(i *IntelligenceInput) { i.Kind = "other" }, func(i *IntelligenceInput) { i.Evidence = "" }, func(i *IntelligenceInput) { i.Status = "confirmed" }, + func(i *IntelligenceInput) { i.Confidence = "inferred"; i.Status = "verified" }, func(i *IntelligenceInput) { i.AssetIDs = []int64{-1} }, + func(i *IntelligenceInput) { i.Content = strings.Repeat("x", 32769) }, + } { + in := testIntelligenceInput() + change(&in) + if _, err := NormalizeIntelligence(in); !errors.Is(err, ErrIntelligenceInvalid) { + t.Fatalf("accepted invalid input: %v", err) + } + } + in := testIntelligenceInput() + in.AssetIDs = []int64{2, 1, 2} + got, err := NormalizeIntelligence(in) + if err != nil || got.Status != "unverified" || len(got.AssetIDs) != 2 { + t.Fatalf("normalization failed: %+v %v", got, err) + } +} +func TestIntelligenceProvenanceDedupAndRedaction(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Fatal(err) + } + defer d.Close() + first, err := d.CreateTask("intelligence source", "test", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(first.ID) + second, err := d.CreateTask("intelligence unrelated", "test", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer d.DeleteTask(second.ID) + node, err := d.Exploration(first.ExplorationID).AddIntent(map[string]any{"summary": "read fixture"}, 5, nil, "qa") + if err != nil { + t.Fatal(err) + } + in := testIntelligenceInput() + item, err := d.RecordIntelligence(t.Context(), first.ID, node, "worker-qa", in) + if err != nil { + t.Fatal(err) + } + in.Content = `{ "password":"local-fixture-secret", "username":"qa" }` + in.Title = "不同标题" + again, err := d.RecordIntelligence(t.Context(), first.ID, node, "worker-qa", in) + if err != nil || again.ID != item.ID { + t.Fatalf("dedup failed: %+v %v", again, err) + } + if _, err = d.RecordIntelligence(t.Context(), second.ID, node, "qa", in); !errors.Is(err, ErrIntelligenceInvalid) { + t.Fatalf("cross-task node accepted: %v", err) + } + page, err := d.ListIntelligence(t.Context(), IntelligenceFilter{TaskID: first.ID}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(page) + if page.Total != 1 || !page.Items[0].Redacted || strings.Contains(string(raw), "local-fixture-secret") || strings.Contains(string(raw), "/tmp/fixture") { + t.Fatal("list leaked secret or lost record") + } + if err = d.UpdateIntelligenceStatus(t.Context(), second.ID, item.ID, "verified"); !errors.Is(err, ErrIntelligenceNotFound) { + t.Fatalf("cross-task mutation accepted: %v", err) + } + if err = d.UpdateIntelligenceStatus(t.Context(), first.ID, item.ID, "verified"); err != nil { + t.Fatal(err) + } + if _, err = d.RecordIntelligence(t.Context(), first.ID, node, "qa", in); err != nil { + t.Fatal(err) + } + got, err := d.GetIntelligence(t.Context(), item.ID) + if err != nil || got.Status != "verified" { + t.Fatal("duplicate reset triage") + } + page, err = d.ListIntelligence(t.Context(), IntelligenceFilter{TaskID: second.ID}) + if err != nil || page.Total != 0 { + t.Fatal("unrelated task sees intelligence") + } + if _, err = d.Exec(`INSERT INTO task_relations(task_id,source_task_id) VALUES($1,$2)`, second.ID, first.ID); err != nil { + t.Fatal(err) + } + page, err = d.ListIntelligence(t.Context(), IntelligenceFilter{TaskID: second.ID}) + if err != nil || page.Total != 1 || !page.Items[0].Inherited { + t.Fatalf("inheritance failed: %+v %v", page, err) + } + if err = d.DeleteTask(second.ID); err != nil { + t.Fatal(err) + } + if _, err = d.RecordIntelligence(t.Context(), second.ID, 0, "qa", in); !errors.Is(err, ErrIntelligenceNotFound) { + t.Fatalf("deleted task accepted: %v", err) + } +} + +func TestIntelligenceArchiveAssetRemapPreservesManualTriage(t *testing.T) { + row := map[string]any{"kind": "credential", "title": "manual validation", "content": "fixture", "evidence": "fixture", "confidence": "inferred", "status": "verified", "asset_ids": []int64{10, 20}} + raw, _ := json.Marshal([]any{row}) + tables, err := remapArchiveAssetReferences(map[string]json.RawMessage{"task_intelligence": raw}, map[int64]int64{10: 30}) + if err != nil { + t.Fatal(err) + } + var rows []map[string]any + if err = json.Unmarshal(tables["task_intelligence"], &rows); err != nil { + t.Fatal(err) + } + expected := intelligenceFingerprint(IntelligenceInput{Content: "fixture", AssetIDs: []int64{20, 30}}) + if len(rows) != 1 || rows[0]["fingerprint"] != expected || rows[0]["status"] != "verified" { + t.Fatal("remap lost dedup or manual triage") + } +} diff --git a/db/schema.sql b/db/schema.sql index cab9a5c..a3bb565 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1547,3 +1547,24 @@ CREATE TABLE IF NOT EXISTS task_schedule_history ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_schedule_history ON task_schedule_history(schedule_id,id DESC); + +-- Task intelligence is separate from shared asset authentication and graph compaction. +CREATE TABLE IF NOT EXISTS task_intelligence ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL, + kind TEXT NOT NULL CHECK (kind IN ('credential','lead','sensitive_data')), + title TEXT NOT NULL, + content TEXT NOT NULL, + evidence TEXT NOT NULL DEFAULT '', + confidence TEXT NOT NULL CHECK (confidence IN ('observed','inferred')), + status TEXT NOT NULL, + asset_ids JSONB NOT NULL DEFAULT '[]', + worker TEXT NOT NULL DEFAULT '', + fingerprint TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(task_id,kind,fingerprint) +); +CREATE INDEX IF NOT EXISTS idx_task_intelligence_task ON task_intelligence(task_id,id DESC); +CREATE INDEX IF NOT EXISTS idx_task_intelligence_assets ON task_intelligence USING gin(asset_ids); diff --git a/db/task_archives.go b/db/task_archives.go index ec710a9..eca928c 100644 --- a/db/task_archives.go +++ b/db/task_archives.go @@ -16,7 +16,7 @@ import ( ) const ( - TaskArchiveFormatVersion = 3 + TaskArchiveFormatVersion = 4 TaskArchiveLegacyFormatVersion = 1 TaskArchiveLLMRecordsPath = "database/llm_records.ndjson" ) @@ -533,7 +533,10 @@ UNION SELECT anchor.asset_id FROM exploration_anchors anchor UNION SELECT value::bigint FROM findings finding CROSS JOIN LATERAL jsonb_array_elements_text( CASE WHEN jsonb_typeof(finding.asset_ids)='array' THEN finding.asset_ids ELSE '[]'::jsonb END - ) value WHERE finding.task_id=$1 AND value ~ '^[0-9]+$'` + ) value WHERE finding.task_id=$1 AND value ~ '^[0-9]+$' +UNION SELECT value::bigint FROM task_intelligence intelligence + CROSS JOIN LATERAL jsonb_array_elements_text(intelligence.asset_ids) value + WHERE intelligence.task_id=$1 AND value ~ '^[0-9]+$'` } // SnapshotTaskArchive reads one repeatable PostgreSQL snapshot. Task-owned Agent @@ -587,6 +590,7 @@ func (d *DB) snapshotTaskArchive(taskID int64, llmRecords io.Writer) (*TaskArchi {"task_llm_profiles", `SELECT * FROM task_llm_profiles WHERE task_id=$1 ORDER BY position`, []any{taskID}}, {"task_scope", `SELECT * FROM task_scope WHERE task_id=$1 ORDER BY id`, []any{taskID}}, {"findings", `SELECT * FROM findings WHERE task_id=$1 ORDER BY id`, []any{taskID}}, + {"task_intelligence", `SELECT * FROM task_intelligence WHERE task_id=$1 ORDER BY id`, []any{taskID}}, {"finding_traffic_bindings", `SELECT b.* FROM finding_traffic_bindings b JOIN findings f ON f.id=b.finding_id WHERE f.task_id=$1 ORDER BY b.finding_id,b.position,b.id`, []any{taskID}}, {"traffic_evidence_snapshots", `SELECT s.* FROM traffic_evidence_snapshots s WHERE EXISTS(SELECT 1 FROM finding_traffic_bindings b JOIN findings f ON f.id=b.finding_id WHERE b.snapshot_id=s.id AND f.task_id=$1) ORDER BY s.id`, []any{taskID}}, {"llm_records", `SELECT * FROM llm_records WHERE COALESCE(task_id,'')=$1 ORDER BY id`, []any{strconv.FormatInt(taskID, 10)}}, diff --git a/db/task_archives_restore.go b/db/task_archives_restore.go index 5249cd1..01094ca 100644 --- a/db/task_archives_restore.go +++ b/db/task_archives_restore.go @@ -92,6 +92,7 @@ WHERE relation.source_task_id=$1 LIMIT 1`, taskID).Scan(&dependent) {`DELETE FROM task_asset_links WHERE task_id=$1`, []any{taskID}}, {`DELETE FROM task_llm_profiles WHERE task_id=$1`, []any{taskID}}, {`DELETE FROM task_scope WHERE task_id=$1`, []any{taskID}}, + {`DELETE FROM task_intelligence WHERE task_id=$1`, []any{taskID}}, {`DELETE FROM task_constraints WHERE exploration_id=$1`, []any{expID}}, {`DELETE FROM activity WHERE exploration_id=$1`, []any{expID}}, {`DELETE FROM exploration_nodes WHERE exploration_id=$1`, []any{expID}}, @@ -242,7 +243,7 @@ WHERE archive.id=$1 FOR UPDATE OF archive,task`, archiveID).Scan(&taskID, &expID } // Insert graph rows in foreign-key order. The archived stub has no graph rows, // so an ID conflict signals external corruption and must stop the restore. - for _, table := range []string{"exploration_nodes", "exploration_edges", "exploration_anchors", "task_constraints", "activity"} { + for _, table := range []string{"exploration_nodes", "exploration_edges", "exploration_anchors", "task_constraints", "activity", "task_intelligence"} { if err := insertArchiveRows(tx, table, remappedTables[table]); err != nil { return nil, fmt.Errorf("restore %s: %w", table, err) } @@ -386,7 +387,7 @@ func insertArchiveRows(tx *sql.Tx, table string, raw json.RawMessage) error { } allowed := map[string]bool{ "exploration_nodes": true, "exploration_edges": true, "exploration_anchors": true, - "task_constraints": true, "activity": true, "task_asset_links": true, "findings": true, + "task_intelligence": true, "task_constraints": true, "activity": true, "task_asset_links": true, "findings": true, "llm_records": true, "llm_usage": true, "skill_usage": true, "tool_usage": true, "side_question_sessions": true, "side_question_requests": true, } @@ -563,7 +564,7 @@ func remapArchiveAssetReferences(tables map[string]json.RawMessage, mapping map[ } out[table], _ = json.Marshal(rows) } - for _, table := range []string{"exploration_nodes", "findings"} { + for _, table := range []string{"exploration_nodes", "findings", "task_intelligence"} { rows, err := decodeArchiveRows(tables[table]) if err != nil { return nil, err @@ -589,6 +590,17 @@ func remapArchiveAssetReferences(tables map[string]json.RawMessage, mapping map[ container[key] = values } } + if table == "task_intelligence" { + for _, row := range rows { + raw, _ := json.Marshal(row) + var input IntelligenceInput + if err := json.Unmarshal(raw, &input); err != nil { + return nil, err + } + // Triage is human editable; preserve it even when the original observation was inferred. + row["fingerprint"] = intelligenceFingerprint(input) + } + } out[table], _ = json.Marshal(rows) } return out, nil diff --git a/db/task_archives_test.go b/db/task_archives_test.go index a3296ec..f650f9f 100644 --- a/db/task_archives_test.go +++ b/db/task_archives_test.go @@ -204,6 +204,10 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro if _, err := d.AddFinding(task.ID, nodeID, vulnclass, "archive finding", SeverityCritical, "summary", "evidence", "worker", []int64{assetID}); err != nil { t.Fatal(err) } + intel, err := d.RecordIntelligence(t.Context(), task.ID, 0, "archive-qa", IntelligenceInput{Kind: "credential", Title: "archive credential", Content: "archive-fixture", Evidence: "local fixture", Confidence: "observed"}) + if err != nil { + t.Fatal(err) + } archive, err := d.QueueTaskArchive(task.ID) if err != nil { t.Fatal(err) @@ -273,6 +277,10 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro if err != nil { t.Fatal(err) } + restoredIntel, err := d.GetIntelligence(t.Context(), intel.ID) + if err != nil || restoredIntel.Content != "archive-fixture" { + t.Fatalf("intelligence lost after archive: %+v %v", restoredIntel, err) + } warningFound := false for _, warning := range warnings { if strings.Contains(warning, fmt.Sprintf("任务企业 %d 已删除", companyID)) { diff --git a/server/assembly.go b/server/assembly.go index 2a46e56..4ff8efe 100644 --- a/server/assembly.go +++ b/server/assembly.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "fmt" "io" "log" "path/filepath" @@ -211,6 +212,17 @@ func seedPrompts(pg *db.DB) { // and缺省入参 get injected. MCP/skill/host tools have no row and pass through. func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) { agent.FindingTrafficBindingEnabled = func() bool { return pg.GetBool(settingAgentTrafficBinding, false) } + agent.IntelligenceEnabled = func() bool { return pg.GetBool(db.SettingTaskIntelligence, false) } + agent.IntelligenceRecord = func(ctx context.Context, taskID, nodeID int64, worker string, input db.IntelligenceInput) (int64, error) { + if !pg.GetBool(db.SettingTaskIntelligence, false) { + return 0, fmt.Errorf("任务情报自动记录已关闭") + } + item, err := pg.RecordIntelligence(ctx, taskID, nodeID, worker, input) + if err != nil { + return 0, err + } + return item.ID, nil + } // Seed the built-in domain tools (first-insert only; DO NOTHING preserves edits). // No startup prune: rows we didn't seed are left alone so future user-defined // custom tools (system=false, added via the UI) survive restarts. diff --git a/server/intelligence.go b/server/intelligence.go new file mode 100644 index 0000000..f1156b5 --- /dev/null +++ b/server/intelligence.go @@ -0,0 +1,134 @@ +package server + +import ( + "errors" + "net/http" + "strconv" + + "github.com/Autumn-27/artex/db" +) + +func intelligenceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, db.ErrIntelligenceInvalid): + writeErr(w, 400, err.Error()) + case errors.Is(err, db.ErrIntelligenceNotFound): + writeErr(w, 404, "情报不存在或不属于当前范围") + default: + writeErr(w, 500, err.Error()) + } +} +func (s *Server) intelligenceFilter(w http.ResponseWriter, r *http.Request) (db.IntelligenceFilter, bool) { + f := db.IntelligenceFilter{} + id, ok := pathInt(r, "id") + if !ok || id <= 0 { + writeErr(w, 400, "无效 ID") + return f, false + } + if r.PathValue("scope") == "assets" { + f.AssetID = id + } else { + f.TaskID = id + } + var exists bool + var err error + if f.TaskID > 0 { + err = s.m.pg.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM tasks WHERE id=$1 AND deleted_at IS NULL)`, id).Scan(&exists) + } else { + err = s.m.pg.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM assets WHERE id=$1)`, id).Scan(&exists) + } + if err != nil { + intelligenceError(w, err) + return f, false + } + if !exists { + writeErr(w, 404, "任务或资产不存在") + return f, false + } + q := r.URL.Query() + f.Kind = q.Get("kind") + f.Status = q.Get("status") + f.Query = q.Get("q") + f.Limit, _ = strconv.Atoi(q.Get("limit")) + f.Offset, _ = strconv.Atoi(q.Get("offset")) + if len(f.Query) > 500 { + writeErr(w, 400, "搜索关键词过长") + return f, false + } + return f, true +} +func (s *Server) listIntelligence(w http.ResponseWriter, r *http.Request) { + f, ok := s.intelligenceFilter(w, r) + if !ok { + return + } + page, err := s.m.pg.ListIntelligence(r.Context(), f) + if err != nil { + intelligenceError(w, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, page) +} +func (s *Server) getIntelligence(w http.ResponseWriter, r *http.Request) { + f, ok := s.intelligenceFilter(w, r) + if !ok { + return + } + id, ok := pathInt(r, "intelID") + if !ok { + writeErr(w, 400, "无效情报 ID") + return + } + item, err := s.m.pg.GetIntelligence(r.Context(), id) + if err != nil { + intelligenceError(w, err) + return + } + allowed := false + if f.TaskID > 0 { + err = s.m.pg.QueryRowContext(r.Context(), `SELECT $1::bigint=$2::bigint OR EXISTS(SELECT 1 FROM task_relations WHERE task_id=$1 AND source_task_id=$2)`, f.TaskID, item.TaskID).Scan(&allowed) + } else { + for _, assetID := range item.AssetIDs { + if assetID == f.AssetID { + allowed = true + } + } + } + if err != nil { + intelligenceError(w, err) + return + } + if !allowed { + intelligenceError(w, db.ErrIntelligenceNotFound) + return + } + item.Inherited = f.TaskID > 0 && item.TaskID != f.TaskID + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, item) +} +func (s *Server) updateIntelligence(w http.ResponseWriter, r *http.Request) { + taskID, ok := pathInt(r, "id") + if !ok { + writeErr(w, 400, "无效任务 ID") + return + } + id, ok := pathInt(r, "intelID") + if !ok { + writeErr(w, 400, "无效情报 ID") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + var req struct { + Status string `json:"status"` + } + if err := decode(r, &req); err != nil { + writeErr(w, 400, "无效请求") + return + } + if err := s.m.pg.UpdateIntelligenceStatus(r.Context(), taskID, id, req.Status); err != nil { + intelligenceError(w, err) + return + } + writeJSON(w, 200, map[string]bool{"updated": true}) +} diff --git a/server/intelligence_test.go b/server/intelligence_test.go new file mode 100644 index 0000000..1aaf48c --- /dev/null +++ b/server/intelligence_test.go @@ -0,0 +1,76 @@ +package server + +import ( + "encoding/json" + "fmt" + "github.com/Autumn-27/artex/db" + "strings" + "testing" +) + +func TestIntelligenceHTTPSettingsAndScope(t *testing.T) { + s, initial, request := trafficEvidenceServer(t) + f, err := s.m.pg.GetFinding(initial.FindingID) + if err != nil { + t.Fatal(err) + } + taskID := *f.TaskID + if s.settingsPayload()[db.SettingTaskIntelligence] != false { + t.Fatal("default must be off") + } + if r := request("PUT", "/api/settings", `{"task_intelligence_enabled":true}`); r.Code != 200 { + t.Fatal(r.Code, r.Body) + } + defer s.m.pg.SetBool(db.SettingTaskIntelligence, false) + if !s.m.pg.GetBool(db.SettingTaskIntelligence, false) { + t.Fatal("setting not persisted") + } + in := db.IntelligenceInput{Kind: "credential", Title: "test credential", Content: "local-api-fixture-secret", Evidence: "local fixture evidence", Confidence: "observed"} + item, err := s.m.pg.RecordIntelligence(t.Context(), taskID, 0, "qa", in) + if err != nil { + t.Fatal(err) + } + base := fmt.Sprintf("/api/tasks/%d/intelligence", taskID) + r := request("GET", base, "") + if r.Code != 200 || strings.Contains(r.Body.String(), in.Content) || strings.Contains(r.Body.String(), in.Evidence) { + t.Fatalf("list leaked secret: %d %s", r.Code, r.Body) + } + r = request("GET", fmt.Sprintf("%s/%d", base, item.ID), "") + if r.Code != 200 || !strings.Contains(r.Body.String(), in.Content) { + t.Fatal("explicit reveal failed", r.Code, r.Body) + } + if r = request("PATCH", fmt.Sprintf("%s/%d", base, item.ID), `{"status":"verified"}`); r.Code != 200 { + t.Fatal(r.Code, r.Body) + } + other, err := s.m.pg.CreateTask("unrelated intel API", "test", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + defer s.m.pg.DeleteTask(other.ID) + for _, method := range []string{"GET", "PATCH"} { + r = request(method, fmt.Sprintf("/api/tasks/%d/intelligence/%d", other.ID, item.ID), `{"status":"invalid"}`) + if r.Code != 404 { + t.Fatal("cross task access", method, r.Code, r.Body) + } + } + if _, err = s.m.pg.Exec(`INSERT INTO task_relations(task_id,source_task_id) VALUES($1,$2)`, other.ID, taskID); err != nil { + t.Fatal(err) + } + r = request("GET", fmt.Sprintf("/api/tasks/%d/intelligence/%d", other.ID, item.ID), "") + if r.Code != 200 { + t.Fatal(r.Code, r.Body) + } + var got db.Intelligence + if err = json.Unmarshal(r.Body.Bytes(), &got); err != nil || !got.Inherited { + t.Fatal("missing inherited marker") + } + if r = request("PATCH", fmt.Sprintf("/api/tasks/%d/intelligence/%d", other.ID, item.ID), `{"status":"invalid"}`); r.Code != 404 { + t.Fatal("inherited mutation accepted") + } + if r = request("PUT", "/api/settings", `{"task_intelligence_enabled":false}`); r.Code != 200 { + t.Fatal(r.Code, r.Body) + } + if r = request("GET", base, ""); r.Code != 200 || !strings.Contains(r.Body.String(), `"total":1`) { + t.Fatal("disable hid saved records") + } +} diff --git a/server/server.go b/server/server.go index 01a7d12..0c57ef8 100644 --- a/server/server.go +++ b/server/server.go @@ -858,6 +858,17 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks) mux.HandleFunc("GET /api/llm/records/by-model", s.pgTokenByModel) // 按模型聚合本任务 token 用量 mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord) + mux.HandleFunc("GET /api/tasks/{id}/intelligence", s.listIntelligence) + mux.HandleFunc("GET /api/tasks/{id}/intelligence/{intelID}", s.getIntelligence) + mux.HandleFunc("PATCH /api/tasks/{id}/intelligence/{intelID}", s.updateIntelligence) + mux.HandleFunc("GET /api/assets/{id}/intelligence", func(w http.ResponseWriter, r *http.Request) { + r.SetPathValue("scope", "assets") + s.listIntelligence(w, r) + }) + mux.HandleFunc("GET /api/assets/{id}/intelligence/{intelID}", func(w http.ResponseWriter, r *http.Request) { + r.SetPathValue("scope", "assets") + s.getIntelligence(w, r) + }) mux.HandleFunc("GET /api/settings", s.getSettings) mux.HandleFunc("PUT /api/settings", s.putSettings) // 漏洞 IM 推送。渠道是「多实例 + 各自过滤规则」的资源,因此独立成一组 @@ -3397,19 +3408,20 @@ func (s *Server) settingsPayload() map[string]any { concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI } return map[string]any{ - "traffic_capture": s.m.TrafficEnabled(), - "agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false), - "llm_record": s.m.LLMRecordEnabled(), - "web_search_enabled": on, - "web_search_backend": backend, - "brave_key_set": strings.TrimSpace(braveKey) != "", - "tavily_key_set": strings.TrimSpace(tavilyKey) != "", - "web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连 - "global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连 - "python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测) - "workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效 - "task_concurrency_enabled": concOn, // 任务并发上限开关(默认关) - "task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5) + "traffic_capture": s.m.TrafficEnabled(), + "agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false), + "task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false), + "llm_record": s.m.LLMRecordEnabled(), + "web_search_enabled": on, + "web_search_backend": backend, + "brave_key_set": strings.TrimSpace(braveKey) != "", + "tavily_key_set": strings.TrimSpace(tavilyKey) != "", + "web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连 + "global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连 + "python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测) + "workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效 + "task_concurrency_enabled": concOn, // 任务并发上限开关(默认关) + "task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5) // LLM 轮询(故障转移)。默认关;开启后走全局激活配置的 agent 在当前配置不可用时 // 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。 "llm_pool_enabled": s.m.LLMPoolEnabled(), @@ -3469,6 +3481,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { var req struct { TrafficCapture *bool `json:"traffic_capture"` AgentTrafficBinding *bool `json:"agent_traffic_binding"` + TaskIntelligence *bool `json:"task_intelligence_enabled"` LLMRecord *bool `json:"llm_record"` // LLM 录制开关(默认关);即时生效,无需重建 agent // Web search. WebSearchEnabled/Backend toggle the tool + backend; BraveKey/TavilyKey // are optional — omit (null) to leave a stored key untouched, send "" to clear. @@ -3611,6 +3624,12 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { // built under the OLD switch state — drop them so they pick up the new one. s.invalidateProfileAgents() } + if req.TaskIntelligence != nil { + if err := s.m.pg.SetBool(db.SettingTaskIntelligence, *req.TaskIntelligence); err != nil { + writeErr(w, 500, err.Error()) + return + } + } if req.AgentTrafficBinding != nil { if err := s.m.pg.SetBool(settingAgentTrafficBinding, *req.AgentTrafficBinding); err != nil { writeErr(w, 500, err.Error()) diff --git a/web/src/app/(main)/function/assets/page.tsx b/web/src/app/(main)/function/assets/page.tsx index e312335..cea0c82 100644 --- a/web/src/app/(main)/function/assets/page.tsx +++ b/web/src/app/(main)/function/assets/page.tsx @@ -19,6 +19,7 @@ import { import { toast } from "sonner"; import { AssetDslSearch } from "@/components/asset-dsl-search"; +import { IntelligencePanel } from "@/components/intelligence-panel"; import { ScopeTextEditor } from "@/components/scope-text-editor"; import { AlertDialog, @@ -108,6 +109,7 @@ const TABS: { key: string; label: string; icon: LucideIcon }[] = [ ]; export default function AssetsPage() { + const [intelligenceAsset, setIntelligenceAsset] = React.useState(null); const [rows, setRows] = React.useState([]); const [total, setTotal] = React.useState(0); const [companies, setCompanies] = React.useState([]); @@ -314,6 +316,22 @@ export default function AssetsPage() { return (
+ { + if (!open) setIntelligenceAsset(null); + }} + > + + + 资产 #{intelligenceAsset} 的关联情报 + 按来源任务查看凭据、线索和敏感数据。 + + {intelligenceAsset !== null ? ( + + ) : null} + +

资产

@@ -322,6 +340,17 @@ export default function AssetsPage() { 共 {totalAssets} 项资产 + {tab !== "company" ? ( + + ) : null} {selected.size > 0 && ( +
+ {error ? ( + + 读取情报失败 + {error} + + ) : null} + {!data && !error ? ( + + ) : data?.items.length ? ( + + + + 类别 / 标题 + 状态 + 来源 + 资产 + 发现时间 + 操作 + + + + {data.items.map((item) => ( + + +
+ {KINDS.find((k) => k.value === item.kind)?.label} + {item.title} + {item.redacted ? ( + 内容已隐藏 · 查看详情后显示 + ) : null} +
+
+ +
+ {STATUS[item.kind][item.status] ?? item.status} + + {item.confidence === "observed" ? "直接观察" : "推测"} + +
+
+ +
+ + 任务 #{item.task_id} + + + {item.worker || "Agent"} + {item.node_id ? ` · 节点 #${item.node_id}` : ""} + + {item.inherited ? 继承 · 只读 : null} +
+
+ + {item.asset_ids.length ? item.asset_ids.map((id) => `#${id}`).join("、") : "未关联"} + + {time(item.created_at)} + + + +
+ ))} +
+
+ ) : data ? ( + + + 暂无符合条件的情报 + + {query || kind !== "all" || status !== "all" + ? "尝试调整分类或筛选条件。" + : data.enabled + ? "Agent 在后续执行中发现有价值的信息后,会在此显示。" + : "开启自动记录后,新增情报会在此显示;历史会话不会自动补提取。"} + + + + ) : null} + + + 共 {data?.total ?? 0} 条 · 每页 25 条 +
+ + +
+
+ + { + if (!value) close(); + }} + > + + + {selected?.title ?? "情报详情"} + 保留实际来源;凭据与敏感数据仅在点击显示后加载。 + + {selected ? ( +
+
+ {KINDS.find((k) => k.value === selected.kind)?.label} + {selected.confidence === "observed" ? "直接观察" : "推测"} + + 任务 #{selected.task_id} + {selected.node_id ? ` · 来源节点 #${selected.node_id}` : ""} · {selected.worker || "Agent"} + +
+ {scope === "tasks" && !selected.inherited ? ( + + ) : ( +

+ {STATUS[selected.kind][selected.status]} · 请到来源任务修改状态 +

+ )} + {selected.kind !== "lead" ? ( + + ) : null} + {detail ? ( + <> +
+ 内容 + +
+
+                    {prettyContent(detail.content)}
+                  
+

来源证据

+
+                    {detail.evidence}
+                  
+ + ) : ( +

敏感内容和来源证据已隐藏

+ )} +
+ 关联资产:{selected.asset_ids.map((id) => `#${id}`).join("、") || "未关联"} + 发现于 {time(selected.created_at)} +
+ + 查看来源任务会话 + +
+ ) : null} +
+
+
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ef19382..ed2906d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -50,6 +50,7 @@ import type { FindingsPage, FindingTraffic, FindingTrafficDetail, + IntelligencePage, IntentAsset, InterceptApprovalFilter, InterceptApprovalRow, @@ -91,6 +92,7 @@ import type { TaskCategory, TaskConstraint, TaskGoal, + TaskIntelligence, TaskLLMResolutions, TaskNode, TaskSchedule, @@ -337,7 +339,8 @@ export const api = { runScheduleNow: (id: number) => post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}), taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)), - updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) => patch(`/tasks/${id}`, input), + updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) => + patch(`/tasks/${id}`, input), renameTask: (id: string, name: string) => patch(`/tasks/${id}`, { name }), pinTask: (id: string, pinned: boolean) => patch(`/tasks/${id}`, { pinned }), createTaskCategory: (name: string) => post("/task-categories", { name }), @@ -913,6 +916,12 @@ export const api = { trafficDeleteAll: () => del<{ deleted: number; reclaimed: number }>(`/traffic/all`), // ---- app settings (runtime toggles) ---- + intelligence: (scope: "tasks" | "assets", id: string, query: URLSearchParams) => + get(`/${scope}/${id}/intelligence?${query.toString()}`), + intelligenceDetail: (scope: "tasks" | "assets", id: string, intelligenceId: number) => + get(`/${scope}/${id}/intelligence/${intelligenceId}`), + updateIntelligence: (taskId: number, intelligenceId: number, status: string) => + patch<{ updated: boolean }>(`/tasks/${taskId}/intelligence/${intelligenceId}`, { status }), settings: () => get(`/settings`), basicAuthSettings: () => get<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth"), setBasicAuthSettings: (settings: { enabled: boolean; username: string; password: string }) => diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index ae10ff2..86b547d 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -3324,6 +3324,7 @@ export const tools: Tool[] = [ // ── Settings ───────────────────────────────────────────────────────────────── export const settings: Settings = { + task_intelligence_enabled: false, traffic_capture: true, agent_traffic_binding: false, llm_record: false, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 911508d..b8a7dd2 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -947,6 +947,7 @@ export interface TrafficHost { // ---- App settings (runtime toggles) ---- export interface Settings { traffic_capture: boolean; + task_intelligence_enabled: boolean; // 默认关闭;关闭不注入情报工具或提示 agent_traffic_binding: boolean; // Agent 自动绑定流量证据,默认关闭;不影响人工绑定 llm_record: boolean; // LLM 录制开关(默认关);关闭时不记录任何 LLM 调用 // Web search. brave_key_set / tavily_key_set reflect whether a key is stored @@ -1741,3 +1742,28 @@ export interface FindingCaseReviewRun { state: "queued" | "running" | "done" | "failed"; error: string; } + +export type IntelligenceKind = "credential" | "lead" | "sensitive_data"; +export interface TaskIntelligence { + id: number; + task_id: number; + node_id?: number; + kind: IntelligenceKind; + title: string; + content: string; + evidence: string; + confidence: "observed" | "inferred"; + status: string; + asset_ids: number[]; + worker: string; + created_at: string; + updated_at: string; + inherited: boolean; + redacted: boolean; +} +export interface IntelligencePage { + items: TaskIntelligence[]; + total: number; + counts: Record; + enabled: boolean; +}