从0打造编码Agent 阅读 + 实验 ≈ 25 分钟 页内实验 ×1
A3 权限与审批:它要执行 rm -rf 怎么办? 第 1 课

拦危险命令,写个黑名单行不行?

黑名单
把四个真实项目里的黑名单(learn-claude-code 两版、CoreCoder、Pi)移植进浏览器,对同一批命令现算:改写过的 rm 一条都拦不住,文件名里带 sudo 的 cat 却被拒绝。改进方向是:由 harness 执行、解析命令,并承认规则看不见的部分。
❓

本课一问:模型要执行 rm -rf /,harness 该怎么拦?最容易想到的办法是列一张危险命令的黑名单——这行不行?

这一课先不碰:规则多了互相冲突听谁的(第 2 课)、没把握的调用要不要停下来问人(第 3 课)、规则拦不住的部分交给操作系统沙箱(A4)。

从已有出发

到 A2 为止,模型已经能通过工具真的改文件、跑命令了。我们的贯穿任务「让失败的 test_parse_date 通过」在项目 miniagent 上真实跑了 20 次(Qwen/Qwen3-8B-MLX-4bit,本机录制),模型一共发出 116 次工具调用,其中 bash 33 次,内容只有这几种:「python -m unittest -v test_dateparse.py」、「python -m unittest -v test_dateparse」。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

每次调用在执行前都要经过 harness——那正是放闸门的地方。循环里的 tool_use 到了这一步,harness 可以选择执行它、拒绝它(把拒绝理由当作 tool result 回填给模型),或者先停下来问人。这一章回答:谁来拦、按什么拦、拦不住什么。

真实运行里的命令都很温和。但同一个循环也会原样执行 rm -rf /——只要模型发出来。所以先做最朴素的防线。

先自己设计:列一张危险命令黑名单

教学项目 learn-claude-code 的第 1 章就这么做:run_bash 执行前检查命令里有没有 5 个危险子串(s01_agent_loop/code.py:72-74)。第 3 章升级成三道闸:先查一张子串 DENY_LIST,再用正则找 rm / del 转人工,其余放行(s03_permission/code.py:146-202)。CoreCoder 的 bash 工具里有 10 条正则(tools/bash.py:39-53),Pi 的示例扩展 permission-gate 有 3 条(permission-gate.ts:11)。

下面把这四套黑名单原样移植进浏览器,对同一批命令现算。命令是构造输入(第 10 条取自真实运行),裁决由移植的原算法现算;页面最下方的自检行说明移植与 pin 源码实跑结果一致。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案子串 / 正则黑名单
四个黑名单,同一批命令

组件加载中……(需要 JavaScript;所有裁决都在你的浏览器里由移植的原算法现算)

🎯

试三件事:① 看第 2 行 r''m -rf /:四列全是「放行」——shell 执行前会去掉那对空引号,跑的还是 rm;② 在输入框里输入 rm -r -f /、本意选「危险」,看 lcc s01 那一列(它只认连写的 rm -rf /);③ 取消勾选「Pi 有 UI」,Pi 那一列的「问人」全部变成「拦下」,其中 rm -rf build/ 变成误拦。

四个黑名单在这 6 条危险、5 条日常命令上的成绩(构建时由 pin 源码的实跑结果统计):

黑名单漏拦(危险却放行)误拦(日常却直接拒绝)
lcc s01:5 个子串5 / 62 / 5
lcc s03:三道闸4 / 62 / 5
CoreCoder:10 条正则3 / 61 / 5
Pi permission-gate(有 UI,命中即问人)4 / 60 / 5

有 3 条危险命令在四个黑名单里全部放行:「r''m -rf /」、「python3 -c "import shutil; shutil.rmtree('/')"」、「find / -delete」。它们各代表一种改写:插空引号、换一个程序做同一件事、不用 rm 也能删。命令替换 $(echo rm) -rf / 碰巧被 CoreCoder 的正则认了出来——rm 两个字母还留在字面上;换成 $(printf r)m 之类的写法,它也认不出。黑名单只能列举写法,而同一个效果的写法没有尽头。

为什么子串挡不住:拆开一条正则看

lcc s03 的第二道闸是整张黑名单里最「聪明」的部分。逐项点开,看它防的是什么、漏的是什么:

lcc s03 Gate 2 的正则(s03_permission/code.py:156-158)
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
这条正则只决定「转人工」,不直接拒绝;真正直接拒绝的是第一道闸的子串表 DENY_LIST(code.py:146),cat sudoku_solver.py 就是被那里的 sudo 拒掉的。

两类失败都是结构性的,不是写得不够细:

⚠

常见误解:「在 CLAUDE.md 里写『不许删文件』就够了。」

不够。prompt 和 CLAUDE.md 只影响模型想做什么,不影响 harness 允许什么。Claude Code 官方文档的说法是「Permission rules are enforced by Claude Code, not by the model.」:提示词和 CLAUDE.md 塑造的是模型的尝试,改变不了 Claude Code 放行什么。 lcc s03 自己就是例证:它的 system prompt 写着「All destructive operations require user approval」(code.py:59),真正起作用的却是 check_permission 这段代码。

改进 1:闸门在 harness 里,不在 prompt 里

三道闸之所以是「闸」,是因为它们写在 harness 的循环里:模型发出 tool_use → harness 调 check_permission → 返回 False 就不执行,把拒绝原因当作 tool result 回填。模型看得到被拒,可以换一种做法,但没有办法跳过这段代码。后面每一代方案都保留这一点;变化的只是「按什么拦」。

