DESIGN WALKTHROUGH · 2026

ARTEX 设计思路全书
从零读懂一套 LLM 多 Agent 自主渗透系统

这不是一份 API 文档,而是一次完整的「设计复盘」:为什么要把系统拆成两张图、为什么规划者必须是唯一出主意的人、 为什么执行者写完就得停、为什么审批门要给模型解释「这不是目标的防御」。读完你应当能从空白文件开始,复述出这套系统的每一处关键决策。

17 个业务包 327 个 Go 文件 ~63k 行非测试 Go 153 个测试文件 152 个 TSX 文件 Go 1.26 · norma 0.4.3
00

怎么读这本书

三条阅读路径,按你的目标选

这份文档假设你没有读过一行源码,但会写 Go、知道什么是 HTTP 和数据库。所有概念都从「为什么需要它」讲起,再落到「代码在哪里、怎么实现的」。

PATH A · 3 小时

只想看懂设计

读 01 → 03 → 04 → 05 → 15。这五章覆盖了系统 80% 的独创性,其余是工程实现细节。

PATH B · 1 天

要改代码 / Fork

全读,重点 06 工具集、07 提示词、09 审批、13 Go 手法。最后用 16 章的路线把关键文件过一遍。

PATH C · 1 周

想复刻同类系统

按 16 章七天路线走,每天一个子系统,配合每章末尾的自检题,最后做 18 章综合题。

阅读约定

文中 等宽字体 是代码标识符或文件名;§ 开头的引用对应源码注释里的设计文档编号;灰色折叠块是「再深一层」的补充,第一遍可以跳过。

先划清边界

ARTEX 是授权环境下的自主渗透测试系统,README 明确限定「仅供个人学习、代码研究与本地隔离环境验证」。这份文档讲的是它的软件设计——多 Agent 协作、图结构推理、人机协同——这些模式同样适用于代码审计、应急响应、自动化运维等任何长任务自治场景。请勿把系统用于未授权目标。

01

心智模型:它到底在解决什么问题

从「扫描器」到「作战指挥部」的认知切换

1.1 一个 LLM 单 Agent 走不完全程

如果你让一个 LLM 直接做渗透测试,它会遇到四个结构性障碍,而且都不是「模型不够聪明」能解决的:

障碍具体表现根因
上下文爆炸跑 30 分钟、几百次工具调用后,早期关键发现被挤出窗口,模型开始重复劳动单会话线性累积,没有外部记忆结构
目标漂移本来要打 A 系统,探着探着去挖 B 接口的细节,最后忘了最初要证明什么没有独立的「目标判定」角色,执行者自己兼裁判
无法并行一条路走死才发现另一条更优,时间全浪费在串行试错单线程思维,没有任务分解与调度层
结论不可审计报告说「存在 SQL 注入」,但拿不出请求响应,也没法复现过程与结论混在对话里,没有结构化证据链

ARTEX 的答案不是「换个更强的模型」,而是把一个人的工作拆成一个组织的分工:有人看图、有人定方向、有人动手、有人记录、有人把关。

1.2 一句话说清架构

核心命题

把「目标是什么」和「测到什么程度」拆成两张图;让唯一的规划者读图产意图,让无状态的执行者领一条意图、干完、写回图、然后停下;图的每一次变化都重新唤醒规划者。

这个闭环里有两个关键角色和两个关键约束:

ROLE · PLANNER

规划者:唯一的「出主意的人」

它不执行任何攻击动作。职责只有三件:读态势、判目标是否达成、把尚未被覆盖的新方向写成意图丢进队列。它是系统里唯一能生成意图的角色。

ROLE · WORKER

执行者:只做一条意图的「临时工」

领到一条意图,用真实工具(Bash / HTTP / Kali 工具链)把它做完,把新资产、事实、漏洞写回图,然后立即停止。它不被允许自己开辟新方向。

为什么这个约束是关键

如果允许多个 Agent 同时「想下一步」,它们会重复派活、互相覆盖、把同一条路走三遍。把意图生成收敛到单点,才让并发执行成为可能——这是整个系统最重要的一次权力集中。

1.3 与传统扫描器的本质区别

维度传统扫描器ARTEX
驱动方式预定义插件 / POC 列表,命中即报目标驱动:为「证明目标达成」动态生成探索方向
状态载体内存里的队列 + 结果列表持久化的双图(资产真值 + 探索过程),可中断可恢复
失败处理报错跳过,进入下一条把「否定结论」也记为事实,规划者据此换路线;同一否定方向允许复核一次
攻击链几乎不支持多步依赖共享 todolist 支撑串行链:注入 → 取凭据 → 横向 → 提权,按前置逐步派发
人的位置开跑前配置,跑完看报告全程在环:随时加目标、加提示、纠偏、杀意图、审批危险动作
结论形态漏洞条目漏洞 + 血缘(由哪条意图、哪些事实推导而来)+ 流量证据 + 复测记录

1.4 用一张「作战指挥部」类比记住全系统

MAP

资产图 = 地图

全公司共享的一张真值地图:域名、子域、IP、服务、应用、端点。谁都能看,程序负责测绘。

LOG

探索图 = 作战日志

每个任务一本:目标、派出去的方向、带回来的情报、确认的战果。只属于这一仗。

STAFF

Planner = 参谋

盯着日志和地图,判断「还要不要打、往哪打」,写完作战指令就退场。

SQUAD

Worker = 突击小组

领一条指令出门,打完把情报交回,不擅自改变作战目标。

再补三个角色:Goals 分解器是开战前的参谋部,把「拿下这个站」拆成可验收的目标并划清行动边界;Main Agent 是司令部的热线,人通过它下指令;Guard / Intercept 是宪兵,每个动作出门前都要过它。

Q1为什么系统不允许 worker 自己生成新的探索方向?
权限只是表象。真正的理由是并发正确性:多个写者同时决定「下一步做什么」,无法去重、无法保证依赖顺序。把这件事收敛到 planner,才换来 worker 的安全并行。
02

整体架构:六层职责与单二进制的取舍

从浏览器到 PostgreSQL,一次请求经过谁

2.1 分层全景

ACCESS · 接入层 浏览器 / Edge Next.js 静态导出(go:embed 进二进制) REST / SSE 同源 JWT(HS256) INTERFACE · 接口层 server/server.go net/http 路由 · 中间件 · SSE server/manager.go 任务 / 引擎 / Store 生命周期 Manager.HostTools() 流量工具 · 编排工具 · 自定义工具 ORCHESTRATION · 调度层 Engine.plannerLoop 1 个 / 任务 · debounce 800ms Engine.workerLoop × N 默认 3 · 空转 800ms Scheduler · 每 5s 触发器 / 并发补位 / 水位线 INTELLIGENCE · 智能层 goals 分解器 只跑一次 · 目标+约束+范围 planner 唯一意图生成者 worker × N 单意图执行 · 写回即停 mainagent 人在环路 · 不下场执行 CAPABILITY · 能力层 ToolSet 双图工具 Guard 审批门 traffic MITM 代理 enrich 异步补全 MCP · Skills · Memory STORAGE · 存储层 PostgreSQL:资产图 / 探索图 / 任务 / LLM 记录 SQLite:流量索引 文件:证据 / 工作区 / transcript
图 2-1 六层职责:上层只依赖下层的抽象,能力层可被任何 Agent 组合
层职责关键设计点
前端Next.js 静态导出,可视化任务 / 资产 / 探索链路 / 覆盖图,人在环路对话构建期 output: export,产物被 go:embed all:webui/dist 打进二进制;只有 -tags embedui 才内嵌
servernet/http 路由 + JWT + SSE;Manager 托管任务、引擎、Store 生命周期单进程多任务:每任务一组 goroutine,任务间通过全局资产图与 LLM 池耦合
engine每任务一个 plannerLoop + N 个 workerLoop;意图领取、超时、暂停、排空事件驱动 + debounce;所有状态机转换在数据库里用条件 UPDATE 做 CAS
agentgoals / planner / worker / mainagent,ToolSet 把双图暴露成 LLM 工具每个角色的工具集是白名单,不是黑名单;prompt 三段式拼装
db双图的 PostgreSQL 落地(pgx);schema.sql 每次启动幂等执行「重启即迁移」:只用 CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / 约束重建 DO 块
支撑记录型 MITM 代理、审批门、异步补全、MCP / 技能 / 记忆 / 报告 / 通知叶子包原则:notify 只依赖标准库,方便脱离数据库单测

2.2 为什么是「单二进制」

部署形态直接影响了架构:前端在构建期导出为纯静态文件,编译时嵌进 Go 二进制,运行时只暴露一个端口(默认 :8787)。这带来三个连锁决策:

DECISION 1

SSE 必须同源

前端和 API 同端口,所以实时活动流不需要配 NEXT_PUBLIC_SSE_BASE,公网只开 443 反代即可。

DECISION 2

反代必须关缓冲

SSE 是长连接持续推送,Nginx 不关 proxy_buffering 会出现「能连上但收不到事件」——页面一直转圈。

DECISION 3

自更新必须自己做

没有包管理器、没有容器编排,升级只能由程序下载新二进制、校验、换装、失败回滚。

代价

单二进制意味着前端改动也要重新编译;-tags embedui 不是默认开启,本地开发时前端走 5173 端口的 dev server,通过 /api 反代到后端。这个取舍换来的是「拷贝一个文件就能跑」的部署体验。

2.3 进程内的真实拓扑

理解 ARTEX 的并发,关键是记住这张图:任务不是线程,而是一组 goroutine + 一个数据库里的 exploration。

Manager(进程级) tasks map[string]*Task 每个 Task 持有 Store + Guard AssetStore(全局一份) 资产图跨任务共享 traffic.Traffic(可选) MITM 代理 + SQLite 索引 llmpool.Pool + Registry 全进程一条降级链 Scheduler(5s tick) 触发器 · 并发补位 任务排队 1 : N Task A(exploration_id = 41) goroutine: plannerLoop 等待 notify / heartbeat(≥600s) workerLoop #1 claim → run workerLoop #2 claim → run exploration_nodes / edges / activity(PG 行,不是内存对象) 意图队列 = state='open' 的 intent 行,按 priority DESC, id ASC Task B(exploration_id = 42) goroutine: plannerLoop 独立 debounce / 独立 todo workerLoop #1 并发上限内 workerLoop #2 并发上限内 独立 exploration · 共享 AssetStore 探索图隔离,资产图合流 进程退出 = 所有 goroutine 结束;重启后靠数据库状态恢复
图 2-2 任务 = 1 个 planner goroutine + N 个 worker goroutine + 一组 PG 行
读码提示

看 ARTEX 代码时,先问自己「这个变量属于进程 / 任务 / 单次 run 哪一级」。比如 Manager 是进程级,Task.Guard 是任务级,ToolSet 是单次 run 级——三级生命周期搞混,就会误判锁的粒度和状态归属。

03

双图架构:整套系统的心脏

资产图是真值,探索图是过程,锚点把它们缝在一起

如果把 ARTEX 的其他所有设计都删掉、只留一个,那就是双图。理解了它,引擎、工具集、覆盖度、报告全都是它的自然推论。

3.1 为什么不能只有一张图

直觉上「把资产和探索都塞进一张图」更简单。但这两类信息在四个维度上根本冲突:

维度资产(真值)探索(过程)
作用域全局共享:所有任务看同一份每任务隔离:一次任务的思考过程
写者所有任务 + 资产同步 + 异步补全 + 人工只有本任务的 planner / worker
语义「世界上存在什么」——可被反复观察、更新、去重「我们试过什么、得出什么」——只增不改,是历史
生命周期永久,跨任务复用随任务归档 / 删除而消失

混在一起会立刻出问题:任务 A 删掉自己的探索记录,不能把公司域名也删了;任务 B 也不能因为「没测过这个子域」就认为它不存在。把真值与过程分离,是这类长任务系统最基础的一条不变量。

3.2 两张图的形状

探索图 · 每任务独立 exploration_nodes + exploration_edges goal 目标 intent 意图 A fact 事实 intent B finding spawns yields derived_from proves 节点状态随类型约束,见 3.4 状态机 边只有 5 种,方向统一「上游 → 下游」 资产图 · 全局共享 assets / companies / company_scope root_domain subdomain service endpoint include resolve expose 父子关系与去重 key 全部由程序计算 agent 只提交原始信息,不做归一化 虚线 = exploration_anchors 锚点 (node_id ↔ asset_id,多对多) 过程:谁派了它、它产出什么 真值:世界上有什么、怎么连的
图 3-1 左:因果血缘;右:包含关系;虚线锚点把「过程」钉到「实体」上

3.3 资产图:程序算结构,Agent 只交原料

资产图有六种节点,每种都有明确的唯一索引,这就是去重的真正实现——数据库唯一约束,不是应用层查重:

类型语义去重键(唯一索引)关键字段
root_domain根域名,如 example.comUNIQUE(domain) WHERE type='root_domain'icp、company_id
subdomain解析记录级子域UNIQUE(domain, COALESCE(record_type,''))record_type(A/CNAME/MX…)、record_value[]
ip单个 IPUNIQUE(ip) WHERE type='ip'c_segment(CIDR)、open_ports(jsonb[])、bound_domains[]
service端口上的服务http:UNIQUE(url);other:(domain,ip,port,service_name)service_type、technologies[]、favicon_mmh3、status_code
app移动应用bundle_id 唯一;无 bundle 时 app_name 唯一app_name、category、app_icp
endpoint具体接口UNIQUE(url, method)params(jsonb[])、auth(jsonb[])
设计要点

agent 只提交「我看到了什么」,不负责推导层级。给它一个 URL https://a.example.com/api/login,系统会自动解析出 subdomain → service → endpoint,并补齐 root_domain 的父子链。因为如果让 LLM 来归一化,它一定会把 a.example.com 和 example.com 混着写,去重立刻失效。

3.4 探索图:七种节点,各有一套状态机

探索图是「这次任务的思考轨迹」。节点类型不是随意设计的,每一种都对应一个明确的问题:

kind回答什么问题合法状态谁写
begin这次探索的根在哪open(永远)任务创建时程序写入
goal要达成什么结果open → met / abandonedgoals 分解器 / 人 / planner 的 prove_goal
intent下一步去试什么方向open → running → done / blocked / exhausted / stopped;可 paused、deleted(软删)planner / main agent 直投
fact观察到了什么(含否定结论)confirmed / dismissed / originworker / planner / main
finding确认了什么漏洞(带 PoC)confirmed / dismissedworker / planner / 复测 agent
hint人给了什么战略提示active / consumedmain agent / 编排工具
digest一组冷节点的压缩摘要active / supersededCompactor(后台 LLM)
为什么没有「step / action」节点

