你说「改 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.ts 对 login.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.ts 和 Login.ts、auth.service.ts 和 auth.test.ts、user.ts 和 users.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.ts 和 packages/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.ts 和 login.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 规则也会拦住这个编辑。规则按 deny → ask → allow 的顺序求值;任意一层只要命中 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,会在权限规则求值之前就把这次工具调用拦下来。
如何确认已经修好
- 重跑同一个 task。
git diff --stat应该只列出你打算改的那个/那些文件。 - 主动触发旧故障:让 Claude「顺便清理一下
legacy/」或「把共享 util 也修了」。有deny规则在,这个编辑会被拒绝而不是被执行。 - 在会话里跑
/permissions,确认你的Edit(...)deny 规则在列,且来源是预期的那个settings.json。 - 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 能用 sed 或 cat > 在 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.ts 和 login.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 #排查 #误改