同步
This commit is contained in:
1 parent
e67ff26e90
commit
58f23a2773
61 files changed
+11611
-443
No files matched your search
+438
@@ -52,6 +52,12 @@ type ToolSet struct {
|
||||
ts *db.ExplorationStore
|
||||
worker string
|
||||
taskID int64 // PG tasks.id; 0 when unknown (tests / orchestrator cross-task reads)
|
||||
// coverageDisabled mirrors tasks.coverage_enabled=false. Stored inverted so the
|
||||
// zero value (all existing ToolSet constructions) means ENABLED — matching the
|
||||
// DB default (true). When true: graphOverviewData drops the coverage block, the
|
||||
// auto-scope hook (insertAssets) is skipped, and add_task_scope/list_untested_assets
|
||||
// are filtered out of the agent's tool list. The scope field stays regardless.
|
||||
coverageDisabled bool
|
||||
// ownerNode is the exploration node that writes attach to: assets this run
|
||||
// touches get anchored to it as lineage/provenance (NOT visibility — the asset
|
||||
// graph is global and shared). Worker = its claimed intent; planner = begin root.
|
||||
@@ -146,6 +152,90 @@ func NewToolSet(ts *db.ExplorationStore, worker string) *ToolSet {
|
||||
// dual-write to the standalone findings table (which survives task deletion).
|
||||
func (t *ToolSet) SetTaskID(id int64) { t.taskID = id }
|
||||
|
||||
// SetCoverageEnabled records whether this task has the asset-coverage feature on
|
||||
// (default enabled). Passing false makes graphOverviewData omit the coverage block,
|
||||
// the insertAssets auto-scope hook a no-op, and CoverageTools reports the two
|
||||
// coverage-only tools so callers can drop them from the agent's tool list.
|
||||
func (t *ToolSet) SetCoverageEnabled(enabled bool) { t.coverageDisabled = !enabled }
|
||||
|
||||
// CoverageDisabled reports whether the coverage feature is off for this task.
|
||||
func (t *ToolSet) CoverageDisabled() bool { return t.coverageDisabled }
|
||||
|
||||
// coverageOnlyTools are the LLM tools that only make sense when asset coverage is
|
||||
// on. When the feature is off they are filtered out of the agent's tool list so
|
||||
// they neither pollute the prompt nor let the model build a disabled denominator.
|
||||
var coverageOnlyTools = map[string]bool{"add_task_scope": true, "list_untested_assets": true}
|
||||
|
||||
// DropCoverageTools returns tools with the coverage-only ones removed when this
|
||||
// task has the feature disabled; otherwise it returns tools unchanged.
|
||||
func (t *ToolSet) DropCoverageTools(tools []actool.CoreTool) []actool.CoreTool {
|
||||
if !t.coverageDisabled {
|
||||
return tools
|
||||
}
|
||||
out := tools[:0:0]
|
||||
for _, tool := range tools {
|
||||
if coverageOnlyTools[tool.Name()] {
|
||||
continue
|
||||
}
|
||||
out = append(out, tool)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StripCoverageParams hides coverage-only parameters (currently insert_assets'
|
||||
// per-item `related`, which only decides whether an asset enters the coverage
|
||||
// denominator) from the model-facing schema when coverage is disabled — the param
|
||||
// has no effect then, so showing it just pollutes the prompt. MUST run on the FINAL
|
||||
// tool list (after AugmentTools/ToolResolve): the DB tools table is authoritative on
|
||||
// schema, so stripping the code schema earlier would be overwritten. No-op when
|
||||
// enabled or when the list has no insert_assets. The schema is deep-copied before
|
||||
// editing so the shared/DB schema map is never mutated.
|
||||
func (t *ToolSet) StripCoverageParams(tools []actool.CoreTool) []actool.CoreTool {
|
||||
if !t.coverageDisabled {
|
||||
return tools
|
||||
}
|
||||
for i, tool := range tools {
|
||||
if tool.Name() != "insert_assets" {
|
||||
continue
|
||||
}
|
||||
schema := deepCopyJSONMap(tool.InputSchema())
|
||||
if props, ok := nestedMap(schema, "properties", "assets", "items", "properties"); ok {
|
||||
delete(props, "related")
|
||||
}
|
||||
tools[i] = DecorateTool(tool, tool.Description(), schema)
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// deepCopyJSONMap returns a JSON round-trip deep copy of a schema map so callers can
|
||||
// edit it without touching the original (which may be shared/cached). Falls back to
|
||||
// the input on any marshal error (edits then become best-effort no-ops upstream).
|
||||
func deepCopyJSONMap(m map[string]any) map[string]any {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
var out map[string]any
|
||||
if json.Unmarshal(b, &out) != nil {
|
||||
return m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nestedMap walks a chain of string keys through nested map[string]any values,
|
||||
// returning the final map and whether the whole path resolved to one.
|
||||
func nestedMap(m map[string]any, keys ...string) (map[string]any, bool) {
|
||||
cur := m
|
||||
for _, k := range keys {
|
||||
next, ok := cur[k].(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// Cross-task reuse: exported accessors returning the per-task tool logic bound to
|
||||
// THIS ToolSet's store. Host-side orchestration tools build a ToolSet for an
|
||||
// arbitrary task, then Call these — so cross-task reads/hint reuse the exact
|
||||
@@ -285,12 +375,286 @@ func (t *ToolSet) graphOverviewData() map[string]any {
|
||||
_ = json.Unmarshal(h.Payload, &p)
|
||||
hsum = append(hsum, map[string]any{"id": h.ID, "state": h.State, "text": p["text"]})
|
||||
}
|
||||
<<<<<<< Updated upstream
|
||||
out["hints"] = hsum
|
||||
// lineage from the exploration edges: an intent's parents (what it
|
||||
// derived_from — possibly several facts combined) and its yields (the
|
||||
// facts/findings it produced). factFrom maps a fact → the intent that
|
||||
// produced it. This is the relationship layer the flat lists lacked.
|
||||
edges, _ := t.ts.Edges(5000)
|
||||
=======
|
||||
}
|
||||
out["running_intents"] = compactIntents(running, parentsOf, yieldsOf)
|
||||
out["recent_done_intents"] = compactIntents(recentDone, parentsOf, yieldsOf)
|
||||
out["frontier_open"] = len(fr)
|
||||
// findings (confirmed vulns) and facts (worker exploration results) are
|
||||
// now distinct node kinds. recent_facts surfaces fact summaries (esp.
|
||||
// negative results) so the planner sees them in one call; full content
|
||||
// via node_detail(id).
|
||||
vulnNodes, _ := t.ts.ListByKind(db.KindFinding, 1000)
|
||||
factNodes, _ := t.ts.ListByKind(db.KindFact, 1000) // newest first
|
||||
out["findings"] = len(vulnNodes) // 确认漏洞数(目标判定看它)
|
||||
out["facts"] = len(factNodes) // 探索事实/结论数(含否定结论)
|
||||
recentFacts := make([]map[string]any, 0, 20)
|
||||
for _, n := range factNodes {
|
||||
if len(recentFacts) >= 20 {
|
||||
break
|
||||
}
|
||||
m := compactNode(n)
|
||||
if from := factFrom[n.ID]; from > 0 {
|
||||
m["from_intent"] = from // 本事实由哪个意图产生
|
||||
}
|
||||
// confidence 带进概览:让规划者一眼看出哪条结论只是 inferred(尤其否定结论
|
||||
// 别当铁案);evidence 较长,留给 node_detail(id)。
|
||||
var fp map[string]any
|
||||
if json.Unmarshal(n.Payload, &fp) == nil {
|
||||
if c, ok := fp["confidence"].(string); ok && c != "" {
|
||||
m["confidence"] = c
|
||||
}
|
||||
}
|
||||
recentFacts = append(recentFacts, m)
|
||||
}
|
||||
out["recent_facts"] = recentFacts // 最近事实的 {id, summary, from_intent, confidence?},详情/证据用 node_detail(id)
|
||||
// the original task (root) so the planner always has it, not just the
|
||||
// decomposed goals.
|
||||
if description, goal, err := t.ts.Root(); err == nil {
|
||||
out["task"] = map[string]any{"description": description, "goal": goal}
|
||||
}
|
||||
// Direct source tasks are a live, read-only blackboard view. Keep their
|
||||
// summaries in a separate field so their intents never enter this task's
|
||||
// frontier or get mistaken for locally claimable work.
|
||||
out["related_tasks"] = t.relatedTaskOverviews()
|
||||
// coverage:粗略的资产测试覆盖度参考——范围(task_scope)内的资产里,被 fact 碰过的
|
||||
// 占比 + by_type(按类型的 总数/已测)。要看未测的具体资产由 agent 按需调 list_untested_assets 自行判断。仅任务上下文有。
|
||||
// 资产覆盖度功能关闭时(coverageDisabled):只保留 scope/hosts(范围边界与目标主机的
|
||||
// 感知信息,company 关联经由 scope 在此浮现),丢弃 denominator/tested/pct/by_type/note
|
||||
// 等覆盖度度量,避免污染上下文、也不诱导已隐藏的 add_task_scope/list_untested_assets。
|
||||
if t.as != nil && t.ts != nil && t.taskID > 0 {
|
||||
{
|
||||
m := map[string]any{}
|
||||
if !t.coverageDisabled {
|
||||
if cov, err := t.as.TaskCoverageWithSources(t.taskID); err == nil {
|
||||
m["denominator"] = cov.Denominator
|
||||
m["tested"] = cov.Tested
|
||||
m["by_type"] = cov.ByType
|
||||
m["note"] = "coverage资产测试覆盖度(包括接口等各种相关资产),粗略估计、仅供参考:包含当前任务与直接关联任务的 scope、事实锚点;关联 scope 只读。容器型资产/大量枚举会让它偏低,勿据此认为已测完;可用 add_task_scope 增补本任务范围、list_untested_assets 看未测资产【通常不调用list_untested_assets,按照任务推进即可】;"
|
||||
if cov.Denominator == 0 {
|
||||
m["pct"] = nil
|
||||
m["status"] = "范围未锚定"
|
||||
} else {
|
||||
m["pct"] = cov.Pct
|
||||
}
|
||||
}
|
||||
}
|
||||
// scope:当前测试范围的根资产(task_scope 原始行),让 agent 知道这个任务到底
|
||||
// 圈定了哪些目标(不是全部测试资产,而是范围边界本身)。覆盖度开关无关,始终提供。
|
||||
if rows, err := t.as.ListTaskScopeWithSources(t.taskID); err == nil && len(rows) > 0 {
|
||||
scope := make([]map[string]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
e := map[string]any{"kind": r.Kind, "source": r.Source, "task_id": r.TaskID}
|
||||
if r.TaskID != t.taskID {
|
||||
inheritedMap(e, r.TaskID)
|
||||
}
|
||||
switch {
|
||||
case r.Domain != "":
|
||||
e["value"] = r.Domain
|
||||
case r.Net != "":
|
||||
e["value"] = r.Net
|
||||
case r.CompanyID != nil:
|
||||
e["company_id"] = *r.CompanyID
|
||||
if t.cs != nil {
|
||||
if company, err := t.cs.GetCompany(*r.CompanyID); err == nil && company != nil {
|
||||
e["company_name"] = company.Name
|
||||
}
|
||||
if rules, err := t.cs.GetScope(*r.CompanyID); err == nil {
|
||||
keywords := make([]string, 0)
|
||||
companyScope := make([]map[string]any, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
value := rule.Raw
|
||||
if value == "" {
|
||||
switch rule.Kind {
|
||||
case "domain":
|
||||
value = rule.Domain
|
||||
case "ip", "cidr":
|
||||
value = rule.Net
|
||||
default:
|
||||
value = rule.Value
|
||||
}
|
||||
}
|
||||
entry := map[string]any{"kind": rule.Kind, "value": value}
|
||||
if rule.Reason != "" {
|
||||
entry["reason"] = rule.Reason
|
||||
}
|
||||
companyScope = append(companyScope, entry)
|
||||
if rule.Kind == "keyword" && rule.Raw != "" {
|
||||
keywords = append(keywords, rule.Raw)
|
||||
}
|
||||
}
|
||||
if len(companyScope) > 0 {
|
||||
e["company_scope"] = companyScope
|
||||
}
|
||||
if len(keywords) > 0 {
|
||||
e["company_keywords"] = keywords
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scope = append(scope, e)
|
||||
}
|
||||
m["scope"] = scope
|
||||
}
|
||||
if hosts, err := t.as.HostsByTaskWithSources(t.taskID); err == nil {
|
||||
const hostContextLimit = 500
|
||||
visible := hosts
|
||||
if len(visible) > hostContextLimit {
|
||||
visible = visible[:hostContextLimit]
|
||||
}
|
||||
m["hosts"] = visible
|
||||
m["host_count"] = len(hosts)
|
||||
if len(visible) < len(hosts) {
|
||||
m["hosts_truncated"] = true
|
||||
}
|
||||
}
|
||||
if len(m) > 0 {
|
||||
out["coverage"] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func inheritedMap(m map[string]any, sourceTaskID int64) map[string]any {
|
||||
m["source_task_id"] = sourceTaskID
|
||||
m["inherited"] = true
|
||||
return m
|
||||
}
|
||||
|
||||
const (
|
||||
relatedOverviewTotalTextRunes = 48_000
|
||||
relatedOverviewMaxTextPerSource = 8_000
|
||||
relatedOverviewMaxGoalsPerSource = 8
|
||||
relatedOverviewMaxHintsPerSource = 6
|
||||
relatedOverviewMaxFactsPerSource = 12
|
||||
relatedOverviewMaxFindingsPerTask = 6
|
||||
relatedOverviewMaxIntentsPerTask = 8
|
||||
relatedOverviewMaxScopePerSource = 12
|
||||
)
|
||||
|
||||
// overviewTextBudget bounds inherited prompt text while preserving a fair slice
|
||||
// for every direct source. Full evidence remains available through the on-demand
|
||||
// read tools, so truncation here does not discard persisted blackboard data.
|
||||
type overviewTextBudget struct {
|
||||
remaining int
|
||||
truncated bool
|
||||
}
|
||||
|
||||
func relatedOverviewBudgetForSources(sourceCount int) int {
|
||||
if sourceCount <= 0 {
|
||||
return 0
|
||||
}
|
||||
if sourceCount > db.MaxTaskSourceCount {
|
||||
sourceCount = db.MaxTaskSourceCount
|
||||
}
|
||||
perSource := relatedOverviewTotalTextRunes / sourceCount
|
||||
if perSource > relatedOverviewMaxTextPerSource {
|
||||
perSource = relatedOverviewMaxTextPerSource
|
||||
}
|
||||
return perSource
|
||||
}
|
||||
|
||||
func (b *overviewTextBudget) take(value any, fieldLimit int) string {
|
||||
var text string
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
text = strings.TrimSpace(value)
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
text = strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
if b.remaining <= 0 || fieldLimit <= 0 {
|
||||
b.truncated = true
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
limit := fieldLimit
|
||||
if limit > b.remaining {
|
||||
limit = b.remaining
|
||||
}
|
||||
if len(runes) > limit {
|
||||
b.truncated = true
|
||||
if limit == 1 {
|
||||
text = "…"
|
||||
} else {
|
||||
text = string(runes[:limit-1]) + "…"
|
||||
}
|
||||
runes = []rune(text)
|
||||
}
|
||||
b.remaining -= len(runes)
|
||||
return text
|
||||
}
|
||||
|
||||
func recentTerminalIntents(store *db.ExplorationStore, limit int) []*db.Node {
|
||||
if limit <= 0 {
|
||||
return []*db.Node{}
|
||||
}
|
||||
const batch = 300
|
||||
cursor := int64(0)
|
||||
out := make([]*db.Node, 0, limit)
|
||||
for len(out) < limit {
|
||||
page, more, err := store.ListByKindPage(db.KindIntent, cursor, batch)
|
||||
if err != nil || len(page) == 0 {
|
||||
break
|
||||
}
|
||||
for _, intent := range page {
|
||||
switch intent.State {
|
||||
case "done", "blocked", "exhausted", "stopped":
|
||||
out = append(out, intent)
|
||||
}
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
cursor = page[len(page)-1].ID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// relatedTaskOverviews distills persistent blackboard state from direct source
|
||||
// tasks. It intentionally reads each source's local store methods, never its own
|
||||
// related sources, so inheritance is one level only.
|
||||
func (t *ToolSet) relatedTaskOverviews() []map[string]any {
|
||||
sources, err := t.ts.DirectSourceStores()
|
||||
if err != nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
if len(sources) > db.MaxTaskSourceCount {
|
||||
sources = sources[:db.MaxTaskSourceCount]
|
||||
}
|
||||
perSourceTextBudget := relatedOverviewBudgetForSources(len(sources))
|
||||
out := make([]map[string]any, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
ts := source.Store
|
||||
budget := overviewTextBudget{remaining: perSourceTextBudget}
|
||||
item := map[string]any{
|
||||
"source_task_id": source.Task.TaskID,
|
||||
"inherited": true,
|
||||
"task": map[string]any{
|
||||
"description": budget.take(source.Task.Description, 800),
|
||||
"goal": budget.take(source.Task.Goal, 800),
|
||||
"status": source.Task.Status,
|
||||
},
|
||||
}
|
||||
stats, statsErr := ts.Stats()
|
||||
|
||||
edges, _ := ts.Edges(5000)
|
||||
>>>>>>> Stashed changes
|
||||
parentsOf := map[int64][]int64{}
|
||||
yieldsOf := map[int64][]int64{}
|
||||
factFrom := map[int64]int64{}
|
||||
@@ -923,6 +1287,80 @@ func (t *ToolSet) setGoals() actool.CoreTool {
|
||||
})
|
||||
}
|
||||
|
||||
type constraintItem struct {
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"` // allow | deny
|
||||
}
|
||||
|
||||
// addOneConstraint 落一条操作约束到 task_constraints。origin 取 t.worker(缺省 system):
|
||||
// 拆解器写 "goals"、主 agent 写 "human"。
|
||||
func (t *ToolSet) addOneConstraint(it constraintItem) (int64, error) {
|
||||
text := strings.TrimSpace(it.Text)
|
||||
if text == "" {
|
||||
return 0, fmt.Errorf("text 不能为空")
|
||||
}
|
||||
kind := strings.TrimSpace(strings.ToLower(it.Type))
|
||||
if kind == "" {
|
||||
kind = "deny" // 默认按禁止处理:未标注类型时更保守
|
||||
}
|
||||
if kind != "allow" && kind != "deny" {
|
||||
return 0, fmt.Errorf("type 必须是 allow 或 deny")
|
||||
}
|
||||
return t.ts.AddConstraint(kind, text, t.worker)
|
||||
}
|
||||
|
||||
// setConstraints 给【本任务】新增操作约束(allow=允许做什么 / deny=禁止做什么)。既是目标
|
||||
// 拆解器 round-0 抽约束的提交工具,也是主 agent 运行时补约束的工具——同一受管工具,可在 web
|
||||
// 端改描述/schema、按 agent 绑定。约束会被注入 planner/worker 的系统提示以约束探索边界。
|
||||
func (t *ToolSet) setConstraints() actool.CoreTool {
|
||||
return writeTool("set_constraints",
|
||||
"给【本任务】新增操作约束,用来框定探索边界:type=allow(允许做的操作)或 deny(禁止做的操作)。\n"+
|
||||
"约束=对『可以/不可以做哪些操作』的规定(如『仅测当前端口,不扫其他端口』『禁止对生产库做写操作』『只允许被动侦察』),不是目标、也不是攻击步骤。\n"+
|
||||
"★优先批量:多条放进 constraints 数组一次提交,返回 ids 与之等长同序(失败项 id=0,详情见 errors)。单条则省略 constraints 直接给顶层 text/type。\n"+
|
||||
"只登记任务目标/描述里【明确写出】的约束,不要臆造;拿不准类型时用 deny(更保守)。",
|
||||
obj(map[string]any{
|
||||
"constraints": map[string]any{"type": "array", "description": "【优先用这个】要新增的约束数组,按顺序处理。每个元素:text(必填,一条约束)+ type(allow|deny)。返回 ids 与本数组等长、同序。", "items": map[string]any{"type": "object"}},
|
||||
"text": str("[单条] 一条操作约束的内容"),
|
||||
"type": str("[单条] allow(允许)或 deny(禁止);缺省按 deny 处理"),
|
||||
}),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
if t.ts == nil {
|
||||
return actool.Errorf("set_constraints 未启用: ExplorationStore 未初始化"), nil
|
||||
}
|
||||
var a struct {
|
||||
Constraints []constraintItem `json:"constraints"`
|
||||
constraintItem // 单条模式:顶层 text/type
|
||||
}
|
||||
_ = json.Unmarshal(in, &a)
|
||||
batch := len(a.Constraints) > 0
|
||||
items := a.Constraints
|
||||
if !batch {
|
||||
items = []constraintItem{a.constraintItem}
|
||||
}
|
||||
ids := make([]int64, len(items))
|
||||
errs := map[string]string{}
|
||||
for i, it := range items {
|
||||
id, err := t.addOneConstraint(it)
|
||||
if err != nil {
|
||||
errs[strconv.Itoa(i)] = err.Error()
|
||||
continue
|
||||
}
|
||||
ids[i] = id
|
||||
}
|
||||
if !batch { // 单条:保持简单返回
|
||||
if e, bad := errs["0"]; bad {
|
||||
return actool.Errorf(e), nil
|
||||
}
|
||||
return actool.Text(fmt.Sprintf("constraint added: %d", ids[0])), nil
|
||||
}
|
||||
out := map[string]any{"ids": ids}
|
||||
if len(errs) > 0 {
|
||||
out["errors"] = errs
|
||||
}
|
||||
return jsonResult(out)
|
||||
})
|
||||
}
|
||||
|
||||
func (t *ToolSet) addHint() actool.CoreTool {
|
||||
return writeTool("add_hint", "把人类/主 agent 的战略提示挂到探索图,规划者下次生成意图时会读到它。\n"+
|
||||
"★优先批量:多条提示放进 hints 数组一次提交(比逐条调用省往返)。返回 ids 数组,与 hints 等长同序(失败项 id=0,详情见 errors)。单条则省略 hints 直接给顶层 text。",
|
||||
|
||||
Reference in new issue
Block a user