mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
In a gated scope, the two HTTP clients the platform owns connect only to addresses that a pin or a stored exemption covers at the moment of the connection, on every hop. - intercept.PinnedDialer(tool, scoped): the DialContext of both clients. It reads the target scope from the context net/http passes to the dial. Gate off, or no scope in a client built without one: plain dial, as today. A client built for a scope whose scope is missing at the dial fails closed with TARGET_GATE_UNAVAILABLE. - dialTarget runs the gate decision on the dialled host:port with a fresh lookup (block rules first, exemptions, pins, port precedence) and connects only to the addresses it compared, never to the name. Derived exemptions and name-only pins dial the address as received. An address no pin covers finds or opens the request (new_ip / no_pin, wait 0) and fails at once with TARGET_APPROVAL_PENDING. Redirect hops are dialled against the hop's own (host, port). - WebFetch (withWebFetch, now given the run context): DialContext set on every agent kind; DisableDirectRetry when the run's scope is gated. - Custom http tool: a direct call in a gated scope gets a DefaultTransport clone with the pinned dialer, no environment proxy and keep-alive off. Proxied calls are unchanged (the recording proxy is step 2.2). - Name-only: request reason says the platform does not bind the connection; the first-use pin_match row records the effective state. - Derived exemptions: a proxy URL without a port is listed with the port net/http dials (1080 for socks5) (OPEN-9). - go.mod: Norma v0.4.8 (DialContext, DisableDirectRetry).
507 lines
37 KiB
Go
507 lines
37 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/harness"
|
||
"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
|
||
// lessonsBlock is the code-owned RSI evidence block appended after the
|
||
// rendered system prompt. Empty = off. Rendered by the server as data.
|
||
lessonsBlock string
|
||
|
||
// 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 }
|
||
|
||
// SetLessonsBlock sets the code-owned RSI evidence block appended after the
|
||
// rendered system prompt. Empty disables it. The server renders it from
|
||
// outcome records; it is never editable through the prompt UI.
|
||
func (p *Planner) SetLessonsBlock(block string) { p.lessonsBlock = block }
|
||
|
||
// 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[Your planning todos (kept across wake-ups, written by you last round)]:\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("Advance accordingly: dispatch intents only for next steps [whose prerequisite steps are completed / whose depended-on facts exist]; update the list with TodoWrite (mark steps already satisfied by facts as completed). Don't re-dispatch steps already pending/in_progress on the list.")
|
||
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 = the summary).
|
||
// "goal" — the human (via the main agent's set_goals) added one OR MORE goals in
|
||
// a single call (Goals = the goal texts added this time, 1+; set_goals
|
||
// supports batch).
|
||
// "goal_deleted" — the human deleted a goal from the overview's goal management
|
||
// (Detail = the deleted goal text).
|
||
// "goal_edited" — the human edited a goal from the overview's goal management
|
||
// (OldGoal→NewGoal texts).
|
||
// "cancelled" — the human deleted intent IntentID (Detail = the deletion reason).
|
||
// 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" only: the intent summary captured before deletion (after a real delete the node no longer exists and can't be looked up)
|
||
Goals []string // Kind=="goal" only: the goal texts added by this set_goals call (one or more)
|
||
OldGoal string // Kind=="goal_edited" only: the goal text before the edit
|
||
NewGoal string // Kind=="goal_edited" only: the goal text after the edit
|
||
Hints []string // Kind=="hint" only: the hint texts added by this add_hint call (one or more)
|
||
}
|
||
|
||
// 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[The actual changes that triggered this round (read here first, then decide whether to add directions)]:")
|
||
for _, ev := range evs {
|
||
switch ev.Kind {
|
||
case "goal":
|
||
if len(ev.Goals) == 1 {
|
||
b.WriteString(fmt.Sprintf("\n- The human (main agent) added a goal: %s — a new goal to be met; please add exploration directions accordingly (if no corresponding intent exists yet).", ev.Goals[0]))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("\n- The human (main agent) added %d goals: %s — all are new goals to be met; please add exploration directions for each goal that has no corresponding intent yet.", len(ev.Goals), strings.Join(ev.Goals, "; ")))
|
||
}
|
||
case "hint":
|
||
if len(ev.Hints) == 1 {
|
||
b.WriteString(fmt.Sprintf("\n- The human (main agent) added a strategic hint: %s — already attached to the exploration graph; please adjust/add exploration directions accordingly (if no corresponding intent exists yet).", ev.Hints[0]))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("\n- The human (main agent) added %d strategic hints: %s — all are attached to the exploration graph; please adjust/add exploration directions accordingly, one by one.", len(ev.Hints), strings.Join(ev.Hints, "; ")))
|
||
}
|
||
case "goal_deleted":
|
||
b.WriteString(fmt.Sprintf("\n- The human deleted this goal: %s — the goal has been removed; re-judge the remaining goals/directions accordingly (no need to dispatch intents for it anymore).", ev.Detail))
|
||
case "goal_edited":
|
||
b.WriteString(fmt.Sprintf("\n- The human edited a goal, from \"%s\" to \"%s\" — please adjust exploration directions to the new goal (stop dispatching the old directions if they no longer apply).", ev.OldGoal, ev.NewGoal))
|
||
case "finding":
|
||
b.WriteString(fmt.Sprintf("\n- The worker of intent #%d (%s) reported a finding: %s", ev.IntentID, intentSummary(ts, ev.IntentID), ev.Detail))
|
||
case "cancelled":
|
||
// For the intent content prefer the Summary captured at deletion time (after a real delete the node no longer exists, so intentSummary can't find it).
|
||
sm := ev.Summary
|
||
if sm == "" {
|
||
sm = intentSummary(ts, ev.IntentID)
|
||
}
|
||
b.WriteString(fmt.Sprintf("\n- Intent #%d was deleted by the user; the intent was: %s; the deletion reason: %s. The intent has been deleted (no longer executed); please re-plan accordingly.", ev.IntentID, sm, ev.Detail))
|
||
default: // "done"
|
||
b.WriteString(fmt.Sprintf("\n- The worker of intent #%d (%s) finished; output conclusion: %s", ev.IntentID, intentSummary(ts, ev.IntentID), workerOutput(ts, ev.IntentID)))
|
||
if fids := factIDsYielded(ts, ev.IntentID); fids != "" {
|
||
b.WriteString(fmt.Sprintf("; new fact ids produced by this intent: %s ", fids))
|
||
}
|
||
}
|
||
}
|
||
b.WriteString("\n(Full details can be queried via 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 "(failed to fetch output)"
|
||
}
|
||
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 "(this work has no output yet)"
|
||
}
|
||
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]) + " …(truncated; full text via 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[This round's situation (graph_overview pre-fetched; identical to what calling that tool would return; call node_detail/list_facts etc. on demand for details)]:\n" + string(b)
|
||
}
|
||
|
||
// plannerDefaultTmpl is the built-in EDITABLE body (section [A]) of the planner
|
||
// prompt, seeded into agent_prompts. Goal is a {{.Goal}} template var; the
|
||
// intermediate artifact output rules tail is code-owned (artifactSpec) and appended
|
||
// by plannerSystem after rendering.
|
||
const plannerDefaultTmpl = `You are the "planner" of a network-security platform's authorized penetration testing system, woken up frequently (on every graph change). Duties: read the situation → judge the goals → **add exploration intents only when there is a genuinely uncovered new direction**. You are a planner, not an executor: this round's only outputs may be [generating/articulating intents] or [judging goals] — never do the actual work inside a plan.
|
||
|
||
Task goal: {{.Goal}}
|
||
|
||
**How many intents should this round produce (think this through first)**:
|
||
- **Hard floor (top priority)**: as long as [the goal is unmet] AND [there are currently no open or running intents] (frontier_open=0 and running_intents is empty), this round [must] produce at least one intent advancing toward the goal — with no running work to wait for and no queued direction, producing 0 intents = the task stalls; even if every known direction appears only in recent_done, open a new one or re-dispatch one per the done/exhausted/blocked judgment below.
|
||
- Beyond the hard floor, **producing 0 intents is a normal outcome, but it needs a legitimate reason** (not "fewer is safer" as a default): ①**Already covered** — every direction you can think of is already handled by intents still open/running (re-emitting an existing intent in different wording is a serious error); ②**Waiting on a dependency** — the next step depends on the output of currently running work that isn't out yet (dispatching now would leave the downstream spinning without its prerequisite; wait for the next wake after the graph updates).
|
||
- Conversely: when there IS a genuinely [uncovered, and not dependent on running work] new direction, or the goal is unmet and untested surfaces remain in scope, dispatch it — don't make 0 intents the lazy default.
|
||
|
||
**Decision flow for each wake**:
|
||
|
||
1. **The full situation is attached below this prompt** (it IS the graph_overview return — no need to call it again): task (original title + goal/root node), asset counts, goals+states, open/running/recent_done intents, sites_without_endpoints (sites with no endpoints — a hint of possibly unexplored directions), facts (exploration-fact count, a different kind from findings), recent_facts ({id,summary,confidence?}).
|
||
- **Scope**: exploration nodes (goals/intents/facts/findings) belong only to this task; **the asset graph is global and shared** (one copy across tasks; asset counts are global-in-scope, not task-private) — ignore assets unrelated to this task when they appear.
|
||
- **Lineage**: every intent carries parents (upstream: which facts/intents it derived from) and yields (downstream: which facts/findings it produced); each recent_fact carries from_intent; use these to understand "which direction produced which facts, and whether they synthesize into a new direction".
|
||
- **Negative/doubtful observations** ("port closed" / "not injectable" etc. in recent_facts) are a worker's observations, not verdicts: before trusting one, check evidence via node_detail(id) — treat the direction as tentatively sealed only when the evidence is solid, confidence=observed, and the means are exhausted; if evidence is missing, it's just "looks like it / probed only once", or confidence=inferred, treat it as [not yet proven] — if it's in scope and no other intent covers it, by default dispatch ONE re-examination intent to confirm or refute it (**at most one re-examination per negative direction**; if it stays negative after that with reasonable evidence, respect the conclusion and don't dispatch again).
|
||
- **Call for deeper detail only as needed**: list_facts (paginated, newest first, default 20, q to filter, before to page, with total/has_more), list_findings (all findings), node_detail(id) (full evidence/details; lists/recent_facts give only summaries), list_assets (pull: q search, type/company_id/task_id filters, pagination, or direct fetch by id/ids), asset_neighbors. Assets are global and shared — don't pull the full set by default.
|
||
|
||
2. **Judge goals (core duty)**: the goals field already carries goals and states; for an unmet goal proven by some finding/fact, call prove_goal(goal_id, evidence_id, reason) to mark it met. **When the one you mark happens to be the last unmet goal, the system automatically judges the whole task complete** — completion is driven solely by individual prove_goal calls; there is no other "one-click finish".
|
||
- ⚠️ **Quantitative acceptance check (strictly no early stamping)**: when a goal contains quantifiable conditions (coverage reaches X%, capture N flags, obtain some privilege), before prove_goal you [must] check the measured values in the graph_overview above (coverage.pct, findings_total, etc.): if not met, prove_goal is [forbidden] — dispatch intents to close the gap instead; never mark met early on grounds of "mostly done / the core is taken". Example: coverage required 100% but measured coverage.pct=40% → not met; keep dispatching supplementary testing intents.
|
||
|
||
3. **(Optional, only at the very start, extremely light) probing for understanding**: only when the graph has almost no facts yet (recent_facts basically empty, task just started) and the situation alone can't make the initial intents concrete, use Bash etc. for a very small number of read-only probes of the target (e.g. 1–2 curls to see the homepage/fingerprint). **The only legitimate output is a more precise intent description** — never the discovery/verification/exploitation of a vulnerability, nor enumeration results of endpoints/directories/parameters (those are the worker's job; write them as intents and dispatch them down). Three hard boundaries:
|
||
- If the graph already has worker-produced facts (facts>0 / recent_facts non-empty) → probing yourself is [forbidden]; base every judgment on existing facts, and this round's output can only be "dispatch new intents" or "finish"; to dig into a clue → dispatch an intent for a worker to investigate, don't curl it yourself.
|
||
- Even at the start, probe at most ≤3 times before stopping, only to make the initial intents precise; the moment you catch yourself "investigating in depth" rather than "quickly setting a direction" (enumerating endpoints/directories one by one, trying ids one by one, decoding chains, re-probing the same interface, any injection/privilege-escalation/vulnerability test-verification — all heavy worker work), stop immediately and write it as an intent.
|
||
- If it can be judged from existing facts/the situation, don't probe at all.
|
||
|
||
4. **Decide which new directions to add**: **"restraint" here means only [not duplicating existing intents], not "dispatch as few as possible"** — while the goal is unmet, the default question is "to close in on the goal, which deeper, harder, still-uncovered plays remain", not "can we wrap up". Intents are [open exploration directions] (not fixed types/a menu); judge directions from known facts, assets, and goals, and compare each against open + running + recent_done:
|
||
- Already covered by open/running → don't generate (being handled).
|
||
- Appears in recent_done → **first look at that intent's state (each carries one) to tell how it ended, then decide**:
|
||
· **done (ran to completion)**: covered → don't re-dispatch as-is; whether it's a dead end depends on the fact conclusions it yielded, not the state; re-dispatch only on [material new mechanics] (new facts/assets/parameters/clearly different play), with the summary stating what's different from last time; rephrasing or "maybe one more try" doesn't count — no retries.
|
||
· **exhausted (budget ran out mid-exploration, cut off halfway, only partially written back) / blocked (model or network failure, barely explored)**: both ended badly mid-way with incomplete information — first use get_worker_trace / get_worker_output to see how far it actually got and where it stuck, then choose from: cut off by budget near a breakthrough → dispatch "continue from last progress"; pure external failure that never ran (blocked often is) → re-dispatch the same direction directly; stuck at the same spot every time → change tactics/direction. The basis is always the real progress in the trace, not the state itself.
|
||
- A brand-new direction with zero coverage by any intent → generate.
|
||
- All known directions covered by still open/running intents → don't generate, finish directly (there is running/queued work; wait for it to advance); but if only recent_done covers them, no open/running remains, and the goal is unmet → per the hard floor at the top you must open a new one or re-dispatch.
|
||
- **Depth over coverage**: coverage is a floor/acceptance item, not the exploration goal itself; after finding a high-value entry point (possibly leading to RCE/privilege escalation/data exfiltration), prioritize intents that [drive that route deep], rather than spreading wide to flatten coverage by shallow-testing asset by asset.
|
||
- **Keep routes diverse; don't converge too early**: while the goal is unmet, if existing intents all crowd onto one route/entry point and there is a [fundamentally different] uncovered direction (another entry surface / another asset class / another exploit chain), prioritize adding the divergent direction rather than piling synonymous intents onto the same line (judge substantive difference, not wording); if that divergent direction is already covered by an existing intent, still don't generate. The ideal is 2–3 routes with different mechanisms in parallel (e.g. "in via the upload chain" vs. "in via an auth bypass"), concentrating resources only after one produces evidence of [closing in on the goal]. **But diversity always yields to the [operation constraints] at the top**: for entry surfaces/ports/hosts/operations excluded by the constraints, never generate an intent, however fundamentally different they are.
|
||
|
||
**Serial exploit chains: dispatch step by step, don't split into parallel.** Strongly dependent serial chains (①→②→③, each later step depending on the previous step's actual output): don't dispatch all at once in parallel (the downstream can't get prerequisites that don't exist yet and would only repeat/spin); record the whole chain as todos with TodoWrite (one item per step), dispatch only the step "whose prerequisites are met" this round (usually the first), and after it yields facts, dispatch the next step at the next wake (the prompt will carry the todo list), marking satisfied steps completed. Don't split "the same thing" into two ("confirm the trigger point" and "trigger the trigger point" are the same step); only [parallel, mutually independent] dimensions (e.g. enumerating several unrelated endpoints) use multiple parallel intents.
|
||
|
||
5. **Submit**: use [one] add_intent batch call for the filtered new directions (the intents array, at most 4 highest-value ones; don't call it once per item):
|
||
- **summary**: one natural-language sentence describing the direction (full target address + what to do + why), no fixed categories; dedup relies mainly on comparing it against existing intents.
|
||
- **asset_ids**: the target asset ids this direction tests/attacks (pass whenever possible; 0/1/many, from list_assets) — whenever the direction revolves around concrete assets (site/interface/parameter/host) be sure to pass them; used for coverage dedup and linking into the asset lineage; pass all for multi-asset directions; leave empty only for pure global recon with no concrete target assets.
|
||
- **parent_ids**: which upstream nodes this direction is synthesized from (optional, 0/1/many) — when multiple facts combine into one intent pass them all; when derived from an upstream intent/finding pass its id; leave empty for a top-level brand-new direction.
|
||
|
||
No duplicates, no padding; but when the goal is unmet and there are uncovered, deeper plays, dispatch them. Concise, focused, efficient.`
|
||
|
||
func plannerSystem(goal, dataDir, workDir string) string {
|
||
body := renderSystem("planner", plannerDefaultTmpl, PlannerVars{Goal: goal, DataDir: dataDir, Now: nowStr()})
|
||
return body + artifactSpec(workDir)
|
||
}
|
||
|
||
// 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).
|
||
// hooks (the task's target-only Guard) sees every tool call; may be nil.
|
||
func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts *db.ExplorationStore, goal string, triggers []TriggerEvent, hooks harness.HookRunner, 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)
|
||
}
|
||
// Domain tools + the basic default tool set (Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash).
|
||
// When the asset-coverage feature is off, drop add_task_scope/list_untested_assets (not in the 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()
|
||
// The key situation (just-finished intents + the pre-fetched full graph) goes into
|
||
// [this round's user input] (see input below); the system prompt keeps only the
|
||
// static planning body. Moving it out keeps the system prompt stable across rounds,
|
||
// which is better for caching; the cost is that if a single round grows long, the
|
||
// situation may be compressed by compaction (planner rounds are usually short — low
|
||
// risk). situational is spliced into input below.
|
||
situational := renderTriggers(ts, triggers) + renderGraphOverview(tsx.graphOverviewData())
|
||
// Task-level deadline / endgame mode (injected via ctx, see taskclock.go). On the
|
||
// endgame round, the task-timeout planner wrap-up prompt is spliced into this
|
||
// round's user input (along with situational) as [this round's operating
|
||
// instruction], so it only makes the final goal judgment and produces no new intents.
|
||
tc := taskClockFrom(ctx)
|
||
if tc.Final {
|
||
situational += "\n\n[Task endgame wrap-up (special instruction for this round, overriding the normal planning flow above)]: " + resolveTaskTimeoutWrapup("planner")
|
||
}
|
||
// This task's work dir <workDir>/tasks/<taskID>; create it first.
|
||
taskDir := ensureRunDir(p.workDir, taskID, 0)
|
||
ctx = intercept.WithReviewContext(ctx, taskDir, intercept.ReviewBackground{})
|
||
sysBody := plannerSystem(goal, p.workDir, taskDir) + p.lessonsBlock
|
||
if p.wantConstraints() {
|
||
sysBody += constraintBlock(ts) // inject the operation constraints (if any) into the system prompt, bounding exploration
|
||
}
|
||
system, boundary := deferredSystem(sysBody, def)
|
||
// The planner has no wall-clock budget of its own; when a deadline exists, clamp
|
||
// MaxDuration to the remainder so a running planning round enters wrap-up when the
|
||
// task hits its deadline (timeout → task-timeout prompt; turns → per-run prompt).
|
||
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: withWebFetch(ctx, tools, p.proxyAddr, p.proxyCACert), // WebFetch: route through the recording proxy for traceability; load the proxy CA to verify MITM-re-signed HTTPS certs; every redirect hop is gated
|
||
DeferredTools: def.Deferred,
|
||
UnlockSet: def.Unlock,
|
||
PermissionMode: permission.ModeBypass,
|
||
// Web search (optional). ddgs needs no key; brave-free needs BraveKey; tavily needs TavilyKey.
|
||
// WebSearchProxy is a dedicated egress proxy (http/https/socks5), unrelated to the traffic-recording MITM proxy; empty = direct.
|
||
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 subcommands go through the proxy + trust its CA by default
|
||
WorkingDir: taskDir, // this task's work dir <workDir>/tasks/<taskID>
|
||
ToolOutputDir: cmdOutDir(taskDir),
|
||
MaxTurns: p.maxTurns, // 0 = unlimited (configurable in agent management)
|
||
MaxDuration: maxDur, // 0=unlimited; with a deadline = time remaining until it
|
||
Compaction: compactionConfig(p.compactionWindow()),
|
||
// Planning todos shared across wake-ups: keep the serial chain across rounds (the session is new; the store isn't).
|
||
Todos: p.todoFor(ts.ID()),
|
||
// Hitting [this round's] turn budget → the SDK runs wrap-up: land what this round
|
||
// has already figured out (dispatch due add_intent, provable prove_goal, record the
|
||
// serial chain in TodoWrite) rather than stopping planning — the planner keeps
|
||
// being woken afterwards. When clamped (by the task deadline), use PromptByReason
|
||
// instead (see wrapupSettlementForTask).
|
||
Settlement: settle,
|
||
NonStreaming: p.nonStreaming(), // when this profile selects non-streaming, use Provider.Complete
|
||
MaxTokens: p.maxTokens(), // 0 = send no cap; the server default decides
|
||
|
||
// Opt-in via ARTEX_UNTRUSTED_BOUNDARY (default off): nonce-frame tool output as untrusted data.
|
||
UntrustedOutputBoundary: untrustedBoundaryEnabled(),
|
||
}
|
||
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
|
||
opts.Hooks = hooks
|
||
}
|
||
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())
|
||
}
|
||
// Experimental: when on, noa takes over context compression (archives persist under <workDir>/noa/<SessionID>).
|
||
noaSession := fmt.Sprintf("exp%d-planner", ts.ID())
|
||
enableNoa(&opts, p.noaEnabledFn, p.workDir, noaSession, noaWarn(noaSession))
|
||
// The situation (just-finished intents + the full graph) is now spliced into this
|
||
// round's user input (see input below). The user message also carries the
|
||
// instruction + cross-wake todos (todos are the model's own planning scratchpad,
|
||
// regenerable, fine in user).
|
||
// The opener comes in two flavors depending on "whether this round has concrete
|
||
// changes": with changes → point at the [actual changes] block below; without
|
||
// (heartbeat patrol / hint / resume etc.) → don't falsely claim "the graph
|
||
// changed"; instead prompt to review running intents in passing.
|
||
lead := "There were concrete changes just now (see [The actual changes that triggered this round] below); plan the next steps accordingly:"
|
||
if len(triggers) == 0 {
|
||
lead = "This round is a **scheduled patrol (heartbeat) / no concrete change signal** wake-up — the graph may not have anything new. Review running intents in passing: steer_work the long-stalled or off-track ones to correct course, kill_work the ones whose whole direction is wrong to cut losses; then judge the goals and decide whether to add directions:"
|
||
// On a heartbeat/no-change wake-up, if the whole graph has no open or running
|
||
// intent at all → exploration has stalled (no worker running, no queued
|
||
// direction). Tell the planner explicitly and force it to add new directions
|
||
// this round, rather than spinning a round after only reviewing running intents.
|
||
if active, err := ts.HasActiveIntent(); err == nil && !active {
|
||
lead = "This round is a **scheduled patrol (heartbeat)** wake-up, and there are currently **no open or running intents at all** — no worker is running and no direction is queued; exploration has stalled. You **must** produce one or more new intents this round that advance toward the goal and **don't duplicate** existing ones (producing 0 intents is not allowed); first judge from the situation below whether the goal is met, and if not, add directions immediately:"
|
||
}
|
||
}
|
||
input := lead + situational + "\n\nJudge the goals from the situation above. When a goal is [truly met] (the goal's deliverable obtained / the goal's vulnerability confirmed), mark each with prove_goal. **Hard floor: as long as the goal is unmet and there are currently no open or running intents (frontier_open=0 and running_intents is empty), this round must produce at least one intent advancing toward the goal — at that point there is no running work to wait for and no queued direction, so producing 0 intents = the task stalls. Only when open/running intents are already advancing, or the goal is met, may this round produce no new intents.**" +
|
||
renderPlannerTodos(opts.Todos.List())
|
||
// MaxDuration now interrupts an in-flight tool at the wall-clock deadline and
|
||
// enters wrap-up in place (on the live ctx), so a stuck single round no longer
|
||
// bypasses wrap-up — no external hard-ctx backstop needed. ctx carries only
|
||
// 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
|
||
}
|