工具调用的每一步不进入图,而是写进 activity 表——它是扁平的、按 id 递增的流水,不是图结构。图只记「结论级」的东西。否则一次任务几万步工具调用会把图画成毛线团,规划者根本读不动。图是给决策用的,流水是给回放用的。

3.5 五种边与方向约定

rel从 → 到语义例子
spawnsgoal → intent这个方向是为哪个目标派生的goal「拿到后台权限」spawns intent「测试登录接口越权」
derived_fromintent → 上游节点这个方向由哪些事实/意图综合而来intent B derived_from fact「响应头泄露内网 IP」
yieldsintent → fact / finding这条意图产出了什么intent「枚举备份文件」yields fact「发现 /backup.zip」
provesfact / finding → goal什么证据证明了目标达成finding「SQL 注入」proves goal「证明存在注入」
coversdigest → 冷节点摘要覆盖了哪些节点(派生层,不参与因果计算)digest#88 covers fact#12、#15
方向约定

所有边统一为「上游 → 下游」(From 是父,To 是子)。这个约定写在 agent/coldgraph.go 的注释里,因为「可达性」「冷热判定」全靠它——一旦某条边的方向搞反,整张图的可达性计算就错了。

3.6 锚点:把「过程」钉到「实体」上

两张图通过一张极简的连接表缝合:

db/schema.sql
-- 一个节点可以锚定多个资产;一个资产可以被多个节点锚定
CREATE TABLE IF NOT EXISTS exploration_anchors (
    node_id   BIGINT NOT NULL REFERENCES exploration_nodes(id) ON DELETE CASCADE,
    asset_id  BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
    PRIMARY KEY (node_id, asset_id)
);
CREATE INDEX IF NOT EXISTS idx_anchor_asset ON exploration_anchors(asset_id);

这三个字段的组合能回答两类完全不同的问题,而且是双向的:

正向 · 从过程看实体

这条意图打的是哪些资产?

planner 派意图时带上 asset_ids,写回时自动建立锚点。于是「意图 #37 测试了 3 个端点」是可查的。

反向 · 从实体看过程

这个端点被哪些意图测过?

资产详情页能列出全部相关意图与事实。这也是覆盖度的计算基础:tested = 被 fact 锚定过的资产去重数。

3.7 一个完整例子:血缘链长什么样

假设目标是「证明 example.com 存在可利用漏洞」,实际跑出来的图可能是:

探索图(exploration #41)
goal #1  「证明 example.com 存在可利用漏洞」           state=open
  │ spawns
  ├─▶ intent #2  「对 www.example.com 做指纹与目录探测」   state=done
  │     │ yields
  │     ├─▶ fact #5   「使用 ThinkPHP 5.0.24,/index.php 可访问」 confidence=observed
  │     └─▶ fact #6   「/backup/ 返回 403,但响应体泄露绝对路径」 confidence=observed
  │
  ├─▶ intent #9  「验证 ThinkPHP 5.0.24 已知 RCE」        state=exhausted
  │     │ derived_from: fact #5
  │     └─▶ fact #11  「payload 被 WAF 拦截,多种编码均 403」 confidence=observed
  │
  └─▶ intent #14 「利用 fact#6 的路径泄露读取配置文件」    state=done
        │ derived_from: fact #6
        ├─▶ fact #15  「/backup/../config.php 可读取,含 DB 密码」 confidence=observed
        └─▶ finding #16「任意文件读取导致敏感配置泄露」 severity=high
              │ proves
              └──▶ goal #1  state=met

这条链的价值在于:报告里说「存在任意文件读取」,能一路点回 哪条意图、基于哪条事实、用了什么 payload、拿到什么响应。而且 planner 在下一轮读到 fact #11(WAF 拦截)时,会知道「这条方向已被试过且失败」,不会让另一个 worker 重复撞墙。

Q2某个 worker 报告了一条否定事实「端口 3306 关闭」。按系统设计,planner 应该怎么处理?
这是 planner 提示词里明确写死的规则:否定观察是「worker 的观察」而非「定论」。observed + 手段穷尽 → 尊重;inferred / 只探一次 → 按「尚未探明」处理,但复核只允许一次,避免无限循环。
04

引擎闭环:事件驱动的一次探索

图一变就唤醒,派意图、领意图、写回、再唤醒

引擎(server/engine.go)不到 1300 行,却是整个系统的心跳。它不实现任何业务逻辑,只做三件事:什么时候唤醒 planner、谁去领意图、什么时候该停。

4.1 闭环全图

① 图发生变更 fact / asset / finding / goal / hint worker 写回 → notify ② debounce 800ms 合并突发变更 一次唤醒 = 一轮 LLM ③ planner 读态势 → 判目标 → 派意图 graph_overview 预取 + 本轮触发事件 + 跨唤醒 todo 产出 0..4 个 add_intent(多数轮次是 0) add_intent(带 asset_ids / parent_ids) ④ frontier 意图队列 state='open' 的 intent 行 · priority DESC, id ASC ⑤ worker 原子领取 UPDATE … WHERE state='open' ⑥ 执行 + 逐步写 activity Bash / HTTP 全程走记录代理 写回 → 图变 硬底线(写进 prompt,也写进代码) 只要「目标未达成」且「open + running 意图都为 0」,本轮必须产出至少一个意图——没有在跑的 work 可等、也没有排队方向时,产出 0 意图 = 任务停摆。 反之,有 open/running 在推进时可以派 0 个:等待依赖、或方向已被覆盖,都是正当理由。
图 4-1 事件驱动的闭环:没有轮询调度器,只有「变更 → 唤醒 → 写回 → 变更」

4.2 七种唤醒来源

planner 不是定时任务,它是被动唤醒的。每次唤醒都会带上一组 TriggerEvent,告诉它「这次为什么醒」,避免它每轮都重新扫全图:

Kind触发场景提示词里怎么呈现
done某个 worker 跑完了某条意图「意图 #37(摘要)的 worker 结束,输出结论:…;本意图新产生的事实 id:#51、#52」
findingworker 中途报了一个漏洞「意图 #37 的 worker 报告了一个 finding:…」——不必等它跑完
goal人(主 agent)新增了一个或多个目标「人新增了 2 个目标:A;B —— 请逐一为尚无对应意图的目标补充方向」
goal_deleted人删除了某个目标「该目标已移除,请据此重判剩余目标/方向」
goal_edited人修改了目标文本「由「旧」变为「新」—— 原方向若已不适用请停派」
cancelled人删除了一条意图(软删)「意图 #37 由用户删除,意图内容是:…、删除原因是:…。请据此重新规划」
hint人加了一条战略提示「人新增了战略提示:… —— 已挂到探索图上,请据此调整方向」

没有触发事件时(心跳、恢复),提示词会明确告诉它「本轮是定时巡检,图不一定有新变动」,并顺带要求它复查在跑意图——长时间没进展的用 steer_work 纠偏,方向整个错的用 kill_work 止损。

为什么是 debounce 而不是队列

3 个 worker 同时跑完,会连续写回十几条 fact/activity。如果每条都唤醒一次 planner,就是十几次昂贵的 LLM 调用,而且它看到的是「半截状态」。800ms 的合并窗口让这一批变更收敛成一次规划,planner 看到的是完整的本轮增量。

4.3 意图队列与原子领取

「队列」不是一个内存 channel,而是数据库里的一批行。领取用的是条件 UPDATE,靠数据库的行锁保证同一个意图不会被两个 worker 抢到:

db/exploration.go · ClaimIntent
// ClaimIntent atomically moves an open intent to running. Returns true if claimed.
func (s *ExplorationStore) ClaimIntent(id int64, owner string) (bool, error) {
    res, err := s.db.Exec(`UPDATE exploration_nodes SET state='running', owner=$1
WHERE id=$2 AND exploration_id=$3 AND kind='intent' AND state='open'`, owner, id, s.expID)
    if err != nil { return false, err }
    n, _ := res.RowsAffected()
    return n == 1, nil   // 只有真正把 open 改成 running 的那个 worker 才算领到
}

配套的队列查询本身也有讲究:ORDER BY priority DESC, id ASC——高优先级的先跑,同优先级按先进先出。而 priority 是 planner 派意图时可以设置的(比如人直投的意图优先级拉满)。

一个容易忽略的竞态

任务启动时,代码要判断「要不要踢第一轮规划」。如果只数 state='open' 的意图,就可能和 worker 的领取动作打架:worker 刚把种子意图改成 running,planner 这边数到 0,于是白跑一轮规划。所以它数的是 open + running——见 HasActiveIntent() 的注释。这类「用宽集合避免竞态」的手法在系统里反复出现。

4.4 意图的状态机

open running done exhausted blocked stopped paused deleted(软删) claim 人工暂停 删除 done 正常跑完 · exhausted 预算耗尽 · blocked 外部故障没跑成 · stopped 被人工/终局中止 四者的区别决定了 planner 是该「重派」「续跑」还是「换打法」——见 5.3 的处置策略
图 4-2 终态不是一种,而是四种;区分它们是为了让 planner 做出正确的下一步

这张图解释了一个常被误解的设计:worker 跑完不等于成功。系统把「结束」拆成四种,因为每种对应完全不同的后续动作:

终态含义planner 的正确反应
done正常完成,事实已写回看它 yields 出的 fact 结论判断是否死路;只有出现材料性新机理才重派,换措辞重试是禁止的
exhausted步数/时长预算耗尽,探到一半被掐断,只写回部分先看 trace 实际做到哪;接近突破被掐断 → 派「接上次进度继续」
blocked模型或网络失败,基本没探成可直接重派同方向;每次都卡同一处 → 换打法
stopped被人 kill、被任务终局取消、或删除一般不重派;若是人工纠偏,按新指令走

4.5 任务终局的三条路径

任务不是「跑完所有意图就结束」——它可能永远跑不完。系统定义了三种收束方式:

PATH 1

目标全部证明

planner 调 prove_goal 标记某个目标 met。当它标记的恰好是最后一个未完成目标时,系统自动判定任务完成 → 状态落 done → 立刻取消在跑的 worker。

PATH 2

无目标直投(goalless)

人把目标全删了、只直投意图。此时 planner 停跑(跑了会误判 met 并杀掉直投),任务是否结束改由 frontier 决定:意图跑干 → done。

PATH 3

任务级超时

建单时设了 timeout_seconds。到期后协调器停止发新活,给在跑的 run 注入「收尾」指令,等它们写回后落 timeout。

关键细节:没有「一键完成」

源码注释写得很直白:「收官只由逐个 prove_goal 驱动,没有别的一键完成手段」。这是刻意的——如果允许「整体判定完成」,模型会在只完成 60% 时提前盖章。逼它逐个目标给出证据节点,才让「完成」这件事可审计。

4.6 控制面:暂停、杀意图、纠偏

引擎对运行中的任务提供三种控制,注意它们不是同一个东西:

动作作用对象实现语义
Pause整个任务取消任务的 exec context,planner/worker 循环进入等待停止领新活;在跑的 run 被取消并重排队
KillWork单条意图按 intentID 找到 cancel 函数并触发只杀这一条,其他 worker 不受影响
SteerWork单条意图把消息排进该 work 的 steer 队列,worker 在下一次工具调用前注入并重新规划不打断、不丢进展,只改方向

steer 是个很妙的设计:它不是取消重来,而是在 agent 的工具循环里插入一条 user 消息。源码里用 drainSteer(intentID) 取队列,worker 每条工具结果后检查一次——所以人可以在它「正在扫目录」时插一句「别扫了,去看 /admin 的越权」,它下一步就会转向。

Q3为什么引擎用「数据库条件 UPDATE」而不是 Go 的 mutex 来做意图领取?
核心是状态归属:意图的状态是 PG 里的行,不是内存变量。用 mutex 保护内存状态,进程重启就全丢了;而且如果将来多实例部署,mutex 根本跨不了进程。CAS 写在 SQL 里,天然支持崩溃恢复。
05

四种 Agent:把「一个人」拆成一个组织

目标分解、规划、执行、人机接口,各有各的红线

5.1 一张表看清分工

角色何时运行唯一职责工具集(白名单)明确不做什么
goals
目标分解器
任务创建时,只跑一次 从描述里抽出:可验收的目标、操作约束、测试资产范围 set_goals set_constraints add_task_scope 不规划攻击步骤;不臆造用户没提的目标/范围
planner
规划者
图一变 / 心跳,反复唤醒 读态势、判目标、派意图(唯一意图生成者) graph_overview list_facts node_detail add_intent prove_goal kill_work steer_work + 资产检索 不亲自执行攻击动作(开局最多 3 次只读探测,只为把意图说清)
worker
执行者
领到一条意图后 完成这一条意图,把发现写回图,然后停止 insert_assets record_fact report_finding list_findings get_worker_trace + 默认工具(Bash/Read/Write/Edit…) 不开新方向;不做跨 work 的全局复盘;不管理企业范围
mainagent
人机接口
人每次对话 观察(读图/读 trace)+ 操控(加目标、加提示、直投意图、纠偏、改约束) 读工具 + add_hint set_goals set_constraints add_intent steer_work + 资产写 不跑自主循环;不替代 planner 做常规规划
设计原则:角色靠「不给什么」定义

注意 worker 的工具集是刻意做减法的:给它 list_findings(避免重复上报),但不给 list_facts / list_companies / list_worker_traces。源码注释解释得很清楚:读全局上下文、跨 work 复盘是规划职责;执行者只需要执行与写回。工具面越小,越不容易跑偏。

5.2 planner:一个每次醒来都失忆的参谋

planner 最反直觉的地方是:每一轮唤醒都是一个全新的会话。它不记得上一轮想过什么,只能看到图。这带来一个真问题:多步利用链怎么办?

如果每轮都从零开始,「先拿凭据、再横向、再提权」这种串行链会在第一轮被一次性并行派出去——下游拿不到还不存在的前置,只会空转或重复。

共享 todolist(按 exploration_id 常驻,跨唤醒不丢) ① 找到注入点 ✔ completed ② 取数据库凭据 ▶ in_progress ③ 横向到内网 ☐ pending · 前置未满足 ④ 提权到 root ☐ pending · 前置未满足 第 1 轮唤醒 TodoWrite 记录整条链;只派 ① 第 2 轮(①产出 fact) 提示词带上清单;派 ②,把 ① 标完成 第 3 轮(②产出 fact) 派 ③ …… 链稳步推进,不错序 session 是新的,store 不是 —— 这就是「跨唤醒记忆」的全部实现
图 5-1 共享 todo 让无状态会话也能稳定推进串行链

实现上只有一个 map 加一把锁,键是 exploration id:

agent/planner.go
// 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 (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
}

每轮唤醒时,清单会被渲染进 user 输入,并附上一句行为约束:

「据此推进:只对【前置步骤已完成 / 其依赖的 fact 已存在】的下一步派意图;用 TodoWrite 更新清单(把已被 fact 满足的步骤标 completed)。不要重复派已在清单里 pending/in_progress 的步骤。」

