前言
在我还在做安全的 2023 年里, LLM 还只是一个 chatbox,我们在网页对话框里输入问题,LLM 给我们回答,从现在的眼光来看或许会让人觉得很原始,毕竟只是一个对话机器人,但当时其实已经很让人震撼了。2024 年考了一年研之后我开始做静态分析,当时其实也有借助到 LLM ,但最多是帮忙修修 bug,把报错复制给 LLM,问问到底是怎么回事,实际的代码绝大部分还是自己写的。
当时我有一个朋友给我推荐了一个工具,叫做 cursor,说这个东西可以帮忙写代码,下载使用之后我受到了比 chatgpt 还要大的多的震撼——它竟然可以分析和理解整个项目的代码,而不仅仅只是对话里输入的内容!而我当时其实已经意识到了,既然它能分析整个项目然后根据需求来写代码,自然也可以用来挖洞,而再经过了一年的发展,claude code、codex 已经深切证明了我的直觉是正确的,通用 agent 已经拥有了远超常人的能力,并能被实际用来发现真实项目的 0day。于是又回到了最初的那个让人极其好奇的问题:当我们让 Agent 审代码的时候,它到底在做什么?到底是什么让 LLM 从一个 chatbox 变成能实际分析项目、理解项目的智能体呢?
codex
众所周知,codex 一直是开源的,所以我们可以直接找到它的代码:https://github.com/openai/codex
codex 是一个通用 agent,自然从设计之初就不是为了安全场景而服务的,代码中当然也没有任何安全扫描器、AST 分析器或漏洞规则库,整个 agent 其实只是一个极简的循环:把用户的请求 + 环境信息 + 工具 JSON Schema 丢给 LLM → LLM 吐出一个 shell 命令 → 本地执行 → 结果塞回上下文 → 再来一轮,因此对它而言,所谓”理解整个项目”,指的是模型自己决定跑什么命令、读哪些文件,把读到的字节留在上下文里,codex 的本质就是一个循环:
while true:
prompt = { 系统提示词, 历史记录, 工具Schema }
stream = 发给模型(prompt)
for item in stream:
if item 是文本: → 流式显示给你
if item 是 tool_call: → 并发执行,结果作为新 item 追加进历史
if 本轮没有任何 tool_call: → break(唯一的退出条件)
# 否则带着新历史回到循环顶部
所以 codex 把语义理解全部外包给模型,harness 保持无知,它不试图理解项目,也不试图给 LLM 拓展任何外在信息,而只是给模型一个 shell,让模型带着一个 shell 在项目里走一条贪心路径,并把走过的路每一步都往窗口里堆,堆满了就让自己写篇读后感然后把原始材料全扔了
因此当我们给 codex 一个需求:找出这个项目里所有安全漏洞,其实 agent 本身提供的帮助只是给了一个 shell 环境,harness 组装的 prompt 里关于项目的全部信息只有 environment_context(cwd/shell/权限)+ AGENTS.md 。而模型自身会去思考,这个项目到底是什么架构的,于是先去用命令查这是 Python 还是 Java 还是 Node,然后挑对应的 sink 模式,当然这个模式也不是仓库里任何地方的配置文件,是模型凭训练先验临场写出来的,比如查:
rg -n '(SELECT|INSERT|UPDATE|DELETE).*(\+|\|\|)\s*[a-zA-Z_]' -g '*.py' -g '*.java' -g '*.js'
当然了,rg 只告诉你哪一行匹配了,判断不了是不是漏洞,所以要再读具体匹配位置的代码:
sed -n '120,190p' src/api/users.py
假设发现可能确实存在拼接注入的问题,模型就需要去倒追参数来源,比如:
rg -n 'def get_user' -B3 -A25 # 找函数定义
rg -n '@app.route|@router\.(get|post)' # 找能不能从 HTTP 打到
如果模型觉得够了,就不会再调工具,而是直接结束。
而这个 workflow 在实际工作中其实有至少三个缺陷,首先,我们知道上下文窗口是有限的,搜到的代码自然也不可能全部喂给模型,codex 里 max_output_tokens 默认 10000,且是中间截断,截断策略是保留头尾、丢弃中间:
pub fn formatted_truncate_text(content: &str, policy: TruncationPolicy) -> String {
if content.len() <= policy.byte_budget() {
return content.to_string();
}
let original_token_count = approx_token_count(content);
let total_lines = content.lines().count();
let result = truncate_text(content, policy);
format!(
"Warning: truncated output (original token count: {original_token_count})\nTotal output lines: {total_lines}\n\n{result}"
)
}
所以模型需要通过 Warning 知道这里被截断了,然后通过反复的搜索来补全过去被截断的信息。
其次,由于 codex 其实并没有提供什么工具,所以跨函数追污点必须靠一串工具调用,而每步都在消耗窗口,比如对于下面的代码:
# handlers/user.py
@app.get("/user")
def get_user(req):
uid = req.args["id"] # 污点源
return dao.find_by_id(uid) # 跨文件
# dao/user.py
def find_by_id(uid):
return db.execute(f"SELECT * FROM users WHERE id = {uid}") # ← sink
虽然只是一个很简单跨文件污点传播场景,但要把这三步连起来,模型需要:读 handlers/user.py → 猜 dao 是哪个模块 → 读 dao/user.py → 确认,而这至少需要 4~5 次工具调用,且每次的输出都留在窗口里,而如果一个大型项目有几百个入口,很明显这条路走不了多远,光是判断这到底是不是需要的模块可能就已经用完上下文了。
最后,如果上下文到达限制了,就需要压缩上下文保留记忆,压缩会把读过的代码全部丢掉,只留模型自己写的摘要,这里只有一句非常简单的 prompt:
Create a handoff summary for another LLM that will resume the task
新历史由 build_compacted_history 拼装,只有三部分:
[ 最近的真实用户消息,预算 20_000 token ]
[ 模型刚写的 summary ]
[ 重新注入的 environment_context / AGENTS.md ]
这里还有一个特点,旧 summary 不累积,直接被新的替换,所以压缩后它只剩几条自己写的笔记,要重新确认第 30 个文件,得重新读一遍——而历史里没有任何东西告诉它之前扫过哪儿,而这实际上十分考验基模的能力。因此 codex 的设计哲学就是极简,将一切交由模型本身处理,所以当你使用一些可能并不那么适配 codex 的模型,会发现工作效率很差,因为 codex 把一切都交给模型自己了,如果模型自己的能力不够强,harness 环境很难提供什么帮助,因此整体就笨笨的(PS:怪不得我用 deepseek 接入 codex 感觉这么笨,做长任务的时候经常做着做着就忘了之前在干什么了,反复压缩记忆但是任务没有任何推进)
claude code
虽然 claude code 也不开源,但由于众所周知的原因,claude code 的代码泄露过一次,所以我们还是可以一睹 claude code 的设计哲学,这里我用的代码来自:https://github.com/ChinaSiro/claude-code-sourcemap
首先,和 codex 一样,claude code 没有预建代码索引 / 向量库 / RAG,唯一的索引是个文件路径模糊匹配器,是给 UI 的 @文件 补全用,只索引路径字符串,不看文件内容、不懂语义。当然,claude code 也没有漏洞关键词表,更没有 AST / 数据流 / 污点分析 / 符号执行这些高级的程序分析能力,只有正则导航 + 读原文 + 模型推理,因此还是靠模型的通用安全知识 + 通用检索工具来找漏洞,整体的 workflow 是下面这样:
while True:
# 1. 每轮重新组装输入
input = { 系统提示词, 全部历史消息(含历轮 tool_result), 工具schema }
# 2. 发给模型,流式返回
stream = callModel(input)
# 3. 边流边处理
tool_calls = []
for item in stream:
if item 是文本: → 流式显示给用户
if item 是 tool_call: → 记下来
# 4. 有工具调用 → 并发执行,结果作为新的 tool_result 消息追加进历史
if tool_calls:
结果 = 并发执行(tool_calls) # Glob / Grep / Read / Bash / Agent...
历史 += 结果 # 下一轮模型就能"看见"真实文件内容了
continue # 回到循环顶部,带着更长的历史再问一次
# 5. 本轮没有任何 tool_call → 唯一退出条件
break
所以对于 claude code 而言,循环退出的信号是”模型这一轮没有再要求调用工具”,而对于它而言,所谓的”理解项目代码”,便是工具执行后结果回灌进消息历史,模型看到真实文件内容后再决定下一步
我们知道 agent 其实就是有了 tool 的 LLM,模型有了工具,于是可以干涉和影响现实,而不再只是输出一堆文本,claude code 里内置的 tool 其实和 codex 一样挺少的,大部分就是一些必备的 shell 工具:
| 工具 | 实现 | 在”理解项目”里的角色 |
|---|---|---|
Glob | 文件模式匹配,按修改时间返回路径 | 找文件,如 **/*.tsx |
Grep | 真·ripgrep(utils/ripgrep.js,自带 rg 二进制),尊重 gitignore | 内容正则搜索——这就是你说的”关键词匹配”那一步 |
Read | 读文件(带行号,支持图片/PDF/notebook) | 读命中代码的完整上下文 |
Bash | 执行 shell | ls、git status/log、跑工具等 |
Agent | 派生子 agent(Explore 只读、general-purpose、verification) | 并行搜索、隔离上下文 |
所以和 codex 一样,当我们要求 claude code 找出一个项目里的所有漏洞,依靠的更多的只是模型本身的能力,而不是复杂精妙的 harness 设计。它会先摸清一个项目的地形,比如先用 ls 或者 Glob 看目录结构,然后读 README、package.json、pom.xml、equirements.txt 来建立对整个项目的初步理解,接下来基于技术栈猜关键词去 Grep 路由/入口,然后到了经典找 Sink 环节,模型根据上下文现场生成一堆可能存在漏洞的匹配模式,去整个项目的代码里正则匹配看是否存在,如果命中了,就 Read 打开每个命中的文件,看真实上下文,判断这里的 Sink 到底是不是攻击者可控的,用 Grep 找谁调用它、参数从哪来,手工在文件间跳转追溯。如果查询较多时会派 Explore 子 agent 分头搜,把噪音挡在主上下文之外,最后把发现按 文件:行号 组织成报告输出
所以和 codex 一样,当我们要求 claude code 找出一个项目的漏洞,它会遵循 sink 到 source 的模式,先猜一堆 sink 点,然后去看项目代码里到底有没有命中,如果命中了就根据实际的代码上下文分析这个 sink 点是否是外部可控的,harness 并没有提供太多的帮助,更多的还是靠模型本身的能力,模型强则 agent 强,模型弱则 agent 弱
不过有意思的是 claude code 的”记忆”远比 codex 复杂的多,它把”记忆”分成两个正交的东西:会话历史(会溢出,需要压缩) 和 session memory(一个持续维护的笔记文件),对 claude code 而言,压缩 = 把溢出的历史换成摘要 + 重新注入关键上下文
claude code 的记忆压缩有多个层次,第一个层次是 microcompact,不调模型,只丢内容,它只会清掉 Read / Bash / Grep 等工具的结果,保留最近 N 条,并把更早的结果替换成占位符 [Old tool result content cleared],有两种触发时机:
- 时间触发:距上条 assistant 消息超过阈值 → 说明服务端缓存已冷,反正要重写前缀,顺手清掉省 token
- cached microcompact:用 API 的
cache_edits机制在服务端删除工具结果,本地消息内容不动 → 不破坏前缀缓存
第二个层次是 autocompact ,此时会调模型,是真正的摘要,流程如下:
messages_in [m1 .. mN]
│
▼
① PreCompact hook → customInstructions
│
▼
② strip: 图片→[image],去掉重注入附件 → apiMessages
│
▼
③ fork 子agent 调模型 → summaryResponse
cacheSafeParams 对齐 / 工具全拒 / maxTurns=1
▲── 摘要请求自己超限 ── 按API轮次丢最老的组,重试 ≤3 ──┘
│
▼
④ formatCompactSummary: 删 <analysis>,留 <summary> → summaryMessage
│
▼
⑤ 清 readFileState / loadedNestedMemoryPaths
│
▼
⑥ 重建 messages_out
[ 边界标记 , 摘要消息 , 保留的近期消息 , 重注入附件 , hook结果 ]
│
▼
⑦ notifyCompaction / 遥测 / PostCompact hook
│
▼
messages_in ——> messages_out
且不同于 codex 全让模型自由发挥,claude code 的摘要提示词是硬约束的 9 段结构:Primary Request、Key Technical Concepts、Files and Code Sections、Errors and fixes、Problem Solving、All user messages、Pending Tasks、Current Work、Optional Next Step,最后一段甚至要求贴最近对话的原话以避免意图漂移
除了记忆压缩以外,claude code 还有一个 Session Memory,类似于一个后台笔记文件:
post-sampling hook(每轮模型回答后)
└─ shouldExtractMemory? (token 阈值 AND (工具调用数阈值 OR 本轮无工具调用))
└─ fork 子 agent(只允许 Edit 那一个文件)
└─ 更新 ~/.claude/... 下的 markdown 笔记
笔记是固定 9 章节模板:Session Title / Current State / Task specification / Files and Functions / Workflow / Errors & Corrections / Codebase and System Documentation / Learnings / Key results / Worklog,所以 agent 压缩记忆时可以直接拿这份笔记当摘要,不再调一次大模型做摘要,而且它天然是”面向继续工作”而不是”流水账”。
虽然看起来 claude code 的记忆模块远比 codex 复杂的多,但当你用 GPT 接入 codex,其实记忆压缩是由服务端做的,可惜我们看不了 openai 到底是怎么做这一块的,所以 codex 搭配第三方模型效果并不好,很多后端能力用不上,很多工具 Schema 用的并不熟练
PI
PI 向来以极简著称,核心也确实只是一个标准的 ReAct 循环,即”模型决定 → 执行工具 → 回填结果”,每一轮把上下文发给模型 → 模型返回文本 + toolCall → 本地执行 → 结果作为 toolResult 消息 push 回 currentContext.messages → 再发给模型,循环直到模型不再要工具,这就是对于 PI 而言”理解”的全部内容
所以当我们让它去一个项目里找漏洞,它不会先把整个项目详细理解一遍再分析,而是由模型自己即兴决定,所以 workflow 和前面两个都差不多,大概长这样:
ls看根目录,read package.json/README.md判断技术栈find **/*.ts或**/*.py摸清代码规模grep一堆它自己想的模式:eval(、exec(、innerHTML、password、secret、token、SELECT.*\+、child_process、subprocess、deserialize、md5、random……- 对命中文件
read全文,人工式逐行看 - 发现可疑处再
grep追调用方/数据来源,反复迭代 - 输出报告
pi 的工具集也只有 8 个通用的工具:
export const allToolNames: Set<ToolName> = new Set([
"read",
"bash",
"powershell",
"edit",
"write",
"grep",
"find",
"ls",
]);
只读场景走 createReadOnlyToolDefinitions,即 read / grep / find / ls,作用全都是取文本,没有一个负责理解代码结构的工具。不过 PI 的记忆模块倒是挺有意思的,对它而言,记忆是一条 append-only 的消息条目链,模型每轮看到的上下文是最近一次摘要 + 之后的原始条目,历史靠 LLM 生成的摘要有损地保留,不是靠检索
PI 的记忆被以一棵树的形式储存,一个会话一个文件,每条 entry 带 id / parentId / timestamp,甚至 entry 的类型远不止消息,还记录模型切换、思考等级、用量、书签、以及改写历史。当涉及到上下文重建时,模型每轮收到的上下文由 buildContextEntries 从当前叶子沿 parentId 回溯到根,然后只保留最近一次 compaction 及其之后的部分。而当上下文逼近模型窗口就会触发记忆的压缩,只保留最近约 20000 token 的原始消息,其余全部交给模型总结,总结格式是硬编码的结构化模板:
## Goal
## Constraints & Preferences
## Progress
### Done / ### In Progress / ### Blocked
## Key Decisions
## Next Steps
## Critical Context
且记忆的压缩不同于 codex 的丢弃历史重建,而是增量更新:如果存在上一次摘要,会用 UPDATE_SUMMARIZATION_PROMPT 把新内容合并进旧摘要,并明确要求保留所有既有信息、保留精确文件路径/函数名/错误信息
deepseek-harness
deepseek-harness 讲究万物即插件,这又是一种不同于前三者的 agent 设计哲学。虽然 dsh 的核心也是 ReAct 循环,真正干活的核心是 agent-loop 这个插件,基本单位是 step:一次模型请求 + 它发起的所有工具调用。在工作的时候有一份 session 日志,模型看到的所有东西都来自这份日志,工具结果写回日志,下一 step 再投影给模型
非常有趣的是,dsh 的系统提示词默认几乎是空的,只是一堆按 order 排序的分片,每个工具自己贡献一段,因此 dsh 同样没有在提示里教模型应该怎么做,它只是提供能力,让模型用自己的判断去用工具解决问题
但和前面三个 agent 的 tool 里只提供最简单的 shell 命令不同, dsh 其实是提供了 lsp 的,但它明确被定位成辅助,且只在文本匹配歧义时才用:
export const LSP_PROMPT_TEXT =
'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. ...'
所以它本质上仍然只是一种启发式采样,它无法理解整个项目,单次的工具调用结果都有硬上限,glob 100 条路径、grep 250 条匹配、每行 2000 字节,超出的完整结果落盘,把 locator 给模型,需要时再取,subagent 工具把独立子任务丢到独立上下文的子里程去跑,只回传结论,不污染主线上下文,因此当我们给它一个”审计整个项目”的任务,现实里往往是:主线规划 → 拆成 N 个 subagent 各审一块 → 主线汇总,这也是为什么它能对一个远超上下文窗口的项目给出看起来完整的报告,但本质上还是和前面三个一样是关键词匹配然后 Sink to Source 的分析,整体还是依靠模型本身的能力
不过对于 dsh 而言,它其实没有”记忆”这个对象,所谓记忆 = append-only 的会话事件日志;所谓压缩其实只是在日志上追加一次”把 surface 上一段区间替换成一条检点消息”的事务,日志永不删改,模型看到的上下文只是日志的一个派生投影。生成摘要时并不会另起一套 summarizer system prompt,而是原样重放对话前缀(system + tools + 被选区的 messages),把压缩指令作为最后一条 user message 追加。加上工具 schema 后,这次辅助调用是上一次真实请求的真前缀,provider 的预热缓存能直接命中,只有指令那一小段是新的 token,指令强制一个固定 8 段 markdown 模板(Primary Request and Intent / Key Technical Concepts / Files and Code / Errors and Fixes / Pending Jobs / Current Work / Next Step / Critical Context),并明确要求”已有 <compacted-summary> 块时不得照抄,要合并去重”。并且落地前有一套硬校验,必须严格小于被压区间的 route-priced token 数,否则抛错、不提交,所以能保证压完肯定是更小的
总结
所以和我最初的猜想有极大的不同,其实我之前一直以为这些通用 agent 在理解项目代码上会有很复杂的设计,比如 embedding、RAG 等等,而实际上他们的设计远比我想的要简单的多的多,其实只是给模型提供了一些必要的工具,比如搜索代码、执行 shell 命令,而模型本身依靠这些其实很简单的工具就已经能表现出极强的能力了。我们经常能听说什么不懂技术的小白用 claude code 接一个 deepseek 就能发现 0day 了,而其实通用 agent 本身的设计已经非常简单了。
这让我想到了最近陆队写的一篇博客:From Code to Chain: Why WP2Shell Stayed Out of Reach,这篇博客是讲他怎么测试几个主流大模型的长程代码审计能力的,而结果是 GPT 5.6 Sol / GLM-5.2 / Qwen 3.8-Max-Preview 均未能在白盒审计的情况下发现 WP2Shell。这非常有趣,对于黑盒渗透的场景,我们知道 agent 仍有很大的局限,过去我们讲渗透的本质是信息搜集,而在黑盒的情况下很多时候是靠猜,靠经验,而如今的模型还没有训练到全知全能的程度,很多刁钻的场景它猜不到怎么入手,缺乏类似的经验,所以它无法完成任务(比如根据目标公司的名字和年份编社工密码爆破后台)。
但在白盒的场景,我想很多人现在都认为通用 agent 已经能 cover 一切了,毕竟都有代码了嘛,而模型最擅长的就是理解代码,那为什么还是有它发现不了的漏洞呢?从这篇博客里我们知道,甚至不是因为这是一种全新的它没学过的利用手法,而是因为模型缺乏长程探索中的源码搜索、候选保留与跨层漏洞连接能力。把有漏洞的代码发给模型,模型知道这里有问题,但当让它真的拿着一个完整项目代码开始分析时,它可能根本定位不到哪里是有问题的代码。而从上面四个 agent 的分析结果来看我们知道,现在的通用 agent 其实只提供给了模型很基础的能力,只是一些最简单的搜索代码的 tool,因此想要完成任务几乎全部依赖模型自身的能力,如果模型给出的漏洞 pattern 什么都没有匹配到,它也就无计可施了,特别是对于一些不那么经典的非注入类场景更是如此,对于 SQL 注入,好歹还可以定位所有 SQL 查询语句一个一个看,如果是业务逻辑的问题,可能根本就抽象不出来这么简单的 pattern,而模型也不可能一个文件一个文件的完整看完整个项目所有代码,因此就会错过那些真正的漏洞