Files
dela 0335d572de
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
First Commit
2026-10-09 08:38:16 +08:00

198 lines
11 KiB
Go
Raw Permalink 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"
"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
}