planner 还有两个「非常规」工具

STEER

steer_work(intent_id, msg)

给正在跑的 worker 插一句话。用于「它跑偏了但还有救」——不杀进程,只在下一个工具调用前注入纠偏指令。

KILL

kill_work(intent_id)

方向整个错了、或者已经证明是死路但 worker 还在死磕时,直接止损。区别于 pause:只杀这一条。

这两个工具的注释里有个细节:「读上下文、跨 work 复盘是规划职责」。planner 能看到所有 worker 的输出与 trace,所以它才有资格判断「这条跑偏了」。worker 看不到全局,自然也不该有这个权力。

5.3 planner 的决策流程(提示词骨架)

它的 system prompt 是一份决策手册,不是角色扮演。五步结构非常清晰:

  1. 读完整态势

    本轮触发事件 + graph_overview 预取(目标、意图、事实、资产计数、覆盖度)。不需要再调工具拿概况,细节才按需查。

  2. 判目标

    对已被证明的目标调 prove_goal(goal_id, evidence_id, reason)。含量化条件的目标(覆盖度 80%、拿 N 个 flag)必须先核对实测值,未达标禁止 prove_goal。

  3. (可选)极轻量探测

    只在任务刚开局、图里几乎没有 fact 时允许 1–3 次只读探测,唯一合法产物是「一句更精准的意图描述」。一旦图里有 worker 产出的事实,就禁止自己再探。

  4. 决定补哪些方向

    与 open + running + recent_done 逐一比对去重。done 的看 fact 结论;exhausted/blocked 的看 trace 实际进度;保持路线多样,但多样性服从操作约束。

  5. 一次批量提交

    用一次 add_intent 提交最多 4 个最高价值方向,带 summary / asset_ids / parent_ids,不要逐条多次调。

提示词里的「反作弊」条款

这份 prompt 花了大量篇幅写「不要做什么」:不要把 0 意图当偷懒的默认;不要因为「大体达成」提前盖章;不要把「换措辞重试」当成新方向;不要在已有 fact 时自己动手。这些不是啰嗦——它们是控制流。LLM 系统的可靠性,很大一部分是靠这种否定式约束堆出来的。

5.4 worker:一个「只做一件事」的执行者

worker 的 system prompt 开头就是三条红线:

agent/worker.go · workerDefaultTmpl(节选)
你领到【一条意图】(一句话探索方向),唯一职责:**完成这一条意图、把发现写回知识图谱、然后停止返回。**

**边界(红线)**:
1. **只做你领到的这一条意图**。探本意图时若瞥见本意图之外值得深挖的线索,
   **在 fact 的 summary 里点一句交给规划者**。
2. 初次受阻(payload 被过滤 / 404 / 注入无回显)不代表已探透——把本意图的
   所有绕过手段走完再输出结论;
3. 只在授权范围内操作。系统提示顶部若附【操作约束】,那是最高优先级红线。

第 1 条特别值得学:它没有简单禁止 worker「注意到别的线索」,而是给这种注意力一个合法出口——写进 fact 交给规划者。这比「不许分心」有效得多,因为分心是 LLM 的本能,堵不如疏。

三种写回,绝不串图

写回工具写哪张图写什么不写什么
insert_assets资产图资产本身:子域、服务、端点、指纹、凭据探索结论、判断、推测
record_fact探索图探索结论/事实,含否定结论(端口关闭、参数不可注入)资产属性(那属于 insert_assets)
report_finding探索图真实触发过、有可复现证据的漏洞 + PoC版本匹配 CVE、看起来可注入、代码 diff 推断

写 fact 有三条硬规矩,全都写进了 prompt:

RULE 1

一次一条,别拆碎

多个观察汇总成一条事实:summary 一句总结 + detail 展开。一个属性一条会让图无限膨胀。

RULE 2

只写增量

只记这次新得到的;已存在的事实换个措辞重记,等于制造噪音,还会误导 planner 以为有新进展。

RULE 3

只写真实看到的

必须给 evidence(一行命令 + 关键输出),并标 confidence:observed 直接看到 / inferred 据现象推断。

为什么 worker 没有 Glob / Grep / MultiEdit

这是刻意的工具面收敛:

agent/worker.go
// worker 刻意不给 MultiEdit/Glob/Grep:文件精改用 Edit、检索走 Bash(grep/find),
// 收敛工具面、减少低价值调用。其余 SDK 默认工具(Read/Write/Edit/LS/Bash/Sleep)照常。
base = append(base, defaultToolsExcept("MultiEdit", "Glob", "Grep")...)

给 LLM 的工具越多,它越倾向于「先搜一搜再说」。把重复能力的工具砍掉,等于强制它用 Bash 一步到位,减少无价值的往返。

5.5 worker 之间的「过程级信息交换」

一个很实际的问题:worker A 在跑的时候,某条命令的报错里泄露了一个内网地址——但它没把这条写成 fact(也许觉得不够格)。后面的 worker C 本来可以顺着这条线索走,却永远不知道。

ARTEX 的解法不是让 worker 共享上下文(那会破坏隔离),而是让它们能检索彼此的 activity 流水:

worker A 意图 #12 worker B 意图 #34 worker C 意图 #56 · 正在跑 activity(过程库) 每一步工具调用 / 输出 按 exploration_id + id 递增 step#901 意图#12 curl … step#902 意图#12 响应含 10.0.3.7 step#940 意图#34 nmap … 扁平流水,不是图 search_all_worker_traces(q) 命中:意图 #12 的 step#902 排除自己的意图步骤 带 intent_id / step_id 再 get_worker_trace 取全文
图 5-2 信息以「执行过程」为粒度流动,但每个 worker 的任务边界不变
工具用途
search_all_worker_traces(q)在本任务其他 work 的执行过程里按关键字检索,命中项带 intent_id / step_id;自动排除自己
list_worker_traces()列出已跑过的 work 索引(intent_id + 摘要 + 状态),用于发现「谁值得翻」;注释里标注「通常不推荐使用」,因为提示词已经把大部分信息给了
get_worker_trace(intent_id, step_ids=[…])锁定某条 work 后,取具体几步的完整内容(不传 step_ids 则列步骤)

这三件套的设计意图是「信息可发现,但边界不越」:worker C 能复用 A 的观察,但它依然只做 #56 这一条意图,不会去接管 A 的活。

5.6 goals 分解器:只跑一次,但决定了后面所有事

任务创建时,这个 Agent 从「任务目标 + 描述」里抽三样东西,顺序很重要:

  1. 先抽操作约束 → set_constraints

    把「不扫端口」「不做写操作」「只测某子域」这类边界登记下来,并按 allow / deny 分类。约束会被单独注入执行阶段的提示,所以必须自包含:把「当前目标」这类指代词替换成具体域名/IP。

  2. 再登记测试范围 → add_task_scope

    遵循最小范围原则:目标是 https://a1b2c3.lab.example.net/path 就登记 a1b2c3.lab.example.net,绝不能缩成 example.net——那会把范围扩到授权之外。只有裸根域名或明确说「所有子域」才用 root_domain。

  3. 最后拆目标 → set_goals

    只拆「最终可交付/可核验的结果」,禁止把「信息收集」「漏洞验证过程」「攻击步骤」列为子目标。多个独立交付物才拆多条。

这条提示词写得极狠

原文:「只登记目标/描述里【明确写出或强调】的约束,严禁臆造;拿不准类型时用 deny(更保守)」、「严禁把带子域的主机名缩成根域名——会把范围扩到用户目标之外」。在安全系统里,把授权范围判大是致命错误,所以这里所有的默认值都偏向「更小、更保守」。

5.7 mainagent:人的手柄,不是第二个大脑

它跑在聊天页,每次人发消息才启动。它有三类别名工具:

观察

读

graph_overview / list_facts / list_findings / node_detail / get_worker_output / get_worker_trace

操控

写意图与提示

add_hint(战略提示)/ add_intent(直投方向)/ steer_work(纠偏在跑的)

治理

改目标与边界

set_goals(加目标,可复活已终态的任务)/ set_constraints(改约束)/ add_company_scope

它有三个专用回调,是别的角色没有的:

回调解决什么问题
resumeTask任务已经 done / paused,人又加了个目标——普通的 notify 会被 planner 的终态门吞掉,必须把任务复活成 running 并重启循环
notifyGoal一次 set_goals 加 3 个目标,只记一条「人新增了 3 个目标」触发,而不是 3 条
notifyHint同上,一次 add_hint 批量多条提示只发一次唤醒
批处理意识

这三个回调看起来是琐碎的工程细节,但体现了一个重要原则:唤醒是有成本的(一次 LLM 调用)。所以「一次人类操作 = 一次规划轮」,而不是「一条数据 = 一次唤醒」。同理,worker 的 finding 也会中途唤醒 planner,但走的是单独的 notifyFinding——因为它需要带上「哪个意图找到了什么」的上下文。

Q4worker 在跑自己那条意图时,发现了一个明显属于别人的高价值线索。按系统设计它应该怎么做?
提示词给这种「分心」留了合法出口:写进 fact 的 summary 点一句交给规划者。完全不报是浪费情报,自己转过去则是越权——中间这条路才是设计想要的。
06

工具集:给 LLM 划一个刚好够用的动作空间

工具不是越多越好;每个角色的工具面都是刻意裁剪的

6.1 三个工具集,三种世界观

planner(规划视角)worker(执行视角)mainagent(人的视角)
graph_overviewlist_findingsgraph_overview
list_findings / list_factsreport_findinglist_facts / list_findings
node_detail / expand_digestrecord_factnode_detail / expand_digest
get_worker_output / get_worker_traceinsert_assetsget_worker_output / get_worker_trace
search_all_worker_traceslist_assetssearch_all_worker_traces
list_goals / add_intentsearch_all_worker_tracesadd_hint / add_intent
prove_goal / goal_metget_worker_tracesteer_work
kill_work / steer_worknode_detailset_goals / set_constraints
report_finding—insert_assets / record_fact / report_finding
list_companies / list_assets—add_company_scope / add_task_scope
add_company_scope / add_task_scope—list_untested_assets
list_untested_assets——

注意几个「缺失」都是刻意的:

MISSING 1

worker 没有 list_facts

它不该读全局事实去做「规划性判断」。要知道别人发现了什么,用 get_worker_trace 看过程,或看启动时下发的全局态势。

MISSING 2

worker 没有 add_company_scope

定义企业资产范围属于规划/主控职责。让执行者改授权范围,等于让士兵自己划战区。

MISSING 3

planner 没有 set_goals

目标只能由人(或分解器)设定。planner 能做的是「证明某个目标达成」,不是「新增目标」——这是防止它自己给自己加戏。

6.2 读工具:三级信息粒度

读工具的设计围绕一个矛盾:planner 需要全局态势,但不能把整张图塞进上下文。解法是三级粒度:

L1 · 态势(预取) graph_overview 目标 + 计数 + 意图列表 (带 parents / yields 血缘) 最近事实、覆盖度 每轮自动拼进 user 输入 省掉一次工具往返 L2 · 列表(分页) list_facts / list_findings id + 摘要 + 状态 limit / before 游标 / q 关键字 返回 total + has_more + next_before 摘要过长会截断 按需翻阅,不拉全量 L3 · 详情(按 id) node_detail(id) 完整 payload + evidence + 锚定资产 + 血缘 只在需要核对证据时调 这是「验证否定结论」的入口 也是「确认 PoC」的入口 设计原则:默认给最小够用信息,深度按需付费(每深一层都是一次 LLM 往返 + 更多 token)
图 6-1 情报的三级粒度:态势免费、列表便宜、详情昂贵
一个非常聪明的取舍

planner 的提示词里写着:「完整态势已附在本提示下方(就是 graph_overview 的返回,无需再调它)」。这把一次工具调用变成了零次——每轮省一次往返、省一次 token 生成。代价是 system/user 提示变长,但因为它放在 user turn 里,可以被 compaction 压缩,风险可控。

6.3 资产查询:给 LLM 一门小语言

让 LLM 写 SQL 是危险的,让它填一堆结构化参数又太笨重。ARTEX 选了中间路线:一门极简 DSL。

list_assets · dsl 示例
domain = "example.com" and port >= 8000
technology = "nginx" or technology = "php"
status_code = 200 and page_title ~ "后台"
type=service and root_domain = "example.com"

支持的字段被白名单限定在几个 map 里(字符串 / 数组 / 数值三类),解析成 AST 后再拼参数化 SQL。未知字段直接报错,绝不透传:

db/asset_dsl.go
// knownStringFields maps DSL field name → SQL column name.
// NOTE: "type" is intentionally excluded — it is a separate parameter, not a DSL field.
var knownStringFields = map[string]string{
    "domain": "domain", "root_domain": "root_domain",
    "ip": "ip", "url": "url", "page_title": "page_title",
    "technology": "technologies", // … 白名单
}

另一个细节:list_assets 在没传 id/ids 时 dsl 不能为空,否则直接报错「不允许无条件查询全部资产」。这是一个防呆设计——LLM 很容易图省事查全表,然后在上下文里塞进 5000 条资产。

6.4 写工具:每个都有「不写什么」的定义

三个写入口的 schema 都很朴素,真正的约束在描述文本里。这些描述长达几百字,读起来像产品需求文档——因为它们确实是给模型的 API 契约:

agent/tools.go · record_fact 描述(节选)
把探索【事实/结论】写入探索图,连到产生它的意图(intent_id)。
用于记录探索结果——包括指纹/枚举等【正向结论】,和'端口关闭'/'参数不可注入'/
'未发现登录入口'等【否定结论】。
…
**多个观察汇总成【一条】事实**(summary 一句总结 + detail 写对总结的拓展)…
**只写增量**:只记这次【新得到】的,别把已有事实换措辞重记…
**只写真实看到的**:给 evidence(一行:命令+最能证明的一两行输出),
标 confidence(observed=直接看到 / inferred=据现象推断)。
为什么描述要写这么长

工具的 description 是模型唯一能看到的「使用说明书」。写得含糊,模型就会用它做别的事:拿 record_fact 记资产、拿 report_finding 记怀疑、拿 insert_assets 记结论。三个写入口的边界,是靠文字划出来的,不是靠代码校验。这是 LLM 工程与传统 API 设计最大的差别之一。

6.5 工具不是固定的:三层动态装配

一个 Agent 最终能看到的工具,是三次过滤的结果:

