mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
feat: add optional task intelligence recording and views
This commit is contained in:
1 parent
e66789633a
commit
d70dd15e11
24 files changed
+1537
-40
No files matched your search
@@ -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。此功能不自动补提取历史会话。
|
||||
+3
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-12
@@ -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 { // 单条:保持原返回
|
||||
|
||||
+12
-3
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
+6
-2
@@ -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)}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+32
-13
@@ -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())
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [rows, setRows] = React.useState<Asset[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [companies, setCompanies] = React.useState<Company[]>([]);
|
||||
@@ -314,6 +316,22 @@ export default function AssetsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-6rem)] min-h-0 flex-col gap-4">
|
||||
<Dialog
|
||||
open={intelligenceAsset !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setIntelligenceAsset(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>资产 #{intelligenceAsset} 的关联情报</DialogTitle>
|
||||
<DialogDescription>按来源任务查看凭据、线索和敏感数据。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{intelligenceAsset !== null ? (
|
||||
<IntelligencePanel key={intelligenceAsset} scope="assets" scopeId={String(intelligenceAsset)} />
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">资产</h1>
|
||||
@@ -322,6 +340,17 @@ export default function AssetsPage() {
|
||||
<span className="text-sm text-muted-foreground">
|
||||
共 <span className="tabular-nums">{totalAssets}</span> 项资产
|
||||
</span>
|
||||
{tab !== "company" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={selected.size !== 1}
|
||||
title="选择一项资产查看关联情报"
|
||||
onClick={() => setIntelligenceAsset(Array.from(selected)[0])}
|
||||
>
|
||||
关联情报
|
||||
</Button>
|
||||
) : null}
|
||||
{selected.size > 0 && (
|
||||
<Button variant="destructive" size="sm" onClick={() => openDelete(Array.from(selected) as number[])}>
|
||||
<Trash2Icon className="size-3.5" /> 删除已选 ({selected.size})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArchiveIcon, ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, PauseIcon, PlayIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { IntelligencePanel } from "@/components/intelligence-panel";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -56,6 +57,7 @@ const TABS = [
|
||||
{ value: "graph", label: "探索链路" },
|
||||
{ value: "broadcast", label: "播报板" },
|
||||
{ value: "findings", label: "发现" },
|
||||
{ value: "intelligence", label: "任务情报" },
|
||||
{ value: "retests", label: "复测" },
|
||||
{ value: "assets", label: "测试资产" },
|
||||
{ value: "coverage", label: "资产覆盖图" },
|
||||
@@ -209,7 +211,11 @@ function TaskDetailInner() {
|
||||
const [task, setTask] = React.useState<Task | null>(null);
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState("sessions");
|
||||
const requestedTab = searchParams.get("tab") ?? "sessions";
|
||||
const [tab, setTab] = React.useState(TABS.some((item) => item.value === requestedTab) ? requestedTab : "sessions");
|
||||
React.useEffect(() => {
|
||||
if (TABS.some((item) => item.value === requestedTab)) setTab(requestedTab);
|
||||
}, [requestedTab]);
|
||||
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [archiving, setArchiving] = React.useState(false);
|
||||
@@ -429,6 +435,9 @@ function TaskDetailInner() {
|
||||
<TabsContent value="broadcast" className="mt-0">
|
||||
<BroadcastTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="intelligence" className="mt-0">
|
||||
<IntelligencePanel scope="tasks" scopeId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="findings" className="mt-0">
|
||||
<FindingsTab taskId={id} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -15,10 +16,12 @@ import { api } from "@/lib/api";
|
||||
import { CHAT_SEND_MODE_OPTIONS, type ChatSendMode, setChatSendMode, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import type { Settings } from "@/lib/types";
|
||||
|
||||
import { UpdateCard } from "./_components/update-card";
|
||||
import { BasicAuthCard } from "./_components/basic-auth-card";
|
||||
import { UpdateCard } from "./_components/update-card";
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const [intelligenceEnabled, setIntelligenceEnabled] = React.useState(false);
|
||||
const [savingIntelligence, setSavingIntelligence] = React.useState(false);
|
||||
const [trafficCapture, setTrafficCapture] = React.useState(false);
|
||||
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
|
||||
const [webSearch, setWebSearch] = React.useState(false);
|
||||
@@ -48,6 +51,7 @@ export default function SystemSettingsPage() {
|
||||
const sendMode = useChatSendMode();
|
||||
|
||||
const apply = React.useCallback((s: Settings) => {
|
||||
setIntelligenceEnabled(!!s.task_intelligence_enabled);
|
||||
setTrafficCapture(!!s.traffic_capture);
|
||||
setAgentTrafficBinding(!!s.agent_traffic_binding);
|
||||
setWebSearch(!!s.web_search_enabled);
|
||||
@@ -434,6 +438,41 @@ export default function SystemSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>任务情报</CardTitle>
|
||||
<CardDescription>自动记录任务中获取的凭据、重要线索和敏感数据,并保留来源证据。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Field orientation="horizontal">
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="task-intelligence">Agent 自动记录</FieldLabel>
|
||||
<FieldDescription>
|
||||
默认关闭。开启会增加提示和上报的 Token 消耗,不启动额外总结 Agent。 对后续 Agent
|
||||
执行生效;关闭后停止新增记录,已有情报仍可查看。
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
<Switch
|
||||
id="task-intelligence"
|
||||
checked={intelligenceEnabled}
|
||||
disabled={!loaded || savingIntelligence}
|
||||
onCheckedChange={async (value) => {
|
||||
setSavingIntelligence(true);
|
||||
try {
|
||||
const settings = await api.setSettings({ task_intelligence_enabled: value });
|
||||
setIntelligenceEnabled(!!settings.task_intelligence_enabled);
|
||||
toast.success(value ? "任务情报自动记录已开启" : "任务情报自动记录已关闭");
|
||||
} catch (error) {
|
||||
toast.error("保存失败:" + (error as Error).message);
|
||||
} finally {
|
||||
setSavingIntelligence(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -446,8 +485,8 @@ export default function SystemSettingsPage() {
|
||||
记录代理,独立于流量捕获。
|
||||
<br />
|
||||
来源可选 <b>DuckDuckGo(ddgs)</b>(无需 Key)、<b>Brave(免费版)</b>(需填写 Brave API Key)、{" "}
|
||||
<b>Tavily</b>(需填写 Tavily API Key)或 <b>DeepSeek</b>(复用当前 LLM 配置)。总开关关闭时,各
|
||||
Agent 的网络搜索开关不可用。
|
||||
<b>Tavily</b>(需填写 Tavily API Key)或 <b>DeepSeek</b>(复用当前 LLM 配置)。总开关关闭时,各 Agent
|
||||
的网络搜索开关不可用。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { CopyIcon, EyeIcon, EyeOffIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { IntelligenceKind, IntelligencePage, TaskIntelligence } from "@/lib/types";
|
||||
|
||||
const KINDS: { value: IntelligenceKind; label: string }[] = [
|
||||
{ value: "credential", label: "凭据" },
|
||||
{ value: "lead", label: "重要线索" },
|
||||
{ value: "sensitive_data", label: "敏感数据" },
|
||||
];
|
||||
const STATUS: Record<IntelligenceKind, Record<string, string>> = {
|
||||
credential: { unverified: "未验证", verified: "已验证", invalid: "已失效" },
|
||||
lead: { pending: "待跟进", in_progress: "跟进中", dismissed: "已排除", converted: "已转漏洞" },
|
||||
sensitive_data: { suspected: "疑似", confirmed: "已确认", dismissed: "已排除" },
|
||||
};
|
||||
function prettyContent(value: string) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
function time(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
export function IntelligencePanel({ scope, scopeId }: { scope: "tasks" | "assets"; scopeId: string }) {
|
||||
const [kind, setKind] = React.useState("all");
|
||||
const [status, setStatus] = React.useState("all");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [data, setData] = React.useState<IntelligencePage | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [refresh, setRefresh] = React.useState(0);
|
||||
const [selected, setSelected] = React.useState<TaskIntelligence | null>(null);
|
||||
const [detail, setDetail] = React.useState<TaskIntelligence | null>(null);
|
||||
const [revealing, setRevealing] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const detailRequest = React.useRef(0);
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setQuery(search.trim());
|
||||
setPage(0);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refresh explicitly reloads this resource after a user action.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
let busy = false;
|
||||
const load = async () => {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "25", offset: String(page * 25) });
|
||||
if (kind !== "all") params.set("kind", kind);
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (query) params.set("q", query);
|
||||
const result = await api.intelligence(scope, scopeId, params);
|
||||
if (alive) {
|
||||
setData(result);
|
||||
setError("");
|
||||
}
|
||||
} catch (e) {
|
||||
if (alive) setError((e as Error).message);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) void load();
|
||||
}, 8000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [scope, scopeId, kind, status, query, page, refresh]);
|
||||
const open = (item: TaskIntelligence) => {
|
||||
detailRequest.current++;
|
||||
setSelected(item);
|
||||
setDetail(item.redacted ? null : item);
|
||||
setRevealing(false);
|
||||
};
|
||||
const close = () => {
|
||||
detailRequest.current++;
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
setRevealing(false);
|
||||
};
|
||||
const reveal = async () => {
|
||||
if (!selected) return;
|
||||
const request = ++detailRequest.current;
|
||||
setRevealing(true);
|
||||
try {
|
||||
const result = await api.intelligenceDetail(scope, scopeId, selected.id);
|
||||
if (request === detailRequest.current) setDetail(result);
|
||||
} catch (e) {
|
||||
if (request === detailRequest.current) toast.error("读取失败:" + (e as Error).message);
|
||||
} finally {
|
||||
if (request === detailRequest.current) setRevealing(false);
|
||||
}
|
||||
};
|
||||
const update = async (value: string) => {
|
||||
if (!selected) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateIntelligence(selected.task_id, selected.id, value);
|
||||
setSelected((item) => (item ? { ...item, status: value } : null));
|
||||
setDetail((item) => (item ? { ...item, status: value } : null));
|
||||
setRefresh((n) => n + 1);
|
||||
toast.success("情报状态已更新");
|
||||
} catch (e) {
|
||||
toast.error("更新失败:" + (e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const statusOptions = kind === "all" ? {} : STATUS[kind as IntelligenceKind];
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{data && !data.enabled && scope === "tasks" ? (
|
||||
<Alert>
|
||||
<AlertTitle>自动记录已关闭</AlertTitle>
|
||||
<AlertDescription>
|
||||
已有情报仍可查看。前往{" "}
|
||||
<Link href="/system/settings" className="underline">
|
||||
系统设置
|
||||
</Link>{" "}
|
||||
开启后,Agent 将在执行中记录新增情报,并产生额外 Token 消耗。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{scope === "tasks" ? "任务情报" : "资产关联情报"}</CardTitle>
|
||||
<CardDescription>
|
||||
{scope === "tasks" ? "集中查看本次获取与直接关联任务继承的情报。" : "按来源任务查看此资产关联的情报。"}{" "}
|
||||
凭据与敏感数据按需显示,发现凭据不代表验证成功。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-w-0 flex-col gap-4">
|
||||
<Tabs
|
||||
value={kind}
|
||||
onValueChange={(value) => {
|
||||
setKind(value);
|
||||
setStatus("all");
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<TabsList className="max-w-full overflow-x-auto">
|
||||
<TabsTrigger value="all">
|
||||
全部 {data ? Object.values(data.counts).reduce((a, b) => a + b, 0) : ""}
|
||||
</TabsTrigger>
|
||||
{KINDS.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value}>
|
||||
{item.label} {data?.counts[item.value] ?? 0}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
aria-label="搜索情报标题或上报者"
|
||||
placeholder="搜索标题或上报者"
|
||||
value={search}
|
||||
maxLength={200}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="min-w-40 flex-1"
|
||||
/>
|
||||
{kind !== "all" ? (
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
setStatus(value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label="筛选情报状态">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
{Object.entries(statusOptions).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Button variant="outline" onClick={() => setRefresh((n) => n + 1)}>
|
||||
<RefreshCwIcon data-icon="inline-start" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>读取情报失败</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!data && !error ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : data?.items.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>类别 / 标题</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>资产</TableHead>
|
||||
<TableHead>发现时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="max-w-80">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Badge variant="secondary">{KINDS.find((k) => k.value === item.kind)?.label}</Badge>
|
||||
<span className="break-words">{item.title}</span>
|
||||
{item.redacted ? (
|
||||
<span className="text-xs text-muted-foreground">内容已隐藏 · 查看详情后显示</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Badge variant="outline">{STATUS[item.kind][item.status] ?? item.status}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.confidence === "observed" ? "直接观察" : "推测"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link href={`/function/tasks/detail?id=${item.task_id}&tab=intelligence`} className="underline">
|
||||
任务 #{item.task_id}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.worker || "Agent"}
|
||||
{item.node_id ? ` · 节点 #${item.node_id}` : ""}
|
||||
</span>
|
||||
{item.inherited ? <Badge variant="outline">继承 · 只读</Badge> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.asset_ids.length ? item.asset_ids.map((id) => `#${id}`).join("、") : "未关联"}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">{time(item.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => open(item)}>
|
||||
详情
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : data ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无符合条件的情报</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{query || kind !== "all" || status !== "all"
|
||||
? "尝试调整分类或筛选条件。"
|
||||
: data.enabled
|
||||
? "Agent 在后续执行中发现有价值的信息后,会在此显示。"
|
||||
: "开启自动记录后,新增情报会在此显示;历史会话不会自动补提取。"}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-muted-foreground">共 {data?.total ?? 0} 条 · 每页 25 条</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={page === 0} onClick={() => setPage((n) => n - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!data || (page + 1) * 25 >= data.total}
|
||||
onClick={() => setPage((n) => n + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={!!selected}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) close();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selected?.title ?? "情报详情"}</DialogTitle>
|
||||
<DialogDescription>保留实际来源;凭据与敏感数据仅在点击显示后加载。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selected ? (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">{KINDS.find((k) => k.value === selected.kind)?.label}</Badge>
|
||||
<Badge variant="outline">{selected.confidence === "observed" ? "直接观察" : "推测"}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
任务 #{selected.task_id}
|
||||
{selected.node_id ? ` · 来源节点 #${selected.node_id}` : ""} · {selected.worker || "Agent"}
|
||||
</span>
|
||||
</div>
|
||||
{scope === "tasks" && !selected.inherited ? (
|
||||
<Select value={selected.status} disabled={saving} onValueChange={(value) => void update(value)}>
|
||||
<SelectTrigger aria-label="更新情报状态">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(STATUS[selected.kind]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{STATUS[selected.kind][selected.status]} · 请到来源任务修改状态
|
||||
</p>
|
||||
)}
|
||||
{selected.kind !== "lead" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={revealing}
|
||||
onClick={() => {
|
||||
if (detail) {
|
||||
detailRequest.current++;
|
||||
setDetail(null);
|
||||
} else void reveal();
|
||||
}}
|
||||
>
|
||||
{detail ? <EyeOffIcon data-icon="inline-start" /> : <EyeIcon data-icon="inline-start" />}
|
||||
{revealing ? "读取中…" : detail ? "隐藏敏感内容" : "显示敏感内容"}
|
||||
</Button>
|
||||
) : null}
|
||||
{detail ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm">内容</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(detail.content);
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动选择内容复制");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CopyIcon data-icon="inline-start" />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-muted p-3 text-xs">
|
||||
{prettyContent(detail.content)}
|
||||
</pre>
|
||||
<p className="text-sm">来源证据</p>
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-lg bg-muted p-3 text-xs">
|
||||
{detail.evidence}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<p className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">敏感内容和来源证据已隐藏</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>关联资产:{selected.asset_ids.map((id) => `#${id}`).join("、") || "未关联"}</span>
|
||||
<span>发现于 {time(selected.created_at)}</span>
|
||||
</div>
|
||||
<Link href={`/function/tasks/detail?id=${selected.task_id}&tab=sessions`} className="text-sm underline">
|
||||
查看来源任务会话
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+10
-1
@@ -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<Task>(`/tasks/${id}`, input),
|
||||
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) =>
|
||||
patch<Task>(`/tasks/${id}`, input),
|
||||
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
|
||||
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
|
||||
createTaskCategory: (name: string) => post<TaskCategory>("/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<IntelligencePage>(`/${scope}/${id}/intelligence?${query.toString()}`),
|
||||
intelligenceDetail: (scope: "tasks" | "assets", id: string, intelligenceId: number) =>
|
||||
get<TaskIntelligence>(`/${scope}/${id}/intelligence/${intelligenceId}`),
|
||||
updateIntelligence: (taskId: number, intelligenceId: number, status: string) =>
|
||||
patch<{ updated: boolean }>(`/tasks/${taskId}/intelligence/${intelligenceId}`, { status }),
|
||||
settings: () => get<Settings>(`/settings`),
|
||||
basicAuthSettings: () => get<{ enabled: boolean; username: string; password_set: boolean }>("/settings/basic-auth"),
|
||||
setBasicAuthSettings: (settings: { enabled: boolean; username: string; password: string }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IntelligenceKind, number>;
|
||||
enabled: boolean;
|
||||
}
|
||||
Reference in new issue
Block a user