ARTEX 设计思路全书
从零读懂一套 LLM 多 Agent 自主渗透系统
这不是一份 API 文档,而是一次完整的「设计复盘」:为什么要把系统拆成两张图、为什么规划者必须是唯一出主意的人、 为什么执行者写完就得停、为什么审批门要给模型解释「这不是目标的防御」。读完你应当能从空白文件开始,复述出这套系统的每一处关键决策。
怎么读这本书
这份文档假设你没有读过一行源码,但会写 Go、知道什么是 HTTP 和数据库。所有概念都从「为什么需要它」讲起,再落到「代码在哪里、怎么实现的」。
只想看懂设计
读 01 → 03 → 04 → 05 → 15。这五章覆盖了系统 80% 的独创性,其余是工程实现细节。
要改代码 / Fork
全读,重点 06 工具集、07 提示词、09 审批、13 Go 手法。最后用 16 章的路线把关键文件过一遍。
想复刻同类系统
按 16 章七天路线走,每天一个子系统,配合每章末尾的自检题,最后做 18 章综合题。
文中 等宽字体 是代码标识符或文件名;§ 开头的引用对应源码注释里的设计文档编号;灰色折叠块是「再深一层」的补充,第一遍可以跳过。
ARTEX 是授权环境下的自主渗透测试系统,README 明确限定「仅供个人学习、代码研究与本地隔离环境验证」。这份文档讲的是它的软件设计——多 Agent 协作、图结构推理、人机协同——这些模式同样适用于代码审计、应急响应、自动化运维等任何长任务自治场景。请勿把系统用于未授权目标。
心智模型:它到底在解决什么问题
1.1 一个 LLM 单 Agent 走不完全程
如果你让一个 LLM 直接做渗透测试,它会遇到四个结构性障碍,而且都不是「模型不够聪明」能解决的:
| 障碍 | 具体表现 | 根因 |
|---|---|---|
| 上下文爆炸 | 跑 30 分钟、几百次工具调用后,早期关键发现被挤出窗口,模型开始重复劳动 | 单会话线性累积,没有外部记忆结构 |
| 目标漂移 | 本来要打 A 系统,探着探着去挖 B 接口的细节,最后忘了最初要证明什么 | 没有独立的「目标判定」角色,执行者自己兼裁判 |
| 无法并行 | 一条路走死才发现另一条更优,时间全浪费在串行试错 | 单线程思维,没有任务分解与调度层 |
| 结论不可审计 | 报告说「存在 SQL 注入」,但拿不出请求响应,也没法复现 | 过程与结论混在对话里,没有结构化证据链 |
ARTEX 的答案不是「换个更强的模型」,而是把一个人的工作拆成一个组织的分工:有人看图、有人定方向、有人动手、有人记录、有人把关。
1.2 一句话说清架构
把「目标是什么」和「测到什么程度」拆成两张图;让唯一的规划者读图产意图,让无状态的执行者领一条意图、干完、写回图、然后停下;图的每一次变化都重新唤醒规划者。
这个闭环里有两个关键角色和两个关键约束:
规划者:唯一的「出主意的人」
它不执行任何攻击动作。职责只有三件:读态势、判目标是否达成、把尚未被覆盖的新方向写成意图丢进队列。它是系统里唯一能生成意图的角色。
执行者:只做一条意图的「临时工」
领到一条意图,用真实工具(Bash / HTTP / Kali 工具链)把它做完,把新资产、事实、漏洞写回图,然后立即停止。它不被允许自己开辟新方向。
如果允许多个 Agent 同时「想下一步」,它们会重复派活、互相覆盖、把同一条路走三遍。把意图生成收敛到单点,才让并发执行成为可能——这是整个系统最重要的一次权力集中。
1.3 与传统扫描器的本质区别
| 维度 | 传统扫描器 | ARTEX |
|---|---|---|
| 驱动方式 | 预定义插件 / POC 列表,命中即报 | 目标驱动:为「证明目标达成」动态生成探索方向 |
| 状态载体 | 内存里的队列 + 结果列表 | 持久化的双图(资产真值 + 探索过程),可中断可恢复 |
| 失败处理 | 报错跳过,进入下一条 | 把「否定结论」也记为事实,规划者据此换路线;同一否定方向允许复核一次 |
| 攻击链 | 几乎不支持多步依赖 | 共享 todolist 支撑串行链:注入 → 取凭据 → 横向 → 提权,按前置逐步派发 |
| 人的位置 | 开跑前配置,跑完看报告 | 全程在环:随时加目标、加提示、纠偏、杀意图、审批危险动作 |
| 结论形态 | 漏洞条目 | 漏洞 + 血缘(由哪条意图、哪些事实推导而来)+ 流量证据 + 复测记录 |
1.4 用一张「作战指挥部」类比记住全系统
资产图 = 地图
全公司共享的一张真值地图:域名、子域、IP、服务、应用、端点。谁都能看,程序负责测绘。
探索图 = 作战日志
每个任务一本:目标、派出去的方向、带回来的情报、确认的战果。只属于这一仗。
Planner = 参谋
盯着日志和地图,判断「还要不要打、往哪打」,写完作战指令就退场。
Worker = 突击小组
领一条指令出门,打完把情报交回,不擅自改变作战目标。
再补三个角色:Goals 分解器是开战前的参谋部,把「拿下这个站」拆成可验收的目标并划清行动边界;Main Agent 是司令部的热线,人通过它下指令;Guard / Intercept 是宪兵,每个动作出门前都要过它。
整体架构:六层职责与单二进制的取舍
2.1 分层全景
| 层 | 职责 | 关键设计点 |
|---|---|---|
| 前端 | Next.js 静态导出,可视化任务 / 资产 / 探索链路 / 覆盖图,人在环路对话 | 构建期 output: export,产物被 go:embed all:webui/dist 打进二进制;只有 -tags embedui 才内嵌 |
| server | net/http 路由 + JWT + SSE;Manager 托管任务、引擎、Store 生命周期 | 单进程多任务:每任务一组 goroutine,任务间通过全局资产图与 LLM 池耦合 |
| engine | 每任务一个 plannerLoop + N 个 workerLoop;意图领取、超时、暂停、排空 | 事件驱动 + debounce;所有状态机转换在数据库里用条件 UPDATE 做 CAS |
| agent | goals / 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)。这带来三个连锁决策:
SSE 必须同源
前端和 API 同端口,所以实时活动流不需要配 NEXT_PUBLIC_SSE_BASE,公网只开 443 反代即可。
反代必须关缓冲
SSE 是长连接持续推送,Nginx 不关 proxy_buffering 会出现「能连上但收不到事件」——页面一直转圈。
自更新必须自己做
没有包管理器、没有容器编排,升级只能由程序下载新二进制、校验、换装、失败回滚。
单二进制意味着前端改动也要重新编译;-tags embedui 不是默认开启,本地开发时前端走 5173 端口的 dev server,通过 /api 反代到后端。这个取舍换来的是「拷贝一个文件就能跑」的部署体验。
2.3 进程内的真实拓扑
理解 ARTEX 的并发,关键是记住这张图:任务不是线程,而是一组 goroutine + 一个数据库里的 exploration。
看 ARTEX 代码时,先问自己「这个变量属于进程 / 任务 / 单次 run 哪一级」。比如 Manager 是进程级,Task.Guard 是任务级,ToolSet 是单次 run 级——三级生命周期搞混,就会误判锁的粒度和状态归属。
双图架构:整套系统的心脏
如果把 ARTEX 的其他所有设计都删掉、只留一个,那就是双图。理解了它,引擎、工具集、覆盖度、报告全都是它的自然推论。
3.1 为什么不能只有一张图
直觉上「把资产和探索都塞进一张图」更简单。但这两类信息在四个维度上根本冲突:
| 维度 | 资产(真值) | 探索(过程) |
|---|---|---|
| 作用域 | 全局共享:所有任务看同一份 | 每任务隔离:一次任务的思考过程 |
| 写者 | 所有任务 + 资产同步 + 异步补全 + 人工 | 只有本任务的 planner / worker |
| 语义 | 「世界上存在什么」——可被反复观察、更新、去重 | 「我们试过什么、得出什么」——只增不改,是历史 |
| 生命周期 | 永久,跨任务复用 | 随任务归档 / 删除而消失 |
混在一起会立刻出问题:任务 A 删掉自己的探索记录,不能把公司域名也删了;任务 B 也不能因为「没测过这个子域」就认为它不存在。把真值与过程分离,是这类长任务系统最基础的一条不变量。
3.2 两张图的形状
3.3 资产图:程序算结构,Agent 只交原料
资产图有六种节点,每种都有明确的唯一索引,这就是去重的真正实现——数据库唯一约束,不是应用层查重:
| 类型 | 语义 | 去重键(唯一索引) | 关键字段 |
|---|---|---|---|
root_domain | 根域名,如 example.com | UNIQUE(domain) WHERE type='root_domain' | icp、company_id |
subdomain | 解析记录级子域 | UNIQUE(domain, COALESCE(record_type,'')) | record_type(A/CNAME/MX…)、record_value[] |
ip | 单个 IP | UNIQUE(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 / abandoned | goals 分解器 / 人 / planner 的 prove_goal |
intent | 下一步去试什么方向 | open → running → done / blocked / exhausted / stopped;可 paused、deleted(软删) | planner / main agent 直投 |
fact | 观察到了什么(含否定结论) | confirmed / dismissed / origin | worker / planner / main |
finding | 确认了什么漏洞(带 PoC) | confirmed / dismissed | worker / planner / 复测 agent |
hint | 人给了什么战略提示 | active / consumed | main agent / 编排工具 |
digest | 一组冷节点的压缩摘要 | active / superseded | Compactor(后台 LLM) |
工具调用的每一步不进入图,而是写进 activity 表——它是扁平的、按 id 递增的流水,不是图结构。图只记「结论级」的东西。否则一次任务几万步工具调用会把图画成毛线团,规划者根本读不动。图是给决策用的,流水是给回放用的。
3.5 五种边与方向约定
| rel | 从 → 到 | 语义 | 例子 |
|---|---|---|---|
spawns | goal → intent | 这个方向是为哪个目标派生的 | goal「拿到后台权限」spawns intent「测试登录接口越权」 |
derived_from | intent → 上游节点 | 这个方向由哪些事实/意图综合而来 | intent B derived_from fact「响应头泄露内网 IP」 |
yields | intent → fact / finding | 这条意图产出了什么 | intent「枚举备份文件」yields fact「发现 /backup.zip」 |
proves | fact / finding → goal | 什么证据证明了目标达成 | finding「SQL 注入」proves goal「证明存在注入」 |
covers | digest → 冷节点 | 摘要覆盖了哪些节点(派生层,不参与因果计算) | digest#88 covers fact#12、#15 |
所有边统一为「上游 → 下游」(From 是父,To 是子)。这个约定写在 agent/coldgraph.go 的注释里,因为「可达性」「冷热判定」全靠它——一旦某条边的方向搞反,整张图的可达性计算就错了。
3.6 锚点:把「过程」钉到「实体」上
两张图通过一张极简的连接表缝合:
-- 一个节点可以锚定多个资产;一个资产可以被多个节点锚定
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 存在可利用漏洞」,实际跑出来的图可能是:
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 重复撞墙。
observed + 手段穷尽 → 尊重;inferred / 只探一次 → 按「尚未探明」处理,但复核只允许一次,避免无限循环。引擎闭环:事件驱动的一次探索
引擎(server/engine.go)不到 1300 行,却是整个系统的心跳。它不实现任何业务逻辑,只做三件事:什么时候唤醒 planner、谁去领意图、什么时候该停。
4.1 闭环全图
4.2 七种唤醒来源
planner 不是定时任务,它是被动唤醒的。每次唤醒都会带上一组 TriggerEvent,告诉它「这次为什么醒」,避免它每轮都重新扫全图:
| Kind | 触发场景 | 提示词里怎么呈现 |
|---|---|---|
done | 某个 worker 跑完了某条意图 | 「意图 #37(摘要)的 worker 结束,输出结论:…;本意图新产生的事实 id:#51、#52」 |
finding | worker 中途报了一个漏洞 | 「意图 #37 的 worker 报告了一个 finding:…」——不必等它跑完 |
goal | 人(主 agent)新增了一个或多个目标 | 「人新增了 2 个目标:A;B —— 请逐一为尚无对应意图的目标补充方向」 |
goal_deleted | 人删除了某个目标 | 「该目标已移除,请据此重判剩余目标/方向」 |
goal_edited | 人修改了目标文本 | 「由「旧」变为「新」—— 原方向若已不适用请停派」 |
cancelled | 人删除了一条意图(软删) | 「意图 #37 由用户删除,意图内容是:…、删除原因是:…。请据此重新规划」 |
hint | 人加了一条战略提示 | 「人新增了战略提示:… —— 已挂到探索图上,请据此调整方向」 |
没有触发事件时(心跳、恢复),提示词会明确告诉它「本轮是定时巡检,图不一定有新变动」,并顺带要求它复查在跑意图——长时间没进展的用 steer_work 纠偏,方向整个错的用 kill_work 止损。
3 个 worker 同时跑完,会连续写回十几条 fact/activity。如果每条都唤醒一次 planner,就是十几次昂贵的 LLM 调用,而且它看到的是「半截状态」。800ms 的合并窗口让这一批变更收敛成一次规划,planner 看到的是完整的本轮增量。
4.3 意图队列与原子领取
「队列」不是一个内存 channel,而是数据库里的一批行。领取用的是条件 UPDATE,靠数据库的行锁保证同一个意图不会被两个 worker 抢到:
// 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 意图的状态机
这张图解释了一个常被误解的设计:worker 跑完不等于成功。系统把「结束」拆成四种,因为每种对应完全不同的后续动作:
| 终态 | 含义 | planner 的正确反应 |
|---|---|---|
done | 正常完成,事实已写回 | 看它 yields 出的 fact 结论判断是否死路;只有出现材料性新机理才重派,换措辞重试是禁止的 |
exhausted | 步数/时长预算耗尽,探到一半被掐断,只写回部分 | 先看 trace 实际做到哪;接近突破被掐断 → 派「接上次进度继续」 |
blocked | 模型或网络失败,基本没探成 | 可直接重派同方向;每次都卡同一处 → 换打法 |
stopped | 被人 kill、被任务终局取消、或删除 | 一般不重派;若是人工纠偏,按新指令走 |
4.5 任务终局的三条路径
任务不是「跑完所有意图就结束」——它可能永远跑不完。系统定义了三种收束方式:
目标全部证明
planner 调 prove_goal 标记某个目标 met。当它标记的恰好是最后一个未完成目标时,系统自动判定任务完成 → 状态落 done → 立刻取消在跑的 worker。
无目标直投(goalless)
人把目标全删了、只直投意图。此时 planner 停跑(跑了会误判 met 并杀掉直投),任务是否结束改由 frontier 决定:意图跑干 → done。
任务级超时
建单时设了 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 的越权」,它下一步就会转向。
四种 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 最反直觉的地方是:每一轮唤醒都是一个全新的会话。它不记得上一轮想过什么,只能看到图。这带来一个真问题:多步利用链怎么办?
如果每轮都从零开始,「先拿凭据、再横向、再提权」这种串行链会在第一轮被一次性并行派出去——下游拿不到还不存在的前置,只会空转或重复。
实现上只有一个 map 加一把锁,键是 exploration id:
// 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 输入,并附上一句行为约束:
planner 还有两个「非常规」工具
steer_work(intent_id, msg)
给正在跑的 worker 插一句话。用于「它跑偏了但还有救」——不杀进程,只在下一个工具调用前注入纠偏指令。
kill_work(intent_id)
方向整个错了、或者已经证明是死路但 worker 还在死磕时,直接止损。区别于 pause:只杀这一条。
这两个工具的注释里有个细节:「读上下文、跨 work 复盘是规划职责」。planner 能看到所有 worker 的输出与 trace,所以它才有资格判断「这条跑偏了」。worker 看不到全局,自然也不该有这个权力。
5.3 planner 的决策流程(提示词骨架)
它的 system prompt 是一份决策手册,不是角色扮演。五步结构非常清晰:
- 读完整态势
本轮触发事件 + graph_overview 预取(目标、意图、事实、资产计数、覆盖度)。不需要再调工具拿概况,细节才按需查。
- 判目标
对已被证明的目标调
prove_goal(goal_id, evidence_id, reason)。含量化条件的目标(覆盖度 80%、拿 N 个 flag)必须先核对实测值,未达标禁止 prove_goal。 - (可选)极轻量探测
只在任务刚开局、图里几乎没有 fact 时允许 1–3 次只读探测,唯一合法产物是「一句更精准的意图描述」。一旦图里有 worker 产出的事实,就禁止自己再探。
- 决定补哪些方向
与 open + running + recent_done 逐一比对去重。done 的看 fact 结论;exhausted/blocked 的看 trace 实际进度;保持路线多样,但多样性服从操作约束。
- 一次批量提交
用一次
add_intent提交最多 4 个最高价值方向,带summary/asset_ids/parent_ids,不要逐条多次调。
这份 prompt 花了大量篇幅写「不要做什么」:不要把 0 意图当偷懒的默认;不要因为「大体达成」提前盖章;不要把「换措辞重试」当成新方向;不要在已有 fact 时自己动手。这些不是啰嗦——它们是控制流。LLM 系统的可靠性,很大一部分是靠这种否定式约束堆出来的。
5.4 worker:一个「只做一件事」的执行者
worker 的 system prompt 开头就是三条红线:
你领到【一条意图】(一句话探索方向),唯一职责:**完成这一条意图、把发现写回知识图谱、然后停止返回。** **边界(红线)**: 1. **只做你领到的这一条意图**。探本意图时若瞥见本意图之外值得深挖的线索, **在 fact 的 summary 里点一句交给规划者**。 2. 初次受阻(payload 被过滤 / 404 / 注入无回显)不代表已探透——把本意图的 所有绕过手段走完再输出结论; 3. 只在授权范围内操作。系统提示顶部若附【操作约束】,那是最高优先级红线。
第 1 条特别值得学:它没有简单禁止 worker「注意到别的线索」,而是给这种注意力一个合法出口——写进 fact 交给规划者。这比「不许分心」有效得多,因为分心是 LLM 的本能,堵不如疏。
三种写回,绝不串图
| 写回工具 | 写哪张图 | 写什么 | 不写什么 |
|---|---|---|---|
insert_assets | 资产图 | 资产本身:子域、服务、端点、指纹、凭据 | 探索结论、判断、推测 |
record_fact | 探索图 | 探索结论/事实,含否定结论(端口关闭、参数不可注入) | 资产属性(那属于 insert_assets) |
report_finding | 探索图 | 真实触发过、有可复现证据的漏洞 + PoC | 版本匹配 CVE、看起来可注入、代码 diff 推断 |
写 fact 有三条硬规矩,全都写进了 prompt:
一次一条,别拆碎
多个观察汇总成一条事实:summary 一句总结 + detail 展开。一个属性一条会让图无限膨胀。
只写增量
只记这次新得到的;已存在的事实换个措辞重记,等于制造噪音,还会误导 planner 以为有新进展。
只写真实看到的
必须给 evidence(一行命令 + 关键输出),并标 confidence:observed 直接看到 / inferred 据现象推断。
为什么 worker 没有 Glob / Grep / MultiEdit
这是刻意的工具面收敛:
// 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 流水:
| 工具 | 用途 |
|---|---|
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 从「任务目标 + 描述」里抽三样东西,顺序很重要:
- 先抽操作约束 → set_constraints
把「不扫端口」「不做写操作」「只测某子域」这类边界登记下来,并按
allow/deny分类。约束会被单独注入执行阶段的提示,所以必须自包含:把「当前目标」这类指代词替换成具体域名/IP。 - 再登记测试范围 → add_task_scope
遵循最小范围原则:目标是
https://a1b2c3.lab.example.net/path就登记a1b2c3.lab.example.net,绝不能缩成 example.net——那会把范围扩到授权之外。只有裸根域名或明确说「所有子域」才用 root_domain。 - 最后拆目标 → 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——因为它需要带上「哪个意图找到了什么」的上下文。
工具集:给 LLM 划一个刚好够用的动作空间
6.1 三个工具集,三种世界观
| planner(规划视角) | worker(执行视角) | mainagent(人的视角) |
|---|---|---|
graph_overview | list_findings | graph_overview |
list_findings / list_facts | report_finding | list_facts / list_findings |
node_detail / expand_digest | record_fact | node_detail / expand_digest |
get_worker_output / get_worker_trace | insert_assets | get_worker_output / get_worker_trace |
search_all_worker_traces | list_assets | search_all_worker_traces |
list_goals / add_intent | search_all_worker_traces | add_hint / add_intent |
prove_goal / goal_met | get_worker_trace | steer_work |
kill_work / steer_work | node_detail | set_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 | — | — |
注意几个「缺失」都是刻意的:
worker 没有 list_facts
它不该读全局事实去做「规划性判断」。要知道别人发现了什么,用 get_worker_trace 看过程,或看启动时下发的全局态势。
worker 没有 add_company_scope
定义企业资产范围属于规划/主控职责。让执行者改授权范围,等于让士兵自己划战区。
planner 没有 set_goals
目标只能由人(或分解器)设定。planner 能做的是「证明某个目标达成」,不是「新增目标」——这是防止它自己给自己加戏。
6.2 读工具:三级信息粒度
读工具的设计围绕一个矛盾:planner 需要全局态势,但不能把整张图塞进上下文。解法是三级粒度:
planner 的提示词里写着:「完整态势已附在本提示下方(就是 graph_overview 的返回,无需再调它)」。这把一次工具调用变成了零次——每轮省一次往返、省一次 token 生成。代价是 system/user 提示变长,但因为它放在 user turn 里,可以被 compaction 压缩,风险可控。
6.3 资产查询:给 LLM 一门小语言
让 LLM 写 SQL 是危险的,让它填一堆结构化参数又太笨重。ARTEX 选了中间路线:一门极简 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。未知字段直接报错,绝不透传:
// 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 契约:
把探索【事实/结论】写入探索图,连到产生它的意图(intent_id)。 用于记录探索结果——包括指纹/枚举等【正向结论】,和'端口关闭'/'参数不可注入'/ '未发现登录入口'等【否定结论】。 … **多个观察汇总成【一条】事实**(summary 一句总结 + detail 写对总结的拓展)… **只写增量**:只记这次【新得到】的,别把已有事实换措辞重记… **只写真实看到的**:给 evidence(一行:命令+最能证明的一两行输出), 标 confidence(observed=直接看到 / inferred=据现象推断)。
工具的 description 是模型唯一能看到的「使用说明书」。写得含糊,模型就会用它做别的事:拿 record_fact 记资产、拿 report_finding 记怀疑、拿 insert_assets 记结论。三个写入口的边界,是靠文字划出来的,不是靠代码校验。这是 LLM 工程与传统 API 设计最大的差别之一。
6.5 工具不是固定的:三层动态装配
一个 Agent 最终能看到的工具,是三次过滤的结果:
延迟工具(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 防护:一个工具崩了不该带走整个进程
// 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 不是一次故障,而是无限重启循环。所以必须在工具边界兜住。
list_assets 在没有 id/ids 时要求 dsl 必填?提示词工程:把控制流写进文字里
7.1 三段式:可编辑的身体 + 不可编辑的尾巴
每个 Agent 的 system prompt 都由三部分拼成,分界线是「谁拥有它」:
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,和用户存进数据库的版本走同一套渲染:
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 的处理是不让它直接死,而是进入一个「结算回合」:
你即将因预算耗尽被终止。不要再运行任何命令/探测。请依次:
(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
三个细节值得注意:
单独注入,不混在目标里
约束和目标语义完全不同:目标是「要达成什么」,约束是「不许怎么做」。混在一起模型会拿约束当目标去「完成」。
要求自包含
「只测当前目标」脱离上下文就没法判断。所以分解器必须把指代词替换成具体域名/IP。
可运行时增删
人在总览页或对话里改约束,下一轮立刻生效(每次渲染 prompt 都重新读)。
目标描述、资产标题、HTTP 响应体都可能包含「忽略之前的指令」这类注入文本。ARTEX 的缓解手段不是靠模型自觉,而是结构性隔离:约束只在 system 里、由人写;worker 的写回必须带 evidence;report_finding 要求真实触发。也就是说,即使模型被骗,它也很难伪造出一个「可复现证据」。
上下文工程:让长任务不撑爆窗口
这是所有长任务 Agent 系统都会撞上的墙:工具输出越攒越多,20 轮之后上下文就满了。ARTEX 用了四层互不干扰的机制,每层解决不同尺度的问题。
8.1 cold-digest:把「冷掉的推理链」压成摘要
这一层是 ARTEX 最有原创性的设计。它解决的问题不是「对话太长」,而是「图太大,planner 每轮读不动」。
什么是冷节点
一个节点如果连续 R 个规划轮次都没有任何下游分支(没有新事实从它派生、没有新意图引用它),它就被判定为「冷」。系统用 cold_since_round 记录它变冷的那一轮,用 round_no 记录当前轮次。
冷却判定
连续 6 个 planner 轮次无下游活动,才记为冷。新节点不会立刻被折叠。
最小折叠块
至少 2 个节点才折。单节点折叠没有收益,只会增加一次 LLM 调用。
minor 阈值
冷节点攒到 20 个触发一次「增量折叠」,只处理尚未被覆盖的冷集。
major 阈值
攒到 8 个 digest 时触发「全量重算」,重新分组并合并碎片。
怎么折
不是随机打包,而是按连通性分组:把通过上游/下游边相连的冷节点聚成一块,再让 LLM 为这一块写一段摘要,生成 kind=digest 的新节点,并用 covers 边指向所有成员。
同一块里的节点本来就有因果关系,摘要能写成「这一支探索做了什么、得到什么、结论是什么」;随机打包只能写成一堆互不相关的句子,planner 读了也没用。另外还有一个同父单例救援规则:如果某个冷节点是某父节点唯一的孩子,即使它自己不够「成块」,也跟着父节点一起折——否则父节点会变成一个悬空的孤儿。
并发与正确性
后台折叠最怕的是「折叠期间节点复活了」——摘要刚生成,那个节点又被新意图引用了,于是 digest 覆盖了一个活跃节点。解法是提交时再检查一次存活:
// 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 旁路:
- 抓快照
主 Agent 每次模型请求前后,把结构化消息、system prompt、工具定义、生成参数深拷贝成一份不可变快照。
- 独立回答
旁路服务用「快照 + 最近成功问答 + 新问题」发一次独立请求,不创建 agent session、不接工具执行器、不写主 transcript。
- 独立历史
问答存在
side_question_requests表,可追问、可停止、可清空。清空只影响旁路,主上下文快照保留。
快照按 250ms 合并写入,数据库比较 (run_id, version) 防止旧版本覆盖;正在生成的半段回复不发布;流式中止保留上一个有效边界;服务重启把遗留 running 请求标记为 interrupted,保留已落库的部分回答而不自动重放(重放会重复计费)。这些细节决定了「旁路」是不是真的旁路。
8.3 token 账本
所有这些机制要能调优,前提是知道 token 花在哪。系统在每个 activity 上记四个数:input_tokens / output_tokens / cache_read_tokens / cache_write_tokens,并按任务、会话、日维度聚合。
-- 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,不回表。设计文档里连「为什么是这个索引」都写在注释里。
安全边界:约束、审批门、审计
这套系统让 LLM 直接跑 Bash、发 HTTP、调 Kali 工具。安全设计不能靠「模型应该不会乱来」,必须在工具调用边界做强制拦截。ARTEX 用了三道闸,粒度从粗到细。
9.1 三道闸的分工
9.2 拦截规则:三种动作,一套优先级
| action | 行为 | 适用场景 |
|---|---|---|
allow | 立即放行,跳过所有更低优先级规则 | 白名单:已知安全的高频命令,避免每次都问人 |
deny | 直接阻止,返回一条消息给模型 | 硬禁止:rm -rf /、反弹 shell 到外部、批量拖库 |
ask | 阻止并创建 pending 记录,等人在 UI 上批准/拒绝 | 高风险但可能合法:写操作、凭据导出、影响面大的扫描 |
规则的结构比想象中完整——它不只是「匹配什么」,还包括超时后怎么办:
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。
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 返回给模型的不是「禁止执行此工具」,而是:
【ARTEX 平台管控·非目标防御】此调用被平台拦截。原因:…。此操作被禁止。
源码注释解释了为什么要加这个前缀:
在 Agent 系统里,错误信息的措辞会改变模型的行为。同一件事,「403 Forbidden」会触发绕过策略,「平台政策禁止,换个方向」会触发重规划。这不是 prompt 技巧,而是接口设计——把「谁的意志」说清楚,比说「不行」有效得多。
9.5 审批的等待与审计
一次 ask 的完整生命周期:
- 创建 pending 记录
写入
intercept_pending,同时往当前会话的 activity 流写一条intercept_request,于是它会内联出现在对话记录里,人不用去另一个页面找。 - 阻塞等待
为这条请求建一个 channel,worker 的 goroutine 阻塞在上面。人在 UI 点「批准 / 拒绝」→ HTTP handler 调
Decide(pendingID, allowed)→ 唤醒 channel。 - 超时处理
按规则配置的
timeout_seconds与timeout_action决定放行还是拒绝;ctx 取消则立即拦截。 - 落审计
无论 allow / deny / ask 的结果,都写一条
intercept_log,带工具名、参数、规则、判定、耗时。审批记录页可展开看完整调用详情。
审计侧还有一个「失败归因」机制(源码里叫 Observer / G5):PostToolUse 钩子把 Bash 结果分成三类:
正常
命令成功执行,输出可解析。
被目标拦截
输出里命中 403 / forbidden / waf / rate limit / captcha 等模式。
执行出错
命令本身失败(参数错、依赖缺失、超时)。
这三类的区分让 planner 能做出不同判断:blocked 说明目标有防护,要换绕过思路;error 说明自己命令写错了,要修命令。如果都归为「失败」,模型只会盲目重试。
9.6 为什么「内置规则」也放在数据库里
系统预置的破坏性/外泄拦截规则,不是硬编码在 Go 里,而是以普通 [内置] 规则的形式 seed 进数据库,用户可以禁用甚至删除。
好处:不同团队的风控尺度不同,硬编码会让系统无法适配。代价:用户可能误删安全规则。缓解手段是「内置标记 + 默认启用 + 审计留痕」,而不是「不许改」。这是把策略与机制分离的经典做法——机制(hook 一定被调用)在代码里,策略(拦什么)在数据里。
流量留痕:让每一次请求都可回查
10.1 为什么必须留痕
渗透测试的结论如果没有原始请求响应,就等于「我说有洞」。留痕解决三个问题:
可验证
报告里的每条漏洞都能点回具体请求与响应,而不是只有一段描述。
可复用
worker 不必重复 curl 同一个 URL——先查流量,能找到之前访问过的资源和响应。
可诊断
payload 到底发出去了什么、服务端返回了什么,只有抓包是真相。
10.2 架构:代理 + 文件树 + 旁路索引
10.3 三个让工具「零配置」的细节
细节一:把代理和 CA 塞进子进程环境
worker 跑的 curl 不该需要人教它加 -x 和 -k。所以系统在启动 Bash 子进程时注入一整套环境变量:
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,必须独立存一份。
// 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、不许为了凑证据重复探测 |
模型非常擅长编造看起来合理的数字。如果它随手填一个不存在的 traffic_id,绑定就会失败或者绑错——而报告上却显示「有证据」。所以提示词里反复强调「不要猜测 ID」,且服务端对无效引用做整条上报失败的处理(fail fast,而不是静默丢弃)。
资产模型与覆盖度
11.1 归属:显式 vs 范围推导
一个资产属于哪家公司?系统允许两种来源,且永远不会互相覆盖:
| company_source | 含义 | 什么时候产生 |
|---|---|---|
explicit | 人或 agent 明确指定了公司 | 手动录入、资产同步时带 company_id |
scope | 由 company_scope 规则自动推导 | 新增范围规则后重算命中资产 |
因为「重算范围归属」是一个批量操作。如果它把人工标注的公司也一起覆盖了,就永久丢失了人的判断。所以显式归属被标记出来,重算时只动 scope 来源的行。存量库迁移时也保守地把所有旧数据标成 explicit——宁可少算,不可错删。
company_scope:五种范围规则
| kind | 匹配方式 | 例子 |
|---|---|---|
domain | 精确域名匹配(含子域归属推导) | example.com |
ip | 单个 IP | 203.0.113.10 |
cidr | 网段包含(GiST 索引加速) | 203.0.113.0/24 |
icp | ICP 备案号归一化后匹配 | 粤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 覆盖度的公式(以及它为什么是「约」)
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(仅供参考)」。为什么不敢说精确?
「测过」的定义模糊
请求了一次首页算不算测过?只看了一眼指纹算不算?系统用「有 fact 锚定」作为代理指标,粗糙但可解释。
资产在动态增长
探测过程中不断发现新子域/端点,分母一直在变。同一时刻的百分比才有意义。
范围规则可能重叠
公司范围 + 域名范围可能命中同一资产,所以必须 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 引擎,异步、带冷却、可去重。
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 从零开始扫全网,它可以只做「拿到一批资产后,自主决定怎么打」。
可靠性与运维:让系统能跑一整夜
12.1 LLM 池:一条链 + 一个全进程熔断器
自主系统最怕的不是漏洞打不出来,而是跑到一半模型没额度了。ARTEX 的做法是把多个 LLM 配置串成一条降级链:
三个值得学的点:
熔断器是进程级的
Registry 比 Pool 活得久。重建池(改配置、保存无关设置)不会清空「哪些后端坏了」的知识。
窗口不够就跳过
某个配置的上下文窗口装不下这次请求,直接跳到下一个,而不是发过去等它 400。这叫「路由前的可行性检查」。
同 Rank 轮流领跑
两个配置优先级相同,用原子计数器轮转起点,避免总是打同一个账号。
llmrec:记录「线上的原始字节」
常规的 LLM 调用日志记的是规范化后的请求对象——但排查问题时,你需要的往往是「实际发出去的 JSON 长什么样」。所以系统在 HTTP transport 层挂了捕获器:
// 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。到点后的处理顺序很讲究:
- 停止发新活
worker 循环看到
isSettling就不再领新意图;planner 的普通唤醒被丢弃(收尾轮由协调器直接驱动)。 - 给在跑的 run 注入收尾词
通过 ctx 携带的 deadline 夹逼
MaxDuration,让每个 run 自然进入结算回合,把已识别的写回。 - 等 drain
协调器等待在跑的 worker 结束(有上限),不是无限等。
- 落终态
用
SetTerminalStatusGuarded做 CAS,避免和用户手动暂停 / 删除打架。
直接取消会让 worker 手上一堆「已经看到但还没写回」的发现全部丢失——等于白跑。收尾设计的核心思想是:预算耗尽不是异常,是正常的生命周期终点,必须给它一个体面的结束流程。
12.4 归档:把任务打包成可搬运的证据
任务跑完不能一直占着数据库。归档会把任务、探索图、资产关联、活动记录、LLM 记录、旁路问答打包成一个压缩包(当前格式版本 3,兼容 v1/v2)。
| 设计点 | 原因 |
|---|---|
状态机 archive_queued → archiving → ready / archive_failed | 归档是耗时操作,必须异步、可重试、可观测,不能阻塞 HTTP 请求 |
前置条件:任务必须 paused 或终态 | 归档一个正在跑的任务会撕裂数据一致性 |
| 不能有活着的继承任务依赖它 | 继承任务会读源任务的探索历史;源被归档后引用会断 |
| 恢复也是队列化的 | 恢复要重建 exploration / nodes / edges / 外键,同样耗时且可能失败 |
| 格式带版本号 | 老归档要能被新版本读;新字段要能优雅降级 |
12.5 自更新:三次启动的原子换装
这是整个项目里最「运维」的一段代码,也是最值得学的工程范式:把易错逻辑放进强类型语言,把守护循环留给脚本。
源码注释写得很实在:「sha256 校验和冒烟测试在 sh 和 bat 上要写两套(sha256sum / shasum / certutil),而这恰恰是最不能出错的一环——换上一个跑不起来的二进制,守护进程会忠实地反复拉起它,用户只能上机器手工救。」
这就是职责划分的依据:脚本只做它擅长的事(进程退出后按退出码决定是否重启),所有分支逻辑交给 Go。跨平台的哈希算法差异、状态机、回滚,都是脚本的噩梦。
12.6 通知:叶子包 + 拨号守卫
漏洞发现后要推送到钉钉/飞书/企微/Webhook/Telegram/邮件。这个子系统被刻意做成不依赖数据库、不依赖 server 的叶子包:
// 分层:本包是**叶子包**,只依赖标准库。它不认识数据库、不认识 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 重绑定和同主机重定向。
// isBlockedDialIP 报告目标 IP 是否属于「默认不允许投递」的地址段。
//
// 只拒绝环回、链路本地(含云元数据 169.254.169.254)、未指定与组播。
// **不拒绝** RFC1918 私网:内网自建 Mattermost / SMTP 中继是很常见的合法用法,
// 把它们一并挡掉会让功能在真实环境里直接不可用。这条取舍是刻意的——
// 防护要挡住真正敏感的目标,同时不能把正常部署一起废掉。
很多系统会一刀切禁掉所有私网地址,结果内网部署全部报废。ARTEX 选择只挡真正危险的(环回、链路本地/云元数据、组播),保留私网,并提供一个显式环境变量 ARTEX_NOTIFY_ALLOW_LOCAL=1 用于本地测试。防护的精度比强度更重要。
12.7 可观测性
三个入口,覆盖三种排障场景:
| 入口 | 数据源 | 适合看什么 |
|---|---|---|
/api/logs/stream | 内存日志环 + SSE | 实时排障:刚才那次规划为什么失败 |
活动流 /api/exploration/activity/stream | activity 表 + 广播器 | 任务执行过程:每一步工具调用、token 消耗、审批请求 |
| LLM 记录页 | llm_records(原始报文) | 模型侧问题:请求体、SSE 帧、被丢弃的重试 |
Go 工程手法:这套代码值得抄的十二个习惯
即使你完全不关心渗透测试,这个项目的 Go 代码本身就是一份很好的「长任务系统」参考实现。下面这些手法在任何一个后台服务里都用得上。
13.1 并发:任务是一组有名字的 goroutine
每个任务启动 1 + N 个 goroutine,全部登记在一个 taskRuntime 里,统一用 runTaskRoutine 包裹。这样做的目的不是「优雅」,而是可观测与可停止:
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,把它重排队;恢复后继续 |
kill | planner 杀掉单条意图 / 用户删除 | 只终止这一条,标记 stopped,不重排 |
goal_met | 目标已完成,任务终局 | 取消所有在跑 worker,归为 stopped 而非 blocked |
shutdown | 进程退出 | 尽量写回收尾,标记中断,重启后可恢复 |
实现上用 context.WithCancelCause 而不是普通 cancel——context.Cause(ctx) 能取回具体原因,引擎据此决定「重排队、标 stopped、还是标 blocked」。这就是 agent/cancelcause.go 存在的意义。
13.3 状态机的 CAS:谁先写谁赢
任务终态是竞争最激烈的地方:planner 判完成、用户点暂停、超时协调器落 timeout,可能同时发生。系统用「带条件的 UPDATE」做仲裁:
// 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…)。代码里有一条明确约定:
// 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 次结果一样。
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 记录悄悄丢了)。所以每个字符串入库前都过一遍:
// 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:
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、通知、归档)如果无限制地堆积,会把主流程拖死。项目里所有旁路都有三道闸:
有界队列
enrich 队列 1024,满了就丢。丢一条补全不影响正确性。
冷却窗口
同一资产 5 分钟内不重复解析;同一任务 60 秒内不重复折叠。
去重 key
sync.Map 记 kind:id 的最后执行时间,天然幂等。
13.9 降级而不是崩溃
系统里到处是「做不到就退一步」的代码,而不是「做不到就报错」:
| 场景 | 降级行为 |
|---|---|
| SQLite 驱动没有 FTS5 | 降级为「无全文搜索」,录制功能照常 |
| 用户 prompt 模板渲染失败 | 回退内置默认模板 → 再回退原始字符串 |
| LLM 未配置 | 引擎 idle,不报错、不空转;配置后自动开始 |
| 资产库未初始化 | 资产类工具返回「未启用」,其它工具照常 |
| 单个工具 panic | 转成普通工具错误,进程继续 |
| 活动写入违反外键 | 丢弃并计数,日志里聚合显示丢弃量而非刷屏 |
// 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 首轮」
记录了一个踩过的坑,防止后人「优化」回去。
这些注释的价值在于:它们把设计决策的上下文固化了。半年后有人想简化代码,会先看到「这里为什么不能简化」。
一次任务的一生
把前面所有章节串起来,看一个任务从创建到归档会经过什么。每一步都标注了「谁做的」和「写进了哪张表」。
- ① 建单(POST /api/tasks)
写入
tasks行 + 新建explorations行(这次任务的探索空间)+ 写入begin根节点。如果设了并发上限且已满,任务标记queued排队。
代码:Manager.CreateTaskWithOptions/db.CreateTask - ② 目标分解(goals agent,只跑一次)
LLM 读「目标 + 描述」,产出:操作约束(
task_constraints)、测试范围(task_scope)、目标节点(exploration_nodes, kind=goal)。
如果 LLM 不可用,回退到规则拆分,保证 goal 节点一定存在。 - ③ 启动引擎(Engine.Run)
起 1 个 plannerLoop + N 个 workerLoop,启动 deadline 协调器。如果已有 open/running 意图(种子意图),跳过首轮规划;否则踢第一轮。
关键判断:HasActiveIntent()(open + running),不是只数 open。 - ④ 第一轮规划
planner 读到「图几乎是空的」,于是根据 goal 派 2–4 条初始意图(侦察、指纹、目录、端口…),带
asset_ids锚定到范围内资产。
产物:intent节点(state=open)+spawns边。 - ⑤ worker 领取
空闲 worker 轮询 frontier,用条件 UPDATE 抢到一条意图,状态变
running,owner 记成work#2。构造 system prompt(含意图 + 目标资产原始数据),Resume 该意图的历史 transcript(如有)。 - ⑥ 执行与留痕
每一轮工具调用:PreToolUse 过审批门 → 执行 → PostToolUse 归因 → 写
activity。HTTP 流量进 MITM 代理,落文件树 + SQLite 索引。
每个工具结果都可能触发一手写回:insert_assets / record_fact / report_finding。 - ⑦ 图变更 → 唤醒 planner
worker 的写回触发
t.Notify();引擎 800ms debounce 后跑下一轮规划。planner 看到本轮触发事件(done / finding),判断要不要补方向。
这是闭环的核心:写回本身就是下一轮的输入。 - ⑧ 串行链推进(可能多轮)
如果存在依赖链,planner 用共享 todo 记录整条链,每轮只派「前置已满足」的一步。新事实出现 → 下一轮派下一步。
期间可能有人插话:steer_work 纠偏、add_hint 加提示、add_intent 直投。 - ⑨ 目标证明
某条 finding/fact 足以证明 goal 时,planner 调
prove_goal(goal_id, evidence_id, reason),写入proves边,goal 状态变met。
当最后一个 open goal 被标 met → 系统判定任务完成 → 取消在跑 worker。 - ⑩ 收尾与终态
任务状态落
done/failed/timeout(CAS 保证只有一个赢家)。在跑的 worker 进入结算回合,把已识别的写回,最后输出一句总结。 - ⑪ 报告与复测
report 包从探索图渲染 Markdown:漏洞列表(含严重级、证据、PoC)+ 资产统计。漏洞可发起复测——独立的 retester agent 会话,结论三选一(仍可复现 / 已修复 / 无法确认)。
复测成功且判「已修复」时,自动把漏洞处置状态改为已修复。 - ⑫ 归档
任务暂停或终态后,可归档成压缩包(格式 v3):任务、探索图、活动、LLM 记录、旁路问答。归档后从主库移除,需要时再恢复。
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 里,重启就等于丢任务。
设计取舍清单
学一个系统最好的方式不是记它做了什么,而是理解它在哪些岔路口选了哪条、放弃了什么。下面是这个项目里最重要的 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 如果让我重做,哪些地方可以更好
客观地说,这套设计也有明显的成本和风险,读代码时应该带着批判:
planner 是单点
它一次只能想一件事,且每轮都是一次完整 LLM 调用。任务规模上去后,规划延迟会成为瓶颈。可能的改进:分层规划(战略层 / 战术层)或按分支并行 planner。
提示词即逻辑,难测试
大量行为约束写在 prompt 里,改一个字可能改变全局行为,但没有单元测试能覆盖。只能靠端到端跑分与人工评审。
图的规模没有上限
大任务可能产生上万节点,overview 再压缩也会变大。cold-digest 缓解了这一点,但它本身也是 LLM 调用,有成本。
单进程架构的天花板
所有任务共享一个进程、一条 LLM 链、一个资产库。横向扩展需要把 Engine 拆成独立 worker 服务,那是另一套架构了。
七天读码路线
不要从 main.go 开始逐行读——这个项目的入口很薄,真正的结构在数据模型和引擎里。按下面的顺序,每一天都能独立跑通一小块。
先看数据库,不看代码
读:db/schema.sql(重点 A/B 两段)、db/exploration.go 的 Node/Edge/ClaimIntent。
产出:手画一张 ER 图,标出 7 种节点、5 种边、3 张范围表的关系。
自检:为什么意图队列用 priority DESC, id ASC?为什么资产表每个类型都配了 partial unique index?
看一个任务怎么跑起来
读:server/engine.go 的 Run → plannerLoop → workerLoop → runIntent;配合 server/manager.go 的 Task 结构。
产出:画出 goroutine 生命周期图,标出所有退出路径(pause / kill / done / timeout / shutdown)。
自检:为什么首轮规划要用 HasActiveIntent 而不是 Frontier?
读三个 system prompt
读:agent/planner.go、worker.go、goals.go、mainagent.go;重点看 plannerDefaultTmpl / workerDefaultTmpl 全文。
产出:列出每个角色的「红线清单」,对比哪些约束是重叠的、哪些是独有的。
自检:为什么 worker 不给 list_facts?为什么 goals 必须先登记范围再拆目标?
做一张角色×工具矩阵
读:agent/tools.go(重点 PlannerTools)、tools_insert.go(WorkerTools / MainAgentTools)、assembly.go(三层装配)。
产出:把所有工具按「读/写/控制」分类,标出每个角色的可见性。
自检:延迟工具解决什么问题?guardPanic 为什么必须最后应用?
跟一次审批走到底
读:guard/guard.go、intercept/intercept.go(Match/Judge/HandleAsk/Decide)、traffic/traffic.go(record/Search)、evidence/store.go。
产出:画一条从 PreToolUse 到人工点击批准、再回到 worker 的完整调用链。
自检:为什么拦截文案要强调「平台管控」?为什么 SSRF 守卫在拨号时而不是保存时?
从页面追到 SQL
读:server/server.go 的路由表(655 行起)、server/dto.go、web/src/app/(main) 的页面结构。
产出:挑三个页面(仪表盘 / 任务详情 / 覆盖图),各追一条「页面 → API → store 方法 → SQL」的链路。
自检:SSE 为什么要关反代缓冲?为什么前端不需要配 API base?
写一页你自己的设计
读: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.1 术语表
| 术语 | 英文/代码 | 含义 |
|---|---|---|
| 双图 | dual graph | 资产图(真值、全局共享)+ 探索图(过程、任务隔离)的总称 |
| 资产图 | asset graph | assets 表:root_domain / subdomain / ip / service / app / endpoint |
| 探索图 | exploration graph | exploration_nodes + exploration_edges,一次任务的因果链 |
| 锚点 | anchor | exploration_anchors:把探索节点钉到具体资产上,支撑覆盖度 |
| 意图 | intent | 一句话探索方向,由 planner 生成,worker 领取执行 |
| 事实 | fact | 探索结论,含否定结论;带 evidence 与 confidence |
| 发现 | finding | 确认的漏洞,必须真实触发过、有可复现 PoC |
| 提示 | hint | 人给的战略提示,挂在图上,planner 下一轮读到 |
| 摘要节点 | digest | 冷节点压缩产物,通过 covers 边覆盖成员 |
| frontier | frontier | state=open 的意图集合,即待办队列 |
| 领取 | claim | 用条件 UPDATE 把意图从 open 改成 running 的原子操作 |
| 防抖 | debounce | 800ms 内合并多次图变更,只跑一轮规划 |
| 结算回合 | settlement | 预算耗尽时强制进入的收尾轮:禁止新动作,只写回 |
| 冷节点 | cold node | 连续 R 轮没有下游活动的节点,可被折叠 |
| 覆盖度 | coverage | 被 fact 锚定的范围内资产 / 范围内资产总数 |
| 操作约束 | constraint | allow/deny 形式的行为边界,注入 system prompt |
| 拦截规则 | intercept rule | 匹配工具名/参数,动作 allow/deny/ask |
| 审批门 | guard / pending | 工具执行前的强制检查点,ask 会阻塞等人 |
| 记录代理 | recording proxy | MITM 代理,把每个目标 HTTP 交换写进流量库 |
| 降级链 | LLM pool | 多个 LLM 配置按优先级串联,失败自动换下一个 |
| 熔断 | circuit breaker | 某配置持续失败就暂时跳过,冷却 1/5/30 分钟 |
| 旁路提问 | side question / btw | 用上下文快照回答临时问题,不污染主会话 |
| 归档 | archive | 把任务打包成压缩包并从主库移除,可恢复 |
17.2 文件地图:想改什么,去哪里
| 包 / 目录 | 关键文件 | 职责 |
|---|---|---|
cmd/artex | main.go | 启动流程:日志捕获 → 自更新 Bootstrap → Manager → Server → 监听 |
config | config.go | 配置读取(config.json + 环境变量覆盖),路径推导 |
db | schema.sql / exploration.go / assets.go / task_scope.go / tasks.go | 所有 SQL 与数据模型;双图、资产、范围、任务、归档 |
agent | planner.go / worker.go / goals.go / mainagent.go / tools.go / prompt.go / compaction.go | 四种 Agent 的提示词与执行逻辑、工具集、上下文压缩 |
server | engine.go / manager.go / server.go / orchestration.go / scheduler.go | HTTP API、引擎循环、任务生命周期、跨任务编排、触发器 |
guard | guard.go | PreToolUse / PostToolUse 钩子、审计、失败归因 |
intercept | intercept.go / prompt.go | 规则匹配、LLM 裁判、审批等待与落库、审批详情上下文 |
traffic | traffic.go / archive.go | MITM 录制、文件树 + SQLite 索引、blob、查询与清理 |
evidence | store.go | 证据 blob 的落盘、校验、与流量的解耦 |
enrich | enrich.go | DNS 解析 / HTTP 探测的异步补全 |
llmpool | pool.go / health.go | LLM 降级链与进程级熔断器 |
llmrec | capture.go | 记录 LLM 请求的原始报文(含失败重试) |
mcphttp | client.go | 远程 MCP 客户端(Streamable HTTP / 旧版 SSE) |
notify | notify.go / http.go / sign_test.go | 六种通知渠道、签名、过滤、脱敏、SSRF 拨号守卫 |
report | report.go / findings.go | 从探索图渲染 Markdown 报告 |
selfupdate | selfupdate.go / bootstrap.go / stage.go | 下载 / 校验 / 暂存 / 换装 / 回滚 |
sidequestion | capture.go / service.go / context.go | /btw 旁路提问:快照、独立历史、上下文预算 |
web/src | app/(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
综合自检
exhausted,planner 下一步最合理的动作是什么?exhausted 的语义是「探到一半被掐断,信息不全」,不是「这条路走不通」。原样重派会重复劳动;直接放弃会丢掉已经接近的成果。正确做法是看过程再决定。18.1 最后一页:这套系统的三句话
把真值和过程分开
资产图回答「世界有什么」,探索图回答「我们试过什么」。锚点把两者缝在一起,于是既能看全局,也能回溯因果。
把决策和执行分开
唯一的规划者读图产意图,无状态的执行者领一条干一条。并发因此变得安全,血缘因此变得清晰。
把变化变成驱动力
没有中央调度器。写回改变图,图的变化唤醒规划者,规划者再派活——系统靠自身的输出推动自己前进。
如果你要基于这个项目做二次开发,建议从 Day 4(工具集) 入手——加一个工具是最安全、最能立刻看到效果的改动。如果你想改行为,去改 prompt(段 A),但改完一定要跑一遍真实任务,因为提示词没有单元测试。如果你想改架构,先回到第 3 章,确认你的改动没有破坏那几条不变量。