ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI编程代理不守规则?用Hook钩子强制拦截违规操作

AI编程代理不守规则?用Hook钩子强制拦截违规操作

在实际项目里,很多同学会遇到下面这种情形:规则文件写得清清楚楚,AI 编程代理在对话里也复述得明明白白,可真正动手改代码时,它还是会绕过限制,把不该动的文件改了。重试几次、把规则加粗、甚至把规则移到了上下文最前面,依然无效。

这篇文章想聊的就是这个问题:当编程代理无视你定下的规则时,真正可靠的兜底手段不是“再加一条规则”,而是给它挂上钩子(Hook)。

这里说的“编程代理”,指的是在 IDE 或命令行中帮你完成代码编写、文件修改、命令执行的 AI 编程代理,例如常见的 Cursor、Cline、Claude Code,以及基于大模型 API 自建的 Agent。它们的共同特点是:能听懂自然语言指令,能读取项目文件,能执行工具调用。但这同时也带来了一个麻烦——它可能在执行过程中“忘掉”规则,或者对规则的理解和你不一样。

本文会从规则失效的原因讲起,介绍钩子的工作原理,然后给出一套可运行的拦截脚本和配置示例,最后聊聊团队落地时需要注意的边界和坑。无论你是在个人项目里使用 AI 编程代理,还是在团队里统一推广这套玩法,都可以直接参考。

1. 编程代理为什么会“无视”你的规则

1.1 典型场景:规则写了,代理就是不听

先还原一个典型场景。

你维护一个老项目,里面src/api目录下的接口签名已经冻结,任何修改都必须经过评审。于是你写了一份规则文件,明确要求“不得修改src/api目录下的文件”。代理第一次接手时表现很好,你让它去修一个用户查询接口的 bug,它能准确说出“这个文件受保护,不能直接改”。

但第二次让它做一个小重构时,它却直接打开了src/api/user.ts并开始改写。你问它为什么违反规则,它能原封不动地把规则背出来,然后告诉你“我认为需要调整这里的返回结构,所以改了”。

问题的关键就在这里:它不是在故意对抗你,而是把“规则”当成了可以权衡的建议。在它的理解里,当前任务优先级更高,规则可以被“适度妥协”。你需要的不是让模型更聪明,而是让它根本没有机会执行违规操作。

1.2 规则失效的五个原因

结合大量对抗这种问题的经验,规则文件失效的原因基本可以归成五类。

第一,上下文被压缩。现代大模型的上下文窗口有限,当项目文件多、对话轮次长时,早期写入的规则可能被自动截断或压缩。代理不是“不愿意”遵守,而是真的“看不到”了。

第二,规则文件太长。如果规则文件超过几百行,代理实际能记住的内容有限。它更倾向于记住最近几条、最显眼几条,而把靠后的规则忽略掉。规则并不是写得越多越好,写得越多,失效风险反而越大。

第三,规则表达模糊。像“注意代码风格”“尽量不要改无关文件”这种描述,模型很难转成具体执行动作。什么叫“尽量”?什么算“无关”?没有清晰边界,模型只能靠猜,而猜就一定会出错。

第四,规则加载位置不对。有些代理只读取固定名称或固定目录下的规则文件,比如.cursorrules、.clinerules、CLAUDE.md。如果你把规则放在docs/目录下,或者文件名写错,代理根本没读到,后续所有表现自然不达标。

第五,临时指令覆盖了项目规则。在长会话中,你在某一步随口说的“直接改吧,不用管那么多”,对模型来说可能是比项目规则更近、更具体的指令。它会认为新指令覆盖旧规则,于是放开手脚去改。

1.3 钩子要解决的问题

明白了原因,就可以对症下药。

规则文件解决的是“让代理理解你的意图”,钩子解决的是“在动作发生前拦住它”。钩子是一种事件驱动机制,它监听代理执行过程中的关键节点,在动作真正发生前或发生后运行一段外部脚本。脚本可以通过退出码、输出内容等方式告诉代理:这次操作被允许、被拒绝,或者需要改写。

钩子的优势在于:它不依赖模型是否“记得住”,也不依赖模型是否“心情好”。只要配置了钩子,工具调用一旦触发,脚本就必须执行,代理想绕也绕不过去。

