ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
474 lines
33 KiB
Go
474 lines
33 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
"github.com/Autumn-27/artex/intercept"
|
||
"github.com/Autumn-27/norma/agentcore"
|
||
"github.com/Autumn-27/norma/llm"
|
||
"github.com/Autumn-27/norma/permission"
|
||
actool "github.com/Autumn-27/norma/tool"
|
||
"github.com/Autumn-27/norma/transcript"
|
||
)
|
||
|
||
// Planner is the event-driven LLM planner (docs §4.3): each time the asset or
|
||
// exploration graph changes (debounced), it reads the exploration route, queries
|
||
// assets, judges whether the task goal is met, and emits 0..N exploration intents
|
||
// into the frontier. It is the sole intent generator.
|
||
type Planner struct {
|
||
findingRecorder FindingRecorder
|
||
prov llm.Provider
|
||
model string
|
||
tx *transcript.Store // raw LLM conversation persistence (nil = off)
|
||
window int // context window in tokens (for compaction)
|
||
windowFn func() int // optional dynamic task-chain minimum
|
||
maxTurns int // max agent turns per run (0 = unlimited)
|
||
killWork func(intentID int64) error // engine callback to terminate a running work (nil = off)
|
||
steerWork func(intentID int64, msg string) error // engine callback to steer a running work mid-run (nil = off)
|
||
proxyAddr string // recording proxy for WebFetch (empty = direct)
|
||
proxyCACert string // recording proxy's CA cert path (HTTPS verify)
|
||
webSearch WebSearchOpts // web_search tool backend selection (off by default)
|
||
workDir string // shared work dir (surfaced in prompt as artifact-output target)
|
||
injectConstraints func() bool // resolver: inject task operation constraints into system prompt? (nil = yes)
|
||
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
||
noaEnabledFn func() bool // resolver: use experimental noa compaction? (nil = off)
|
||
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
|
||
compactor *Compactor // cold-node compaction (§7); nil = disabled
|
||
|
||
// todos keeps ONE plan-scratchpad per task (keyed by exploration id) so the
|
||
// planner's multi-step plan survives across wake-ups — each Plan() is a fresh
|
||
// session, but the shared store lets it record a serial exploit chain once and
|
||
// dispatch it step-by-step over rounds instead of front-loading it in parallel.
|
||
todoMu sync.Mutex
|
||
todos map[int64]*actool.TodoStore
|
||
}
|
||
|
||
func NewPlanner(prov llm.Provider, model, workDir string, tx *transcript.Store, window, maxTurns int) *Planner {
|
||
return &Planner{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns, todos: map[int64]*actool.TodoStore{}}
|
||
}
|
||
|
||
func (p *Planner) SetCompactionWindowResolver(fn func() int) { p.windowFn = fn }
|
||
|
||
// SetCompactor wires the cold-node compactor (cold-digest §7). Called each
|
||
// planner wake-up to advance the round counter, maintain cold stamps, and
|
||
// (off the hot path) fold cold nodes into digests. nil = feature disabled.
|
||
func (p *Planner) SetCompactor(c *Compactor) { p.compactor = c }
|
||
|
||
// SetNonStreaming wires a resolver deciding whether runs use the non-streaming
|
||
// model path (true = non-streaming). nil/unset = streaming (default).
|
||
func (p *Planner) SetNonStreaming(fn func() bool) { p.nonStreamingFn = fn }
|
||
|
||
func (p *Planner) nonStreaming() bool { return p.nonStreamingFn != nil && p.nonStreamingFn() }
|
||
|
||
// SetNoaEnabled wires a resolver deciding whether runs use the experimental noa
|
||
// context-compression mechanism. nil/unset = off (built-in compaction). Read per
|
||
// run so the settings toggle takes effect without rebuilding the agent.
|
||
func (p *Planner) SetNoaEnabled(fn func() bool) { p.noaEnabledFn = fn }
|
||
|
||
// SetMaxTokens wires a resolver for the per-reply output cap. nil/unset or 0 =
|
||
// send no cap and let the endpoint decide. Read per run, like nonStreaming.
|
||
func (p *Planner) SetMaxTokens(fn func() int) { p.maxTokensFn = fn }
|
||
|
||
func (p *Planner) maxTokens() int {
|
||
if p.maxTokensFn == nil {
|
||
return 0
|
||
}
|
||
return p.maxTokensFn()
|
||
}
|
||
|
||
func (p *Planner) compactionWindow() int {
|
||
if p.windowFn != nil {
|
||
return p.windowFn()
|
||
}
|
||
return p.window
|
||
}
|
||
|
||
// SetProxy points the planner's WebFetch at the recording proxy plus the CA cert
|
||
// it trusts to verify HTTPS through it (empty addr = direct).
|
||
func (p *Planner) SetProxy(addr, caCert string) { p.proxyAddr, p.proxyCACert = addr, caCert }
|
||
|
||
// SetWebSearch selects the web_search backend for the planner (off by default).
|
||
func (p *Planner) SetWebSearch(o WebSearchOpts) { p.webSearch = o }
|
||
|
||
// SetConstraintInject wires a resolver deciding whether this task's operation
|
||
// constraints get injected into the planner system prompt. Read per round so the
|
||
// settings toggle takes effect without rebuilding the agent. nil = inject (default).
|
||
func (p *Planner) SetConstraintInject(fn func() bool) { p.injectConstraints = fn }
|
||
|
||
// wantConstraints reports whether constraint injection is enabled (default yes).
|
||
func (p *Planner) wantConstraints() bool { return p.injectConstraints == nil || p.injectConstraints() }
|
||
|
||
// todoFor returns the task's persistent planning todo store, creating it on first
|
||
// use. Shared across all of this task's planner wake-ups.
|
||
func (p *Planner) todoFor(expID int64) *actool.TodoStore {
|
||
p.todoMu.Lock()
|
||
defer p.todoMu.Unlock()
|
||
s := p.todos[expID]
|
||
if s == nil {
|
||
s = actool.NewTodoStore()
|
||
p.todos[expID] = s
|
||
}
|
||
return s
|
||
}
|
||
|
||
// SetKillWork wires the engine's per-work terminate callback so the planner's
|
||
// kill_work tool can stop a single running worker.
|
||
func (p *Planner) SetKillWork(fn func(intentID int64) error) { p.killWork = fn }
|
||
|
||
// SetSteerWork wires the engine's per-work steering callback so the planner's
|
||
// steer_work tool can inject a mid-run course-correction into a running worker.
|
||
func (p *Planner) SetSteerWork(fn func(intentID int64, msg string) error) { p.steerWork = fn }
|
||
|
||
// renderPlannerTodos formats the persistent planning todo for injection into the
|
||
// wake-up prompt (empty when there are no todos yet — first wake-up).
|
||
func renderPlannerTodos(items []actool.Todo) string {
|
||
if len(items) == 0 {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("\n\n【你的规划待办(跨唤醒保留,上一轮你写的)】:\n")
|
||
for _, it := range items {
|
||
mark := map[actool.TodoStatus]string{actool.TodoPending: "☐", actool.TodoInProgress: "▶", actool.TodoCompleted: "✔"}[it.Status]
|
||
if mark == "" {
|
||
mark = "☐"
|
||
}
|
||
b.WriteString(fmt.Sprintf(" %s %s\n", mark, it.Content))
|
||
}
|
||
b.WriteString("据此推进:只对【前置步骤已完成 / 其依赖的 fact 已存在】的下一步派意图;用 TodoWrite 更新清单(把已被 fact 满足的步骤标 completed)。不要重复派已在清单里 pending/in_progress 的步骤。")
|
||
return b.String()
|
||
}
|
||
|
||
// TriggerEvent describes what concretely caused this planning round to fire, so
|
||
// the planner looks first at the actual change instead of re-scanning the whole
|
||
// overview. Kind:
|
||
//
|
||
// "done" — a worker finished intent IntentID (its output conclusion is fetched).
|
||
// "finding" — a worker reported a finding on intent IntentID (Detail = 摘要).
|
||
// "goal" — the human (via 主 agent 的 set_goals) added one OR MORE goals in a
|
||
// single call (Goals = 本次新增的目标文本,1+ 条;set_goals 支持批量).
|
||
// "goal_deleted" — the human deleted a goal from 总览的目标管理 (Detail = 被删目标文本).
|
||
// "goal_edited" — the human edited a goal from 总览的目标管理 (OldGoal→NewGoal 文本).
|
||
// "cancelled" — the human deleted intent IntentID (Detail = 删除原因). The intent is
|
||
// stopped (not deleted) and the reason is attached to it as a fact.
|
||
type TriggerEvent struct {
|
||
Kind string
|
||
IntentID int64
|
||
Detail string
|
||
Summary string // Kind=="cancelled" 专用:删除前捕获的意图摘要(真删除后节点已不存在,无法再查)
|
||
Goals []string // Kind=="goal" 专用:本次 set_goals 新增的目标文本(1 条或多条)
|
||
OldGoal string // Kind=="goal_edited" 专用:修改前的目标文本
|
||
NewGoal string // Kind=="goal_edited" 专用:修改后的目标文本
|
||
Hints []string // Kind=="hint" 专用:本次 add_hint 新增的提示文本(1 条或多条)
|
||
}
|
||
|
||
// renderTriggers spells out the change(s) that fired this round: for a finished
|
||
// worker — which intent + its output conclusion; for a finding — which intent +
|
||
// what was found. Empty for time/heartbeat wakes. Reads the store (best-effort;
|
||
// a blank field never blocks the round).
|
||
func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
|
||
if len(evs) == 0 || ts == nil {
|
||
return ""
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("\n\n【本次触发本轮的实际变动(先看这里,再决定是否补方向)】:")
|
||
for _, ev := range evs {
|
||
switch ev.Kind {
|
||
case "goal":
|
||
if len(ev.Goals) == 1 {
|
||
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了一个目标:%s —— 新的待达成目标,请据此补充探索方向(若尚无对应意图)。", ev.Goals[0]))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了 %d 个目标:%s —— 均为新的待达成目标,请逐一为尚无对应意图的目标补充探索方向。", len(ev.Goals), strings.Join(ev.Goals, ";")))
|
||
}
|
||
case "hint":
|
||
if len(ev.Hints) == 1 {
|
||
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了一条战略提示:%s —— 已挂到探索图上,请据此调整/补充探索方向(若尚无对应意图)。", ev.Hints[0]))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("\n- 人(主 agent)新增了 %d 条战略提示:%s —— 均已挂到探索图上,请逐一据此调整/补充探索方向。", len(ev.Hints), strings.Join(ev.Hints, ";")))
|
||
}
|
||
case "goal_deleted":
|
||
b.WriteString(fmt.Sprintf("\n- 人删除了该目标:%s —— 该目标已移除,请据此重判剩余目标/方向(不必再为它派意图)。", ev.Detail))
|
||
case "goal_edited":
|
||
b.WriteString(fmt.Sprintf("\n- 人修改了目标,由「%s」变为「%s」—— 请据新目标调整探索方向(原方向若已不适用请停派)。", ev.OldGoal, ev.NewGoal))
|
||
case "finding":
|
||
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 报告了一个 finding:%s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
|
||
case "cancelled":
|
||
// 意图内容优先用删除时捕获的 Summary(真删除后节点已不存在,intentSummary 查不到)。
|
||
sm := ev.Summary
|
||
if sm == "" {
|
||
sm = intentSummary(ts, ev.IntentID)
|
||
}
|
||
b.WriteString(fmt.Sprintf("\n- 意图 #%d 由用户删除,意图内容是:%s、删除原因是:%s。该意图已删除(不再执行);请据此重新规划。", ev.IntentID, sm, ev.Detail))
|
||
default: // "done"
|
||
b.WriteString(fmt.Sprintf("\n- 意图 #%d(%s)的 worker 结束,输出结论:%s", ev.IntentID, intentSummary(ts, ev.IntentID), workerOutput(ts, ev.IntentID)))
|
||
if fids := factIDsYielded(ts, ev.IntentID); fids != "" {
|
||
b.WriteString(fmt.Sprintf(";本意图新产生的事实 id:%s ", fids))
|
||
}
|
||
}
|
||
}
|
||
b.WriteString("\n(完整细节可 node_detail / get_worker_output / list_findings 再查。)")
|
||
return b.String()
|
||
}
|
||
|
||
// factIDsYielded lists the fact ids an intent produced this run as "#12、#15", so the
|
||
// planner can jump straight to the round's incremental facts. Empty (best-effort) when
|
||
// the intent yielded no facts or the lookup fails.
|
||
func factIDsYielded(ts *db.ExplorationStore, id int64) string {
|
||
ids, err := ts.FactsYielded(id)
|
||
if err != nil || len(ids) == 0 {
|
||
return ""
|
||
}
|
||
parts := make([]string, len(ids))
|
||
for i, fid := range ids {
|
||
parts[i] = fmt.Sprintf("#%d", fid)
|
||
}
|
||
return strings.Join(parts, "、")
|
||
}
|
||
|
||
// intentSummary reads an intent node's one-line summary (best-effort, "?" on miss).
|
||
func intentSummary(ts *db.ExplorationStore, id int64) string {
|
||
n, err := ts.GetNode(id)
|
||
if err != nil || n == nil {
|
||
return "?"
|
||
}
|
||
var p map[string]any
|
||
if json.Unmarshal(n.Payload, &p) == nil {
|
||
if s, ok := p["summary"].(string); ok && s != "" {
|
||
return s
|
||
}
|
||
}
|
||
return "?"
|
||
}
|
||
|
||
// workerOutput returns the finished worker's conclusion for an intent — the last
|
||
// 'result' (else 'text') activity's full detail, truncated. Same source get_worker_output uses.
|
||
func workerOutput(ts *db.ExplorationStore, id int64) string {
|
||
acts, _, err := ts.ActivityList(&id, 0, 1000)
|
||
if err != nil {
|
||
return "(取输出失败)"
|
||
}
|
||
var pick *db.Activity
|
||
for i := range acts {
|
||
if acts[i].Kind == "result" {
|
||
pick = &acts[i]
|
||
} else if acts[i].Kind == "text" && pick == nil {
|
||
pick = &acts[i]
|
||
}
|
||
}
|
||
if pick == nil {
|
||
return "(该 work 尚无输出记录)"
|
||
}
|
||
out, _ := ts.ActivityDetail(pick.ID)
|
||
if out == "" {
|
||
out = pick.Summary
|
||
}
|
||
return truncOutput(out, 800)
|
||
}
|
||
|
||
// truncOutput caps a worker-output blob so the trigger context doesn't bloat the
|
||
// system prompt every round; full text is one get_worker_output call away.
|
||
func truncOutput(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + " …(已截断,完整见 get_worker_output)"
|
||
}
|
||
|
||
// renderGraphOverview folds the pre-computed graph_overview snapshot into the
|
||
// wake-up prompt so the planner starts each round with the full situation in
|
||
// hand — saving the round-trip it would otherwise spend calling the tool. It is
|
||
// the exact same JSON graph_overview would return; deeper detail is still one
|
||
// tool call away (node_detail / list_facts / …).
|
||
func renderGraphOverview(data map[string]any) string {
|
||
b, err := json.Marshal(data)
|
||
if err != nil {
|
||
return "" // fall back to the model calling graph_overview itself
|
||
}
|
||
return "\n\n【本轮态势(graph_overview 预取,等同你调用该工具的返回;需要细节再按需调 node_detail/list_facts 等)】:\n" + string(b)
|
||
}
|
||
|
||
// plannerDefaultTmpl is the built-in EDITABLE body (段 [A]) of the planner prompt,
|
||
// seeded into agent_prompts. Goal is a {{.Goal}} template var; the 中间产物输出规约
|
||
// tail is code-owned (artifactSpec) and appended by plannerSystem after rendering.
|
||
const plannerDefaultTmpl = `你是一个网络安全平台授权渗透测试系统的"规划者",被频繁唤醒(图一变就唤醒)。职责:读态势 → 判目标 → **只在确有未被覆盖的新方向时**补充探索意图。你是规划者、不是执行者:本轮所有产物只能是【生成/说清意图】或【判定目标】,绝不在 plan 里把活干了。
|
||
|
||
任务目标:{{.Goal}}
|
||
|
||
**本轮该产出几个意图(先想清楚这条)**:
|
||
- **硬底线(最高优先)**:只要【目标未达成】且【当前没有任何 open 或 running 意图】(frontier_open=0 且 running_intents 为空),本轮就【必须】产出至少一个向目标推进的意图——没有在跑的 work 可等、也没有在排队的方向时,产出 0 意图=任务停摆;哪怕已知方向都只在 recent_done 里,也要据下面 done/exhausted/blocked 的判断另开一条或续派一条。
|
||
- 硬底线之外,**产出 0 个意图是正常结果,但要有正当理由**(不是"少派更稳"的默认):①**已覆盖**——你想到的方向都已被仍在 open/running 的意图处理(换措辞重复生成已存在的意图是严重错误);②**等待依赖**——下一步依赖当前在跑 work 的产出、而它还没出来(此时硬派会让下游拿不到前置而空转,应等下次唤醒图更新后再派)。
|
||
- 反过来:确有【未覆盖、且不依赖在跑 work】的新方向,或目标未达成且范围内仍有未测面,就该派——别把 0 意图当偷懒的默认。
|
||
|
||
**每次唤醒的决策流程**:
|
||
|
||
1. **完整态势已附在本提示下方**(就是 graph_overview 的返回,无需再调它):task(原始标题+目标/根节点)、资产计数、goals+状态、open/running/recent_done 意图、sites_without_endpoints(无端点的站点,提示可能待探的方向)、facts(探索事实数,与漏洞是两类)、recent_facts({id,summary,confidence?})。
|
||
- **范围**:探索节点(goals/意图/facts/findings)只含本任务;**资产图全局共享**(多任务同一份,资产计数是全局在范围内的、非本任务独有)——出现非本任务相关的资产时忽略。
|
||
- **血缘**:每个意图带 parents(上游:派生自哪些事实/意图)和 yields(下游:产生了哪些事实/发现),recent_facts 每条带 from_intent;据此理解"哪些事实来自哪个方向、能否综合出新方向"。
|
||
- **否定/存疑观察**(recent_facts 里"端口关闭/不可注入"等)是 worker 的观察、不是定论:采信前先 node_detail(id) 看 evidence——evidence 扎实、confidence=observed 且手段已穷尽的才视为该方向暂时封住;evidence 缺失、只是"看起来像/只探一次"、或 confidence=inferred 的,按【尚未探明】处理,若在范围内且无其它意图覆盖,默认派一条复核意图去证实或推翻(**同一否定方向至多复核一次**;复核后仍为否定、且证据合理,就尊重该结论、不再派)。
|
||
- **要更深细节才按需调**:list_facts(分页,最新在前,默认 20,可 q 过滤、before 翻页,带 total/has_more)、list_findings(全部漏洞)、node_detail(id)(完整证据/详情;列表/recent_facts 只给摘要)、list_assets(pull:q 搜索、type/company_id/task_id 过滤、分页,或 id/ids 直取)、asset_neighbors。资产全局共享,别默认拉全量。
|
||
|
||
2. **判目标(核心职责)**:goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id, evidence_id, reason) 标 met。**当你标记的恰是最后一个未完成目标时,系统自动判定整个任务完成**——收官只由逐个 prove_goal 驱动,没有别的"一键完成"手段。
|
||
- ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、拿 N 个 flag、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。
|
||
|
||
3. **(可选,仅开局、极轻量)探测理解**:仅当图里几乎还没有 fact(recent_facts 基本为空、任务刚开始)、仅凭态势无法把初始意图说具体时,才用 Bash 等对目标做极少量、只读的探测(如 1–2 次 curl 看首页/指纹)。**唯一合法产物是一句更精准的意图描述**——绝不是漏洞的发现/验证/利用,也不是端点/目录/参数的枚举结果(那些是 worker 的活,写成意图派下去)。三条硬边界:
|
||
- 图里已有 worker 产出的 fact(facts>0 / recent_facts 非空)→【禁止】再自己探测,一切判断基于已有 fact,本轮产物只能是"派新意图"或"结束";想深挖某线索 → 派意图让 worker 去查,不是自己 curl。
|
||
- 即使开局也最多探 ≤3 次就收手,只为把初始意图说清;一旦发现自己在"深入查证"而非"快速定方向"(逐个枚举端点/目录、逐个试 id、解码链、反复探同一接口、任何注入/越权/漏洞的测试验证——全是 worker 的重活),立刻停手写成意图。
|
||
- 能从现有事实/态势判断的,根本不必探测。
|
||
|
||
4. **决定补哪些新方向**:**这里的"克制"只指【不重复已存在的意图】,不是"能少派就少派"**——目标未达成时,默认追问是"为逼近目标,还有哪些更深、更狠、尚未覆盖的打法",而不是"是否可以收尾"。意图是【开放的探索方向】(不是固定类型/菜单),结合已知事实、资产、目标自判方向,逐一与 open + running + recent_done 比对:
|
||
- 已有 open/running 覆盖 → 不再生成(正在处理)。
|
||
- 在 recent_done 里出现过 → **先看该意图的 state(每条都带)分辨怎么停的,再决定**:
|
||
· **done(正常跑完)**:已覆盖 → 不原样重派;是否死路看它 yields 出的 fact 结论、而非 state;仅出现【材料性新机理】(新事实/资产/参数/明显不同的打法)才重派,且 summary 写清与上次的不同;换措辞、"再试一次说不定行"不算,禁止重试。
|
||
· **exhausted(预算耗尽、探到一半被掐断,只写回部分)/ blocked(模型或网络失败、基本没探成)**:都是中途没善终、信息不全——先用 get_worker_trace / get_worker_output 看它实际做到哪、卡在哪,再从下列里选:接近突破被预算掐 → 派"接上次进度继续";纯外部故障没跑成(blocked 常是)→ 直接重派同方向;每次卡同一处 → 换打法/方向。依据永远是 trace 里的真实进度,不是 state 本身。
|
||
- 完全无任何意图覆盖的全新方向 → 生成。
|
||
- 所有已知方向都被仍在 open/running 的意图覆盖 → 不生成、直接结束(有在跑/在排队的 work,等它们推进);但若只剩 recent_done 覆盖、已无 open/running 而目标未达成 → 按顶部硬底线必须另开或续派。
|
||
- **深度优先于覆盖度**:coverage 是下限/验收项、不是探索目标本身;发现高价值入口(可能通向 RCE/提权/数据外泄)后,优先派意图把那条路【往深打穿】,而不是为拉平覆盖度去铺广、逐个资产浅测。
|
||
- **保持路线多样、别过早收敛**:目标未达成时,若现有意图都挤在同一条路线/入口,而存在【本质不同】的未覆盖方向(另一入口面/另一类资产/另一条利用链),优先补那条分歧方向,而不是在同一线上加同义意图(看实质差异,不看措辞);若该分歧方向已被现有意图覆盖,仍不生成。理想是 2–3 条机理不同的路线并存(如"从上传链打"与"从认证绕过打"),某条交出【目标逼近】的证据后才把资源集中过去。**但多样性永远服从顶部【操作约束】**:被约束排除的入口面/端口/主机/操作,即使本质不同也绝不生成意图。
|
||
|
||
**串行利用链:分步派,别拆成并行。** 强依赖串行链(①→②→③,后一步依赖前一步的实际产出):不要一次性并行下发(下游拿不到还不存在的前置只会重复/空转);用 TodoWrite 把整条链记成待办(每步一条),本轮只派"前置已满足"的那步(通常第一步),待它产出 fact 后下次唤醒(提示会带上待办清单)再派下一步并把已满足的标 completed。"同一件事"别拆两条("确认触发点"和"触发触发点"是同一步);只有【平行、互不依赖】的维度(如枚举多个不相关端点)才用多意图并行。
|
||
|
||
5. **提交**:用【一次】add_intent 批量提交筛出的新方向(intents 数组,最多 4 个最高价值的,不要逐条多次调):
|
||
- **summary**:一句话自然语言描述该方向(测试目标完整地址 + 做什么 + 为什么),不套固定分类;去重主要靠它与已有意图比对。
|
||
- **asset_ids**:本方向要测试/攻击的目标资产 id(尽量传,0/1/多个,来自 list_assets)——只要方向围绕具体资产(站点/接口/参数/主机)就务必传,用于覆盖去重、连入资产链路,跨多资产就都传;纯全局侦察无具体资产才留空。
|
||
- **parent_ids**:本方向由哪些上游节点综合得出(可选,0/1/多个)——多个事实结合产生一个意图就都传,派生自某上游意图/发现也传其 id,顶层全新方向留空。
|
||
|
||
不重复、不硬凑;但目标未达成、又有未覆盖且更深的打法时,该派就派。简洁、聚焦、高效。`
|
||
|
||
func plannerSystem(goal, dataDir, workDir string) string {
|
||
body := renderSystem("planner", plannerDefaultTmpl, PlannerVars{Goal: goal, DataDir: dataDir, Now: nowStr()})
|
||
return body + artifactSpec(workDir) + langDirective()
|
||
}
|
||
|
||
// Plan runs one planning round. emit, if non-nil, receives the planner's execution
|
||
// steps (so users can see how it reads the situation and judges goals — the
|
||
// planner is the intent generator and was previously a black box). Returns whether
|
||
// the planner judged the goal met.
|
||
// triggers carries the concrete change(s) that fired this round — worker(s) done
|
||
// and/or finding(s) reported (may be several — the engine debounces a burst; empty
|
||
// for time/heartbeat wakes). They are spelled out at the top of the prompt so the
|
||
// planner looks first at the actual change (which intent, its output/finding).
|
||
func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal string, triggers []TriggerEvent, emit func(db.Activity)) (met bool, reason string, err error) {
|
||
// cold-digest §2.3/§7: advance this task's planner-round counter, maintain the
|
||
// cold_since_round stamps, and (if a threshold is hit) kick off background
|
||
// compaction. Synchronous part is cheap (a few queries); the LLM compaction
|
||
// runs in a detached goroutine so it never adds latency to this round.
|
||
p.compactor.OnPlannerRound(ctx, ts)
|
||
tsx := NewToolSet(ts, "planner")
|
||
tsx.SetFindingRecorder(p.findingRecorder)
|
||
if as != nil {
|
||
tsx.SetAssetStore(as, as.Companies())
|
||
}
|
||
tsx.SetTaskID(taskID)
|
||
tsx.SetCoverageEnabled(as == nil || as.CoverageEnabled(taskID))
|
||
tsx.killWork = p.killWork // enable kill_work tool (nil = unavailable)
|
||
tsx.steerWork = p.steerWork // enable steer_work tool (nil = unavailable)
|
||
if origin, _ := ts.OriginFactID(); origin > 0 {
|
||
tsx.SetOwnerNode(origin) // planner-side anchors default to the task root (origin fact)
|
||
}
|
||
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
|
||
// 资产覆盖度功能关闭时剔除 add_task_scope/list_untested_assets(不入 prompt)。
|
||
base := append(tsx.DropCoverageTools(tsx.PlannerTools()), actool.DefaultTools()...)
|
||
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
|
||
tools, def, cleanup := AugmentTools(ctx, "planner", base)
|
||
defer cleanup()
|
||
// 关键态势(刚完成的意图 + 预取的完整图)改放【本轮 user 输入】(见下方 input),system
|
||
// 只留静态规划正文。move-out 让 system 每轮稳定、更利于缓存;代价是若单轮变长,态势可能
|
||
// 被 compaction 压缩(planner 单轮通常短,风险低)。situational 会拼进下方 input。
|
||
situational := renderTriggers(ts, triggers) + renderGraphOverview(tsx.graphOverviewData())
|
||
// 任务级 deadline / 终局模式(经 ctx 注入,见 taskclock.go)。终局那一轮把任务超时
|
||
// planner 收尾词作为【本轮操作指令】拼进本轮 user 输入(随 situational),让它只做最后
|
||
// 目标判定、不产新意图。
|
||
tc := taskClockFrom(ctx)
|
||
if tc.Final {
|
||
situational += "\n\n【任务终局收尾(本轮特殊指令,覆盖上面的常规规划流程)】:" + resolveTaskTimeoutWrapup("planner")
|
||
}
|
||
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
|
||
taskDir := ensureRunDir(p.workDir, taskID, 0)
|
||
ctx = intercept.WithReviewContext(ctx, taskDir, intercept.ReviewBackground{})
|
||
sysBody := plannerSystem(goal, p.workDir, taskDir)
|
||
if p.wantConstraints() {
|
||
sysBody += constraintBlock(ts) // 操作约束(若有)注入系统提示,框定探索边界
|
||
}
|
||
system, boundary := deferredSystem(sysBody, def)
|
||
// planner 无自身墙钟预算;有 deadline 时把 MaxDuration 夹逼到剩余,让在跑的规划轮在
|
||
// 任务到点时进收尾(因超时→任务超时词,因步数→per-run 词)。
|
||
maxDur, clamped := clampMaxDuration(tc.DeadlineUnix, 0)
|
||
settle := wrapupSettlement("planner", nil)
|
||
if tc.DeadlineUnix > 0 {
|
||
settle = wrapupSettlementForTask("planner", nil, clamped)
|
||
}
|
||
opts := agentcore.Options{
|
||
Provider: p.prov,
|
||
SystemPrompt: system,
|
||
DynamicBoundary: boundary,
|
||
Tools: tools,
|
||
DeferredTools: def.Deferred,
|
||
UnlockSet: def.Unlock,
|
||
PermissionMode: permission.ModeBypass,
|
||
EnableWebFetch: true, // 走记录代理留痕;载入代理 CA 验证 MITM 重签的 HTTPS 证书
|
||
WebFetchProxy: p.proxyAddr,
|
||
WebFetchCACert: p.proxyCACert,
|
||
// 联网搜索(可选)。ddgs 无需 key;brave-free 需 BraveKey;tavily 需 TavilyKey。
|
||
// WebSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关;空则直连。
|
||
EnableWebSearch: p.webSearch.Enabled,
|
||
WebSearchBackend: p.webSearch.Backend,
|
||
BraveSearchAPIKey: p.webSearch.BraveKey,
|
||
TavilySearchAPIKey: p.webSearch.TavilyKey,
|
||
DeepSeekSearchBaseURL: p.webSearch.DeepSeekBaseURL,
|
||
DeepSeekSearchAPIKey: p.webSearch.DeepSeekAPIKey,
|
||
DeepSeekSearchModel: p.webSearch.DeepSeekModel,
|
||
WebSearchProxy: p.webSearch.Proxy,
|
||
BashEnv: proxyEnv(p.proxyAddr, p.proxyCACert), // Bash 子命令默认走代理+信任 CA
|
||
WorkingDir: taskDir, // 本任务工作目录 <workDir>/tasks/<taskID>
|
||
ToolOutputDir: cmdOutDir(taskDir),
|
||
MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management)
|
||
MaxDuration: maxDur, // 0=不限;有 deadline 时=距 deadline 剩余
|
||
Compaction: compactionConfig(p.compactionWindow()),
|
||
// 跨唤醒共享的规划待办:让串行链在多轮之间保留(session 是新的,store 不是)。
|
||
Todos: p.todoFor(ts.ID()),
|
||
// 命中【本轮】步数预算→ SDK 跑收尾:把本轮已想清楚的结论落地(该派的 add_intent、
|
||
// 能证的 prove_goal、串行链记 TodoWrite),而非停止规划——planner 之后仍会被反复唤醒。
|
||
// clamped(被任务 deadline 夹逼)时改用 PromptByReason(见 wrapupSettlementForTask)。
|
||
Settlement: settle,
|
||
NonStreaming: p.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
||
MaxTokens: p.maxTokens(), // 0 = 不发上限,由服务端默认值决定
|
||
}
|
||
if p.tx != nil { // persist raw LLM conversation; one accumulating file per task's planner
|
||
opts.Transcript = p.tx
|
||
opts.SessionID = fmt.Sprintf("exp%d-planner", ts.ID())
|
||
}
|
||
// 实验功能:开启后由 noa 接管上下文压缩(归档集中在 <workDir>/noa/<SessionID> 下,持久)。
|
||
noaSession := fmt.Sprintf("exp%d-planner", ts.ID())
|
||
enableNoa(&opts, p.noaEnabledFn, p.workDir, noaSession, noaWarn(noaSession))
|
||
// 态势(刚完成的意图 + 完整图)现在拼进本轮 user 输入(见下方 input)。user 里还有
|
||
// 指令 + 跨唤醒待办(todo 是模型自己的规划便签,可再生,放 user 即可)。
|
||
// 开场白按「本轮有无具体变动」分两种:有变动 → 指向下方【实际变动】块;无变动
|
||
// (心跳定时巡检 / hint / 恢复等) → 别谎称"图发生了变化",转而提示顺带复查在跑意图。
|
||
lead := "刚有具体变动(见下面的【本次触发本轮的实际变动】),据此规划下一步:"
|
||
if len(triggers) == 0 {
|
||
lead = "本轮是**定时巡检(心跳到点)/无具体变动信号**的唤醒——图不一定有新变动。顺带复查在跑意图:长时间无进展或跑偏的用 steer_work 纠偏、方向整个错的用 kill_work 止损;再判定目标、决定是否补方向:"
|
||
// 心跳/无变动唤醒时,若全图已无任何 open 或 running 意图 → 探索已停摆(没 worker 在跑、
|
||
// 也没排队方向)。明确告知 planner 并强制其本轮补出新方向,别只复查在跑意图后空转一轮。
|
||
if active, err := ts.HasActiveIntent(); err == nil && !active {
|
||
lead = "本轮是**定时巡检(心跳到点)**的唤醒,且当前**已没有任何 open 或 running 的意图**——没有 worker 在跑、也没有排队中的方向,探索已停摆。你**必须**在本轮产出一个或多个向目标推进、且与图中既有意图**互不重复**的新意图(不得产出 0 意图);先据下面的态势判定目标是否已达成,未达成则立即补方向:"
|
||
}
|
||
}
|
||
input := lead + situational + "\n\n据上面的态势,判定目标。目标已【真正达成】(已拿到目标成果/已确认目标漏洞)时用 prove_goal 逐个标记。**硬底线:只要目标尚未达成、且当前没有任何 open 或 running 意图(frontier_open=0 且 running_intents 为空),本轮就必须产出至少一个向目标推进的意图——此时没有在跑的 work 可等、也没有在排队的方向,产出 0 意图=任务停摆。仅当已有 open/running 意图在推进、或目标已达成时,本轮才可以不产出新意图。**" +
|
||
renderPlannerTodos(opts.Todos.List())
|
||
// MaxDuration 现在会在墙钟到点打断在跑工具并就地进收尾(在活 ctx 上),单轮卡死不再
|
||
// 绕过收尾,无需外部硬 ctx 兜底。ctx 只承载 pause / kill / shutdown。
|
||
_, _, err = captureRun(ctx, opts, input,
|
||
func(r db.Activity) {
|
||
if emit != nil {
|
||
r.Worker = "planner" // planner activity has no intent_id (it generates them)
|
||
emit(r)
|
||
}
|
||
})
|
||
return tsx.GoalMet, tsx.Reason, err
|
||
}
|