Claude Code 改错文件:6 种命名混淆 + 用 permissions.deny 强制锁死

改了你没让改的文件(同名 / 大小写差 / import 链外扩)。先 git 回退,再用 .claude/settings.json 里的 permissions.deny Edit 规则把边界锁死。

你说「改 src/auth/login.ts」。Claude Code 改了 src/auth/logIn.ts(大小写不一样,一个你忘了存在的过期文件)。或者改了 src/auth/login.test.ts,以为 bug 在那。或者改了 src/legacy/auth/login.ts,因为名字匹配、它先找到了这个。

最快修法: git checkout HEAD -- <改错的文件> 回退坏改动,然后用反引号包住完整路径重新发 prompt,再附一行 deny 清单。长效修法:.claude/settings.json 里加一条 permissions.deny 规则,比如 Edit(/legacy/**),这样 Claude Code 会被直接拦住,而不是只被「好言相劝」。prompt 和 CLAUDE.md 里的文字只能影响 Claude 做什么;真正能拦住它的只有 permission 规则(Anthropic 在权限文档里明确说了这一点)。

改错文件有两个根因:命名歧义(多个候选都匹配你的描述),或 scope drift(agent 沿 import / call 链超出了你给的范围)。两种的恢复方式一样;预防略有不同,下面的表会告诉你属于哪一类。

你属于哪一类?

diff 里的症状大概率原因最佳预防
同文件名、大小写不同(Login.tslogin.ts相似名,agent 挑了先匹配到的prompt 给完整路径 + 删/改掉过期的孪生文件
改动落在 utils/lib/common/沿 import 链外扩对共享目录加 Edit(...) deny 规则
目标附近 2-3 处不相关小改「顺手」清理,scope creep收紧 allow 清单 + 先进 Plan mode
改动在另一个 package 里monorepo 命名碰撞路径带 workspace 前缀,或用 /add-dir 收敛
改动在 .test.ts / .spec.ts改了测试而非生产代码显式点名 implementation 文件
改动碰了 migrations/*.snap、lockfile、dist/没有常设护栏permissions.deny 规则(强制生效)

常见原因

按命中率从高到低。

1. 两个相似名文件——agent 挑错了

login.tsLogin.tsauth.service.tsauth.test.tsuser.tsusers.ts。没有显式消歧时,agent 会改文件扫描里先浮出来的那个。

如何判断: find . -iname "login*" 返回多个候选。没有路径,「login」就是歧义。

2. Agent 沿 import 链外扩到邻居文件

你说「修 billing.ts」。它读了 billing.ts,看到 import { format } from "../utils",判断问题在 utils.ts 就去那改了。

如何判断: diff 里有 utils/ / lib/ / common/ 的文件,尽管你的 task 是针对某一个 feature 的。

3. Agent 顺手「清理」了邻居文件

改目标的同时,它注意到隔壁文件有 typo 或风格不一致,就「修」了。邻居文件进了 diff。

如何判断: diff 里有目标附近 2-3 处不相关的小改动。单看每处都无害,合起来就是 scope creep。

4. Monorepo 或重复目录带来的命名歧义

apps/web/src/auth/login.tspackages/shared/auth/login.ts 都存在。agent 按工具调用里先出现的路径挑。

如何判断: diff 在你预期之外的另一个 package 里。同文件名、不同祖先目录。

5. Agent 把测试文件当成生产代码改了

login.test.ts 里的 mock 调用了 login()。agent 看到 mock setup,当成了生产代码,改了 mock 而不是真正的 implementation。

如何判断: 尽管你要的是生产修法,diff 却在 .test.ts / .spec.ts 里。

6. case-insensitive 文件系统混淆

macOS 默认 case-insensitive,磁盘上 Login.tslogin.ts 是同一个文件。Linux CI(case-sensitive)上它们是两个不同文件。一个在本地看着没问题的改动,一到 CI 就可能落在错的 canonical 形式上。

如何判断: 路径里内容相同、大小写不同。用 git log --follow 看有没有改名。

最短修复路径

Step 1-2 负责恢复、止血。Step 3-8 防再发,耐久度递增。

Step 1:用 git status + git diff 盘点损失

# 改了什么?
git status

# 详细 diff
git diff --stat
git diff

每个非预期文件逐个回退:

# 单文件
git checkout HEAD -- src/wrong/file.ts

# 多文件 / 整个目录
git checkout HEAD -- src/wrong/ src/another-wrong/

# 已经 commit 了坏改动
git revert <commit-hash>

改动没 commit 也没 stage 时,git checkout HEAD -- <file> 就够了。如果你 stage 了,用 git restore --staged --worktree <file> 一步清掉暂存区和工作区两份。

Step 2:重发 prompt,带显式 allow + deny 清单

EDIT exactly these files (and no others):
- src/auth/login.ts

DO NOT touch any of these (even if you think they need changes):
- src/auth/Login.ts (deprecated, do not revive)
- src/auth/login.test.ts (test file, separate concern)
- src/legacy/ (entire directory is off-limits)
- src/utils/ (shared, requires separate approval)

If the task requires editing anything else, STOP and ask first.

deny 清单是关键,它能 catch「agent 以为自己在帮忙」这类 allow 清单单独抓不到的情况。但这一层是 prompt 级别的引导,只影响意图,并不强制生效。对那些绝对不能碰的文件,直接跳到 Step 5。

Step 3:prompt 用完整路径,别用「那个 auth 文件」

差: fix the auth file
好: fix `apps/web/src/auth/login.ts`

完整路径能消除命名解析的歧义。monorepo 里始终带上 workspace 前缀(apps/web/...packages/shared/...),这样两个 login.ts 就不会被混淆。

Step 4:动手前先在 Plan mode 里诊断

对有风险或有歧义的 task,先进 Plan mode:按 Shift+Tab 切换模式(或启动时用 claude --permission-mode plan)。Plan mode 下 Claude 会读文件、跑只读命令来探索,但在你批准计划之前不能编辑你的源码。一个字节都还没写,你就能看清它打算改哪些文件。批准后切回 default,再让它实现。

Step 5:用 permissions.deny 锁死边界(强制生效)

这是长效修法。.claude/settings.json 里的 permission 规则由 Claude Code 本身强制执行,而不是由模型执行,所以即便 prompt 或 CLAUDE.md 会引导 Claude 去改,deny 规则也会拦住这个编辑。规则按 denyaskallow 的顺序求值;任意一层只要命中 deny 就生效。

{
  "permissions": {
    "deny": [
      "Edit(/legacy/**)",
      "Edit(/migrations/**)",
      "Edit(**/*.snap)",
      "Edit(/dist/**)",
      "Edit(/build/**)",
      "Edit(**/.env)",
      "Edit(/pnpm-lock.yaml)"
    ]
  }
}