不过钩子也不是万能的。它无法保证代理生成的代码逻辑正确,无法判断产品方案是否合理,更无法替代代码评审。它解决的是一个非常具体的问题:硬性规则被强制执行。

2. 钩子:给代理装一个“强制护栏”

2.1 什么是钩子

钩子(Hook)原本是编程领域的概念,指程序执行到某个位置时触发预先注册的回调函数。在 AI 编程代理这个语境下,钩子可以理解为“代理执行链路中的拦截点”。

它的工作方式大致是:代理准备调用某个工具时,先把请求信息通过标准输入传给钩子脚本;脚本读取后做判断,返回退出码;代理根据退出码决定继续、暂停还是中止。整个过程在毫秒到秒级完成,用户几乎感知不到。

不同代理工具对钩子的叫法可能不一样,比如pre_tool_use、PostToolUse、Stop、Notification等。但核心思想是一致的:在关键节点插入外部逻辑。

2.2 常见触发点

目前常见编程代理的钩子触发点大致有以下几类。

会话开始/初始化时。在代理第一次读取任务前触发,适合动态生成规则摘要、注入任务上下文。比如你可以把仓库最新的保护目录列表注入到会话里。

准备调用工具前。代理即将执行写文件、跑命令、删除文件等动作时触发。这是最重要的拦截点,适合做硬性规则校验。

工具执行完成后。代理已经拿到工具结果,准备生成下一步指令时触发。适合做输出校验、日志记录、格式检查。

生成最终回答前。代理准备输出最终结论时触发。适合检查它是否遗漏了必须执行的步骤,例如“修改之后必须运行测试”。

会话停止/结束时。整个任务结束或被人为中断时触发。适合写总结、清理临时文件、收集执行记录。

2.3 钩子与规则文件的分工

钩子和规则文件不是替代关系,而是分工关系。

规则文件负责“软约束”,告诉代理项目背景、代码风格、命名习惯、推荐做法。这类信息适合让模型理解,不适合用脚本硬判断。比如“使用 TypeScript 写新代码”“错误信息要包含上下文”这类规则,交给模型判断效率更高。

钩子负责“硬约束”,负责那些一旦违反就会造成严重后果的边界。比如“禁止修改src/api”“禁止删除测试文件”“禁止直接提交到main分支”。这类规则一旦被突破,可能造成难以挽回的损失,所以要用脚本强制执行。

能力规则文件钩子机制
让代理理解项目背景强弱
阻止代理执行某个操作弱,靠模型自觉强,硬性拦截
读取文件内容做判断弱强
写入日志与审计记录弱强
修改上下文弱部分支持
维护成本低中高

3. 环境准备与示例项目结构

3.1 需要准备什么

在开始配置钩子之前,先确认你满足以下条件。

操作系统不限,Windows、macOS、Linux 都可以。示例代码以 Python 3 和 Bash 为主,建议本机已经安装 Python 3,版本不需要特别新,3.8 以上即可。

需要安装 Git 2.x。因为实战中我们会用 Git 钩子作为兜底防线。

需要你正在使用一款支持钩子机制的 AI 编程代理。不同工具支持程度不同:有的支持事件钩子,有的只支持启动命令注入,有的需要付费版本。建议先查看官方文档,确认你的工具支持事件型 Hook,再继续往下看。

版本的问题要特别说明:AI 编程代理迭代非常快,配置字段、事件名称、环境变量几乎每几个月就可能变化。本文给出的配置片段是通用思路,具体使用时请以你所使用工具的官方文档为准,不要照搬后抱怨“格式对不上”。

3.2 示例项目结构

为了便于理解,下面用一个假想项目演示。项目结构如下。

my-project/ ├── .ai/ │ ├── rules.md │ └── hooks/ │ ├── guard.py │ └── post_run.py ├── src/ │ ├── api/ │ │ └── user.ts │ └── core/ │ └── data.ts ├── tests/ │ └── api.test.ts ├── .git/ ├── package.json └── README.md

.ai/rules.md存放项目规则,.ai/hooks/存放钩子脚本。把钩子脚本放在项目目录内有一个好处:可以进版本库,团队内共享,新成员 clone 下来就能获得同样的防护。如果有敏感路径,用模板变量或本地配置文件覆盖。

