Files
ARTEX/agent/goals.go
T
2026-08-24 14:14:15 +08:00

154 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"context"
"encoding/json"
"strings"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/agentcore"
acperm "github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
)
// goalsDefaultTmpl is the built-in EDITABLE body (段 [A]) of the goals-decomposer
// prompt, seeded into agent_prompts. No template vars are used today.
const goalsDefaultTmpl = `你是渗透测试目标分解器。你的职责是从用户输入中识别出**最终要达成的结果**,而不是规划攻击步骤。
**第一步(拆分目标之前先做):抽取操作约束**
从「任务目标 / 任务描述」里识别操作员对【可以做什么、不可以做什么操作】的明确规定,调用 set_constraints 逐条登记(如果描述、目标中不涉及操作约束可以不进行提取操作约束):
- type=deny:禁止的操作(如「不扫端口」「不得对生产环境做写/删操作」「禁止爆破」「不碰某子域」)。
- type=allow:明确允许/限定的操作范围(如「只允许被动侦察」「仅针对某域名」)。
- 约束 ≠ 目标,也 ≠ 攻击步骤:它是对操作行为边界的规定。
- **约束必须【自包含、写死具体目标】**:把「当前目标/当前端口/当前IP/当前域名/本站」这类**指代词**替换成任务目标/描述里的**具体值**。约束会被单独注入到执行阶段的提示里,脱离上下文后指代词无法判断指谁。
例:目标是 https://abc.example.net → 写「只允许测试 abc.example.net」而不是「只允许测试当前目标」;「仅测目标端口 443,不扫其他端口」而不是「只测当前端口」。若原文只说「当前目标」但目标地址已明确,就把地址填进去。
- **只登记目标/描述里【明确写出或强调】的约束,严禁臆造**;拿不准类型时用 deny(更保守)。
- 若目标/描述里确实没有任何操作约束,则**不要**调用 set_constraints。
登记完约束(如有)后,再进行下面的目标拆分。
**目标 = 最终可交付/可核验的结果**
**不是目标的内容(禁止列为子目标)**:
- 信息收集、侦察、端点扫描
- 漏洞分析与验证过程
- 攻击步骤、利用手段
- 结果验证步骤
**拆分原则**:
- 用户描述的最终目标只有一个 → 输出一个
- 存在多个**相互独立**的最终交付物 → 分别列出
- 能对应明确漏洞类的标注 vulnclass;信息收集/业务逻辑类目标留空
- 严禁臆造用户未提及的目标
调用 set_goals 提交结果。`
// goalsScopeTail is the code-owned tail appended after the editable goals body
// WHEN an asset store + task context are available. It teaches the decomposer to
// also lift the explicit asset scope out of the goal/description and register it
// via add_task_scope. Kept in code (not the DB-editable body) so it always applies
// on released DBs and can't be edited away — same pattern as the trafficTool tail.
const goalsScopeTail = `
**额外职责:登记测试资产范围**
除拆分目标外,你还要从「任务目标 / 任务描述」里识别出**明确给出的测试资产范围**,调用 add_task_scope 登记(资产测试覆盖度的分母,也是授权边界)。**最小范围原则:只登记用户明确点到的那一个目标,绝不擅自放大。**
- 目标是 URL 或带主机名的地址(如 https://xxx.example.com/path、app.example.com)→ 取其**完整主机名**,kind=subdomain,value=完整主机名。
例:目标 https://a1b2c3.lab.example.net/path → kind=subdomain,value=a1b2c3.lab.example.net(**不是** example.net)。
**严禁**把带子域的主机名缩成根域名——看到 xxx.example.com 就登记整个 example.com 会把范围扩到用户目标之外,违背最小范围原则。
- 仅当用户给的就是**裸根域名、且不含任何子域**(如直接写 example.com),或明确说“整个站点 / 所有子域 / 全域名” → 才用 kind=root_domain,value=example.com。
- 纯 IP 或网段 → kind=ip / cidr,value=IP 或 CIDR。
- **不要**登记公司范围(company)——任务刚建立、资产系统里通常还没有这家公司,登记不上,公司级范围交由后续 plan 阶段处理。
其它规则:
- 只登记**目标/描述里明确写出**的范围;严禁臆造或推断未提及的域名/IP。
- reason 简述依据来自哪句话,便于审计。
- 若目标/描述中没有任何明确资产范围,则**不要**调用 add_task_scope。
先用 add_task_scope 登记范围(如有),再调用 set_goals 提交目标。`
// GoalSpec is one decomposed objective.
type GoalSpec struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass,omitempty"`
}
// DecomposeGoals asks the LLM to break a pentest task goal into discrete,
// independently-verifiable objectives (each becomes a goal node). Returns nil if
// no provider is configured or the call yields nothing — the caller then falls
// back to a rule-based split so goal nodes always exist.
//
// desc is the task's free-text description (背景:靶标范围/flag 数量/交战说明等).
// It is fed alongside the goal so the decomposer no longer splits blind — the
// prompt still forbids inventing anything the two texts don't state.
//
// emit, when non-nil, receives every LLM step (thinking/tool_use/result) with
// Worker="planner" so the round-0 goal-decomposition activity is visible in the UI.
//
// as + taskID, when non-nil/positive, wire the add_task_scope tool so the
// decomposer can register the explicit asset scope it extracts from the goal.
//
// ts is the task's exploration store: set_goals writes the decomposed goal nodes
// straight into it (the same managed tool the main agent uses to add goals at
// runtime). The returned specs are read back from the store so callers can emit
// per-goal activity and detect the "LLM produced nothing" case for their fallback.
func DecomposeGoals(ctx context.Context, c Config, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
prov, err := c.NewProvider()
if err != nil {
return nil
}
// worker="goals" tags the goal nodes' provenance; ts/taskID let set_goals link
// each goal under the task root. This is the catalog's real set_goals tool, so a
// web-edited description/schema on it applies here too.
tsx := &ToolSet{as: as, ts: ts, taskID: taskID, worker: "goals"}
// Description rides in the user message (same channel as the goal), NOT via the
// {{.EngagementDescription}} template var — else a prompt that references the var
// would inject the description twice. System prompt stays pure static instructions.
sys := renderSystem("goals", goalsDefaultTmpl, GoalsVars{DataDir: dataDir, Now: nowStr()})
// set_constraints 始终可用(不依赖 asset store):正文已含「先抽操作约束再拆目标」这步
// (可在 agent 编辑页改措辞),这里只需接上工具。
tools := []actool.CoreTool{tsx.setGoals(), tsx.setConstraints()}
// Wire add_task_scope only when we have a real asset store + task to write to.
// The scope-extraction tail is appended in lockstep so the prompt never asks for
// a tool that isn't present.
if as != nil && taskID > 0 {
tools = append(tools, tsx.addTaskScope())
sys += goalsScopeTail
}
userMsg := "任务目标:\n" + goalText
if d := strings.TrimSpace(desc); d != "" {
userMsg += "\n\n任务描述(背景信息,可能含靶标范围/flag 数量/交战说明;仅供参考,不要臆造其中未提及的内容):\n" + d
}
// Use captureRun so every LLM step is emitted as an activity record (visible in
// the plan tab under the round-0 marker). Falls back gracefully when emit is nil.
captureEmit := func(r db.Activity) {
if emit != nil {
r.Worker = "planner"
emit(r)
}
}
captureRun(ctx, agentcore.Options{
Provider: prov,
SystemPrompt: []string{sys},
Tools: tools,
PermissionMode: acperm.ModeBypass,
DisableBackgroundTasks: true,
// 3 步(抽约束 → 登记范围 → 拆目标)各需一次工具调用,给足回合避免收尾前漏调 set_goals。
MaxTurns: 8,
}, userMsg, captureEmit)
// set_goals persisted the goals directly; read them back so the caller sees what
// was written (empty slice ⇒ the LLM produced nothing ⇒ caller falls back).
if ts == nil {
return nil
}
nodes, _ := ts.ListByKind(db.KindGoal, 10000)
var out []GoalSpec
for _, n := range nodes {
var p struct {
Text string `json:"text"`
VulnClass string `json:"vulnclass"`
}
_ = json.Unmarshal(n.Payload, &p)
if strings.TrimSpace(p.Text) != "" {
out = append(out, GoalSpec{Text: p.Text, VulnClass: p.VulnClass})
}
}
return out
}