拦危险命令,写个黑名单行不行?
本课一问:模型要执行 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 源码实跑结果一致。
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
组件加载中……(需要 JavaScript;所有裁决都在你的浏览器里由移植的原算法现算)
改进不是「把黑名单写得更长」,而是换三处:
- 执行点在 harness:规则写在配置里,由 harness 在执行前强制;模型怎么想、prompt 怎么写都不改变结果(下文「改进 1」)。
- 解析命令,而不是匹配子串:OpenCode 用 tree-sitter 把 bash / PowerShell 解析成语法树,对每个子命令分别求权限,没有任何命令黑名单(下文「改进 2」)。
- 承认拦不住的部分:命令替换、
python -c这类在运行时才决定做什么的命令,规则看不见,交给 A4 的操作系统沙箱。
r''m -rf /、$(echo rm) -rf /、python3 -c …、find / -delete);在 5 条日常命令上误拦 2 条:cat sudoku_solver.py 因为含子串 sudo 被拒。 试三件事:① 看第 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 / 6 | 2 / 5 |
| lcc s03:三道闸 | 4 / 6 | 2 / 5 |
| CoreCoder:10 条正则 | 3 / 6 | 1 / 5 |
| Pi permission-gate(有 UI,命中即问人) | 4 / 6 | 0 / 5 |
有 3 条危险命令在四个黑名单里全部放行:「r''m -rf /」、「python3 -c "import shutil; shutil.rmtree('/')"」、「find / -delete」。它们各代表一种改写:插空引号、换一个程序做同一件事、不用 rm 也能删。命令替换 $(echo rm) -rf / 碰巧被 CoreCoder 的正则认了出来——rm 两个字母还留在字面上;换成 $(printf r)m 之类的写法,它也认不出。黑名单只能列举写法,而同一个效果的写法没有尽头。
为什么子串挡不住:拆开一条正则看
lcc s03 的第二道闸是整张黑名单里最「聪明」的部分。逐项点开,看它防的是什么、漏的是什么:
两类失败都是结构性的,不是写得不够细:
- 漏拦:黑名单检查的是命令的文字,而危险来自命令运行时做的事。文字到行为之间隔着 shell 展开(引号、变量、命令替换)和任意程序(Python、find、tar……)。
- 误拦:子串不懂边界。
sudo是sudoku的前缀,shutdown可以是 grep 要搜的词。
常见误解:「在 CLAUDE.md 里写『不许删文件』就够了。」
不够。prompt 和 CLAUDE.md 只影响模型想做什么,不影响 harness 允许什么。Claude Code 官方文档的说法是「Permission rules are enforced by Claude Code, not by the model.」:提示词和 CLAUDE.md 塑造的是模型的尝试,改变不了 Claude Code 放行什么。 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条官方文档原文(2026-09-26 抓取);深度调研报告 §7 三方核查 3-0 通过。 出处Claude Code 文档 · Manage permissions ↗需联网 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 回填。模型看得到被拒,可以换一种做法,但没有办法跳过这段代码。后面每一代方案都保留这一点;变化的只是「按什么拦」。
- Pi 核心、bypassPermissions、CoreCoder --yes解决了零打断,模型想跑什么马上跑。卡在L1 的 6 条本意危险的构造命令(含
rm -rf /)会全部直接执行。 - lcc s01 / s03、CoreCoder bash 正则、Pi permission-gate解决了把已知危险的写法挡在执行之前,代码只有几行。卡在lcc s03 三道闸(pin 源码实跑):6 条危险命令里漏拦 4 条,5 条日常命令里误拦 2 条(
cat sudoku_solver.py因含子串 sudo 被拒)。 - 第一代 每次都问人CoreCoder 交互模式、Claude Code Manual、lcc s03 的第三道闸解决了判断交给人,规则写不全也不怕。卡在我们 26 次真实运行的 139 次工具调用,在 CoreCoder 交互模式下有 74 次要停下来问(53%);Anthropic 自报用户批准了约 93% 的弹窗——问多了,人就不看了。
- 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 拒掉。 - 第三代 分层放行 + 模型审批Claude Code auto、Codex Auto-review(代码名 Guardian)、Hermes smart解决了只读直放、项目内编辑免审,其余交给另一个模型判断;弹窗接近清零。卡在Anthropic 自报:auto 分类器在真实越权动作上仍有 17% 漏判(n = 52),官方原话「does not guarantee safety」。
- 下一章 与 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):
- 用 tree-sitter 同时加载 bash 和 PowerShell 两套语法,把命令解析成语法树(
shell.ts:311-336)。 - 遍历树上每一个
command节点,把它的原文作为一个权限 pattern 去求值;碰到rm、cp、mv这类会动文件的命令,再把参数解析成路径,工作区外的路径单独要external_directory权限(shell.ts:378-414)。 - 每个子命令还生成一条「以后都允许」的候选规则:取命令的稳定前缀再加
*,前缀有多长查一张命令元数表(permission/arity.ts)。下表是在 pin 源码上实跑BashArity.prefix的结果:
| 命令(按词切开) | 生成的「always」规则 |
|---|---|
git status --short | git status * |
npm run dev | npm run dev * |
npm install | npm install * |
python -m unittest -v test_dateparse.py | python -m * |
rm -rf build/ | rm * |
cat sudoku_solver.py | cat * |
git push origin main | git push * |
docker compose up | docker compose up * |
ls -la | ls * |
find / -delete | find * |
- 遇到
$(、${、反引号、$变量时,dynamic()主动放弃推断路径(shell.ts:174-179)。 - 整个仓库没有命令黑名单。拦不拦由用户的规则决定(下一课)。
生产级的 Claude Code 同样解析命令:它识别 &&、||、;、| 等分隔符,规则要对每个子命令分别成立;对 rm / rmdir 指向根目录、主目录、工作目录这类 critical path 的命令,任何 allow 规则和 hook 都不能批准,哪怕它藏在子 shell 或命令替换里 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条官方文档原文;Claude Code 核心闭源,这是它自己描述的行为,本页无法用源码验证。 出处Claude Code 文档 · Critical paths ↗需联网 。
改进 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.」——进程内的任何筛查都是在一个被攻击者影响的字符串上做启发式判断。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 在模型发出的调用真正执行之前,由 harness 决定放行、拒绝还是问人 |
| ② 为什么黑名单不够? | 规则检查的是命令文字,危险来自运行时行为;同一效果的写法没有穷尽,子串又不懂边界 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质:对任意固定的子串或正则集合,都能构造出不含这些模式、行为相同的命令(改写引号、换程序)。本页 4 个黑名单在同一批构造命令上的漏拦与误拦是 pin 源码实跑结果。 出处待补 |
| ③ 实践上选了什么? | 生产级 harness 都把闸门放在 harness 里(满票),并用解析代替子串;OpenCode 完全不设命令黑名单(源码直证);最终兜底交给 OS 沙箱 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条官方文档称权限与沙箱是互补的两层;这是设计选择,不是证明。 出处Claude Code 文档 · Sandboxing ↗需联网 |
一句话
prompt 决定模型想做什么,harness 决定允许什么;匹配子串拦不住改写过的命令。
先停在这里:记住「闸门在 harness 里」和「黑名单既漏又误」两件事,就可以进下一课。
再深一层:展开上面 Hermes 的 Dig,看黑名单做到 4,553 行之后,作者自己怎么评价它。
下一个缺口
规则一多就会冲突,听谁的? → 第 2 课:两条规则冲突时,听谁的?
放弃黑名单之后,规则交给用户来写:「允许 git *」「拒绝 git push *」……两条规则同时命中一次调用时,不同的 harness 会给出相反的答案。
你可能会问
- 「把黑名单写得再全一点呢?」 全不了。上表 3 条全部漏拦的命令各代表一种改写方式,每种都能继续变形;Hermes 做到 77 项危险模式加去混淆,仍然只把它当启发式(见上面的 Dig)。
- 「那黑名单就没用了吗?」 有用,但只能当减少误操作的护栏,不能当安全边界。CoreCoder 的 10 条正则就是这个定位:它在权限层已经问过人之后再检查一遍,拦的是模型手滑。
- 「Pi 为什么只写 3 条?」 Pi 的核心根本没有权限系统,这 34 行只是示例扩展。它的立场见岔路:Pi 为什么说权限弹窗是「安全剧场」。