3.3 钩子事件与配置的对应关系

不同工具的钩子配置格式不同,但大体上包含三个要素:监听哪个事件、匹配哪些工具、执行什么命令。

下面是一段通用示意配置:

{ "hooks": [ { "event": "pre_tool_use", "tools": ["write", "edit", "multi_edit", "run_command"], "command": "python3 .ai/hooks/guard.py", "timeout_seconds": 30 } ] }

event表示触发时机,这里指“工具使用前”;tools表示只对这些工具生效;command表示执行的脚本;timeout_seconds表示脚本运行超时时间。如果你的工具里event叫PreToolUse,tools叫matcher,只需按文档做字段映射即可。

4. 实战:用钩子拦截代理的违规写操作

4.1 先定义硬性规则

在写脚本之前,先明确哪些规则必须硬拦截。假设团队现在有三条规则:

第一,禁止修改src/api目录下的任何文件。这个目录是接口层,签名变更必须走评审流程。

第二,禁止删除tests/目录下的任何测试文件。测试是质量底线,不能因为代理觉得“没用”就删掉。

第三,代理执行命令时,不能直接操作src/api目录,也不能删除测试目录。

这三条规则都可以交给钩子来判断,不依赖模型自觉。

4.2 编写 guard.py 拦截脚本

创建文件.ai/hooks/guard.py,代码如下。

#!/usr/bin/env python3 # 文件路径: .ai/hooks/guard.py # 功能: 在代理执行写操作或命令前,检查是否违反硬性规则 import json import sys from pathlib import Path # 受保护目录,可替换成自己项目的实际路径 PROTECTED_DIRS = [ "src/api", ] # 禁止出现的操作模式 FORBIDDEN_OPERATIONS = [ "rm tests/", "delete tests/", ] # 审计日志路径,日志目录会自动创建 LOG_FILE = Path(__file__).resolve().parent.parent / "logs" / "hook_audit.log" def load_stdin(): """从标准输入读取钩子上文""" try: raw = sys.stdin.read() except Exception: return {} if not raw.strip(): return {} try: return json.loads(raw) except json.JSONDecodeError: return {} def log_action(result, reason=""): """记录拦截结果,方便事后排查""" LOG_FILE.parent.mkdir(parents=True, exist_ok=True) with open(LOG_FILE, "a", encoding="utf-8") as f: record = {"result": result, "reason": reason} f.write(json.dumps(record, ensure_ascii=False) + "\n") def check_path(target_path): """判断路径是否落在受保护目录内""" if not target_path: return [] try: resolved = Path(target_path).resolve() except Exception: resolved = Path(target_path).absolute() hits = [] for d in PROTECTED_DIRS: try: protected_root = Path(d).resolve() except Exception: protected_root = Path(d).absolute() if resolved == protected_root or protected_root in resolved.parents: hits.append(f"protected path: {d}") return hits def main(): ctx = load_stdin() tool_input = ctx.get("tool_input", {}) if not isinstance(tool_input, dict): tool_input = {} # 一些工具会把路径放在 file_path,另一些放在 path 或 command 里 candidates = [ tool_input.get("file_path", ""), tool_input.get("path", ""), tool_input.get("command", ""), ] hits = [] for candidate in candidates: hits.extend(check_path(candidate)) command = candidates[-1] or "" for op in FORBIDDEN_OPERATIONS: if op.lower() in command.lower(): hits.append(f"forbidden operation: {op}") # 去掉重复原因 hits = list(dict.fromkeys(hits)) if hits: reason = "; ".join(hits) print(f"[HOOK] Blocked by project hard rules: {reason}") log_action("blocked", reason) sys.exit(1) log_action("allowed") sys.exit(0) if __name__ == "__main__": main()

这里有几个关键设计要说明。

用Path.resolve()统一处理路径。代理可能传相对路径,可能传绝对路径,可能带../。resolve()会解析成标准绝对路径,避免“表面上不在src/api里,实际上绕进去了”的情况。

从多个字段提取路径,是为了兼容不同工具的差异。有的工具用file_path,有的用path,有的把操作直接拼在command里。多取几个字段不会误伤,只会增加一点判断量。

