九凤
不要再让 AI「请遵守」:把 Prompt 约束升级为机器门禁

不要再让 AI「请遵守」:把 Prompt 约束升级为机器门禁

从 Permissions、PreToolUse Hook、Diff Guard 到批准证据,让 AI Coding 的关键约束从「文字承诺」变成「执行事实」。含三天 202 次真实拦截数据,以及我自己违反自己刚写的规则、被脚本抓住的现场。

Vibe Coding

「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_PHASE0
REQUEST_CHANGES20
ESCALATE_USER30
STOP40

为什么不能是自由文本:因为审查报告里一定会出现这样的句子——

「除了上述三个 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 commit24
治理文件编辑18
部署路径编辑3
git push2
git 破坏性 restore1
git 破坏性 reset1

最后那两条,是这张表里最值钱的。

一次 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 自审」永远不管用。