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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
198 lines
11 KiB
Go
198 lines
11 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/Autumn-27/artex/db"
|
||
"github.com/Autumn-27/norma/agentcore"
|
||
"github.com/Autumn-27/norma/llm"
|
||
acperm "github.com/Autumn-27/norma/permission"
|
||
actool "github.com/Autumn-27/norma/tool"
|
||
"github.com/Autumn-27/norma/transcript"
|
||
)
|
||
|
||
// 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 提交目标。`
|
||
|
||
// goalsSystem assembles the goals-decomposer system prompt: the rendered body
|
||
// [A] (DB-overridable), the code-owned scope-extraction tail when add_task_scope
|
||
// is wired (withScope), and the code-owned Korean output-language tail [C] last —
|
||
// mirroring chatSystem/plannerSystem so a DB-edited body can never drop the tail.
|
||
// DecomposeGoalsWithProvider and the localization test share this one assembly, so
|
||
// the langDirective tail can't drift between runtime and test. EngagementDescription
|
||
// is intentionally left empty: the task description rides in the user message, not
|
||
// the {{.EngagementDescription}} var (see DecomposeGoalsWithProvider).
|
||
func goalsSystem(dataDir string, withScope bool) string {
|
||
sys := renderSystem("goals", goalsDefaultTmpl, GoalsVars{DataDir: dataDir, Now: nowStr()})
|
||
if withScope {
|
||
sys += goalsScopeTail
|
||
}
|
||
return sys + langDirective()
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// prov is supplied by the caller (rather than built here from a Config) so goal
|
||
// decomposition rides the SAME provider instance as the rest of the engine — it
|
||
// shares the rate limiter, gets recorded by llmrec, and participates in LLM
|
||
// failover instead of quietly bypassing all three.
|
||
//
|
||
// 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, prov llm.Provider, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, emit func(db.Activity)) []GoalSpec {
|
||
if prov == nil {
|
||
return nil
|
||
}
|
||
return DecomposeGoalsWithProvider(ctx, prov, dataDir, goalText, desc, as, ts, taskID, false, 0, emit)
|
||
}
|
||
|
||
// DecomposeGoalsWithProvider is the task-runtime variant used when a task has an
|
||
// ordered provider chain. It preserves the same tools and write behavior while
|
||
// letting the caller own provider selection/failover. maxTokens is the profile's
|
||
// per-reply output cap (0 = send none).
|
||
func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, nonStreaming bool, maxTokens int, emit func(db.Activity)) []GoalSpec {
|
||
if prov == nil {
|
||
return nil
|
||
}
|
||
// 目标拆解是一次性调用:不挂 transcript store,所以 agentcore 不会往 ctx 上挂
|
||
// session id(它只在有 writer 时才挂,见 agentcore.Prompt)。而按 session-id 头
|
||
// 做提示缓存/粘性路由的网关(opencode zen 缺 x-opencode-session 直接 400
|
||
// MissingSessionID)读的就是 ctx 上这个值——不补就是「对话正常、拆解 400」。
|
||
// 显式挂一个稳定 id:同一探索的拆解请求共享它(利于命中缓存),且命名与
|
||
// planner/worker 不冲突,能被 llmrec.parseSession 正确归因。
|
||
if ts != nil {
|
||
ctx = transcript.WithSessionID(ctx, fmt.Sprintf("exp%d-goals", ts.ID()))
|
||
}
|
||
// 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"}
|
||
// Wire add_task_scope only when we have a real asset store + task to write to.
|
||
// goalsSystem appends the scope-extraction tail in lockstep (withScope) so the
|
||
// prompt never asks for a tool that isn't present, and it owns the output-language
|
||
// tail last so a DB-edited body can't drop it. Description rides in the user
|
||
// message, NOT the {{.EngagementDescription}} var, so a prompt can't inject it twice.
|
||
withScope := as != nil && taskID > 0
|
||
sys := goalsSystem(dataDir, withScope)
|
||
// set_constraints 始终可用(不依赖 asset store):正文已含「先抽操作约束再拆目标」这步
|
||
// (可在 agent 编辑页改措辞),这里只需接上工具。
|
||
tools := []actool.CoreTool{tsx.setGoals(), tsx.setConstraints()}
|
||
if withScope {
|
||
tools = append(tools, tsx.addTaskScope())
|
||
}
|
||
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,
|
||
NonStreaming: nonStreaming, // 该 profile 选非流式时走 Provider.Complete
|
||
MaxTokens: maxTokens, // 0 = 不发上限,由服务端默认值决定
|
||
}, 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
|
||
}
|