sys.exit(1)是拦截信号。约定俗成是:钩子脚本返回非 0 退出码,表示拦截当前操作。如果你的工具恰好在退出码语义上相反,需要按文档调整。

4.3 注册钩子

脚本写好后,需要把它注册到代理工具上。以通用 JSON 配置为例,注册效果如下。

{ "hooks": [ { "event": "pre_tool_use", "tools": ["write", "edit", "multi_edit", "run_command"], "command": "python3 .ai/hooks/guard.py", "timeout_seconds": 30, "mode": "block_on_error" } ] }

mode设为block_on_error的意思是:脚本抛出异常或返回非 0 时,直接阻断代理当前动作。这是一个很安全的默认值。如果你的工具没有这个字段,通常会默认“非 0 退出码即阻断”,那就更直接。

4.4 人工验证钩子是否生效

配置完成后,不要直接在真实任务里验证。先手动模拟两遍,确认脚本行为符合预期。

制造一个应被拦截的输入:

echo '{"tool_input": {"file_path": "src/api/user.ts"}}' | python3 .ai/hooks/guard.py echo "exit code: $?"

预期输出:

[HOOK] Blocked by project hard rules: protected path: src/api exit code: 1

再制造一个应放行的输入:

echo '{"tool_input": {"file_path": "src/components/Button.tsx"}}' | python3 .ai/hooks/guard.py echo "exit code: $?"

预期输出:

exit code: 0

手动验证这一步很重要,尤其是你第一次接触钩子机制时。它能确认脚本本身没有语法错误,也能确认你对“退出码即拦截”的理解和工具一致。验证过后,再去真实项目里让代理尝试修改受保护文件,观察它是否在动手前被拦下。

4.5 使用效果说明

接入钩子后的效果会非常明显。

以前代理可能在某个无关紧要的重构里顺手改了src/api下的接口类型,然后你花半天时间排查线上报错。现在只要代理尝试写src/api/user.ts,钩子脚本会立刻拦截,并输出类似“Blocked by project hard rules: protected path: src/api”的反馈。

代理收到拦截反馈后,通常会停下来重新规划方案,而不是继续强行执行。即使模型真的想继续,钩子也会在每次工具调用前反复拦截,直到它放弃或切换任务路径。这相当于给规则装上了物理锁。

5. 进阶:校验输出、注入规则与 Git 兜底

5.1 在会话开始时动态注入精简规则

前面提到,规则文件太长会导致代理记不住。一个实用的优化是:在会话开始钩子里动态生成一份精简规则,只包含当前任务需要的硬性边界。

比如你可以编写一个inject_rules.sh,在会话开始时读取.ai/rules.md,过滤出“不得”“禁止”“必须”等关键词后的内容,拼成一段摘要输出给代理。

这样做的好处是:代理一上来就能看到最关键的约束,而不会被几百行规则淹没。规则摘要还能通过脚本动态生成,比如从模板里读取当前分支的部署环境、本次迭代的保护目录,自动适配不同场景。

5.2 对生成结果做格式校验

前置钩子解决“不能做什么”,后置钩子可以解决“做得好不好”。

你可以在工具执行完成后挂一个格式校验钩子。比如代理刚写完 TypeScript 文件,触发post_tool_use钩子,脚本读取package.json里的 lint 命令,然后执行eslint。如果校验失败,钩子把错误信息写回上下文,让代理继续修复,而不是把未通过检查的代码提交到仓库。

需要注意:这类校验脚本的耗时通常比前置钩子长,建议把超时时间放宽一点,并且把失败结果的反馈写得足够具体。泛泛的“你的代码有问题”没有用,直接给出行号、规则名、期望写法,代理才能快速修正。

5.3 用 Git pre-commit 做最后防线

不管 AI 代理有多聪明,最终代码还是要进入 Git 仓库。在 Git 层面再加一道钩子,可以兜住编程代理钩子失效的场景。

创建.git/hooks/pre-commit,内容如下。