① 代码内置 ToolSet.PlannerTools() WorkerTools() MainAgentTools() + actool.DefaultTools() (Bash/Read/Write…) 永不做可见性过滤 内置工具留代码层 ② host 工具 traffic_* 流量查询 跨任务编排工具 平台管理工具 用户自定义工具 流量工具只在录制开启时 真实存在 由 Manager.HostTools() 提供 ③ 技能 / MCP 可见 skill → Skill 元工具 可见 MCP → mcp__server__tool 技能门控的 MCP → 延迟 schema 暂不注入,只给名字 调用前需 Skill 解锁 省 context、防误调 用完即释放连接 ④ DB 最终裁决 tools 表按 agent 绑定 未绑定的内置工具 被剔除 可覆写描述 / schema MCP/技能/host 工具 无行,原样通过 + guardPanic 包裹 工具 panic 变普通错误
图 6-2 工具装配:代码给底座、宿主加能力、DB 做最终裁剪

延迟工具(deferred tools):为什么要把 schema 藏起来

MCP 服务器可能有几十个工具,每个的 schema 都塞进 system prompt,光工具定义就吃掉几千 token,而且模型会忍不住乱调。ARTEX 的做法是:

  • 全局可用的 MCP 工具:只在 system 末尾列名字,schema 不注入;
  • 技能门控的 MCP 工具:连名字都不出现,直到对应 Skill 被加载才解锁;
  • 系统提示末尾的 <available-deferred-tools> 块作为最后一个 segment,配合 DynamicBoundary 让整段 system 可缓存。

这套机制还处理了一个隐蔽的 bug:主 agent 每轮新建会话,内存里的「已解锁集合」会重置。所以 seedUnlockFromHistory 会重放历史里的 Skill() 调用,把解锁状态恢复出来——否则模型能看到工具名,却调不动。

panic 防护:一个工具崩了不该带走整个进程

agent/assembly.go
// guardPanic turns a panicking tool handler into an ordinary tool error. The
// harness runs each tool on its own goroutine, so a panic inside a handler can't
// be recovered by the caller that started the run — it takes the whole process
// down, and on restart the agent replays the same call and crashes again.
// Applied last, so it covers every tool the agent can reach: domain, SDK, MCP and skill.
func guardPanic(t actool.CoreTool) actool.CoreTool { return &guardedTool{CoreTool: t} }

最后这句「在重启后重放同一个调用又会崩」是关键:工具 panic 不是一次故障,而是无限重启循环。所以必须在工具边界兜住。

Q5为什么 list_assets 在没有 id/ids 时要求 dsl 必填?
这是典型的「为模型设计 API」:不是技术上做不到,而是不能让模型轻易做错事。强制它先想清楚查什么,比事后截断结果更有效。
07

提示词工程:把控制流写进文字里

三段式拼装、模板变量、以及「哪段放 system、哪段放 user」的取舍

7.1 三段式:可编辑的身体 + 不可编辑的尾巴

每个 Agent 的 system prompt 都由三部分拼成,分界线是「谁拥有它」:

段 [A] · 可编辑正文(存 agent_prompts 表) 角色定义、边界红线、决策流程、写回规约 用户在「Agent 管理」里可改;用 Go template 变量占位 {{.Goal}} {{.DataDir}} {{.Now}} {{.ProxyAddr}} … 渲染失败 → 回退内置默认 → 再不济用原始字符串,绝不半渲染启动 段 [B] · 代码固定 流量工具说明(仅录制开启时注入) 段 [C] · 代码固定 中间产物输出规约(必须写到任务工作目录 ,不许写 /tmp) 为什么要分?因为 [B]/[C] 是「改了就会坏」的硬约束:用户在 UI 里编辑 [A] 时, 不可能误删流量工具的使用说明,也不可能把产物目录改成 /tmp。
图 7-1 可编辑的归用户,不可编辑的归代码
agent/worker.go · 拼装顺序
func workerSystem(proxyAddr, caCert, dataDir, runDir string) string {
    body := renderSystem("worker", workerDefaultTmpl, WorkerVars{ProxyAddr: proxyAddr, DataDir: dataDir, Now: nowStr()})
    // caCert 只在录制 MITM 开启时存在,正好是 traffic_* 工具注册的时候 → 用它门控说明块
    return body + workerTrafficBlock(caCert != "") + workerArtifactSpec(runDir)
}   //        ↑ 段 B:代码固定            ↑ 段 C:代码固定

注意段 B 的门控条件:它看的是 caCert != ""(录制开启),而不是「有没有配代理」。因为如果只是走了全局代理、没开录制,traffic_* 工具根本不存在,讲它们的用法只会让模型去调一个不存在的工具。

7.2 模板变量与「永不半渲染」

内置默认 prompt 本身就是 Go template,和用户存进数据库的版本走同一套渲染:

agent/prompt.go
func renderSystem(agentKey, def string, vars any) string {
    tmpl := def
    if PromptOverride != nil {
        if t, ok := PromptOverride(agentKey); ok && t != "" { tmpl = t }
    }
    if out, err := renderTmpl(tmpl, vars); err == nil { return out }
    // DB 模板坏了(比如引用了目录外的变量)→ 回退代码默认
    if out, err := renderTmpl(def, vars); err == nil { return out }
    return def
}

三层兜底:用户模板 → 内置模板 → 原始字符串。加上 Option("missingkey=error"),用户只要写了一个不存在的变量,整段模板就渲染失败并回退——而不是渲染出一堆 <no value> 让模型困惑。

7.3 最精彩的一处取舍:态势放 user,意图放 system

planner 和 worker 在这件事上做了相反的决定,而且源码里明确解释了为什么:

角色放哪放什么理由
planner user turn 本轮触发事件 + 完整 graph_overview + 跨唤醒 todo + 开场指令 让 system 每轮保持稳定,利于 prompt 缓存。planner 单轮通常短,态势被 compaction 压掉的风险低。
worker system 它领到的那条意图 + 意图锚定的目标资产原始数据 意图是贯穿整个 run 的不变量。system 每次 run 重新拼、绝不会被 compaction 压掉;续跑时也不依赖 transcript 是否留住了首条消息。
源码原话

「这是刻意的取舍(意图丢失比省 token 严重得多)」。而全局态势 overview 仍然留在 worker 的启动 user 消息里——因为它可降级、容忍 stale,压掉无碍。同一个问题,两种答案,判断标准是「这段信息丢了会不会致命」。

7.4 收尾词:预算耗尽也要把话说完

Agent 跑到步数上限或超时被掐断,最容易烂尾——发现了五个事实,一个都没写回。ARTEX 的处理是不让它直接死,而是进入一个「结算回合」:

agent/worker.go · settleWrapUpPrompt
你即将因预算耗尽被终止。不要再运行任何命令/探测。请依次:
(1) 把你上面已识别但还没写回的内容逐条写回——新资产用 insert_assets、
    探索结论/事实用 record_fact、确认漏洞用 report_finding;
(2) **最后单独用一句话纯文本**总结你做了什么、得到哪些关键结论
    (这句会作为本次运行的结果展示,务必输出)。

配套的机制还有:

  • 结算回合隐藏 Bash(Settlement.DisabledTools),从物理上阻止它继续探测;
  • 墙钟到点会打断在跑的工具并在活 ctx 上就地进结算,不是粗暴 kill;
  • 任务级 deadline 夹逼单个 run 的预算时,收尾词会切换成「任务到点」的版本,而不是「你自己步数用完了」——这两句话对模型的含义完全不同。

7.5 操作约束:最高优先级的红线

goals 分解器抽出的约束,会以一段独立文本注入 planner / worker 的 system prompt 末尾:

约束块(示意)
【操作约束】(最高优先级,覆盖上面所有常规流程)
- 禁止:不扫端口;不得对生产环境做写/删操作;禁止爆破
- 允许:只测试 abc.example.net;仅针对目标端口 443

三个细节值得注意:

DETAIL 1

单独注入,不混在目标里

约束和目标语义完全不同:目标是「要达成什么」,约束是「不许怎么做」。混在一起模型会拿约束当目标去「完成」。

DETAIL 2

要求自包含

「只测当前目标」脱离上下文就没法判断。所以分解器必须把指代词替换成具体域名/IP。

DETAIL 3

可运行时增删

人在总览页或对话里改约束,下一轮立刻生效(每次渲染 prompt 都重新读)。

提示词也是攻击面

目标描述、资产标题、HTTP 响应体都可能包含「忽略之前的指令」这类注入文本。ARTEX 的缓解手段不是靠模型自觉,而是结构性隔离:约束只在 system 里、由人写;worker 的写回必须带 evidence;report_finding 要求真实触发。也就是说,即使模型被骗,它也很难伪造出一个「可复现证据」。

Q6为什么 worker 的「本次意图」要放在 system prompt,而不是首条 user 消息?
代价是 system 混入了 per-intent 的易变数据,失去跨意图的缓存复用。源码里承认了这个代价,但判断「意图丢失比省 token 严重得多」——这是一次典型的正确性优先于成本的取舍。
08

上下文工程:让长任务不撑爆窗口

窗口压缩、图结构折叠、原文落盘、旁路提问

这是所有长任务 Agent 系统都会撞上的墙:工具输出越攒越多,20 轮之后上下文就满了。ARTEX 用了四层互不干扰的机制,每层解决不同尺度的问题。

第 1 层 · 单次 run 内:compaction(SDK 提供) 接近模型窗口阈值时,把较早的消息总结成一段摘要,保留最近若干轮原文。按 profile 配置的上下文窗口动态计算阈值。 粒度:一次会话 · 触发:token 数 · 代价:丢失细节,但保住连贯性 第 2 层 · 跨轮次:cold-digest 冷节点折叠(图结构层) 探索图里长期没有下游分支的「冷节点」,由后台 LLM 压成 digest 节点,通过 covers 边覆盖原节点;需要时 expand_digest 展开。 粒度:整张图 · 触发:≥R 轮不活跃 + 数量阈值 · 代价:细节需二次展开,但因果结构保留 这是本系统独有的一层:折叠的不是对话,是「知识图谱本身」。 第 3 层 · 落盘:transcript / noa 原始对话按会话 ID 存成文件,可 Resume worker 的会话键是 exp{id}-worker-i{intentID} 第 4 层 · 旁路:/btw 侧问题 用主上下文快照回答临时问题,独立历史 不打断主循环、不污染主 transcript 四层各自独立:压缩管 tokens、折叠管图规模、落盘管可恢复、旁路管「别在主线程里问闲话」
图 8-1 四个尺度,四种手段,互不替代

8.1 cold-digest:把「冷掉的推理链」压成摘要

这一层是 ARTEX 最有原创性的设计。它解决的问题不是「对话太长」,而是「图太大,planner 每轮读不动」。

什么是冷节点

一个节点如果连续 R 个规划轮次都没有任何下游分支(没有新事实从它派生、没有新意图引用它),它就被判定为「冷」。系统用 cold_since_round 记录它变冷的那一轮,用 round_no 记录当前轮次。

R = 6

冷却判定

连续 6 个 planner 轮次无下游活动,才记为冷。新节点不会立刻被折叠。

K = 2

最小折叠块

至少 2 个节点才折。单节点折叠没有收益,只会增加一次 LLM 调用。

N = 20

minor 阈值

冷节点攒到 20 个触发一次「增量折叠」,只处理尚未被覆盖的冷集。

M = 8

major 阈值

攒到 8 个 digest 时触发「全量重算」,重新分组并合并碎片。

怎么折

不是随机打包,而是按连通性分组:把通过上游/下游边相连的冷节点聚成一块,再让 LLM 为这一块写一段摘要,生成 kind=digest 的新节点,并用 covers 边指向所有成员。

为什么按连通性

同一块里的节点本来就有因果关系,摘要能写成「这一支探索做了什么、得到什么、结论是什么」;随机打包只能写成一堆互不相关的句子,planner 读了也没用。另外还有一个同父单例救援规则:如果某个冷节点是某父节点唯一的孩子,即使它自己不够「成块」,也跟着父节点一起折——否则父节点会变成一个悬空的孤儿。

并发与正确性

后台折叠最怕的是「折叠期间节点复活了」——摘要刚生成,那个节点又被新意图引用了,于是 digest 覆盖了一个活跃节点。解法是提交时再检查一次存活:

agent/compaction.go · 设计注释
// Concurrency: one compaction per task at a time (mutex), ≥cooldown between runs,
// and a commit-time liveness recheck drops any member that revived while the body
// was being generated so a digest never covers a hot node.

再加三道闸:每任务同时只跑一个折叠(mutex)、两次折叠间隔 ≥60s(cooldown)、单次折叠硬上限 5 分钟。全部在后台 goroutine,绝不阻塞规划主路径。

折叠后还能展开吗

能。planner 有 expand_digest 工具,可以按「摘要正文 → 成员列表 → 具体节点 detail」三级展开。这意味着折叠是无损的索引压缩,不是删除。

8.2 旁路提问:别在主线程里问闲话

人在任务跑到一半时想问「这个 CVE 是什么来着?」——如果走主对话,会把这条问答永久写进 transcript,挤占上下文,还可能干扰 agent 的判断。

ARTEX 的做法是 /btw 旁路:

  1. 抓快照

    主 Agent 每次模型请求前后,把结构化消息、system prompt、工具定义、生成参数深拷贝成一份不可变快照。

  2. 独立回答

    旁路服务用「快照 + 最近成功问答 + 新问题」发一次独立请求,不创建 agent session、不接工具执行器、不写主 transcript。

  3. 独立历史

    问答存在 side_question_requests 表,可追问、可停止、可清空。清空只影响旁路,主上下文快照保留。

几个被认真处理过的边角

快照按 250ms 合并写入,数据库比较 (run_id, version) 防止旧版本覆盖;正在生成的半段回复不发布;流式中止保留上一个有效边界;服务重启把遗留 running 请求标记为 interrupted,保留已落库的部分回答而不自动重放(重放会重复计费)。这些细节决定了「旁路」是不是真的旁路。

8.3 token 账本

所有这些机制要能调优,前提是知道 token 花在哪。系统在每个 activity 上记四个数:input_tokens / output_tokens / cache_read_tokens / cache_write_tokens,并按任务、会话、日维度聚合。

db/schema.sql
-- Task-list polls aggregate result usage and find the latest event repeatedly.
-- Cover the token columns for index-only aggregation and the timestamp order for
-- per-exploration latest-activity lookups.
CREATE INDEX IF NOT EXISTS idx_act_result_usage ON activity(exploration_id)
  INCLUDE (input_tokens, output_tokens, cache_read_tokens, cache_write_tokens)
  WHERE kind='result';

这个 partial index 是个典型的 PG 优化:仪表盘要频繁按任务聚合 token,用覆盖索引(INCLUDE)就能走 index-only scan,不回表。设计文档里连「为什么是这个索引」都写在注释里。

Q7cold-digest 折叠的对象是什么?
这是它与普通「对话摘要」最本质的区别:摘要压缩的是知识结构(哪些事实→哪些意图→什么结论),而不是文本流。所以折叠后 planner 仍然能理解「这一支探索过什么」,只是不再看到每个细节。
09