模式语法(gitignore 风格,出自官方文档):

  • /path 相对于项目根目录,所以 Edit(/legacy/**) 拦的是 <project>/legacy/
  • //path绝对文件系统路径;~/path 从你的家目录算起;裸文件名或 ./path 相对于当前目录
  • * 在单个路径段内匹配,** 跨目录匹配。裸文件名在任意深度匹配,所以 Edit(**/.env)Edit(.env) 都拦住所有 .env

两点值得记住:一条 Edit 规则覆盖所有内置编辑工具,而且 deny 还会拦住 Claude 能识别的写文件 Bash 命令(sed、向文件的重定向),所以 agent 没法绕到 shell 里去改。在会话里用 /permissions 查看和编辑这些规则;想要更严的范围,可以改用 allowlist 姿态——permissions.allow 里写 Edit(/src/**) 之类,配合默认拒绝。

Step 6:相似文件名,审查并收敛

如果你的 repo 有多个相似名文件,从源头消除歧义:

# 找相似名(跳过 node_modules)
find . -iname "login*" -not -path "*/node_modules/*"

# 解决:删掉死文件,规范测试名
git rm src/auth/Login.ts                          # 真死了的话
git mv src/auth/login.test.ts src/auth/login.spec.ts  # 消歧有帮助时

命名整洁的 codebase 改错文件少得多。用 git rm/git mv(而不是裸 rm/mv),改名会被追踪,git log --follow 也能保持干净。

Step 7:危险区域用分支 + worktree

# agent task 用新分支
git checkout -b feature/auth-fix

# 或独立 worktree,让 agent 在自己的目录里干活
git worktree add ../project-agent feature/auth-fix

agent 改错文件时,你可以把整个分支或 worktree 扔掉,不影响 main。worktree 还能让 agent 的工作目录跟你的物理隔离。

Step 8:在 CLAUDE.md 写常设规则(意图层)

CLAUDE.md 给团队记录「为什么」,也能引导 Claude 的行为,但记住它不是强制边界;对任何关键文件,都要配合 Step 5 的 deny 规则一起用。

## Agent 不许动的文件

- `migrations/*.sql`——历史记录,不许改
- `legacy/`——冻结,不许碰
- `*.snap`(snapshot)——靠测试重跑更新,永不手改
- `pnpm-lock.yaml`——靠 `pnpm install` 更新,永不直接编辑
- `dist/``build/`——生成产物,不许改
- `.env*`——密钥,永不改

task 像是要动这些就停下来问。

想要一道连 bypassPermissions 边缘情况都挡得住的硬性拦截,注册一个 PreToolUse hook,拒绝对受保护路径的编辑(见 Anthropic 的 Block edits to protected files 示例)。一个以 exit code 2 退出的 hook,会在权限规则求值之前就把这次工具调用拦下来。

如何确认已经修好

  1. 重跑同一个 task。git diff --stat 应该只列出你打算改的那个/那些文件。
  2. 主动触发旧故障:让 Claude「顺便清理一下 legacy/」或「把共享 util 也修了」。有 deny 规则在,这个编辑会被拒绝而不是被执行。
  3. 在会话里跑 /permissions,确认你的 Edit(...) deny 规则在列,且来源是预期的那个 settings.json
  4. commit 预期的改动,检查 git log -1 --stat 显示的是正确路径,而不是某个相似名文件。

常见问题

CLAUDE.md 能阻止 Claude 编辑某个文件吗? 不能。CLAUDE.md 和 prompt 里的指令只影响 Claude 做什么,并不强制。真正能拦住编辑的只有 permissions.deny 规则(或一个 PreToolUse hook)。把 CLAUDE.md 当文档,把真护栏放进 .claude/settings.json

为什么 Edit(/legacy/**) 拦不住 /legacy/ 开头单个斜杠指的是项目根目录,不是文件系统根目录。Edit(/legacy/**) 匹配的是 <project>/legacy/。要拦绝对路径 /legacy/,得写双斜杠 Edit(//legacy/**)。这一点最容易踩坑。

agent 能用 sedcat > 在 Bash 里绕过 deny 规则吗? 不能。Edit deny 规则同样作用于 Claude Code 能识别的写文件 Bash 命令,比如 sed 和重定向。但它覆盖任意子进程(一个自己打开文件的 Python 或 Node 脚本);想要拦住每个进程的 OS 级强制,需要开启 sandboxing

坏改动已经 commit 并 push 了,怎么恢复? git revert <commit-hash> 会生成一个新 commit 来撤销它,在共享分支上是安全的。git reset --hard 只留给本地、还没 push 的历史。

它在 macOS 上改错了文件,但路径看着没错。 macOS 是 case-insensitive,Login.tslogin.ts 在本地解析成同一个文件,到 Linux CI 上才分叉。跑 git config core.ignorecase false 让大小写差异暴露出来,再 git mv 到 canonical 名字,让那个相似名文件消失。

Plan mode 和 deny 规则,该用哪个? Plan mode 是按会话生效的只读模式,适合一次性的高风险 task——你想在动手前先审一遍计划。permissions.deny 规则是持久且强制的,跨每一个会话生效,适合那些永远不该碰的路径。两个一起用:task 走 Plan mode,禁区交给 deny 规则。

预防建议

  • prompt 用完整路径,不用描述:写 src/auth/login.ts,绝不写「那个 auth 文件」。
  • 每个非琐碎 prompt 都带上 allow + deny 文件清单。
  • .claude/settings.json 里放上强制生效的 Edit(...) deny 规则,覆盖 migration、snapshot、lockfile、生成产物;CLAUDE.md 记录这些规则背后的意图。
  • 高风险 task 用 Plan mode(Shift+Tab)在动手前先审一遍文件清单。
  • 审查 repo 里混淆的同名 / 大小写差异文件,用 git rm/git mv 收敛。
  • 风险 agent 工作走分支或 worktree,回退一条命令搞定。
  • monorepo 里始终带上 workspace 前缀,消除跨包命名碰撞。

相关阅读

标签: #排查 #Claude Code #排查 #误改