#!/usr/bin/env bash # 文件路径: .git/hooks/pre-commit # 功能: 提交前检查,禁止触碰受保护文件、禁止直接提交到主分支 set -euo pipefail echo "Running project policy check before commit..." # 禁止直接往 main/master 提交 current_branch=$(git rev-parse --abbrev-ref HEAD) if [[ "$current_branch" == "main" || "$current_branch" == "master" ]]; then echo "Blocked: direct commits to $current_branch are not allowed." exit 1 fi # 禁止提交受保护文件 if git diff --cached --name-only | grep -E 'src/api/|migrations/schema/' > /dev/null; then echo "Blocked: protected files were changed. Please obtain review before committing." exit 1 fi echo "Policy check passed."

这里有一个细节:.git/hooks/目录下的文件不会随仓库自动共享。团队项目更推荐使用 Husky 这类工具,或者把脚本放在.githooks/目录下,再配置git config core.hooksPath .githooks,这样每个成员 clone 后只需要执行一次配置命令,就能获得同样的提交前检查。

Git 钩子的最大价值在于:它不区分代码是“人写的”还是“代理写的”,只要提交动作发生,就一定会被检查。即使代理的钩子配置被误删、被绕过,Git 层的防线仍然有效。

5.4 审计日志与流水

钩子脚本顺手记录审计日志,是容易被忽略但价值很高的动作。

上面的guard.py已经会在.ai/logs/hook_audit.log里记录每次拦截的路径和原因。生产环境下,建议把日志格式设计成 JSON,方便后续接入日志平台。并且要注意,日志目录不应该提交进仓库,否则每次钩子拦截都会造成工作区脏乱,干扰后续提交。

可以把日志目录加入.gitignore:

.ai/logs/

审计日志的价值在于复盘。每周过一遍日志,你能看到代理最容易违反哪些规则、在哪些目录上犯错最多。这些数据可以用来反向优化规则文件,把高频违规点写得更明确,或者把相关目录加入硬拦截列表。

6. 常见问题与排查清单

6.1 高频问题表

实际使用中,问题通常集中在脚本没生效、拦截错目标、配置不匹配三类。

问题现象常见原因处理思路
钩子脚本一直没有执行事件名或工具匹配写错核对官方文档的事件名和字段
脚本执行了,但代理仍然继续操作退出码返回了 0确保拦截路径返回非 0 退出码
修改src/api文件未被捕获脚本只匹配了相对路径,代理传的是绝对路径统一用Path.resolve()解析
脚本报错导致代理直接卡住脚本本身有异常,且工具配置为 block on error先手动运行脚本,修复语法或依赖问题
日志里看不到拦截原因脚本没有写日志,或日志路径不对统一维护审计日志,路径用绝对路径
Windows 下路径判断失效反斜杠和正斜杠混用使用Path对象,避免字符串硬编码

6.2 按顺序排查

遇到钩子不生效,不要着急改配置,按下面顺序排查。

第一步,确认你的工具确实支持事件型钩子。部分工具只支持“启动时注入提示词”,不支持“工具调用前拦截”。先看文档,别在功能不存在的情况下浪费时间。

第二步,确认钩子配置里的event、tools命名和你的工具一致。不同工具的命名差异很大,同一工具的不同版本也可能改名。

第三步,手动运行脚本,用echo模拟输入,确认退出码符合预期。这一步能排除脚本本身的问题。

第四步,让代理执行一个“明显违规”的操作,比如尝试修改src/api下的文件,观察代理日志里是否出现钩子调用记录。

第五步,检查退出码语义。有的工具约定 0 表示放行,非 0 表示拦截;也有个别工具反着来。以官方文档为准。

6.3 钩子误伤与路径绕过

钩子最容易被批评的是误伤。路径检查一旦写得太宽,代理写任何文件都会被拦,任务寸步难行。解决方法是把保护目录写得尽量精确,比如只保护src/api/v1和migrations/schema,不要保护整个src。

另一个隐患是路径绕过。代理可能通过cd src/api && python3 modify.py这类拼接命令间接操作受保护目录。上面示例里的command字段检查能覆盖一部分情况,但无法覆盖所有变体。更稳妥的做法是:在工具层面干脆禁止代理直接执行任意命令,只允许通过受限脚本操作文件;或者在命令行包装层再做一次拦截。

路径绕过没有完全标准的解法,因为命令字符串的解析复杂度很高。如果项目对接口层安全要求很高,建议把“命令执行”这一类工具本身纳入严格审批,而不是指望一个正则表达式能拦住所有绕过方式。

