让 Agent 自己长经验,又不让它改自己的指令

我给告警系统挂了一个自治调查员:告警升级到一定级别,它就起一个 Agent 会话,读日志、查指标、翻历史,写一份报告回来。只读——它有 Bash、有 MCP、有 WebSearch,但拿到的凭据是只读的,命令还过一道守卫。

跑了一段时间之后我想让它长经验:调查完一次,把过程蒸馏成一份 runbook,下一次遇到同类告警先读它。第五次调查同一个现象,应该比第一次知道得多。

实现这个功能的时候我才注意到一件事:这个 Agent 的工作目录,正是它自己那份常驻指令所在的目录。

结论先放前面:Agent 的常驻指令(CLAUDE.md、system prompt、skills、subagent 角色)如果落在它自己可写的路径上,那么一次提示注入就不再是一次性的——它会操纵此后每一次运行,而且读起来像是运维自己写的 runbook。挡这件事不能靠只读挂载(Agent 和服务同一个 UID、同一棵进程树,挂载分不清谁是谁),只能在工具路径上拦。而且要两道:一道精确拒绝,一道不管你怎么改都能发现。

1. 危险的那个巧合

把条件摊开看:

  • Agent 持有 WriteEdit
  • 它跑在 bypassPermissions 下(无人值守,没人能点”允许”);
  • 它的工作目录是一个可写卷,上面放着:环境记忆 CLAUDE.md、追加的方法论 system-prompt.md、前几次运行蒸馏出的 .claude/skills/、subagent 角色定义。

三条凑在一起,结论就是:没有任何东西阻止一次运行去改写它们,而下一次运行会把这个改动当成指令加载。

我在代码注释里把这件事写成了一句话,因为它是整个设计的支点:

引擎会给不可信的告警正文加围栏,所以一条被注入的指令最多只能操纵读到它的那一次运行;但一条进了 .claude/skills/ 的注入,会操纵它之后的每一次运行,而且读起来像是运维自己写的 runbook

这就是一次性提示注入和持久化提示注入的区别。前者是个事故,后者是个后门。

而且要注意注入源在这个场景里是结构性存在的:调查 Agent 吃的就是告警正文、日志行、异常堆栈——全是外部系统产生、上游可能被污染的文本。围栏能限制它的作用范围,但前提是”作用范围”真的只有这一次运行。

2. 为什么不能用只读挂载解决

我第一反应是把那几个路径挂成 :ro。行不通,两个原因:

第一,挂载分不清 Agent 和服务。 运维要通过 HTTP 端点改环境记忆、装 runbook、审核建议——那是四个合法的写入路径。而 Agent 和服务共享同一个 UID、同一棵进程树:Agent 就是服务 fork 出来的 SDK 子进程。文件系统权限没有任何维度能把”服务在写”和”Agent 在写”区分开。

第二,挂成只读会把运维的四个写入端点一起干掉。 也就是说,为了挡住攻击,你把正常功能一起挡了——这不是防御,这是把功能删掉。

所以这件事只能是代码,而且必须拦在Agent 的工具路径上,让服务自己的写入路径不受影响。这个区分是整个模块存在的理由。

3. 两道防线,因为它们的失效方式不同

3.1 精确拒绝:write_deny_reason

Write/Edit 的入口上判断目标路径,命中就拒绝并说明理由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_PROTECTED = (".claude", "CLAUDE.md", "system-prompt.md",
"audit", "memory-suggestions.jsonl", "remediation")

def write_deny_reason(raw_path, *, workdir, home=None):
candidate = Path(raw_path)
if not candidate.is_absolute():
# 工具给的路径相对于 Agent 的 cwd,也就是 workdir
candidate = workdir / candidate
candidate = _resolve(candidate) # 绝对化 + 解符号链接
for root in protected_paths(workdir, home):
if candidate == root or root in candidate.parents:
return (f"input guard: {root} steers the next run, "
"so this runner may not write it. …")
return None

两个细节值得单说:

  • 必须解符号链接_resolve() 的注释就一句话:绝对化且不含符号链接,这样一个链接就绕不过守卫。少了这步,ln -s .claude/skills tmp 之后写 tmp/x/SKILL.md 就穿过去了。
  • 相对路径要按 Agent 的 cwd 补全,而不是按服务的 cwd。这两个在容器里恰好相同,很容易写错还测不出来。

3.2 不管怎么改:运行前后取指纹

