CLAUDE.md 里写明不许动的文件,Claude 还是照改,这种硬规矩该写在哪

CLAUDE.md 里写一句「migrations/ 下已经上线的迁移文件不许改」,加粗、写 NEVER、挪到文件第一行,Claude 照样有一定概率去改。规则文件放哪、各家工具认哪个,站里这篇聊过;这里想聊的是写了它不听怎么办。

官方 memory 文档是这么说的:CLAUDE.md 是 context, not enforced configuration,它是作为一条用户消息塞在系统提示后面的,Claude 会读、会尽量照做,但不保证严格遵守。文档还建议单个文件控制在 200 行以内,写长了遵守程度会下降。同一段话里也给了出路:真要不管 Claude 怎么想都拦住某个动作,用 PreToolUse hook。

这样分工就比较清楚了:CLAUDE.md 放「希望它这么做」的东西,写得具体、能验证,只跟某些目录有关的挪到 .claude/rules/ 里按路径加载,别让一个文件越写越长;「绝对不能发生」的事交给 hooks 和权限规则,那一层是客户端执行的,跟模型听不听话没关系。

拦住改文件

hooks 指南开头就说,hooks 是在生命周期固定节点跑的 shell 命令,给的是 deterministic control,不靠模型自己决定要不要做。配置写在 .claude/settings.json(项目级,可以提交进仓库),个人全局的放 ~/.claude/settings.json,只想自己生效又不提交的放 .claude/settings.local.json。

拦截脚本可以这么写。hook 从 stdin 收到一段 JSON,里面的 tool_input.file_path 就是要改的文件:

#!/bin/bash
# .claude/hooks/guard-paths.sh
f=$(jq -r '.tool_input.file_path // empty')
case "$f" in
  */migrations/*|*.env|*/.env.*)
    echo "不允许改 $f:上线过的迁移和 env 文件请找人手动处理" >&2
    exit 2 ;;
esac
exit 0

注册的时候 matcher 写 Edit|Write,顺手把交卷前跑测试的 Stop 也挂上(Stop 不支持 matcher,写了也会被忽略):

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guard-paths.sh" }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.sh" }
        ]
      }
    ]
  }
}

脚本记得 chmod +x,配好后在 Claude Code 里敲 /hooks 看有没有列出来。改 settings 一般不用重启会话,文件会被自动重新加载;菜单里迟迟不出现,先检查 JSON 里有没有多余的逗号或注释。

退出码是最容易踩的地方。hooks 参考里写着:exit 2 才是阻断,PreToolUse 上会直接挡掉这次工具调用,stderr 当作原因回给 Claude,它一般会换个做法。exit 1 虽然是 Unix 习惯里的失败码,在这里算 non-blocking error,动作照常执行。脚本路径写错、hook 根本没启动,也只会在记录里出个 hook error 提示,不会拦。

测试和格式化

Stop 在 Claude 答完准备收手时触发,exit 2 会让它停不下来、接着干。run-tests.sh 里先读 stop_hook_active,为 true 说明这一轮本来就是被 hook 拽回来的,直接 exit 0,否则跑测试,没通过就把失败信息写到 stderr 再 exit 2。这么写最多逼它返工一轮。不加这个判断,测试一直红就会一直循环,官方给了个兜底:连续拦 8 次、中间没有工具调用,就强制结束这一轮。

改完文件想自动跑 Prettier、lint,就用 PostToolUse 配 Edit|Write。要注意 PostToolUse 的 exit 2 拦不住任何东西,工具已经跑完了,只是把 stderr 给 Claude 看,适合告诉它 lint 报了什么。

上下文压缩后约定容易丢,可以挂 SessionStart,matcher 写 compact,往 stdout 打印的纯文本会被加进上下文,把几条关键约定 echo 进去就行。

拦了也有漏的

  • Edit|Write 只管那两个工具。Claude 用 Bash 跑 sed -i 或者一段脚本照样能改文件,官方建议另挂 Stop 扫一遍工作区,或者把 Bash|PowerShell 也匹配上,用 git status --porcelain 看改了什么。
  • hook 上的 if 字段是尽力而为,官方明说硬性 allow/deny 用权限规则,比如 permissions.deny 里写 Edit(/migrations/**)。deny 先于 ask 和 allow,allow 没法在 deny 里开例外。
  • 权限规则也管不住 Python、Node 脚本自己开文件,要系统级隔离得开沙箱。

反过来,一个 exit 2 的 PreToolUse hook 优先级比 allow 规则高,所以「Bash 全放行、个别命令用 hook 拒掉」这种搭配也成立。

大家仓库里现在都拦了哪些东西?Bash 改文件这条口子是怎么堵的?