type
Post
status
Published
date
Jan 11, 2026
slug
agent03
summary
tags
Agent
category
icon
password
我做这个 mini runtime 时,一开始把一件事想简单了:模型会改代码,并不等于系统应该让它直接改代码。这里的 runtime,用白话讲,就是夹在模型和代码仓库之间的执行层:模型提出下一步动作,runtime 负责检查状态、核对上下文、决定要不要审批,最后才执行工具。一个 repo 任务里,写文件、打 patch、跑 shell、跑测试,每一步看起来都只是工具调用,但只要其中一步放错了权,后面的 diff、测试和 timeline 都会跟着失去可信度。安全是让每一次写入都有状态、上下文和审批理由。
这篇只讲一句话:一条写代码请求,必须先被 runtime 检查、修正、审批,必要时停住,才有资格落到真实 repo 里。一个能改代码的 Agent,先要学会按规则过关。
第一关:approval 不够,先确认读过目标文件
很多人一开始会把安全理解成“改代码前弹个确认框”,我也这样走偏过。
我最早做审批时,很容易把它想成“模型要改文件,前端弹一个确认框”。往下做我才发现,确认框只是展示层,决定能不能继续的地方应该在 runtime 里。我遇到一种情况:模型可以在没读目标文件的情况下,直接提出 file_patch demo_app/string_tools.py。如果系统只看“这是不是写操作,要不要审批”,它仍然会把这个请求送进审批流;但这时,模型还没有最近的文件上下文。approval 只能回答要不要放行,不能说明模型有没有看清楚目标文件。
所以现在 TaskSession 会在写入前检查 recent file context。file_patch 必须最近读过目标文件;已有文件上的 write_file 也必须最近读过目标文件;只有新文件上的 write_file 才能跳过这层检查,继续进入后面的审批流。对应回归里,模型第一次想直接 patch demo_app/string_tools.py,runtime 会把它打回成先 read_file demo_app/string_tools.py:
file_patch demo_app/string_tools.py
-> invalid: edit without recent file context
-> retry as read_file demo_app/string_tools.py
-> next step can request file_patch
-> approval_required
从这里开始,我就不再把安全写成几个散开的功能点,而是按一条工具请求的通关路径来看:它先过任务状态,再过参数和 patch 检查,再过 recent file context,最后才交给 ApprovalPolicy 做 allow / ask / deny。这个顺序很重要,因为它说明模型输出只是意图,系统还要把这次意图翻译成一个可执行、可审批、可拒绝的动作:
tool_request
-> session state check
-> tool args / patch snippet check
-> recent file context check
-> ApprovalPolicy: allow / ask / deny
-> execute / approval_required / denied
-> latest result + diff + timeline
第二关:计划没批准前,repo 只能读
这条请求继续往前走,第一眼要看的是 session 现在还在不在 plan mode。在这套系统里,plan mode 先是权限状态,随后才是计划阶段。session 刚 begin_task() 以后,repo 还是只读的;只有 plan 被确认并批准以后,变更型工具才有资格继续往下走。这里最有价值的地方是权限状态被显式写进了 session,而不是靠 prompt 提醒模型“先别改”。
begin_task
write_file notes.txt -> denied
approve_plan
write_file notes.txt -> approval_required
resolve_approval -> executed
上面这条回归:计划批准前直接 write_file,结果是 denied,消息里会明确提到 plan mode;批准以后,同样的写入不会直接执行,而是进入 approval_required,再由人决定是否放行。我想说的是,计划批准前不能改代码,这件事必须由 runtime 保证。
第三关:写操作停下来时,原因得说清楚
因为真实 repo 里的写入,风险在“谁来承担这次执行”。
如果这条请求已经过了 plan mode,它也不会因为“可以写”就直接落地。file_patch 和已有文件上的 write_file 会进入 edit approval,session 里会记录 approval_kind=edit,timeline 里也会留下 approval_requested。
我比较在意 approval_kind 这个字段,因为它把不同风险分开了。edit、shell、test 都可能需要审批,但它们的含义完全不同。改代码需要人确认 diff,shell 需要人判断命令风险,未知测试命令需要人确认它是不是当前 repo 可接受的验证方式。把这些都压成一个普通的 approval_required,文章看起来简单,系统调试时会很难受。
我想说的是,停下来问人可以很简单,但停下来的原因要足够清楚。
第四关:shell 和测试不能混在一起
因为只要 shell 太自由,前面的工具边界很容易被绕过去。
如果这条请求想绕到 shell,我会把它当成另一道关来看。我一开始也觉得 shell 很方便:测试可以用 shell 跑,读文件可以用 shell 看,临时检查也可以靠 shell。往下做我才发现,shell 一旦变成通用后门,read_file、run_test、file_patch 这些专门工具的边界都会被冲淡。所以 V1 里我让 shell 保持保守,只承担受限检查,不承担通用执行。
ApprovalPolicy 里现在有三类 shell 判断。危险前缀,像 rm、sudo、curl、wget、ssh、git reset、git clean,直接 deny;只读检查,像 pwd、ls、rg、git status、git diff,可以直接放行;其他 shell 命令默认 ask。这样做不复杂,但它把“命令能不能跑”这件事从模型输出里拿了回来。shell 的安全边界应该靠策略判断,不该靠模型自觉。
测试我也没有放在普通 shell 里随便跑。常见的 pytest / unittest 前缀可以通过 run_test 进入 allowlist,未知测试命令会转成审批;如果模型想用 shell 直接跑测试,runtime 会提示它改用 run_test。这样做的原因很简单:测试结果要和当前 repo 状态、timeline、finish 判断绑在一起,不能散落在一次普通 shell 输出里。测试不是随手跑一下,它是 Agent 判断任务能不能收尾的依据。
如果模型想用 cat 或 sed 直接读 repo 文件,runtime 也会提示它改用 read_file。这不是为了形式统一,而是为了让文件上下文回到 TaskSession 里,后面的 read-before-edit 才有东西可查:
shell rm -rf . -> denied
shell python3 -m unittest ... -> use run_test instead
shell cat demo_app/string_tools.py -> use read_file instead工具分工本身也是安全边界的一部分。
第五关:坏 patch 不该交给人判断
把明显无效的修改送进审批,只会把风险转嫁给人。
如果这条请求终于走到了 patch,本身也要先过机器能判断的质量检查。file_patch 必须带 relative_path、expected_old_snippet 和 new_snippet;expected_old_snippet 找不到会失败,匹配到多处也会失败;new_snippet 和旧片段完全一样,会被判成 no-op。更关键的是,这些错误会在请求审批前就失败,不会先生成一个 pending approval 让人去处理。审批前也要先做机器能判断的质量检查。
这里的取舍很简单:能确定是坏请求的,就不要交给人批准。比如 expected_old_snippet 匹配到多个位置,系统已经知道这个 patch 不够精确;这时让人点 approve 没有意义,应该直接 failed,timeline 里留下 tool_failed。这比“所有写入都进审批”更保守,也更省人的判断成本。
file_patch expected_old_snippet not found -> failed, no approval_requested
file_patch expected_old_snippet matches multiple places -> failed, no approval_requested
file_patch expected_old_snippet == new_snippet -> invalid / repair
approval 不应该替 runtime 背所有校验。
第六关:能修就修,不能修就停
agent 安全不能只看哪些动作能做,也要关注系统什么时候必须把控制权还回来。
agent/loop 遇到 approval_required 会停下来,不会自己绕过审批继续找别的动作。遇到 denied、failed、rejected 也一样,会把 stop reason 明确留下来。这个机制把“不能继续”写成了系统行为,而不是等模型自己意识到该停。
这也是我区分 repair 和 stop 的原则。没先读文件就 patch、用 shell 跑测试、用 shell 读文件,这些错误还能被改写成下一步明确、低风险、可验证的动作,所以可以 repair;一旦触到审批、执行失败、危险命令拒绝,或者需要人判断的时候,就应该 stop。
edit_without_read -> repair to read_file
shell_test_misuse -> repair to run_test
shell_file_read_misuse -> repair to read_file
approval_required -> stop and wait for user
tool_failed / denied / rejected -> stop with reason
能修的错误交给 runtime 修,不能修的风险要把控制权还给人。
总览
安全闸 | 现在已经有的实现 | 安全闸用于哪里 |
read-before-edit | file_patch 和已有文件 write_file 要有 recent file context | 编辑上下文 |
plan mode | 计划未批准前,变更工具直接 denied | 入口权限 |
edit approval | file_patch / write_file 进入 approval_required,带 approval_kind=edit | 写入放权 |
shell policy | 危险前缀 deny,只读前缀 allow,其他 shell ask | 命令边界 |
test allowlist | 常见 pytest / unittest 前缀通过 run_test 放行,未知测试命令审批 | 验证边界 |
patch contract | 坏 snippet、多匹配、no-op patch 在审批前失败 | 审批前校验 |
我现在怎么理解 Agent 安全
为什么 Agent 不能直接改代码:因为模型输出只是一次意图,真实 repo 里的写入、命令和收尾,都要经过 runtime 的状态、策略和上下文判断。plan mode 先管入口,read-before-edit 管编辑上下文,ApprovalPolicy 管放权,patch contract 管请求质量,stop reason 管什么时候把控制权还给人。安全是让系统知道什么时候必须停住。
项目已经开始处理 coding agent 里的一类问题:什么时候可以写,什么时候必须问,什么时候直接拒绝,什么时候停下来。一个能改代码的 Agent,先要学会在正确的地方停住。
分享
