
不要再让 AI「请遵守」:把 Prompt 约束升级为机器门禁
从 Permissions、PreToolUse Hook、Diff Guard 到批准证据,让 AI Coding 的关键约束从「文字承诺」变成「执行事实」。含三天 202 次真实拦截数据,以及我自己违反自己刚写的规则、被脚本抓住的现场。
「vibe coding 工程化」系列第四篇。前三篇讲了为什么需要 Loop、 怎么用 Claude Code + Codex 搭闭环、 怎么用 Task Packet 防止主线漂移。 这一篇回答下一个必然的问题:目标已经写清楚了,怎么保证 AI 真的不越界?
你的 Prompt 里大概写着这样的话:
不要 commit、不要修改生产环境、不要超出允许范围。
这些话不代表这些行为不会发生。
先把结论放前面:
关键约束不能只存在于模型看到的 Prompt 里,还应该存在于模型无法绕过的执行环境里。
一、先讲一件我自己干的事
写这个系列的时候,我给自己定了一条规则:文章里不能出现真实的文件路径、项目名、内部标识。这条规则我写进了流程文档,也写进了给自己的检查清单。
然后我在第二篇里写了这么一句(这里已经打码,原因见下):
Codex 是怎么发现的?它把
xxx/xxx.go的实现、xxx/xxx.go的成功路径、xxx/xxx.go的默认配额、xxx/xxx.go的注册链摆在一起看…
四个真实文件路径,直接写进了准备发到公网的文章里。
我不是忘了这条规则——我当天上午刚写完它。我只是在写那段话的时候,注意力全在「怎么把这件事讲清楚」上,而「不能写路径」这条规则,此刻是我脑子里第一百零一件事。
抓住它的不是我。是一个二十行的扫描脚本:
❌ 命中敏感路径模式(1 处)
这件事的意义不在于「我很马虎」,而在于:
一条规则,从「我知道它」到「我遵守它」,中间隔着的不是意愿,是机制。
我知道那条规则。我认同那条规则。我是那条规则的作者。这三样加起来,仍然没有拦住我。
而一个二十行的脚本拦住了。
故事还有后半段。
写这一节的时候,我为了让例子具体,把那四个路径原样引了出来——就是你上面看到被打码的那四个。我当时的判断是:它们太通用了——那种路径几千个 Go 项目里都有,不构成泄露。
然后我跑了一遍扫描脚本:
❌ 命中敏感路径模式(4 处)
四条全中。
我在一篇论证「机器门禁比人的判断可靠」的文章里,又违反了同一条规则——而且这次是「故意」的,因为我觉得我的判断比规则更准。
我可以给自己开个口子:「这几条无害,加个例外吧。」但那正是门禁失效的标准路径——第一个例外总是合理的,第二个总是参照第一个,第三个就没人记得为什么了。
所以我打码了。门禁说不行,那就不行,哪怕我认为它这次判错了。如果我要改,我改的是规则本身(并且明确记下为什么),而不是在这一次绕过它。
如果这件事会发生在一个有动机、有记忆、能理解后果、并且刚刚才写完这条规则的人身上——那么指望「在 Prompt 里写一句『请遵守』」能拦住模型,这个期望从一开始就不成立。
二、Prompt 约束为什么会失效
上面那件事只是个引子。技术上,Prompt 约束失效有四个原因:
长上下文中指令权重会衰减。 开头那句「不要 commit」,到第 40 轮对话时,跟当前上下文里的一百件事在争夺注意力。它没有被忘记,它只是不再是最重要的那句。
模型可能误判当前授权范围。 「用户上一轮说可以改这个文件」——那是上一轮。「用户说这个任务很急」——那不等于授权跳过验证。这类推断每一步都合理,合起来就越界了。
复合命令和间接脚本会绕过自然语言约束。
你说「不要 git reset --hard」。那 git reset --hard 写在一个 .sh 里,模型执行那个 .sh,算不算违反?它自己会怎么判断?
更麻烦的是 cd x && make clean && git checkout -- . 这种复合命令——自然语言约束是按「意图」写的,而命令是按「效果」执行的。
「我不会执行」不是安全证明。 这条最关键。模型说「我不会做 X」,那是一个预测,不是一个保证。它预测的是自己的行为,而这个预测由同一个会产生该行为的系统给出。
你不能用一个系统的自述,去证明这个系统的安全性。
三、哪些约束应该机器化
不是所有事都值得机器化——机器化有成本。判据很简单:
不可逆的、影响面超出本轮的、以及「出事了没法追溯」的,必须机器化。
按这个判据,我机器化了七类:
| 约束 | 为什么必须是机器 |
|---|---|
| 禁止破坏性 Git 操作 | reset --hard 之后没有「撤销」 |
| commit / push 必须有授权 | 一旦推上去,历史就是公开的 |
| 凭据和敏感路径禁止修改 | 泄露不可逆 |
| 代码改动必须在 allowlist 内 | 越界的改动没人审、没记录 |
| 审查前必须过 Guard | 否则审的可能不是本轮的东西 |
| 审查结论必须符合机器格式 | 自由文本会被误读成「通过」 |
| 批准态必须含测试与证据 | 没证据的批准等于没批准 |
反过来,读代码、改一个函数、跑一次针对性测试——这些不该弹确认。它们可逆、影响面小、出错立刻知道。
四、四层门禁
我的实现是四层,每层管的东西完全不同:
① Harness Permissions ← 工具调用前的硬限制
↓
② PreToolUse Hook ← 理解当前任务上下文
↓
③ Diff Guard ← 验证最终真实发生了什么
↓
④ Evidence Freeze ← 批准态必须带证据
层与层之间不是冗余,是在不同时间点、用不同信息做判断。
① Permissions:调用前的硬限制
最外层,最粗。它不理解上下文,只认命令模式:
"deny": [
"Bash(git reset --hard*)",
"Bash(git clean*)",
"Bash(git checkout --*)",
"Bash(git restore*)"
]
这四条都是不可逆丢弃工作树改动的操作。它们不是「危险」,是没有后悔药。所以是 deny,不是 ask——因为在这一层,我不认为存在「确认一下就可以做」的情况。
三档要分清楚:
| 档 | 用在哪 |
|---|---|
deny | 不可逆、且这一层无法判断是否合理 → 直接挡 |
ask | 影响大但可能合理 → 停下来问人 |
allow | 高频、低风险、可逆 → 别弹,弹了就是骚扰 |
② Hook:理解当前任务上下文
Permissions 只看命令字符串。Hook 能看当前任务是什么。
它回答 Permissions 回答不了的问题:
- 当前的施工单、checkout、guard profile 是否绑定?
- 这个路径属不属于敏感区(部署 / 生产 / SQL)?
- 审查命令是不是漏带了 Guard?
- 这条
kubectl命令,打的是自建集群还是生产?
最后一条最有意思。它靠这样的特征识别:
has_prod_marker() {
[[ "$cmd" == *"k8s/production"* ]] && return 0
[[ "$cmd" =~ --context[=\ ][^[:space:]]*(prod|prd|production) ]] && return 0
...
}
命中 → ask。没命中且是自建集群 → 走快速通道,不弹。
这就是 Hook 存在的意义:同样一条 kubectl rollout restart,打自建集群是日常,打生产是事故。Permissions 分不出来,Hook 能。
③ Diff Guard:验证最终发生了什么
前两层检查的是工具调用。Guard 检查的是真实工作树。
这个区别很重要,因为工具调用不等于最终结果:
- 模型可能改了文件又改回来(调用发生了,结果没变);
- 模型可能通过一个脚本间接改了文件(Hook 看到的是「执行脚本」,不是「改文件」);
- 模型可能先 commit 了,再继续改。
为什么只看 git status 不够:commit 之后,那些改动就从 git status 里消失了。你看到一个干净的工作树,会以为「没改什么」——而实际上这一轮的改动全都藏在 commit 里。
所以 Guard 必须基于 HEAD 基线比对:这一轮开始时 HEAD 是什么,现在的真实 diff(含已 commit 的)是什么。基线一钉死,改动就藏不住了。
allowlist 与 denylist 分工也不一样:
- allowlist:本轮施工单里那组路径。它是每轮不同的。
- denylist:凭据、生产配置、密钥。它是永远生效的,跟哪一轮无关。
④ Evidence Freeze:批准态必须带证据
最后一层不拦操作,它拦的是**「批准」这个动作本身**。
五、审查结论必须是机器格式
这一条看着琐碎,实际是整条链的关键。
审查方的结论必须是四选一,且用唯一末行标记 + 退出码表达:
| 结论 | 退出码 |
|---|---|
APPROVE_PHASE | 0 |
REQUEST_CHANGES | 20 |
ESCALATE_USER | 30 |
STOP | 40 |
为什么不能是自由文本:因为审查报告里一定会出现这样的句子——
「除了上述三个 MAJOR 之外,其余部分可以批准。」
一个基于关键词的解析器,看到「可以批准」就放行了。而这句话的真实意思是不批准。
自由文本是给人读的。机器判断必须用机器能唯一解析的东西。
还有一条容易漏:其他退出码不等于「代码被拒」。审查工具超时、网络断了、身份配置错了——这些都不是 REQUEST_CHANGES,它们是基础设施故障。把它们当成「代码有问题」,你会开始修一个不存在的 bug。
六、批准证据为什么也要 fail-closed
「Fail-closed」的意思是:当机制自己出问题时,默认拒绝,而不是默认放行。
所以这四条必须是硬的:
- 缺测试不能批准 —— 没跑测试的批准,批的是什么?
- Guard 失败不能批准 —— Guard 挂了不等于没越界,等于不知道有没有越界。
- 审查报告不可解析不能批准 —— 解析不了就是不知道结论,不是默认通过。
- Patch 冻结失败不能悄悄继续 —— 证据没冻住,这个批准态三个月后无法复现。
这里有个我很喜欢的实现细节。我的 Hook 依赖 jq 解析工具入参。如果 jq 不在:
"jq is missing, cannot verify tool input. Please confirm manually."
→ permissionDecision: "ask"
门禁自己坏了的时候,它不放行,它转人工。
这是 fail-closed 的精髓:「我检查不了」和「我检查过了没问题」,绝不能是同一个结果。
七、真实数据:三天 202 次
这套东西不是摆设。翻了一下记录,三天内触发 202 次,日均 67 次:
| 拦截类型 | 次数 |
|---|---|
| 生产集群标记 | 77 |
| 越出工作区范围 | 76 |
| git commit | 24 |
| 治理文件编辑 | 18 |
| 部署路径编辑 | 3 |
| git push | 2 |
| git 破坏性 restore | 1 |
| git 破坏性 reset | 1 |
最后那两条,是这张表里最值钱的。
一次 git reset,一次 git restore。 都是不可逆丢弃工作树的操作,都被硬 deny 挡下了。
「请不要 reset」这句话,拦不住这两次。
而前两条(77 次生产标记、76 次越界)说明另一件事:这类边界的触碰是常态,不是意外。 平均每天六十多次——如果靠人盯,你什么也别干了。
八、不是所有事都该机器化
这一节和上一节同样重要。
必须留给人的:
| 决定 | 为什么机器不能做 |
|---|---|
| 架构语义选择 | 「用单行旋转还是每代独立计划行」——这是权衡,不是对错 |
| 产品行为改变 | 「限流该按 IP 还是按失败次数」——这是产品策略 |
| 风险接受 | 「这个并发窗口很小,接受吗」——只有你知道业务能承受什么 |
| 是否发布 | 机器不知道今天是不是大促 |
| 是否为可用性接受降级 | 「宁可多重试一次也别丢单」——这是商业判断 |
上一篇提到的那个限流案例里,审查方发现实现与需求语义不符之后,它没有自己改。它写的是:「是保留纯 IP 请求配额,还是改为失败计数 + 成功清零,属于需用户裁决的安全产品策略」。
这是对的。门禁的职责是把问题挡在门口,不是替你做决定。
九、怎么避免门禁过严
门禁太松等于没有,门禁太严等于把人逼疯——而且后果一样:你会开始无脑点确认。
那时候门禁就彻底失效了,因为它已经变成了一个装饰性的弹窗。
四条:
1. 普通读取、编辑、针对性测试不弹确认。 这些可逆、影响小、错了立刻知道。为它们弹窗,纯粹是训练用户无视弹窗。
2. 自建环境的普通操作走快速通道。
同样是 kubectl,打自建集群和打生产是两件事。Hook 要能分辨,不能一刀切。
3. 只有高影响操作才请求授权。 判据是「不可逆」或「影响面超出本轮」,不是「看起来有点危险」。
4. 收集和复盘失败不得阻断业务。 这条最容易被忽略:记录机制本身不能成为门禁。复盘收件箱写不进去,那就不写,不能因此挡住实施。
我在这条上栽过。我的复盘收件箱记录了 202 次护栏触发——但那 202 次不是问题,是护栏在按设计工作。 收件箱里 96% 是「系统正常」,真正的信号被埋着。 记录「系统正常」等于制造噪音,而噪音会让人学会无视这个收件箱。 这个坑我会在后面单独写一篇。
结论
四句话分工:
Prompt 负责表达意图。 Hook 和 Guard 负责执行边界。 测试负责验证行为。 人负责不可自动化的判断。
搞混任何一层,都会出问题:
- 拿 Prompt 当边界 → 它会在第 40 轮失效;
- 拿 Guard 当测试 → 它只知道你改了哪儿,不知道改得对不对;
- 拿测试当审查 → 它只测你想到的那条路径;
- 拿机器当裁决者 → 它会替你选一个它无权选的产品策略。
回到开头我自己那件事:我知道规则、认同规则、是规则的作者,仍然违反了它。
如果这三样加起来都拦不住一个人,那么「在 Prompt 里写一句请遵守」这件事,从一开始就不是一道边界,只是一个愿望。
下一篇:独立审查——41 条真实审查意见归成 7 类失败模式,以及为什么「让 AI 换个 session 自审」永远不管用。