权限设计的演进:每一代都被上一代的某个真数字逼出来
  1. Pi 核心、bypassPermissions、CoreCoder --yes
    解决了零打断,模型想跑什么马上跑。
    卡在L1 的 6 条本意危险的构造命令(含 rm -rf /)会全部直接执行。
  2. 朴素 黑名单:子串或正则 你在这里
    lcc s01 / s03、CoreCoder bash 正则、Pi permission-gate
    解决了把已知危险的写法挡在执行之前,代码只有几行。
    卡在lcc s03 三道闸(pin 源码实跑):6 条危险命令里漏拦 4 条,5 条日常命令里误拦 2 条(cat sudoku_solver.py 因含子串 sudo 被拒)。
  3. 第一代 每次都问人
    CoreCoder 交互模式、Claude Code Manual、lcc s03 的第三道闸
    解决了判断交给人,规则写不全也不怕。
    卡在我们 26 次真实运行的 139 次工具调用,在 CoreCoder 交互模式下有 74 次要停下来问(53%);Anthropic 自报用户批准了约 93% 的弹窗——问多了,人就不看了。
  4. Claude Code(deny → ask → allow)、OpenCode(last-match)、Codex(最严格者胜)
    解决了把常见的放行和拒绝写成规则,只有规则没覆盖的才问人。
    卡在同一份规则换一种求值语义可能换一个答案:同一份 rules-standard.json 改用 first-match,真实运行 pd-firstmatch-s1 里模型的 python -m unittest -v test_dateparse.py 被规则 #8 拒掉。
  5. Claude Code auto、Codex Auto-review(代码名 Guardian)、Hermes smart
    解决了只读直放、项目内编辑免审,其余交给另一个模型判断;弹窗接近清零。
    卡在Anthropic 自报:auto 分类器在真实越权动作上仍有 17% 漏判(n = 52),官方原话「does not guarantee safety」。
  6. 下一章 与 OS 沙箱分层
    Claude Code sandbox、Codex Seatbelt / bubblewrap
    解决了规则管「允许调用什么」,沙箱管「调用之后能碰到什么」。
    卡在L1 的 python3 -c "import shutil; shutil.rmtree(…)" 在四个黑名单里被 4 个全部放行;规则看不见脚本在运行时做什么 → A4。

数字口径:黑名单与「每次都问」由原实现在 pin 源码上实跑(a03-permission-fixtures.json,2026-09-26);真实运行 = 本课程项目 miniagent 用 Qwen/Qwen3-8B-MLX-4bit 本机录制;93% 与 17% 为 Anthropic 官方自报。

改进 2:解析命令,对每个子命令分别要权限

OpenCode 的 bash 工具走的是另一条路(packages/opencode/src/tool/shell.ts,pin v1.18.32):

命令(按词切开)生成的「always」规则
git status --shortgit status *
npm run devnpm run dev *
npm installnpm install *
python -m unittest -v test_dateparse.pypython -m *
rm -rf build/rm *
cat sudoku_solver.pycat *
git push origin maingit push *
docker compose updocker compose up *
ls -lals *
find / -deletefind *

生产级的 Claude Code 同样解析命令:它识别 &&、||、;、| 等分隔符,规则要对每个子命令分别成立;对 rm / rmdir 指向根目录、主目录、工作目录这类 critical path 的命令,任何 allow 规则和 hook 都不能批准,哪怕它藏在子 shell 或命令替换里 。

改进 3:承认拦不住的部分

解析能让规则「看清命令长什么样」,却看不清命令运行时做什么。python3 -c "…shutil.rmtree('/')" 解析出来就是一个 python3 命令,删除发生在 Python 进程里;$(echo rm) 的命令名在执行时才出现。OpenCode 的做法很诚实:动态的部分直接放弃静态推断,并且在 bash 工具描述里自己承认它以宿主用户的权限运行(packages/core/src/tool/bash.ts:109)。

这条缝要靠另一层补:操作系统沙箱,不管命令怎么写,写 ~/.ssh 这个系统调用都会被内核拒绝。这是 A4 的内容。

🔍 把黑名单做到极致会怎样:Hermes 的 approval.py

通用 agent Hermes(pin 56dc01d9)走了黑名单的极端:tools/approval.py 共 4,553 行,里面有 12 条硬红线(HARDLINE_PATTERNS,:434-471)和一张 77 项的 DANGEROUS_PATTERNS(:693-953),前面还有一整套去混淆流水线,把命令替换折叠、去引号、抽出解释器 -c 的载荷再匹配(_normalize_command_for_detection 起于 :1003)。即便如此,它的 SECURITY.md 第 59-63 行写得很直白:「The only security boundary against an adversarial LLM is the operating system.」——进程内的任何筛查都是在一个被攻击者影响的字符串上做启发式判断。行数与项数均在 pin 副本上用 wc 与 ast 实测。

证据三问

问本课的回答
① 它解决什么问题?在模型发出的调用真正执行之前,由 harness 决定放行、拒绝还是问人
② 为什么黑名单不够?规则检查的是命令文字,危险来自运行时行为;同一效果的写法没有穷尽,子串又不懂边界
③ 实践上选了什么?生产级 harness 都把闸门放在 harness 里(满票),并用解析代替子串;OpenCode 完全不设命令黑名单(源码直证);最终兜底交给 OS 沙箱

一句话

📌

prompt 决定模型想做什么,harness 决定允许什么;匹配子串拦不住改写过的命令。

🚦

先停在这里:记住「闸门在 harness 里」和「黑名单既漏又误」两件事,就可以进下一课。
再深一层:展开上面 Hermes 的 Dig,看黑名单做到 4,553 行之后,作者自己怎么评价它。

下一个缺口

规则一多就会冲突,听谁的? → 第 2 课:两条规则冲突时,听谁的?

放弃黑名单之后,规则交给用户来写:「允许 git *」「拒绝 git push *」……两条规则同时命中一次调用时,不同的 harness 会给出相反的答案。

你可能会问