安全边界:约束、审批门、审计

一个会自己动手的系统,必须在每个动作出门前设卡

这套系统让 LLM 直接跑 Bash、发 HTTP、调 Kali 工具。安全设计不能靠「模型应该不会乱来」,必须在工具调用边界做强制拦截。ARTEX 用了三道闸,粒度从粗到细。

9.1 三道闸的分工

LLM 决定调工具 Bash / HTTP / MCP… 闸 1 · 操作约束 注入 system prompt 软约束:模型自检 不阻止,只框定 闸 2 · 拦截规则(硬) PreToolUse hook 按 priority 逐条匹配 allow / deny / ask 未命中 → LLM 裁判(可选) 闸 3 · 审计 + 观测 每条调用记 audit / intercept_log PostToolUse 归因:ok/blocked/error 供 planner 换策略 真正执行(Bash / 代理 / MCP) 子进程 HTTP 也走记录代理 额外一层 · 资产级阻断名单 asset_intercept_rules 不匹配工具,而是匹配目标资产:精确/模糊域名、IP、URL、CIDR。 用于「这个网段绝对不能碰」这类硬边界;任务级规则可带 allow 白名单。 匹配与执行分离:这一层只存规则,判定逻辑在 server/asset_intercept.go。
图 9-1 软约束管方向,硬规则管动作,审计管事后

9.2 拦截规则:三种动作,一套优先级

action行为适用场景
allow立即放行,跳过所有更低优先级规则白名单:已知安全的高频命令,避免每次都问人
deny直接阻止,返回一条消息给模型硬禁止:rm -rf /、反弹 shell 到外部、批量拖库
ask阻止并创建 pending 记录,等人在 UI 上批准/拒绝高风险但可能合法:写操作、凭据导出、影响面大的扫描

规则的结构比想象中完整——它不只是「匹配什么」,还包括超时后怎么办:

db/intercept.go
type InterceptRule struct {
    Name           string // 人类可读的名字
    Enabled        bool
    Priority       int    // 数值越大越先匹配
    MatchTarget    string // tool / input / both
    MatchType      string // string / regex
    Pattern        string
    Action         string // allow / deny / ask
    Message        string // 返回给模型的解释
    TimeoutEnabled bool   // ask 等多久
    TimeoutSeconds int
    TimeoutAction  string // 超时是放行还是拒绝
}
超时默认应该是拒绝

「等人审批」最危险的失败模式是「没人看,于是默认放行」。所以超时动作必须显式配置,且保守默认应为拒绝。同理,审批请求在 worker 的 ctx 已取消时会直接拦截、不创建 pending 记录——避免任务停止后留下一堆孤儿审批,也让 worker 快速结束。

9.3 LLM 裁判:规则匹配不到时的兜底

正则规则永远写不全。ARTEX 提供可选的「LLM 裁判」:规则未命中时,把工具名 + 完整参数交给一个判定模型,让它输出 allow / deny / ask。

guard/guard.go · applyIntercept
dec, matched := g.interceptor.Match(ev.ToolName, ev.Input)
if !matched {
    // No rule matched. Ask the LLM fallback judge (if enabled); when it is off
    // or unwired, keep current behavior and allow.
    d, judged := g.interceptor.Judge(ctx, ev.ToolName, ev.Input)
    if !judged { return hook.Result{} }
    dec = d
}
switch dec.Action {
case "deny":  /* 记 denied,返回 block */
case "allow": /* 记 allowed,放行 */
case "ask":   /* 创建 pending,阻塞等待人工 */
}

注意 judged=false 时是放行,不是拒绝。这是刻意的:裁判服务挂了不应该让整个系统停摆,硬边界仍然由规则层保证。这是一个「可用性优先于这一层」的取舍,前提是规则层已经覆盖了真正危险的类别。

9.4 那句写给模型的拦截文案

这是整个安全设计里最容易被忽略、但最精妙的一处。guard 返回给模型的不是「禁止执行此工具」,而是:

guard/guard.go · systemBlockMessage
【ARTEX 平台管控·非目标防御】此调用被平台拦截。原因:…。此操作被禁止。

源码注释解释了为什么要加这个前缀:

裸原因(「禁止执行此工具」/「用户拒绝」)读起来和目标的 WAF / 403 一模一样,而渗透 agent 的本能是绕过 WAF——改写命令、换 payload、重新编码、重试。但这是徒劳的(平台拦的是动作类别,不是某个字符串),也是错误的(这是政策决定,不是待攻克的障碍)。前缀明确告诉它:拦截来自平台,不是目标的防护,此操作被禁止——于是它转向别的方法,而不是试图规避。
可迁移的经验

在 Agent 系统里,错误信息的措辞会改变模型的行为。同一件事,「403 Forbidden」会触发绕过策略,「平台政策禁止,换个方向」会触发重规划。这不是 prompt 技巧,而是接口设计——把「谁的意志」说清楚,比说「不行」有效得多。

9.5 审批的等待与审计

一次 ask 的完整生命周期:

  1. 创建 pending 记录

    写入 intercept_pending,同时往当前会话的 activity 流写一条 intercept_request,于是它会内联出现在对话记录里,人不用去另一个页面找。

  2. 阻塞等待

    为这条请求建一个 channel,worker 的 goroutine 阻塞在上面。人在 UI 点「批准 / 拒绝」→ HTTP handler 调 Decide(pendingID, allowed) → 唤醒 channel。

  3. 超时处理

    按规则配置的 timeout_seconds 与 timeout_action 决定放行还是拒绝;ctx 取消则立即拦截。

  4. 落审计

    无论 allow / deny / ask 的结果,都写一条 intercept_log,带工具名、参数、规则、判定、耗时。审批记录页可展开看完整调用详情。

审计侧还有一个「失败归因」机制(源码里叫 Observer / G5):PostToolUse 钩子把 Bash 结果分成三类:

ok

正常

命令成功执行,输出可解析。

blocked

被目标拦截

输出里命中 403 / forbidden / waf / rate limit / captcha 等模式。

error

执行出错

命令本身失败(参数错、依赖缺失、超时)。

这三类的区分让 planner 能做出不同判断:blocked 说明目标有防护,要换绕过思路;error 说明自己命令写错了,要修命令。如果都归为「失败」,模型只会盲目重试。

9.6 为什么「内置规则」也放在数据库里

系统预置的破坏性/外泄拦截规则,不是硬编码在 Go 里,而是以普通 [内置] 规则的形式 seed 进数据库,用户可以禁用甚至删除。

取舍

好处:不同团队的风控尺度不同,硬编码会让系统无法适配。代价:用户可能误删安全规则。缓解手段是「内置标记 + 默认启用 + 审计留痕」,而不是「不许改」。这是把策略与机制分离的经典做法——机制(hook 一定被调用)在代码里,策略(拦什么)在数据里。

Q8为什么拦截时给模型的文案要强调「平台管控·非目标防御」?
用户当然也需要知道,但这句话的读者是模型。它改变的是模型的下一步行为:从「换 payload 绕过」变成「换个方向」。这是把提示词当作接口契约来设计。
10

流量留痕:让每一次请求都可回查

MITM 代理、文件树、SQLite 索引、内容寻址证据

10.1 为什么必须留痕

渗透测试的结论如果没有原始请求响应,就等于「我说有洞」。留痕解决三个问题:

PROOF

可验证

报告里的每条漏洞都能点回具体请求与响应,而不是只有一段描述。

MEMORY

可复用

worker 不必重复 curl 同一个 URL——先查流量,能找到之前访问过的资源和响应。

DEBUG

可诊断

payload 到底发出去了什么、服务端返回了什么,只有抓包是真相。

10.2 架构:代理 + 文件树 + 旁路索引

worker / planner Bash 子进程 / WebFetch 环境变量已注入代理与 CA HTTP(S) go-mitmproxy 每个响应触发 addon 动态签发证书(本地 CA) 只记目标 HTTP,全量明文 文件树(人可浏览) host/path/…/交换文件 请求头 + 响应头 + 正文 大正文 spill 成 blob SQLite 旁路索引 exchanges 元数据 exchange_bodies blob_refs + FTS5(trigram) agent 查询接口 traffic_search(必须指定 host,默认 3 条) traffic_get(id) · traffic_blob(hash) 分段取 为什么要两套存储 文件树:人可直接打开看,删了索引也不丢数据,便于打包带走。 SQLite:分页/过滤/全文检索需要索引,总不能每次扫目录。 FTS5 用 trigram:支持中文与任意子串,不是按词切。 两者解耦:索引损坏可重建;正文按 sha256 去重,同一份响应只存一次。 FTS5 驱动缺失时降级为「无全文搜索」,而不是整个录制功能挂掉。
图 10-1 写入走文件 + 索引,读取走索引 + blob;两者都从同一份交换数据派生

10.3 三个让工具「零配置」的细节

细节一:把代理和 CA 塞进子进程环境

worker 跑的 curl 不该需要人教它加 -x 和 -k。所以系统在启动 Bash 子进程时注入一整套环境变量:

agent/worker.go · proxyEnv
HTTP_PROXY / HTTPS_PROXY / http_proxy / https_proxy / ALL_PROXY …
NODE_USE_ENV_PROXY=1          // Node 24+ 的内置 fetch 才会读代理变量
SSL_CERT_FILE=…               // curl / urllib / Go / openssl
CURL_CA_BUNDLE=…              // curl 专用
REQUESTS_CA_BUNDLE=…          // python requests 不读 SSL_CERT_FILE
GIT_SSL_CAINFO=…              // git
NODE_EXTRA_CA_CERTS=…         // node

注释里有一句经验之谈:「Each ecosystem reads a different CA var (verified empirically)」——每个生态读的变量不一样,是实测出来的。还有个容易漏的点:curl 读 socks5 代理只认 ALL_PROXY,不读 HTTP(S)_PROXY,所以两个都设。

细节二:大正文溢出到 blob,索引只留指针

一个 200MB 的下载响应不能塞进 SQLite。系统对超过阈值的正文做 spill:正文按 sha256 存成文件,索引里只留 @blob sha256:… 标记。agent 要看时用 traffic_blob(hash) 分段读。

内容寻址的副作用是免费去重

同一个 JS 文件被 50 个页面引用,只会存一份。blob_refs 表记录「哪个交换引用了哪个 blob」,删某个 host 的流量时只删引用,blob 在没有任何引用时才回收。

细节三:搜索必须先指定 host

traffic_search 强制要求 host 参数,且默认只返回 3 条极轻量索引(id / method / url / status / resp_len,不含响应内容)。要看正文得再调 traffic_get(id)。

这和 list_assets 的防呆是同一个思路:不让模型一次拿到海量数据。但它留了一个强大的例外——body_contains 可以在请求/响应正文里做全文搜索(至少 3 字符,支持中文),用来找密码、密钥、报错、内网地址。

10.4 证据:与流量解耦的不可变副本

流量是「可丢弃的」——用户可能清理磁盘、归档任务、只保留证据。所以报告用的证据不能直接引用流量 ID,必须独立存一份。

evidence/store.go
// Package evidence preserves finding evidence independently of disposable traffic.
func hashPath(dir, hash string) (string, error) {
    if len(hash) != 64 { return "", errors.New("invalid evidence hash") }
    if _, err := hex.DecodeString(hash); err != nil || strings.ToLower(hash) != hash {
        return "", errors.New("invalid evidence hash")
    }
    return filepath.Join(dir, "blobs", hash[:2], hash+".bin"), nil
}

三个设计点:

  • 严格的 hash 校验:必须 64 位、小写十六进制、能 hex 解码。这防的是路径穿越——如果 hash 可以是 ../../etc/passwd,读证据就变成了任意文件读取。
  • 两级目录:hash[:2] 分桶,避免单目录几十万文件。
  • 写入后校验:verifyFile 重新算 sha256 比对,确认落盘内容没坏。

10.5 流量与漏洞的绑定

一条漏洞可以绑定多条流量,且顺序有意义(复现步骤的先后)。绑定不是随便挑几个 ID,而是有明确的判定纪律:

场景怎么做
HTTP 漏洞、有录制先 traffic_search / traffic_get 核对真实请求,再按复现顺序绑定;域名和时间只用于候选筛选,不代表任务归属
TCP 等非 HTTP 漏洞省略 traffic_refs 或传空数组;在 evidence 里保留命令输出,并说明未绑定原因
没采集 / 无匹配同上,不许猜 ID、不许为了凑证据重复探测
「不许猜 ID」为什么要写进提示词

模型非常擅长编造看起来合理的数字。如果它随手填一个不存在的 traffic_id,绑定就会失败或者绑错——而报告上却显示「有证据」。所以提示词里反复强调「不要猜测 ID」,且服务端对无效引用做整条上报失败的处理(fail fast,而不是静默丢弃)。

Q9为什么证据(evidence)要独立存一份,而不是直接引用流量记录?
这是生命周期匹配的问题:证据要跟报告一起交付、一起归档,可能几年后还要能打开;流量只是运行期的工作数据。把两者绑死,清理磁盘就会毁掉历史报告的可信度。
11

资产模型与覆盖度

六类节点、两张范围表、一个分母

11.1 归属:显式 vs 范围推导

一个资产属于哪家公司?系统允许两种来源,且永远不会互相覆盖:

company_source含义什么时候产生
explicit人或 agent 明确指定了公司手动录入、资产同步时带 company_id
scope由 company_scope 规则自动推导新增范围规则后重算命中资产
为什么要有这个字段

因为「重算范围归属」是一个批量操作。如果它把人工标注的公司也一起覆盖了,就永久丢失了人的判断。所以显式归属被标记出来,重算时只动 scope 来源的行。存量库迁移时也保守地把所有旧数据标成 explicit——宁可少算,不可错删。

company_scope:五种范围规则

kind匹配方式例子
domain精确域名匹配(含子域归属推导)example.com
ip单个 IP203.0.113.10
cidr网段包含(GiST 索引加速)203.0.113.0/24
icpICP 备案号归一化后匹配粤ICP备12345678号-1
keyword关键词匹配(公司名等)某某科技有限公司

表上有一个 CHECK 约束做「三选一」:domain 规则必须有 domain、ip/cidr 必须有 net、icp/keyword 必须有 value,其余字段为 NULL。这样不可能出现「kind=domain 但 net 有值」的脏数据。

11.2 task_scope:授权边界 = 覆盖度的分母

任务级范围比公司级更严格,因为它是这次任务的授权边界:

kind语义覆盖度里怎么算
root_domain整根域(含所有子域)匹配 assets.root_domain
subdomain单个主机名匹配 assets.domain
ip / cidr单 IP 或网段ts.net >>= try_inet(a.ip)(网段包含)
company整家公司匹配 assets.company_id
icp备案号归一化后匹配 icp 或 app_icp