精确拦截有个天花板:它只管 WriteEdit 这两个工具。Agent 还有 Bash。而 bash 守卫是一堆正则——它自己的 docstring 就明说了不声称正则是边界(下面第 6 节)。

所以第二道防线换个思路:每次运行前后各取一份指纹,比对。

1
2
3
4
5
def fingerprint(workdir, home=None):
"""每个输入文件一个摘要,用于跨一次运行的前后比对。"""
...
def changes(before, after):
"""这次运行对自己的输入做了什么,用人话说,或者空列表。"""

不关心文件是怎么变的,所以 echo ... >> .claude/skills/x/SKILL.md 这种绕开工具守卫的写法照样会被抓到。两道防线的分工是:一道管”注入首先会去碰的东西”,一道管”我没想到的路径”。

指纹里刻意不包含 audit/ 那是飞行记录仪,每次工具调用都追加一行——它在每一次健康的运行里都会变,放进指纹会让所有运行都报篡改。一个永远报警的检测器等于没有检测器(这个坑我在别处也踩过)。

3.3 验证本身也是攻击面

指纹计算带了上界:最多 400 个文件,每个文件只摘要前 1 MB。

1
2
_MAX_FILES = 400
_MAX_BYTES = 1 << 20

理由是:一次运行不能靠往 skills 目录里丢一棵极大或极深的树,来让针对它自己的验证变得昂贵。 想到这一层,是因为验证代码和被验证对象跑在同一个资源池里——你的检测机制如果能被它的检测对象拖慢,它就不是检测机制。

4. 保护清单里三个不那么明显的条目

.claude / CLAUDE.md / system-prompt.md 是显然的——它们就是指令。另外三个的理由不一样:

audit/ —— 飞行记录。理由一句话就够:一份能被自己的记录对象改写的记录,不是记录。

memory-suggestions.jsonl —— Agent 是通过报告来”提议”往环境记忆里加事实的,队列由服务写。如果它能直接写这个队列,它就能伪造归属(把自己的话记成别人提的),或者把别人的待审建议冲掉。绕开守卫用 shell 写,最坏也只是往队列里塞几行等着被运维驳回的东西——永远碰不到记忆本身

remediation/ —— 同理,处置建议由报告提出。即使伪造了一条建议的来源,那条建议也是惰性的:审批和白名单挡在任何建议和执行之间。

这三条的共同点是:把”提议”和”落盘”分成两个主体。Agent 说,服务写。

5. 后果:碰过自己输入的运行,不许留下经验

检测到了之后怎么办?记一条 warning 显然不够。真正的后果在学习闭环那一侧:

1
2
if input_changes:
return {"skipped": "run changed its own inputs"}

一次改写过自己输入的运行,它的结论不允许进 runbook。 这个后果和缺陷本身是对齐的:你动过操纵下一次运行的那个面,你就失去了往那个面上写东西的资格。

6. bash 守卫的诚实定位

顺带说一下那道 bash 守卫,因为它的自我定位写得比它的实现更重要:

真正的安全边界是挂进容器的凭据:给这个 runner 一个只读的 kubeconfig 和只能查询的 token,这里的东西就都不重要了。这道守卫是纵深防御——它拦掉一个 SRE Agent 会去碰的那些 CLI 的常见变更动词,让一个过于积极的模型得到一句明确的拒绝,而不是一次实弹误伤。它刻意偏向过度拦截(一个名字里就带 delete 的 pod 会误触):Agent 可以换个说法再查一次,集群不能把一次变更 un-apply。

还有一条边界说明也值得抄:HTTP 动词刻意不管——证据收集本身就要往 Loki / Elasticsearch 的查询接口 POST。云厂商 CLI 太多,数不完,那就去限制它们的凭据,别在这里列白名单。

一道守卫如果不承认自己不是边界,使用它的人就会把它当边界。而正因为它承认了,上面那道指纹才有存在的必要。

容器边界一并收紧了,理由都写在 compose 的注释里:

1
2
3
4
5
mem_limit: ${HOOKPROBE_MEM_LIMIT:-2g}   # SDK 的 CLI 是个 node 进程,一次运行 2g 宽裕
pids_limit: 512 # fork bomb 撞在 512 上,而不是撞在宿主上限上
cap_drop: [ALL] # 调查员只开 socket、读文件
security_opt:
- no-new-privileges:true # setuid 程序不再是提权阶梯

7. 学习闭环:三种写入语义,只有一种对

挡住了乱写,还得让它能正常地写——不然这个功能就没了。这里有三个选项,前两个都错。