7. 最佳实践与工程建议

7.1 软规则与硬规则分开管理

这是所有策略里优先级最高的一条。

每一条规则在进入规则文件之前,都应该先问一句:如果代理违反了它,后果是什么?如果后果是“代码风格难看”“可读性变差”,属于软规则,写进规则文件就够了。如果后果是“线上接口挂掉”“测试被删”“数据库结构被破坏”,属于硬规则,必须用钩子加 Git 钩子双重防护。

规则文件也要控制长度。从使用经验来看,300 行以内的规则文件相对最稳定。超过 500 行后,代理对早期规则的记忆会明显下降。与其把规则写成一本书,不如把硬规则抽出来交给脚本,让规则文件只负责风格和偏好。

7.2 钩子脚本的工程化

钩子脚本看起来只是简单判断,但长期维护后复杂度会上升。建议遵守几条基本规范。

保持幂等。同样的输入,每次运行结果应该一致,不能依赖外部可变状态。

不做入口堆积。一个事件下挂多个脚本时,尽量合并到统一入口,用子命令区分任务,避免环境变量和标准输入被多次包装。

统一输出格式。钩子返回给代理的反馈信息,应该是一句明确的话,包含“被拦截原因”和“下一步怎么做”。不要只输出一个错误码,代理不知道如何调整。

设置合理超时。前置拦截脚本控制在 10 到 30 秒内,后置格式校验可以放宽到 60 秒以上。脚本一旦超时,很多工具会默认放行,这一点要特别注意。

给脚本写单元测试。把路径判断逻辑从标准输入处理中拆出来,单独测“相对路径”“绝对路径”“带..的路径”“Windows 路径”几类边界情况。路径边界是钩子最容易出错的地方。

7.3 安全与权限边界

钩子脚本运行在开发机甚至生产服务器上,安全问题必须重视。

不要在钩子脚本里用root或管理员权限运行。钩子需要的最小权限就是当前用户权限,必要时可以切换到专门的服务账号。

不要把未经过滤的输入直接拼进 shell 命令。上面示例只是读取字段做字符串判断,没有执行外部命令,风险较低。但如果你打算在钩子里动态调用rm、curl等命令,必须先对输入做严格白名单校验。

钩子配置本身也应该被保护。如果攻击者能改写.ai/hooks/guard.py,他就能让所有校验形同虚设。建议对 hooks 目录设置写权限控制,并让这些文件进入 Code Review 流程。

7.4 团队协作建议

在团队里推广钩子机制,要注意“默认拒绝”和“渐进放开”的节奏。

第一周可以先只开一个钩子,保护一个高风险目录,观察日志。确认没有误伤、拦截效果符合预期后,再逐步加入更多规则。不要一次性上十几条硬规则,否则代理会被频繁打断,团队成员会失去耐心。

钩子配置应该随仓库共享,并且写清楚维护说明。每个成员 clone 项目后,最好能通过一条命令完成环境初始化,把必要的 Git 钩子也安装好。

最后,钩子不能替代人。代码评审、测试、发布审批依然要保留。尤其是生产环境变更,必须强调合法授权、最小权限和可回滚原则。钩子只是减少低级错误的概率,真正保证质量的是流程和人的判断。

8. 总结

如果你的编程代理现在经常无视规则,先别急着把规则文件越写越长。把规则分成两类:一类是风格偏好,适合写进规则文件;另一类是“绝对不能做”的硬边界,适合用钩子拦截。

钩子能解决一个很实际的问题:让代理在踩线之前被拦住。它不依赖模型是否记得住规则,也不依赖模型是不是“心情好”。只要配置好,代理触碰保护目录时,脚本就会立刻执行拦截,并把原因反馈给模型。

从落地顺序来看,可以先复制文中的guard.py保护一个高风险目录,手动验证后观察一周审计日志,再逐步扩展规则覆盖面。配合 Git pre-commit 钩子和 CI 检查,团队在使用 AI 编程代理时会省心很多。规则文件加钩子,再加一层 Git 兜底,这套组合基本能覆盖日常开发里绝大多数“代理不听话”的场景。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进