11.3 覆盖度的公式(以及它为什么是「约」)

db/task_scope.go · covTargetCTE
target AS (          -- 分母:task_scope 命中的资产去重
  SELECT DISTINCT a.id, a.type, …
  FROM assets a JOIN task_scope ts ON ts.task_id = $1 AND (
       (ts.kind='company'     AND a.company_id = ts.company_id)
    OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
    OR (ts.kind='subdomain'   AND a.domain = ts.domain)
    OR (ts.kind IN ('ip','cidr') AND ts.net >>= try_inet(a.ip))
    OR (ts.kind='icp' AND …)
  )
),
tested AS (          -- 分子:本探索中被 fact 锚定过的资产
  SELECT DISTINCT ea.asset_id
  FROM exploration_anchors ea
  JOIN exploration_nodes en ON en.id = ea.node_id
  WHERE en.exploration_id = $2 AND en.kind = 'fact'
)

所以:

定义

覆盖度 = 被至少一条 fact 锚定过的范围内资产数 ÷ 范围内资产总数。分母为 0 时返回 null(而不是 0%),因为「没有范围内资产」和「一个都没测」是两回事。

源码注释里明确写着「Reference figure only(仅供参考)」。为什么不敢说精确?

原因 1

「测过」的定义模糊

请求了一次首页算不算测过?只看了一眼指纹算不算?系统用「有 fact 锚定」作为代理指标,粗糙但可解释。

原因 2

资产在动态增长

探测过程中不断发现新子域/端点,分母一直在变。同一时刻的百分比才有意义。

原因 3

范围规则可能重叠

公司范围 + 域名范围可能命中同一资产,所以必须 DISTINCT 去重,否则分母虚高。

但它依然是验收项

planner 的提示词里写着:目标含「覆盖度达 X%」时,prove_goal 前必须核对实测值,未达标禁止盖章。所以这个数虽然粗糙,却是硬指标——粗糙但一致,好过精确但不可复现。

11.4 覆盖图:把「测到哪了」画出来

资产覆盖图是一个力导向布局的包含关系图:company → root_domain → subdomain / ip → service → endpoint,外加 app 挂到 company。

已测

高亮

被 fact 锚定过的资产节点高亮显示,一眼看出哪片区域没动过。

范围外

灰色连接节点

为了把子域连起来而被拉进来的根域、以及它们上面的公司,标 in_scope=false,渲染成灰色——它们只是结构连接,不是测试目标。

合成节点

无资产行的根域

有些根域只有子域、自己没有 assets 行,用一个合成的 r:<domain> 节点表示,避免断链。

11.5 enrich:不靠 LLM 的自动补全

有些事不需要模型判断,纯工程就能做:解析域名、探 HTTP。这些交给 enrich 引擎,异步、带冷却、可去重。

enrich/enrich.go
const (
    cooldown    = 5 * time.Minute   // 同一资产 5 分钟内不重复解析/探测
    httpTimeout = 12 * time.Second
    queueSize   = 1024              // 有界队列:满了就丢,不阻塞写入
)

它做两件事:

  • DNS 解析:用 dnsx 解析域名,把结果写成 IP 节点 + 解析关系,回填 record_type / record_value。
  • HTTP 探测:对站点发一次请求,抓 title、status_code、technologies、favicon hash,并且走记录代理——所以补全产生的流量也会进录制库。
为什么用有界队列

补全是「写资产时顺手触发」的旁路动作。如果队列无界,一次批量导入 10 万资产会把内存吃光;如果同步执行,写资产的操作会被 DNS 超时拖死。有界队列 + 满了丢弃,是典型的旁路不拖垮主路设计。

11.6 资产同步:从 ScopeSentry 直接导入

实际工作中,资产往往已经在别的系统里收集过了。ARTEX 支持按项目/任务维度从 ScopeSentry 同步域名、子域、IP、端口、站点、端点,一键导入并按公司范围归并,直接进入资产图供 agent 使用。

这一步的产品意义大于技术意义:它把「资产收集」和「自主测试」解耦了。你不需要让 ARTEX 从零开始扫全网,它可以只做「拿到一批资产后,自主决定怎么打」。

Q10覆盖度的分子为什么用「被 fact 锚定过的资产」而不是「被任意节点锚定过的资产」?
这是「计划 vs 事实」的区分。意图锚定的资产只是被列入了计划,可能因为依赖没满足而根本没跑;只有 worker 写回了 fact,才证明这条资产被真正观察过。
12

可靠性与运维:让系统能跑一整夜

LLM 熔断、任务排队、超时收尾、归档、自更新

12.1 LLM 池:一条链 + 一个全进程熔断器

自主系统最怕的不是漏洞打不出来,而是跑到一半模型没额度了。ARTEX 的做法是把多个 LLM 配置串成一条降级链:

Rank = RankActive 当前激活配置(链头) 永远排在所有优先级之前 优先级 100 同 Rank 轮流领跑 (负载摊平) 优先级 50 窗口装不下请求 → 跳过 而不是让它失败 优先级 10 / 备用 全部失败 → ErrExhausted 任务进入可诊断的失败态 进程级熔断器 Registry(跨任务共享,重启后从 DB 恢复) 确定性失败 没额度 / key 失效 / 账号封禁 第一次就跳闸 重试没有意义,只会浪费时间 瞬时失败 429 / 5xx / 网络抖动 连续 3 次才跳闸 给限流一点恢复空间 冷却阶梯 第 1 次跳闸冷却 1 分钟 第 2 次 5 分钟 之后 30 分钟 一次成功即重置 Fails
图 12-1 降级链 + 熔断器:一个任务发现某配置没额度,所有任务立刻都跳过它

三个值得学的点:

POINT 1

熔断器是进程级的

Registry 比 Pool 活得久。重建池(改配置、保存无关设置)不会清空「哪些后端坏了」的知识。

POINT 2

窗口不够就跳过

某个配置的上下文窗口装不下这次请求,直接跳到下一个,而不是发过去等它 400。这叫「路由前的可行性检查」。

POINT 3

同 Rank 轮流领跑

两个配置优先级相同,用原子计数器轮转起点,避免总是打同一个账号。

llmrec:记录「线上的原始字节」

常规的 LLM 调用日志记的是规范化后的请求对象——但排查问题时,你需要的往往是「实际发出去的 JSON 长什么样」。所以系统在 HTTP transport 层挂了捕获器:

llmrec/capture.go
// This exists because everything the Recorder itself sees is already normalized:
// llm.CompletionRequest is re-serialized rather than the body buildBody() sent, and
// the response arrives as decoded StreamEvents, not the SSE frames. For debugging a
// live provider, the bytes on the wire are the only ground truth.
//
// norma's doStream retries the request-establishment phase, so one Stream can issue
// several HTTP attempts. Each attempt is kept: the discarded ones … are exactly what
// makes rate-limit and gateway failures diagnosable.

注意「每次重试都保留」这个细节:被丢弃的失败尝试,才是诊断限流和网关问题的关键证据。只记最后一次成功请求,等于把故障现场擦干净了。

12.2 任务级并发:上限与排队

同时跑 20 个任务,每个 3 个 worker,就是 60 个 LLM 并发——大多数账号扛不住。所以系统有一个任务级并发上限:

概念行为
并发上限全局设置,限制同时运行的任务数(不是 worker 数)
排队超限的新任务标记 queued=true,按 queued_at(纳秒级,保证同秒内 FIFO)排序
补位Scheduler 每 5 秒 tick 一次,有空位就启动队首任务
串行化concMu 保护「数空位 → 提升任务」这段逻辑,避免调度 tick 和建任务请求同时数到同一个空位

最后一条是个典型的并发陷阱:两个 goroutine 都读到「还有 1 个空位」,于是各启动一个任务,超限了。解法不是加锁保护计数,而是把「检查 + 占用」串行化。

12.3 任务级超时:到点不是杀死,是收尾

建单时可以设 timeout_seconds。到点后的处理顺序很讲究:

  1. 停止发新活

    worker 循环看到 isSettling 就不再领新意图;planner 的普通唤醒被丢弃(收尾轮由协调器直接驱动)。

  2. 给在跑的 run 注入收尾词

    通过 ctx 携带的 deadline 夹逼 MaxDuration,让每个 run 自然进入结算回合,把已识别的写回。

  3. 等 drain

    协调器等待在跑的 worker 结束(有上限),不是无限等。

  4. 落终态

    用 SetTerminalStatusGuarded 做 CAS,避免和用户手动暂停 / 删除打架。

为什么不直接 cancel

直接取消会让 worker 手上一堆「已经看到但还没写回」的发现全部丢失——等于白跑。收尾设计的核心思想是:预算耗尽不是异常,是正常的生命周期终点,必须给它一个体面的结束流程。

12.4 归档:把任务打包成可搬运的证据

任务跑完不能一直占着数据库。归档会把任务、探索图、资产关联、活动记录、LLM 记录、旁路问答打包成一个压缩包(当前格式版本 3,兼容 v1/v2)。

设计点原因
状态机 archive_queued → archiving → ready / archive_failed归档是耗时操作,必须异步、可重试、可观测,不能阻塞 HTTP 请求
前置条件:任务必须 paused 或终态归档一个正在跑的任务会撕裂数据一致性
不能有活着的继承任务依赖它继承任务会读源任务的探索历史;源被归档后引用会断
恢复也是队列化的恢复要重建 exploration / nodes / edges / 外键,同样耗时且可能失败
格式带版本号老归档要能被新版本读;新字段要能优雅降级

12.5 自更新:三次启动的原子换装

这是整个项目里最「运维」的一段代码,也是最值得学的工程范式:把易错逻辑放进强类型语言,把守护循环留给脚本。

启动脚本 = 傻瓜守护循环(只看退出码) ① 旧版本运行中 收到 /api/update/apply 下载 → 校验 SHA256 → 暂存 artex.new exit 75(请重启我) ② 脚本拉起旧版 Bootstrap 发现 artex.new 再校验 + 跑 -h 冒烟测试 原子换装 → 旧版留作 artex.old ③ 新版启动 计数 +1,写入 artex.upgrade.json 活过 settleDelay → 清除标记 连续 3 次起不来 → 自动回滚 失败不会留下坏程序:校验/冒烟不过就丢弃暂存件继续跑旧版;换装后连崩 3 次自动回滚到 artex.old(失败的留作 artex.failed 供排查)。 开发构建不给更新:版本号是 dev 或 git describe 带后缀时禁用,避免正式版覆盖本地调试二进制。
图 12-2 三次启动完成一次升级;每一步都可回退
为什么换装逻辑不放脚本里

源码注释写得很实在:「sha256 校验和冒烟测试在 sh 和 bat 上要写两套(sha256sum / shasum / certutil),而这恰恰是最不能出错的一环——换上一个跑不起来的二进制,守护进程会忠实地反复拉起它,用户只能上机器手工救。」

这就是职责划分的依据:脚本只做它擅长的事(进程退出后按退出码决定是否重启),所有分支逻辑交给 Go。跨平台的哈希算法差异、状态机、回滚,都是脚本的噩梦。

12.6 通知:叶子包 + 拨号守卫

漏洞发现后要推送到钉钉/飞书/企微/Webhook/Telegram/邮件。这个子系统被刻意做成不依赖数据库、不依赖 server 的叶子包:

notify/notify.go · 包注释
// 分层:本包是**叶子包**,只依赖标准库。它不认识数据库、不认识 server。渠道配置
// 以 map[string]any 传入(对应 notification_channels.config 这一 JSONB 列),
// 待推送内容以 Message 传入。这样拆开的好处是:签名计算、UTF-8 截断、过滤匹配这些
// 真正容易出错的地方可以脱离 PostgreSQL 单测,宿主只需在 server 侧做编排。
//
// 并发约定:Channel 的实现必须**无状态**。同一个 Channel 实例会被多个渠道配置
// (甚至同一渠道的多个机器人实例)并发复用,所有凭据一律从 cfg 参数传入。

「无状态」这条约束很关键:钉钉的加签、飞书的 timestamp+sign,如果实现里缓存了某个 webhook 的 secret,多个机器人实例并发复用就会串号。所有凭据从参数传入,实例就天然可复用。

SSRF 防护:在拨号那一刻检查

通知目标 URL 是用户填的,天然是一个 SSRF 入口。系统没有只在保存配置时校验,而是在 http.Transport 的 DialContext.Control 钩子里检查——因为那才是最终生效点,能同时挡住 DNS 重绑定和同主机重定向。

notify/http.go
// isBlockedDialIP 报告目标 IP 是否属于「默认不允许投递」的地址段。
//
// 只拒绝环回、链路本地(含云元数据 169.254.169.254)、未指定与组播。
// **不拒绝** RFC1918 私网:内网自建 Mattermost / SMTP 中继是很常见的合法用法,
// 把它们一并挡掉会让功能在真实环境里直接不可用。这条取舍是刻意的——
// 防护要挡住真正敏感的目标,同时不能把正常部署一起废掉。
这是一个「安全 vs 可用」的教科书式取舍

很多系统会一刀切禁掉所有私网地址,结果内网部署全部报废。ARTEX 选择只挡真正危险的(环回、链路本地/云元数据、组播),保留私网,并提供一个显式环境变量 ARTEX_NOTIFY_ALLOW_LOCAL=1 用于本地测试。防护的精度比强度更重要。

12.7 可观测性

三个入口,覆盖三种排障场景:

入口数据源适合看什么
/api/logs/stream内存日志环 + SSE实时排障:刚才那次规划为什么失败
活动流 /api/exploration/activity/streamactivity 表 + 广播器任务执行过程:每一步工具调用、token 消耗、审批请求
LLM 记录页llm_records(原始报文)模型侧问题:请求体、SSE 帧、被丢弃的重试
Q11LLM 熔断器为什么把「没额度 / key 失效」和「429 / 5xx」区别对待?
这是「失败语义」的设计:确定性失败和瞬时失败要用不同的重试策略。把两者混为一谈,要么浪费大量时间重试死账号,要么因为一次抖动就禁用了一个健康的配置。
13

Go 工程手法:这套代码值得抄的十二个习惯

并发生命周期、上下文分类、幂等迁移、降级设计

即使你完全不关心渗透测试,这个项目的 Go 代码本身就是一份很好的「长任务系统」参考实现。下面这些手法在任何一个后台服务里都用得上。

13.1 并发:任务是一组有名字的 goroutine

每个任务启动 1 + N 个 goroutine,全部登记在一个 taskRuntime 里,统一用 runTaskRoutine 包裹。这样做的目的不是「优雅」,而是可观测与可停止:

server/engine.go · Run
rt := e.registerTaskRoutines(ctx, t.ID, 1+workers)
e.touch(t.ID)
runTaskRoutine(rt, func(loopCtx context.Context) { e.plannerLoop(loopCtx, t) })
for i := 0; i < workers; i++ {
    name := fmt.Sprintf("work#%d", i+1)
    runTaskRoutine(rt, func(loopCtx context.Context) { e.workerLoop(loopCtx, t, name) })
}
e.startDeadlineCoordinator(ctx, t)

为什么 worker 用 work#1..N 这样的固定槽位名,而 transcript 用 exp{id}-worker-i{intentID}?因为槽位是可复用的,意图不是。用槽位名做会话 ID,第二条意图会读到第一条的对话历史——这是一个非常隐蔽的 bug,源码注释专门点出来了。

13.2 context:四种取消原因必须分开

整个系统里,一个 run 可能因为四种完全不同的原因被取消。如果都当成「取消」,后续处理会全错:

原因触发正确处置
pause用户暂停任务取消当前 run,把它重排队;恢复后继续
killplanner 杀掉单条意图 / 用户删除只终止这一条,标记 stopped,不重排
goal_met目标已完成,任务终局取消所有在跑 worker,归为 stopped 而非 blocked
shutdown进程退出尽量写回收尾,标记中断,重启后可恢复

实现上用 context.WithCancelCause 而不是普通 cancel——context.Cause(ctx) 能取回具体原因,引擎据此决定「重排队、标 stopped、还是标 blocked」。这就是 agent/cancelcause.go 存在的意义。

13.3 状态机的 CAS:谁先写谁赢

任务终态是竞争最激烈的地方:planner 判完成、用户点暂停、超时协调器落 timeout,可能同时发生。系统用「带条件的 UPDATE」做仲裁:

db/tasks.go
// SetTerminalStatusGuarded sets a terminal status only when the task is NOT already
// in a terminal state. Returns won=false when another terminal status already stuck —
// the caller then leaves it alone.
UPDATE tasks SET status=$1, completed_at=…
WHERE id=$2 AND status NOT IN ('done','failed','timeout')

这个模式在整个项目里反复出现:意图状态转换有 ErrIntentStateConflict、归档有 Archive...Failed 状态、审批有 pending 的原子解析。不要用「先读再判断再写」,那是竞态的温床。

13.4 生命周期锁:不要直接读写共享字段

Task 结构体有很多会被并发修改的字段(status、paused、deadline…)。代码里有一条明确约定:

server/manager.go
// taskLifecycleState is an internally consistent view of the mutable task
// lifecycle and inherited-scope context. Callers must use lifecycleSnapshot and
// updateLifecycle instead of reading or writing the corresponding Task fields
// directly after the task has been published by Manager.

做法是把可变字段打包成一个快照结构,读的时候一次性拷贝出来(lifecycleSnapshot()),写的时候在锁里改(updateLifecycle(func(*state)))。好处是:

  • 不会读到「status 已改成 done 但 completed_at 还是 0」这种撕裂状态;
  • 调用方拿到的是一份不会变的副本,不用一直持锁;
  • 所有写路径集中在一处,容易审计。

13.5 幂等迁移:「重启即迁移」

没有 migration 工具、没有版本号表,只有一个每次启动都全量跑的 schema.sql。它必须满足一个强条件:跑 1 次和跑 100 次结果一样。

db/schema.sql · 约束升级的写法
DO $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM pg_constraint
        WHERE conrelid='exploration_nodes'::regclass
          AND conname='ck_node_kind'
          AND pg_get_constraintdef(oid) NOT LIKE '%digest%'
    ) THEN
        ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_kind;
        ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_kind
            CHECK (kind IN ('begin','goal','intent','fact','finding','hint','digest'));
    END IF;
END $$;

这段 SQL 解决的是 CHECK 约束的「只能新建、不能修改」问题:先查现有约束定义里有没有新值,没有才重建。比「无脑 DROP 再 ADD」安全(后者在并发启动时可能互相踩),也比「要求用户手动执行迁移」友好。

幂等迁移的代价

每次启动都要跑几百行 DDL,启动会慢一点;约束越来越复杂,DO 块会越写越长。换来的是升级零操作——用户只需要换二进制,不需要懂 psql。对一个要分发给非运维人员使用的工具,这个取舍非常值。

13.6 脏数据防御:两行代码挡住两类线上事故

渗透工具的输出是「野生字节」:nmap 的乱码、二进制响应、截断的 UTF-8。直接写 PG 会失败,而且是静默失败(activity 记录悄悄丢了)。所以每个字符串入库前都过一遍:

db/exploration.go
// utf8Clean: 替换非法 UTF-8 为 U+FFFD,并去掉 NUL (0x00) 字节。
// PG 的 UTF8 编码拒绝非法字节序列("invalid byte sequence"),
// 而 NUL 是合法 UTF-8(U+0000)却被 text 列拒绝(SQLSTATE 22021)——所以要单独删。
// jsonbClean: json.Marshal 会把 NUL 编码成 \u0000,json 类型能存,jsonb 会拒绝(22P05)。

这段注释的信息量很大:两类列、两种失败码、两个不同的清洗函数。而且 jsonbClean 还要判断反斜杠的奇偶——\\u0000 是字面文本,不能删。这种「看起来只是 trim 一下」的代码,背后是对 PG 编码规则的精确理解。

13.7 nil 安全与 typed-nil

Agent 可能在「没有资产库」「没有流量」「没有 LLM」的情况下被构造(测试、编排、降级)。所以大量可选依赖用 nil 表示「该能力不可用」,且每个工具内部自己 guard:

agent/tools.go / worker.go
if t.cs == nil { return actool.Errorf("list_companies 未启用: CompanyStore 未初始化"), nil }

// typed-nil guard: only set when concrete (avoids harness panic)
if hooks != nil { opts.Hooks = hooks }

第二行是个 Go 的经典坑:把一个 (*Guard)(nil) 赋给 interface 类型的 Hooks,interface 本身不为 nil,调用方法时就会 panic。所以必须先判具体类型再赋。

13.8 有界与冷却:所有旁路都要有闸

旁路任务(enrich、通知、归档)如果无限制地堆积,会把主流程拖死。项目里所有旁路都有三道闸:

BOUND

有界队列

enrich 队列 1024,满了就丢。丢一条补全不影响正确性。

COOLDOWN

冷却窗口

同一资产 5 分钟内不重复解析;同一任务 60 秒内不重复折叠。

DEDUP

去重 key

sync.Map 记 kind:id 的最后执行时间,天然幂等。

13.9 降级而不是崩溃

系统里到处是「做不到就退一步」的代码,而不是「做不到就报错」:

场景降级行为
SQLite 驱动没有 FTS5降级为「无全文搜索」,录制功能照常
用户 prompt 模板渲染失败回退内置默认模板 → 再回退原始字符串
LLM 未配置引擎 idle,不报错、不空转;配置后自动开始
资产库未初始化资产类工具返回「未启用」,其它工具照常
单个工具 panic转成普通工具错误,进程继续
活动写入违反外键丢弃并计数,日志里聚合显示丢弃量而非刷屏
server/engine.go · 丢弃计数
// bumpDrop increments and returns the running count of dropped (unpersistable)
// activity records for a task. Concurrent planner + worker emits race here, so the
// counter is an atomic behind sync.Map. The count in the log shows loss scale at a
// glance instead of forcing a grep-and-count.

注意这里的设计意图:允许丢,但必须知道丢了多少。比起让一条脏 activity 把整个任务写崩,丢掉它并计数是更好的选择。

13.10 叶子包与依赖方向

依赖图是单向的,叶子包不反向依赖:

包依赖方向(简化)
cmd/artex
   └── server ──┬── agent ──┬── db
                │           └── intercept
                ├── guard ────── intercept
                ├── traffic ──── db
                ├── llmpool ──── (norma/llm)
                ├── llmrec ───── (标准库)
                ├── notify ───── (只用标准库)
                ├── evidence ─── db + traffic
                ├── report ───── db
                └── selfupdate ─ (标准库)

notify 和 selfupdate 都是纯标准库实现。这不是洁癖,而是让最容易出错的部分(签名、哈希、换装)可以脱离数据库跑单测。

13.11 测试策略:表驱动 + 真实 PG

153 个测试文件,覆盖密度很高。几个特点:

  • db 包用真实 PostgreSQL(testmain_test.go 起库、跑 schema),因为要测的是 SQL 语义,mock 没意义;
  • 纯算法抽成无依赖文件(coldgraph.go 不 import db / llm),可以纯单测冷热判定与分组;
  • 竞态与生命周期专项测试:company_lock_test.go、task_delete_concurrency_test.go、task_context_lock_test.go——这些都是实际出过 bug 的地方;
  • 端到端捕获测试:provider_capture_e2e_test.go 验证真实 HTTP 报文捕获链路。

13.12 注释:记录「为什么」,不记录「是什么」

这个项目的注释密度极高,但几乎不解释代码在做什么,而是解释为什么这样、不这样会怎样:

反例(没价值)

「遍历 rules,匹配则返回」

读代码就知道,注释纯冗余。

正例(本项目)

「不能用 Frontier 只数 open:worker 领取与本检查存在竞态,会误 kick 首轮」

记录了一个踩过的坑,防止后人「优化」回去。

这些注释的价值在于:它们把设计决策的上下文固化了。半年后有人想简化代码,会先看到「这里为什么不能简化」。

Q12为什么 worker 的 transcript 会话 ID 用 intentID 而不是 work#N 槽位名?
这是「标识符选择」的经典陷阱:槽位标识的是资源(第几个 worker),不是工作单元(哪条意图)。会话历史属于工作单元,所以键必须用 intentID。源码里专门写了这个注释。
14

一次任务的一生

从建单到报告,十二个阶段与它们对应的代码

把前面所有章节串起来,看一个任务从创建到归档会经过什么。每一步都标注了「谁做的」和「写进了哪张表」。

  1. ① 建单(POST /api/tasks)

    写入 tasks 行 + 新建 explorations 行(这次任务的探索空间)+ 写入 begin 根节点。如果设了并发上限且已满,任务标记 queued 排队。
    代码:Manager.CreateTaskWithOptions / db.CreateTask

  2. ② 目标分解(goals agent,只跑一次)

    LLM 读「目标 + 描述」,产出:操作约束(task_constraints)、测试范围(task_scope)、目标节点(exploration_nodes, kind=goal)。
    如果 LLM 不可用,回退到规则拆分,保证 goal 节点一定存在。

  3. ③ 启动引擎(Engine.Run)

    起 1 个 plannerLoop + N 个 workerLoop,启动 deadline 协调器。如果已有 open/running 意图(种子意图),跳过首轮规划;否则踢第一轮。
    关键判断:HasActiveIntent()(open + running),不是只数 open。

  4. ④ 第一轮规划

    planner 读到「图几乎是空的」,于是根据 goal 派 2–4 条初始意图(侦察、指纹、目录、端口…),带 asset_ids 锚定到范围内资产。
    产物:intent 节点(state=open)+ spawns 边。

  5. ⑤ worker 领取

    空闲 worker 轮询 frontier,用条件 UPDATE 抢到一条意图,状态变 running,owner 记成 work#2。构造 system prompt(含意图 + 目标资产原始数据),Resume 该意图的历史 transcript(如有)。

  6. ⑥ 执行与留痕

    每一轮工具调用:PreToolUse 过审批门 → 执行 → PostToolUse 归因 → 写 activity。HTTP 流量进 MITM 代理,落文件树 + SQLite 索引。
    每个工具结果都可能触发一手写回:insert_assets / record_fact / report_finding。

  7. ⑦ 图变更 → 唤醒 planner

    worker 的写回触发 t.Notify();引擎 800ms debounce 后跑下一轮规划。planner 看到本轮触发事件(done / finding),判断要不要补方向。
    这是闭环的核心:写回本身就是下一轮的输入。

  8. ⑧ 串行链推进(可能多轮)

    如果存在依赖链,planner 用共享 todo 记录整条链,每轮只派「前置已满足」的一步。新事实出现 → 下一轮派下一步。
    期间可能有人插话:steer_work 纠偏、add_hint 加提示、add_intent 直投。

  9. ⑨ 目标证明

    某条 finding/fact 足以证明 goal 时,planner 调 prove_goal(goal_id, evidence_id, reason),写入 proves 边,goal 状态变 met。
    当最后一个 open goal 被标 met → 系统判定任务完成 → 取消在跑 worker。

  10. ⑩ 收尾与终态

    任务状态落 done / failed / timeout(CAS 保证只有一个赢家)。在跑的 worker 进入结算回合,把已识别的写回,最后输出一句总结。

  11. ⑪ 报告与复测

    report 包从探索图渲染 Markdown:漏洞列表(含严重级、证据、PoC)+ 资产统计。漏洞可发起复测——独立的 retester agent 会话,结论三选一(仍可复现 / 已修复 / 无法确认)。
    复测成功且判「已修复」时,自动把漏洞处置状态改为已修复。

  12. ⑫ 归档

    任务暂停或终态后,可归档成压缩包(格式 v3):任务、探索图、活动、LLM 记录、旁路问答。归档后从主库移除,需要时再恢复。

14.1 一张时序图看关键交互

人 / UI goals planner frontier worker 审批门 双图 + 流量 建单:目标 + 描述 写 constraints / task_scope / goal 节点 读态势(overview 预取) 判目标 / 比对已有意图 add_intent ×2~4 claim(CAS) PreToolUse allow / deny / ask→人工 执行 + 写 activity + 抓流量 写回 fact / asset / finding 图变更 → notify(debounce 800ms) 下一轮:补方向 / prove_goal 目标达成 → 活动流 / 报告 / 通知
图 14-1 一次任务的关键交互:注意「写回」同时是输出和下一轮的输入

14.2 如果中途出问题,会发生什么

故障系统的反应最终状态
LLM 额度耗尽熔断该配置,降级到链上下一个;全挂则任务停在这里任务保持 running,配置恢复后继续
worker 卡死 / 死循环步数上限触发结算;心跳轮 planner 可以用 kill_work 止损意图 exhausted,不会被无限占用
进程崩溃重启后从 PG 恢复:open 意图仍在队列,running 意图会被重置或重跑任务可继续,不丢图
数据库连接断activity 写入失败被丢弃并计数;核心写回报错由 agent 看到并重试可能丢过程流水,但结论不丢
人暂停任务取消 exec ctx,worker 停下,planner 不再规划paused;恢复后继续
人删除任务先置 deleting 屏障,阻止新写入,再级联删除数据清理干净
「重启可恢复」不是免费的

它要求所有关键状态都在数据库里,而不是内存里。这就是为什么意图队列是表、状态转换是 SQL、todo 虽然是内存但可以从图重建。一旦某个关键状态只活在 goroutine 里,重启就等于丢任务。

15

设计取舍清单

每一个「这样做」,背后都有一个「不这样做」