只能创建,不许更新。 最安全,也最没用:一份 runbook 在第一次事故之后就停止学习了。而”Agent 该去读往期案卷”这件事的全部理由,就是第五次调查应该比第一次知道得多。

更新即整体替换。 这是把退化伪装成学习:一次运行只知道自己这一趟走了什么,一次浅层运行会把一份已经见过五次事故的 runbook 抹平成它自己那点内容。

在标记区内追加一个案例。 这是对的:

1
2
3
# 自动写入唯一被允许触碰的接缝。这之外的一切——frontmatter、标题、
# 信任说明,以及运维自己加的任何东西——每次更新都原样带过、不去读它。
CASES_MARKER = "<!-- hookprobe:cases -->"

新案例插在标记后面、最新的在最前。标记之外的内容一律原样保留——那里正是运维的人工修正所在,所以它们能活过之后的每一次自动写入

还有一个小而漂亮的处理:如果标记不见了(说明运维重构过这份文档),就追加,而不是去猜它现在的结构。不认识的形状不要瞎改,这条在任何”程序修改人写的文件”的场景都成立。

8. 自动写出来的东西,要自己声明可信度

自动蒸馏出来的 runbook,顶上带一段固定的”这东西有多可信”:

  • 它是那次调查结束时自动写的,没有任何人审过。它是一条线索,不是流程
  • 死胡同没有被记录:运行只保留了跑过哪些工具,没保留它们返回了什么,所以上面列出的某一步可能是白跑的。
  • 任何只属于那一天的东西——主机名、金额、一次性的故障——在这里都是噪音,不要因为它出现在上面就照着做
  • 之后同类调查会在上面追加案例,最新在前。你写在那个列表之外的任何东西,都能活过每一次追加。
  • 如果它是错的,删掉它;历史版本在 history/ 里。

这一段的作用是把”机器生成”这个属性写在产物内部,而不是靠读者记得。尤其第二条——没记死胡同——是这类自动蒸馏最容易骗人的地方:一串工具调用看起来像一条推理链,其实里面混着走错的路。

9. 「什么都没做」必须可见

最后一个设计决定,和安全无关但同样重要。自动蒸馏的返回值一定是三者之一,而且三者都记在运行记录上

1
2
3
{"installed": name}   新建了一份
{"updated": name} 追加进已有的
{"skipped": reason} 没写,以及为什么

注释里写了理由:“这个闭环又什么都没做”正是这个功能存在要解决的失败,所以它绝不能是不可见的。

这个功能上线之前,代码里写着”skills 目录是前几次运行蒸馏的产物”、Agent 被告知”去读往期案卷”——而生产环境里那个目录从建立那天起就是空的。闭环恰好断在让下一次调查变便宜的那一步,而且没有任何信号。

10. 清单

  1. Agent 的常驻指令不能落在它自己可写的路径上。先去确认一下你的 workdir 和你的 CLAUDE.md 是不是同一个目录。
  2. 围栏(fence)限制的是注入的作用范围,前提是那个范围真的只有一次运行。能写到指令面上,围栏就白做了。
  3. 只读挂载解决不了:Agent 和服务同 UID、同进程树,挂载分不清;而且会连带干掉运维的合法写入路径。只能在工具路径上拦。
  4. 两道防线,因为失效方式不同:路径精确拒绝(管工具)+ 运行前后指纹比对(管你没想到的路径,比如 shell)。
  5. 路径判断要解符号链接,相对路径按 Agent 的 cwd 补全。
  6. 指纹里不要放每次运行都会变的文件(比如审计日志),否则它永远报警,等于没有。
  7. 验证也有成本,给它加上界——否则被验证对象可以把验证拖慢。
  8. 保护清单里别忘了:审计日志(能被对象改写的记录不是记录)、待审队列(防伪造归属)、处置建议(防伪造来源)。
  9. 检测到之后要有对齐的后果:动过自己输入的运行,不许往输入面上写东西。
  10. 自动沉淀用在标记区内追加,不用创建-only(会停止学习),也不用整体替换(把退化伪装成学习)。标记之外原样保留,人的修正才能活下来。
  11. 自动产物要自带信任声明,尤其要说清”死胡同没记录”。
  12. 闭环”什么都没做”必须可见——那才是它要解决的失败本身。

代码在 github.com/itswl/hookstackhookprobe/ 下,上面引的都在 hookprobe/inputs.pyhookprobe/guard.pyhookprobe/distill.py 里。