上一篇讲怎么用 Cursor 的 Agent 做多文件改造,里面有个关键动作:在 prompt 里把"能碰哪些文件、绝对不许碰哪些"写清楚。评论区一位读者点醒了我,他说只写在 prompt 里的约束,Agent 迟早会在某个顺手的时刻越界;更稳的做法是把禁碰区变成机器能校验的规则,越界就直接拒绝提交,验收标准也最好在动手前先写成会失败的测试,改完跑绿才算数。
我后来真按他说的把这套补了一遍,发现这才是"敢放心让 Agent 动手"的最后一块拼图。前面几篇讲的规则、分支、回滚、补测试,更多是靠人记得去做;提交闸门的价值,是不管你或 AI 那一刻有没有想起来,规则在提交的瞬间会被强制执行,根本没有"手滑提交"的机会。
这篇把四道闸门从易到难讲一遍:CODEOWNERS 给禁区上锁、pre-commit 钩子在提交前自动检查、一个路径白名单脚本专门拦越界改动、动手前先写会失败的红测试。涉及的 git 钩子、配置文件名、托管平台菜单在不同版本上略有差异,具体字段以你用的平台官方当期文档为准。
一、为什么只写在 prompt 里的约束,迟早会被越界
prompt 里的约束本质是"请求",不是"强制"。模型要理解、记住、再执行,这条链路每一步都有概率掉链子:会话聊得很长、上下文被截断、它觉得顺手改一下公共方法更省事,那句"不许碰 src/api"就可能被悄悄绕过去。它不是故意的,是在追求把活干完的过程中,自然地选择了它眼里更顺的路径。
人工逐块审 diff 也一样,它是一道"人肉闸门",前提是你每次都认真看。可赶上赶进度、改动一大片、diff 看着都正常,人很容易就一键全收了。至于检查点和分支回滚,那是出事后的补救,坏改动这时候已经进了提交历史,能退不等于没发生。
机器闸门不一样。它在提交或合并的那一刻自动跑检查,不通过就中止,没有商量,也不看你当天累不累。
| 手段 | 靠什么生效 | 什么时候会失效 |
|---|---|---|
| prompt 里写禁碰区 | 模型记得住 | 长会话、上下文被截断、它觉得顺手 |
| 人工逐块审 diff | 你每次都认真看 | 累、赶时间、改动太多看不过来 |
| 检查点 / 分支回滚 | 出事后能退 | 是事后补救,坏改动已经进了历史 |
| 机器提交闸门 | 提交那一刻强制执行 | 只有规则没配到才会漏,配了就拦 |
我把要补的防护归成四道,从外到内、从易到难:
- CODEOWNERS:给禁区指定负责人,改动必须走审批,这是仓库托管层的锁;
- pre-commit 钩子:每次提交前自动跑格式、lint、快速测试,这是本地通用防护;
- 路径白名单脚本:给 Agent 这次任务圈定允许改动的目录,越界直接拒绝,这是给 AI 定制的保险;
- 先写红测试:验收标准在动手前就写成会失败的测试,改完跑绿才算数,这是把"完成"的标准钉死。
前两道任何项目都该有,属于通用卫生;后两道越是让 Agent 大范围改、越值得配。
二、第一道:CODEOWNERS 给禁区上锁
CODEOWNERS 是代码托管平台支持的一个文件,作用是把"哪些路径归谁管"写在仓库里。它的内容不复杂,每行是一条路径模式加负责人,路径匹配到的代码一旦被改动,就必须由对应负责人审批,配合平台的"保护分支 / 必需评审",这些改动的合并请求没有批准就合不进去。
文件一般放在仓库根目录或平台约定的位置,主流代码托管平台都支持,具体放置路径和语法以你用的平台当期文档为准。下面是一份示意,每行前面是路径、后面跟负责人(实际填你平台上的团队或账号名,这里用中文占位):
# 公共请求层、拦截器,只有基础设施组能改
/src/api/ 基础设施组
# 算钱、金额、支付相关
/src/billing/ 后端负责人
# CI、依赖清单、构建配置
/.github/ 基础设施组
/package.json 基础设施组
/package-lock.json 基础设施组
个人项目或者两三个人的小团队,也别觉得这东西用不上。你可以把这些敏感路径都指给自己,它的实际作用是"强制走一次显式批准",而不是在本地一个回车就把公共层改了直接提交。哪怕只是多一道"我确实要动这里"的确认,也能挡掉绝大多数手滑。
要清楚它的边界:CODEOWNERS 拦的是"合并 / 推送",拦不住你在本地编辑、本地提交。它是仓库门口的保安,不是工位旁边的监督,所以本地还得配上下面这道钩子。
三、第二道:pre-commit 钩子,提交前自动跑
git 本身留了钩子的位置:每次提交前,它会先执行钩子脚本,脚本返回非零,这次提交就被中止。这意味着你可以把平时"记得就跑、不记得就跳过"的检查,变成每次提交都强制跑一遍。
配法有两种。一种是直接用仓库里的原生钩子,优点是简单,缺点是钩子文件不随仓库分发、不进版本管理,团队每个人得各自装一遍。另一种是用 pre-commit 这类钩子管理框架,把检查项写进一份随仓库走的配置文件,成员安装一次就全员生效,具体框架名和配置文件名以你实际选用的为准。
钩子里放什么?格式化检查、lint、类型检查,以及和改动相关的快速测试。下面这个脚本是个最小示意,把你项目里实际的检查命令替换进去就行:
# scripts/preflight_check.py 提交前的快速检查(示意)
import subprocess
import sys
# 只放秒级能跑完的检查;几分钟的全量测试放 CI,别塞钩子
CHECKS = [
["python", "-m", "compileall", "-q", "src"],
# 换成你项目真实的 lint / 快速测试命令
["pytest", "-q", "tests/test_order_filter.py"],
]
def run(cmd):
print(">", " ".join(cmd))
return subprocess.run(cmd).returncode == 0
def main():
if not all(run(cmd) for cmd in CHECKS):
print("提交前检查没通过,已中止提交。修好再来。")
sys.exit(1)
print("提交前检查通过。")
if __name__ == "__main__":
main()
让它在提交前跑起来:用原生钩子的话,在钩子目录里让 pre-commit 去调用这个脚本并赋予可执行权限;用框架的话,把它登记成一个本地钩子。两种方式都是一次性配置,之后自动生效。
这里有个容易被忽略的分寸:钩子要快。你要是把全量测试套件塞进提交前钩子,一跑几分钟,队友和你自己都会忍不住找跳过参数,防护就形同虚设。正确做法是钩子只放秒级的快速检查,耗时的完整测试放到 CI 上跑。
还有那个"跳过钩子"的口子要管住。git 允许提交时带参数跳过钩子,团队要约定非紧急不许用,而且关键检查在服务端(CODEOWNERS 加必需的 CI 检查)再兜底一道——本地就算跳了,服务端检查不过,照样合不进主干。本地钩子和服务端检查是两层,不能只靠一层。
四、第三道:路径白名单脚本,专门拦 Agent 越界
这是给 AI 改代码专门配的一道。场景很具体:你让 Agent 做一个只该动订单页面的需求,它干着干着,觉得公共请求层某个方法不顺手,"帮你"顺手改了一下——这种改动你审 diff 时一眼看漏,就进仓库了。
思路是评论里那位读者给的:提交前跑一个脚本,取出本次改动的文件清单,挨个核对是不是都在允许的路径里,只要冒出禁区文件,直接拒绝提交。取改动清单用 git 自带的按文件名列出改动的能力,下面是一份可直接抄的 Python 实现,全程没有花里胡哨的东西:
# scripts/check_changed_paths.py 提交前路径白名单检查
import subprocess
import sys
# 这次任务允许改动的路径前缀;换一个需求就改这里
ALLOWED_PREFIXES = (
"src/views/order/",
)
# 任何时候都不许碰的路径和文件(永久禁区)
FORBIDDEN = (
"src/api/common/",
"src/router/index.ts",
"package.json",
"package-lock.json",
)
def changed_files():
# 列出已暂存的改动文件;想对主干检查就换成对应分支的对比
result = subprocess.run(
["git", "diff", "--name-only", "--cached"],
capture_output=True,
text=True,
)
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def main():
files = changed_files()
blocked = []
for path in files:
if path.startswith(FORBIDDEN):
blocked.append(("碰到永久禁区", path))
elif not path.startswith(ALLOWED_PREFIXES):
blocked.append(("超出本次允许范围", path))
if blocked:
print("本次提交有越界文件,已拒绝:")
for reason, path in blocked:
print(" [%s] %s" % (reason, path))
print("如果确实要改,请先人工确认,再更新白名单。")
sys.exit(1)
print("改动路径检查通过,共", len(files), "个文件")
if __name__ == "__main__":
main()
把它挂进提交钩子,Agent 那次"顺手改公共层"的提交就会被拦下来,脚本还会把越界文件名列得清清楚楚,你不用在大段 diff 里自己找。
它可以配两种清单:一种是"本次任务的允许清单",随需求换,比如这次只开 src/views/order;另一种是"永久禁区",比如公共请求层、路由、依赖清单,任何任务都不许直接动。
也要说清它管不了什么:它只核对文件路径,不看文件内容。你允许 Agent 改订单页面,它在这个页面里写了错误逻辑,路径脚本是看不出来的——那是红测试和人工 review 的活。路径闸门专门解决"它动了不该动的文件"这一类问题,别指望它包打天下。
五、第四道:先写红测试,把验收标准钉在动手之前
上一篇讲的是怎么给老项目补测试:特征测试、最小回归、开接缝。这一节只往前挪一步,讲一件事——把验收测试的位置从"改完之后"挪到"动手之前",让它成为这次任务能不能算完成的闸门。
事后让 AI"自己验证一下",它的常见动作是跑一遍、看没报错、就回你"没问题"。这中间它很容易用"我觉得没问题"糊弄过去。先写红测试,是把"什么叫没问题"的标准,在它动手前就钉死。
具体流程是四步:
- 需求和验收标准确认后,先让 Cursor 照着验收标准写测试。这时候功能还没做,这些测试必然失败(行话叫红)。
- 你要确认它"红得对"——是因为功能确实缺失才失败,而不是测试本身写错了、或者写成了永远通过的空测试。一个永远绿的测试没有任何价值。
- 再让它写实现,目标明确:把这些测试做到全绿。
- 这组测试挂进提交钩子或 CI,以后不管是谁、包括 Agent,把这块改坏了,提交或合并就过不了。
接着订单筛选的例子,验收测试大概长这样(pytest 风格,以你项目实际框架为准;替身接口怎么造,上一篇讲过,不连真实接口):
# 功能还没做时,这两个测试应该失败——先确认它们红得对
def test_filter_passes_status_to_api(fake_order_api):
order_list = build_order_list(api=fake_order_api, status="paid")
order_list.load()
assert fake_order_api.last_request_params.get("status") == "paid"
def test_changing_filter_resets_pagination(fake_order_api):
order_list = build_order_list(api=fake_order_api)
order_list.go_to_page(3)
order_list.set_filter(status="shipped")
assert order_list.current_page == 1
这两条断言的就是需求里"筛选参数要正确传给接口""切换筛选时分页要重置",而不是某个内部实现细节。功能没实现时它们红,Agent 把实现写到全绿才算完成;以后谁改坏了,这两条立刻翻红。
写红测试有三个标准:它得真的能失败(别写成恒真);它断言的是需求验收点、不是实现过程;它要和上一篇讲的一样隔离依赖、能反复跑出一致结果。
六、让 Cursor 帮你搭这套闸门,加一张落地清单
这些钩子、配置、脚本不用你从头敲,完全可以让 Cursor 帮你生成。把仓库结构、敏感路径、用的测试框架都 @ 给它,让它产出 CODEOWNERS、钩子配置和检查脚本,最后还要让它做一件关键的事:在一个小改动上故意越界,亲眼验证闸门真的把它拦下来了,再把临时改动还原。 没验证过的闸门等于没装。
给它的指令我会写清这几条:
【背景】我要给这个仓库配提交前自动检查,相关目录和现有测试配置已 @ 进来。
先不要动手写配置。
【第一步】读仓库结构、现有 lint 和测试命令,不要引入新框架、新依赖,
除非我明确同意;沿用现有工具链。
【第二步】列出你认为的敏感路径和理由(公共层、算钱、配置、CI、锁文件),
我确认后你再写。
【第三步】产出 CODEOWNERS、提交钩子配置和路径白名单脚本,
脚本要能在本地直接跑,越界时给出可读的提示。
【第四步】用一个故意越界的临时改动,验证钩子和路径脚本确实会拦截,
看到拦截后把临时改动还原,确认仓库恢复干净。
整套闸门落地,对着这张清单过一遍:
- 代码托管平台开了分支保护、必需评审,敏感改动不能裸推
- CODEOWNERS 把公共层、算钱、配置、CI、依赖清单指给了负责人
- 本地装了提交钩子,提交时自动跑格式 / lint / 快速测试
- 给 Agent 的任务配了路径白名单脚本,并实测越界会被拒绝
- 让 Agent 做需求前先写红测试,确认它红得对,再写实现
- 红测试和关键回归挂进了钩子或 CI,服务端有兜底
- 约定非紧急不许跳过钩子,且故意越界验证过整套拦截有效
配的时候容易踩的坑,也一并整理:
| 你遇到的情况 | 多半是怎么回事 | 怎么办 |
|---|---|---|
| 钩子配了却没拦住 | 没装 / 没加执行权限,只在本地、服务端没兜底 | 钩子配置随仓库走;CODEOWNERS 加必需 CI 在服务端兜底 |
| 有人或 AI 用跳过参数绕过 | 钩子只是本地防线 | 服务端设必需检查,本地跳了也合不进 |
| 钩子太慢大家都想跳 | 塞了全量测试 | 钩子只放秒级检查,重测试放 CI |
| 白名单没抓到越界 | 只比了路径、没对比主干,或漏算新文件 | 用相对主干的改动清单,新增文件也算 |
| 红测试一直绿 | 写成恒真或断言太弱 | 先让它在功能缺失时失败,确认红得对 |
| Agent 改了禁区但没提交 | 编辑阶段拦不住,闸门在提交时 | 禁区是最后防线,装了它也照样要开分支、要审 diff |
| 规则太严总误拦正常改动 | 白名单太窄、禁区划太大 | 按任务更新白名单,误拦时显式确认再放行,别一关了之 |
写在最后
前面几篇的规则、prompt、分支、回滚,是"让 AI 尽量别犯错";这篇的提交闸门,是"就算它犯了错,也进不了主干"。这两套不是替代关系,是叠加关系——前者降低犯错概率,后者兜住漏网的那一次。
如果你只想最小起步,一个人或小团队先做两件事就够了:把敏感路径写进 CODEOWNERS、开必需评审,再配一个提交前自动跑测试的钩子。这两步成本极低,能挡掉绝大多数事故;等你开始频繁让 Agent 大范围改代码,再上路径白名单和先写红测试。
评论区留个数字,看看你现在到哪一步:1、还全靠 prompt 里叮嘱,没装任何闸门;2、有 CI 或 lint,但提交前不拦;3、配了提交钩子,但没给 Agent 单独做路径白名单;4、四道闸门都齐了。 票数最高的那档,我下一篇往细里拆。
排期里还压着一篇:让 Cursor 接手一个完全没文档的老项目,怎么快速建立全局认识再下刀;另外有一篇接入渠道的避坑,只讲怎么分辨、不推任何东西。你更想看哪个,评论区说一声。
觉得有用,点个赞、收藏起来对着仓库配一遍;顺手关注一下,这个系列我一篇篇往下更。