学一个系统最好的方式不是记它做了什么,而是理解它在哪些岔路口选了哪条、放弃了什么。下面是这个项目里最重要的 14 个取舍。

决策放弃的替代方案为什么选它代价
双图分离一张图混装资产与探索真值要跨任务共享、过程要任务隔离,生命周期和写者都不同多一张锚点表、多一次 join、概念更多
单一意图生成者每个 worker 都能自主开辟方向并发写者各自决策必然重复与错序;收敛到 planner 才换来安全并行planner 成为瓶颈与单点;它一卡任务就停
worker 写完即停worker 自己连续推进多条意图保持上下文干净、职责单一、可并行、可被独立杀掉每次都要重新加载上下文(靠 system prompt + transcript 缓解)
意图队列放数据库内存 channel / 消息队列崩溃可恢复、CAS 天然并发安全、UI 可直接查每次领取一次 DB 往返;高并发下要调索引
跨唤醒共享 todo把规划状态也交给 LLM 记在对话里会话是新的,但 todo store 不是;串行链必须跨轮存活多一个内存结构;进程重启后丢失(但可从图重建)
worker 意图放 system prompt放首条 user 消息,享受 prompt 缓存意图绝不能丢;system 不会被 compaction 压掉system 混入易变数据,跨意图无法复用缓存
planner 态势放 user放 system,减少每轮拼接system 稳定才利于缓存;planner 单轮短,态势被压掉风险低每轮都要重新拼一段长 user 输入
三段式 prompt整段 prompt 都可编辑用户改正文时不可能误删流量说明和产物目录规约两处维护;用户看不到完整最终 prompt(需要文档解释)
图结构折叠(cold-digest)只靠对话摘要折叠的是知识结构不是文本;planner 仍能理解「这支队探索过什么」多一层后台 LLM 调用与并发正确性问题
幂等 schema 全量重跑版本化 migration 工具升级零操作,换二进制即迁移,适合分发给非运维用户启动变慢;约束升级要写复杂的 DO 块;无回滚
单二进制内嵌前端前后端分离部署一个文件即可运行;无 CORS、无跨域 SSE 配置改前端也要重编译;镜像体积变大
自更新放 Go 不放脚本shell / bat 里做下载校验换装哈希算法跨平台差异大,最不能出错的一环必须强类型 + 可测试更新逻辑与运行时代码耦合;要设计退出码协议
拦截规则存数据库危险命令硬编码在 Go 里不同团队风控尺度不同;策略与机制分离用户可能误删安全规则(靠内置标记 + 审计缓解)
通知包零依赖直接在 server 里实现各渠道签名、截断、过滤这些易错点可以脱离数据库单测配置要用 map[string]any 传递,类型安全性弱
一条隐含的主线

把这些取舍连起来看,会发现一条共同的判断标准:「什么东西丢了会致命」。意图丢了会跑偏 → 放 system;图丢了会重复劳动 → 落 PG;证据丢了报告就废 → 独立存储;状态丢了任务就废 → 全放数据库。反过来,缓存、token、启动速度这些都可以让步。先定不变量,再谈优化,这是这份代码最值得学的思维方式。

15.1 如果让我重做,哪些地方可以更好

客观地说,这套设计也有明显的成本和风险,读代码时应该带着批判:

RISK 1

planner 是单点

它一次只能想一件事,且每轮都是一次完整 LLM 调用。任务规模上去后,规划延迟会成为瓶颈。可能的改进:分层规划(战略层 / 战术层)或按分支并行 planner。

RISK 2

提示词即逻辑,难测试

大量行为约束写在 prompt 里,改一个字可能改变全局行为,但没有单元测试能覆盖。只能靠端到端跑分与人工评审。

RISK 3

图的规模没有上限

大任务可能产生上万节点,overview 再压缩也会变大。cold-digest 缓解了这一点,但它本身也是 LLM 调用,有成本。

RISK 4

单进程架构的天花板

所有任务共享一个进程、一条 LLM 链、一个资产库。横向扩展需要把 Engine 拆成独立 worker 服务,那是另一套架构了。

16

七天读码路线

按依赖顺序,每天一个子系统,每天一个产出

不要从 main.go 开始逐行读——这个项目的入口很薄,真正的结构在数据模型和引擎里。按下面的顺序,每一天都能独立跑通一小块。

DAY 1 · 数据模型

先看数据库,不看代码

读:db/schema.sql(重点 A/B 两段)、db/exploration.go 的 Node/Edge/ClaimIntent。

产出:手画一张 ER 图,标出 7 种节点、5 种边、3 张范围表的关系。

自检:为什么意图队列用 priority DESC, id ASC?为什么资产表每个类型都配了 partial unique index?

DAY 2 · 引擎

看一个任务怎么跑起来

读:server/engine.go 的 Run → plannerLoop → workerLoop → runIntent;配合 server/manager.go 的 Task 结构。

产出:画出 goroutine 生命周期图,标出所有退出路径(pause / kill / done / timeout / shutdown)。

自检:为什么首轮规划要用 HasActiveIntent 而不是 Frontier?

DAY 3 · Agent

读三个 system prompt

读:agent/planner.go、worker.go、goals.go、mainagent.go;重点看 plannerDefaultTmpl / workerDefaultTmpl 全文。

产出:列出每个角色的「红线清单」,对比哪些约束是重叠的、哪些是独有的。

自检:为什么 worker 不给 list_facts?为什么 goals 必须先登记范围再拆目标?

DAY 4 · 工具

做一张角色×工具矩阵

读:agent/tools.go(重点 PlannerTools)、tools_insert.go(WorkerTools / MainAgentTools)、assembly.go(三层装配)。

产出:把所有工具按「读/写/控制」分类,标出每个角色的可见性。

自检:延迟工具解决什么问题?guardPanic 为什么必须最后应用?

DAY 5 · 安全与流量

跟一次审批走到底

读:guard/guard.go、intercept/intercept.go(Match/Judge/HandleAsk/Decide)、traffic/traffic.go(record/Search)、evidence/store.go。

产出:画一条从 PreToolUse 到人工点击批准、再回到 worker 的完整调用链。

自检:为什么拦截文案要强调「平台管控」?为什么 SSRF 守卫在拨号时而不是保存时?

DAY 6 · 服务与前端

从页面追到 SQL

读:server/server.go 的路由表(655 行起)、server/dto.go、web/src/app/(main) 的页面结构。

产出:挑三个页面(仪表盘 / 任务详情 / 覆盖图),各追一条「页面 → API → store 方法 → SQL」的链路。

自检:SSE 为什么要关反代缓冲?为什么前端不需要配 API base?

DAY 7 · 运维与复刻

写一页你自己的设计

读:llmpool/pool.go + health.go、selfupdate/*.go、notify/http.go、db/task_archives.go。

产出:假设你要做一个「自主代码审计系统」,复用这套架构:写出你的双图节点类型、你的 planner 决策流程、你的工具白名单、你的证据模型。不需要写代码,一页纸即可。

验收:如果你能说清「哪些设计可以照搬、哪些必须改」,说明你真的学会了,而不是记住了。

16.1 读码时的三个技巧

技巧怎么做
先读注释,再读代码这个项目的文件头注释基本都写了「这个包为什么存在」。读完注释你往往不用逐行看实现。
跟着一次调用走,不要横向铺开比如「一条意图从被领取到写回」:workerLoop → runWorkerStep → runIntent → Worker.Execute → captureRunSession → ToolSet.recordFact → AppendActivity。走完一遍胜过读十个文件。
用测试当文档db/*_test.go 里有大量「构造 → 操作 → 断言」的用法示例,比读实现快。特别是并发相关的测试,直接告诉你哪些边界被考虑过。
17

术语表与文件地图

名词对齐,以及「想改什么该去哪个文件」

17.1 术语表

术语英文/代码含义
双图dual graph资产图(真值、全局共享)+ 探索图(过程、任务隔离)的总称
资产图asset graphassets 表:root_domain / subdomain / ip / service / app / endpoint
探索图exploration graphexploration_nodes + exploration_edges,一次任务的因果链
锚点anchorexploration_anchors:把探索节点钉到具体资产上,支撑覆盖度
意图intent一句话探索方向,由 planner 生成,worker 领取执行
事实fact探索结论,含否定结论;带 evidence 与 confidence
发现finding确认的漏洞,必须真实触发过、有可复现 PoC
提示hint人给的战略提示,挂在图上,planner 下一轮读到
摘要节点digest冷节点压缩产物,通过 covers 边覆盖成员
frontierfrontierstate=open 的意图集合,即待办队列
领取claim用条件 UPDATE 把意图从 open 改成 running 的原子操作
防抖debounce800ms 内合并多次图变更,只跑一轮规划
结算回合settlement预算耗尽时强制进入的收尾轮:禁止新动作,只写回
冷节点cold node连续 R 轮没有下游活动的节点,可被折叠
覆盖度coverage被 fact 锚定的范围内资产 / 范围内资产总数
操作约束constraintallow/deny 形式的行为边界,注入 system prompt
拦截规则intercept rule匹配工具名/参数,动作 allow/deny/ask
审批门guard / pending工具执行前的强制检查点,ask 会阻塞等人
记录代理recording proxyMITM 代理,把每个目标 HTTP 交换写进流量库
降级链LLM pool多个 LLM 配置按优先级串联,失败自动换下一个
熔断circuit breaker某配置持续失败就暂时跳过,冷却 1/5/30 分钟
旁路提问side question / btw用上下文快照回答临时问题,不污染主会话
归档archive把任务打包成压缩包并从主库移除,可恢复

17.2 文件地图:想改什么,去哪里

包 / 目录关键文件职责
cmd/artexmain.go启动流程:日志捕获 → 自更新 Bootstrap → Manager → Server → 监听
configconfig.go配置读取(config.json + 环境变量覆盖),路径推导
dbschema.sql / exploration.go / assets.go / task_scope.go / tasks.go所有 SQL 与数据模型;双图、资产、范围、任务、归档
agentplanner.go / worker.go / goals.go / mainagent.go / tools.go / prompt.go / compaction.go四种 Agent 的提示词与执行逻辑、工具集、上下文压缩
serverengine.go / manager.go / server.go / orchestration.go / scheduler.goHTTP API、引擎循环、任务生命周期、跨任务编排、触发器
guardguard.goPreToolUse / PostToolUse 钩子、审计、失败归因
interceptintercept.go / prompt.go规则匹配、LLM 裁判、审批等待与落库、审批详情上下文
traffictraffic.go / archive.goMITM 录制、文件树 + SQLite 索引、blob、查询与清理
evidencestore.go证据 blob 的落盘、校验、与流量的解耦
enrichenrich.goDNS 解析 / HTTP 探测的异步补全
llmpoolpool.go / health.goLLM 降级链与进程级熔断器
llmreccapture.go记录 LLM 请求的原始报文(含失败重试)
mcphttpclient.go远程 MCP 客户端(Streamable HTTP / 旧版 SSE)
notifynotify.go / http.go / sign_test.go六种通知渠道、签名、过滤、脱敏、SSRF 拨号守卫
reportreport.go / findings.go从探索图渲染 Markdown 报告
selfupdateselfupdate.go / bootstrap.go / stage.go下载 / 校验 / 暂存 / 换装 / 回滚
sidequestioncapture.go / service.go / context.go/btw 旁路提问:快照、独立历史、上下文预算
web/srcapp/(main)/* / components/*Next.js 前端:仪表盘、任务、资产、覆盖图、流量、审批、系统配置
skills—可加载的技能包(Markdown + 脚本),按 agent 可见性挂载

17.3 常用命令

开发与运行
# 本地开发:后端 :8787 + 流量代理 :8788 + 前端 :5173
./dev.sh

# 只跑后端(不内嵌前端)
go run ./cmd/artex

# 全量测试
go test ./...

# 构建单二进制(需要先把前端导出到 server/webui/dist)
cd web && npm ci && npm run build:static && cd ..
cp -r web/out server/webui/dist
CGO_ENABLED=0 go build -tags embedui -o artex ./cmd/artex

# 跨平台发布包
./build.sh --release
18

综合自检

六道题,覆盖整套设计;答对四道以上说明你真的读懂了
Q13「资产图全局共享,探索图每任务隔离」——这条不变量主要保护了什么?
真值与过程的生命周期完全不同:任务会归档、会被删,公司域名不会。混在一起就会出现「删任务把资产删了」或「任务 B 以为资产不存在」这类错误。
Q14一个 worker 跑完意图后状态是 exhausted,planner 下一步最合理的动作是什么?
exhausted 的语义是「探到一半被掐断,信息不全」,不是「这条路走不通」。原样重派会重复劳动;直接放弃会丢掉已经接近的成果。正确做法是看过程再决定。
Q15为什么「意图」放在数据库里当队列,而「activity」只是扁平流水?
图是给决策用的,流水是给回放用的。如果把几万步工具调用都建成节点,planner 的 overview 会彻底不可读——这就是「什么该进图」的判断标准。
Q16下面哪一项不是 cold-digest 的设计目标?
折叠本身要花 LLM 调用(后台生成摘要),它的目标是让图可读、让上下文可持续,不是省钱。真正的省钱手段是 debounce 和 planner 派 0 意图。
Q17如果一个任务的所有目标都被人删除了,系统会怎么处理?
这是「人工直投」场景:人可能只想让系统跑几条具体意图,不要目标。此时 planner 继续跑反而危险——它会因为没有目标而误判完成,把正在跑的直投意图全取消。
Q18如果让你把这套架构迁移到「自主代码审计」,哪一部分最不需要改?
这是这份文档最想让你带走的东西:资产模型和工具是领域相关的,可以换;但「真值与过程分离 + 单点规划 + 无状态执行 + 写回驱动」这套控制结构是通用的。理解了它,你就能设计自己的长任务自治系统。

18.1 最后一页:这套系统的三句话

ONE

把真值和过程分开

资产图回答「世界有什么」,探索图回答「我们试过什么」。锚点把两者缝在一起,于是既能看全局,也能回溯因果。

TWO

把决策和执行分开

唯一的规划者读图产意图,无状态的执行者领一条干一条。并发因此变得安全,血缘因此变得清晰。

THREE

把变化变成驱动力

没有中央调度器。写回改变图,图的变化唤醒规划者,规划者再派活——系统靠自身的输出推动自己前进。

读完之后

如果你要基于这个项目做二次开发,建议从 Day 4(工具集) 入手——加一个工具是最安全、最能立刻看到效果的改动。如果你想改行为,去改 prompt(段 A),但改完一定要跑一遍真实任务,因为提示词没有单元测试。如果你想改架构,先回到第 3 章,确认你的改动没有破坏那几条不变量。

ARTEX 设计思路全书
基于仓库当前代码整理 · 生成于 2026-11-08 · 仅用于学习与研究
17 packages · 327 go files · ~63k LOC