两条规则冲突时,听谁的?
本课一问:放弃黑名单后,规则交给用户来写。「允许 git *」和「拒绝 git push *」同时命中 git push origin main 时,听谁的?
这一课先不碰:规则没覆盖的调用要不要问人(第 3 课)、规则之外的拦截点 hook(岔路)、让模型来审批(第 4 课)。
从已有出发
上一课的结论:闸门放在 harness 里,按用户写的规则决定放行、拒绝还是问人。我们的项目 miniagent 在修 test_parse_date 的每一次真实运行里,用的都是同一份规则 configs/rules-standard.json:
| # | 工具 | 模式 | 动作 |
|---|---|---|---|
| 1 | read_file | * | 放行 |
| 2 | list_dir | * | 放行 |
| 3 | grep | * | 放行 |
| 4 | edit_file | * | 放行 |
| 5 | write_file | * | 放行 |
| 6 | edit_file | test_* | 拒绝 |
| 7 | write_file | test_* | 拒绝 |
| 8 | bash | * | 拒绝 |
| 9 | bash | python -m unittest* | 放行 |
| 10 | bash | python3 -m unittest* | 放行 |
它的写法是「通用规则在前、例外写在后面」:先允许编辑一切(#4、#5),再拒绝改测试文件(#6、#7);先拒绝一切 bash(#8),再单独放行跑测试(#9、#10)。没命中任何一条时默认「问人」。多条规则同时命中是常态——python -m unittest … 同时命中 #8 和 #9,一条说拒绝、一条说放行。
先自己设计:按书写顺序,第一条命中的算数
最直觉的规则引擎:从上往下找,第一条命中的规则说了算。它就是 miniagent 的 first_match 语义。下面把真实配置和真实命令交给它,先猜再揭示:
- ① 朴素方案
- ② 它在哪失败
- ③ 改进方案
组件加载中……(需要 JavaScript;所有裁决都在你的浏览器里由移植的原算法现算)
问题不在 first-match 本身,而在「写规则的人以为的顺序」和「引擎实际的顺序」对不上。把这份配置换成 last-match(从下往上找),它就按作者的意图工作;把 #8 挪到 #9、#10 下面,first-match 也能工作。
所以改进不是「换一个更好的语义」,而是两步:先选定语义,让规则的写法习惯跟着语义走;再用测试锁住那些顺序敏感的用例(项目里是 tests/test_permissions.py::StandardConfigFlips)。下一节看生产级 harness 各选了什么、为什么。
rules-standard.json 改用 first-match,模型第 2 轮要跑的 python -m unittest -v test_dateparse.py 被规则 #8(bash 一律拒绝)拦下——写在它后面、专门放行测试命令的 #9 永远轮不到。同一份配置在 last-match 下,真实运行里的测试命令放行了 30 / 30 次。 试三件事:① 先猜 first-match 与 last-match 各给什么,再点「揭示」;② 点「▶ 逐步看求值光标」:first-match 从 #1 往下走、last-match 从 #10 往上走,各自停在哪一行;③ 把 #8 拖到最后一行(或点它的 ↓ 两次,每次挪一格),看「重排记录」里谁改了判。
失败发生在真实运行里:A3 同一份规则改用 first-match(seed 1) 的 trace 记录,第 2 轮的测试命令 executed: false,之后模型只做了 3 次调用(read_file test_dateparse.py、read_file dateparse.py、edit_file dateparse.py),没能再跑一次测试。到目前为止用 first-match 录制的 2 次运行里,测试命令一共被拒 3 次,拒掉它们的规则是 #8。这些运行在 trace 里的每个裁决,本页都用移植的 miniagent 求值器复算过,和录制时写下的完全一致(见实验课的自检)。
改进:三家生产级 harness,三种语义
同一个问题,生产级 harness 给了三种答案。每一种都有明确的理由,也都有代价:
| 语义 | 谁在用 | 怎么求值 | 为什么这样设计 | 代价 |
|---|---|---|---|---|
| 分组 first-match:deny → ask → allow | Claude Code | 先在所有 deny 规则里找,再找 ask,最后找 allow;组内第一条命中定案,「规则写得多具体」不改变顺序 | 拒绝永远优先:一条宽泛的 deny 不会被一条更具体的 allow 意外开口子;组织在托管设置里写的 deny,用户无法覆盖 | deny 带不了白名单例外。想「拒绝所有 git 但允许 git status」,只能不写 deny、改用 hook(见岔路) |
| last-match:最后一条命中的说了算 | OpenCode | 所有规则拼成一个列表,findLast 从后往前找第一条命中的;都不命中回落 ask | 规则可以分层拼接:内置默认 → agent 自带 → 用户配置,后来者覆盖前者(agent/agent.ts:119-150 里正是 Permission.merge(defaults, …, user)),写法和 CSS 的层叠一样自然 | 顺序敏感:调整一行的位置,裁决可能静默翻转 |
| 只看工具名 | CoreCoder v0.7.0 | 只读工具直接放行,其余一律问人;「always」按工具记住;没人可问(-p)就拒绝 | 48 行、零配置,没有规则就没有写错的规则 | 表达不了「允许 git status、但 git push 要问」 |
一个直接的后果:把我们项目的 rules-standard.json 原样翻译成 Claude Code 规则,#8 变成 deny Bash——在 Claude Code 里,不带参数的 deny 会把整个 Bash 工具从模型的上下文里拿掉,#9、#10 放行测试的规则再也救不回来(实验课的矩阵里能看到这一格)。同一份意图,在 Claude Code 里只能改用「allow 测试命令 + 不写 deny」或「allow 整个 Bash + hook 收紧」来表达。
另有两家不在下面的求值器里,只在这里说明:Codex 的 execpolicy 用 Starlark 写 prefix_rule,所有命中规则里取最严格的(execpolicy/src/policy.rs:366 的 .max(),三档 Allow < Prompt < Forbidden 按声明顺序比较,decision.rs:7-16),和顺序无关;它的规则语言是按词前缀匹配、不是通配,且 pin 上的 Rust 源码没法在本机跑出对照 fixture,所以没有移植。learn-claude-code 的 s03 没有 allow 规则,只有写死的三道闸。
- 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 官方自报。
核心实验:同一份规则,三个真求值器
组件加载中……(需要 JavaScript;所有裁决都在你的浏览器里由移植的原算法现算)
试三件事:① 默认规则集下先猜再揭示:[拒绝 git push *, 允许 git *] 对 git push origin main,Claude Code 与 OpenCode 各给什么;② 点「整表倒序」,看「重排记录」:只有 OpenCode 改判,Claude Code 不动,CoreCoder 从头到尾没看规则;③ 切到「Claude Code 文档里的 aws 例子」,再倒序一次,看下方黄框里「最后一条命中的动作」和「优先级最高的动作」什么时候相同、什么时候不同。
三个求值器的来源各不相同,页面上标清楚:OpenCode 的 evaluate 与 Wildcard.match 是 pin 源码的逐行移植,期望输出由 bun 在 pin 源码上实跑得到;CoreCoder 的 Permission.check 与 _permit 同样对 pin 源码实跑核对;Claude Code 核心闭源,它的求值器是依据官方文档实现的,不是源码移植,只能对官方文档里的例子自检,和真实 CLI 的逐条对照(T12 黄金矩阵)尚未录制。
把 OpenCode 的求值拆开看,它只有一个表达式:
两家什么时候给出相反的答案?可以精确地说:
- 设命中这次调用的规则集合为 M(两家的通配规则在本页例子上命中同一批规则)。
- OpenCode 取 M 里位置最靠后的那条的动作;Claude Code 取 M 里优先级最高的动作(deny > ask > allow)。
- 所以:两家裁决不同 ⇔ M 里最后一条的动作 ≠ M 里优先级最高的动作 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质:由 findLast 与「deny 组 → ask 组 → allow 组」的定义直接推出;本页求值器对每次调用实时判断这个条件并显示在黄框里。M 为空时两家各自走兜底(OpenCode 回落 ask,Claude Code 看模式与内置只读命令),另当别论。 出处待补 。也就是「书写顺序与动作优先级冲突」时才相反,不是「同一份配置一定相反」。
常见误解一:「deny → ask → allow、第一条命中的算数,是权限规则的通用语义。」
它只是 Claude Code 的语义。OpenCode 是 last-match、没有动作优先级;Codex execpolicy 取最严格者;CoreCoder 不读规则。把一份 Claude Code 的规则直接翻译成 OpenCode 配置,裁决可能变。
常见误解二:「两种语义对同一份配置一定给出相反结果。」
不一定。只有当「最后一条命中的规则」不是「优先级最高的那条」时才相反;上面的实验里,git status 在两家都是放行。
常见误解三:「Codex 的 Granular 审批里,某一类设成 false 就是遇到它时弹窗问我。」
正好相反。pin 源码的注释:「When a field is true, commands in that category are allowed. When it is false, those requests are automatically rejected instead of shown to the user.」(protocol/src/protocol.rs:932-934)false 是自动拒绝。
🔍 OpenCode 的文档和代码对 .env 说法不一样
OpenCode 文档 permissions.mdx 的「Defaults」一节写 .env 文件默认 deny;而 pin v1.18.32 的代码里,内置默认规则是 read: 「*.env」: 「ask」(packages/opencode/src/agent/agent.ts:130-135)。以代码为准,默认是问人。这类「文档与实现漂移」在读任何 harness 时都要留意:课程引用行为时一律钉 pin、以源码为准。
证据三问
| 问 | 本课的回答 |
|---|---|
| ① 它解决什么问题? | 多条规则同时命中一次调用时,给出唯一、可预测的裁决 |
| ② 为什么三家会不同? | 求值语义不同:分组优先级、最后命中、只看工具名。同一输入在三种规则下的结果是确定的,相反的条件可以精确写出 证据等级 · 数学可证明 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条算法性质;三个求值器的行为分别由 pin 源码实跑(OpenCode、CoreCoder)和官方文档例子(Claude Code)核对。 出处待补 |
| ③ 各家为什么这样选? | Claude Code 选安全优先(deny 不可被覆盖),OpenCode 选分层拼接的便利,CoreCoder 选零配置;都是设计取舍,没有实验证明哪一种更好 证据等级 · 有理论动机 有数学或机制上的理由说明它可能有效,但没有证明它一定更好。最后是否采用,仍然要看实验。 本条Claude Code 语义为官方文档原文(3-0 满票核查);OpenCode 语义源码直证(index.ts:28-38);设计理由是本课根据代码结构的归纳。 出处Claude Code 文档 · Manage permissions ↗需联网 |
一句话
同一组规则,换一种求值语义就可能换一个答案;所以先选语义,再用测试把顺序敏感的用例锁住。
先停在这里:记住「Claude Code 先查 deny、OpenCode 听最后一条、CoreCoder 不读规则」,以及相反的精确条件。
再深一层:去实验课把真实运行里的全部调用放进四个规则集里跑一遍;或者走岔路:hook 能放宽规则吗。
下一个缺口
规则再好,每个调用都要人点「允许」吗? → 第 3 课:每个调用都问人,人会怎样?
规则覆盖不到的调用,各家都有一个兜底:问人。问多少次、问多了会怎样,是下一课的问题。
你可能会问
- 「Claude Code 想要『拒绝 git 但允许 git status』怎么办?」 deny 规则带不了例外。官方给的做法是不写 deny,把
Bash整体加进 allow,再用 PreToolUse hook 以退出码 2 拦下你不要的命令——hook 只能收紧,不能放宽。见岔路。 - 「OpenCode 是不是默认一切都问?」 不是。
evaluate找不到规则时才回落 ask;内置 build agent 的默认规则第一条就是*: allow(agent/agent.ts:119-120)。「连 read、grep 都过权限层」说的是 16 个工具调用点每次都会求值,不是每次都弹窗。 - 「我们的项目该选哪种?」 这是本章的里程碑决策,见章首页的项目推进。