你刚给一个 Bash(npm test) 点过 “Yes, and don’t ask again”,两分钟后同样的提示又跳出来。你往 allowlist 加了一条,下一个 Bash(npm run build) 还是在问。每次 Edit、每次 shell 命令都要点一下——你以为的”agent”成了一个 session 要点一百次确认的钥匙保管员。
最快解法: 在 Claude Code 里跑 /permissions,看清当前加载了哪些规则、各来自哪个文件,确认你的命令是否真的命中了某条规则。如果没命中,截至 2026 年 6 月最常见的原因是:命令是复合命令(Claude Code 会按 &&、|、; 拆分,每个子命令都要有自己的规则)、pattern 太具体没法匹配末尾的 flag、或者规则放在了这个项目根本不读的 settings 范围里。如果只是想在一个信任的项目里让提示彻底消停,按 Shift+Tab 切到更宽松的权限模式,或者设 "defaultMode": "acceptEdits"。下面把这些一句话解法拆成一套可靠流程。
先搞懂权限系统怎么运作
每一次弹窗都由三件事决定:
- 三个规则列表。 settings 文件里的
permissions.allow、permissions.ask、permissions.deny。求值顺序是 deny → ask → allow,第一条命中的说了算;更具体的 allow 规则压不过已命中的 ask 或 deny 规则。 - 权限模式。
default对每个工具首次使用时弹窗;acceptEdits自动接受编辑和常见文件系统命令;plan只读;bypassPermissions几乎全跳过。按 Shift+Tab 循环切换,或用 settings 里的"defaultMode"固定一个。 - settings 优先级。 托管设置 > 命令行参数 >
.claude/settings.local.json>.claude/settings.json>~/.claude/settings.json。任意一层的 deny,低层的 allow 都覆盖不了。
跑 /permissions 能看到合并后的实时规则集,以及每条规则的来源文件。光这一条命令就能解决大部分”为什么还在问”的困惑。
你属于哪种情况
| 症状 | 最可能的原因 | 跳转 |
|---|---|---|
循环的命令里有 &&、|、; 或子 shell | 复合命令——每个子命令各需一条规则 | 原因 1 |
规则写的是 Bash(npm test),命令却是 npm test --watch | pattern 太窄(末尾没通配符) | 原因 2 |
规则存在,但 /permissions 里没列出来 | settings 范围错了或 JSON 非法 | 原因 3 |
Edit(/Users/you/...) 永远不匹配 | 路径锚点错了——那是项目根相对路径,不是绝对路径 | 原因 4 |
| 给过 allow,hook 还是强制弹窗 | PreToolUse hook 返回 ask 或 exit 2 | 原因 5 |
| settings 里给过 allow,每次跑还在问 | 模式是 default,你一直点 “Just this time” | 原因 6 |
常见原因和修法
原因 1:命令是复合命令(最容易踩的坑)
Claude Code 是懂 shell 的。它会按 &&、||、;、|、|&、& 和换行拆分命令,然后要求每个子命令都各自命中一条规则。所以 Bash(cd packages/web && npm test) 并不被单独的 Bash(npm test*) 覆盖,老式写法 Bash(cd * && npm *) 也授权不了整条复合命令。
修法: 给每个子命令各加一条规则。当你用 “Yes, and don’t ask again” 批准一条复合命令时,Claude Code 其实已经替你这么做了——它会按子命令分别保存规则(最多 5 条),比如批准 git status && npm test 会保存一条 npm test 的规则。手动预先写的话:
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm run build:*)"
]
}
}
裸的 cd 进入工作目录的子目录会被当成只读、不需要单独规则。有一个例外:把 cd 和 git 放在同一条复合命令里,不管目标目录是哪个都会弹窗。
原因 2:allow pattern 比命令更具体
Bash(npm test) 是精确匹配。一旦 Claude 跑 npm test --watch 或 npm test src/,这条规则就错过了。
用末尾通配符覆盖同族命令。两种等价写法:
Bash(npm test *)——注意*前面有个空格。这个空格强制词边界:Bash(ls *)匹配ls -la但不匹配lsof。Bash(npm test:*)——:*后缀和末尾的*等价,也是权限弹窗倾向于写出的形式。
通配符可以放在任意位置:Bash(git * main) 同时匹配 git push origin main 和 git merge main。单个 * 能跨多个参数(含空格)。
进程包装器会被自动剥离,所以 Bash(npm test *) 也覆盖 timeout 30 npm test。被剥离的集合是固定的:timeout、time、nice、nohup、stdbuf,以及不带 flag 的 xargs。环境运行器不会被剥离——npx、docker exec、mise exec、direnv exec、devbox run 会把参数当命令执行,所以要写一条同时点名运行器和内部命令的规则,比如 Bash(docker exec mycontainer npm test)。
原因 3:规则在这个项目不读的范围里(或 JSON 写坏了)
有三个可写层级,按用途选:
| 规则适用范围 | 改哪个文件 |
|---|---|
| 跨所有项目的个人默认 | ~/.claude/settings.json |
| 团队通过 git 共享 | 仓库根的 .claude/settings.json |
| 这个项目里个人用、不进 git | .claude/settings.local.json |
npm test 这种项目特定但个人用的命令,放 settings.local.json,规则就不会跑进同事的环境。
怎么确认: 跑 /permissions。如果里面没列出你的规则,那 Claude Code 就没读到它——要么放错了范围,要么 JSON 非法(一个多余的逗号就能让整个文件静默失效)。用 python3 -m json.tool < .claude/settings.local.json 校验。当前版本会实时重读 settings,但如果你用的是旧版本,改完后重启 Claude Code。
原因 4:Edit/Read 规则的路径锚点写错了
这个几乎人人都中招。Read 和 Edit 规则走 gitignore 语义,有四种锚点写法,而单个前导斜杠不是绝对路径:
| Pattern | 锚点 | 示例 |
|---|---|---|
//path | 绝对路径,从文件系统根算起 | Edit(//Users/you/proj/src/**) |
~/path | 家目录 | Read(~/.zshrc) |
/path | 项目根,不是文件系统根 | Edit(/src/**) |
path 或 ./path | 当前目录 | Read(*.env) |
所以 Edit(/Users/you/proj/src/**) 会被解释成 <project-root>/Users/you/proj/src/**,什么都匹配不到。项目内相对路径用 Edit(/src/**),真正的绝对路径用 Edit(//Users/you/proj/src/**)(双斜杠)。裸文件名比如 Read(.env) 在任意深度都匹配,等同于 Read(**/.env)。
原因 5:PreToolUse hook 在强制弹窗
PreToolUse hook 能返回 ask 决策,或者以 exit code 2 阻断,这两者都优先于你的 allow 规则。再厉害的 allowlist 也压不住。
把它找出来:
grep -rn "PreToolUse" ~/.claude/settings.json .claude/settings.json .claude/settings.local.json
如果有个 matcher: "Bash" 的 hook 在跑交互检查,它会无视 allow 规则、对每个 Bash 都重新提示。把 matcher 收窄到你真正在意的破坏性命令,或者直接删掉这个 hook。(如果是反过来的问题——hook 挡住了你想要的编辑——见下方相关阅读里”hook 误挡编辑”那篇。)
原因 6:你在 default 模式下一直点 “Just this time”
“Just this time” 按设计就不持久化。只有 “Yes, and don’t ask again” 才会写规则。如果你发现自己在反复点那个临时选项,要么点一次持久化的那个,要么切换 session 的模式。
信任项目的快速通道
在你自己、信任 Claude 判断的仓库里,别去跟一条条规则较劲——直接改模式。按 Shift+Tab 循环(default → acceptEdits → plan → 回到 default),或者持久化设定:
{
"permissions": {
"defaultMode": "acceptEdits",
"deny": [
"Bash(rm -rf *)",
"Bash(git push --force*)",
"Bash(curl * | bash*)"
]
}
}
acceptEdits 自动接受文件编辑和工作目录内的常见文件系统命令(mkdir、touch、mv、cp),同时对更危险的 Bash 仍会弹窗。deny 规则照样优先,所以上面那些破坏性 pattern 仍被挡住。要在容器或 VM 里完全无人值守地跑,可以用 claude --dangerously-skip-permissions 启动(或 --permission-mode bypassPermissions),但绝不要在 agent 可能造成真实损害的机器上这么干——rm -rf / 和 rm -rf ~ 仍会作为保险丝弹窗,但其它什么都不拦了。
如果你想在标准模式下”全放行 + 定点例外”,最干净的做法是一条裸 Bash allow 加一个 PreToolUse hook 来拒绝你要挡的少数命令,而不是去枚举每一条安全命令。
怎么确认已经修好
- 跑
/permissions,确认你加的规则出现在列表里、且来源文件正是你预期的那个。 - 把原本循环的每条命令各手动触发一次,确认不再弹提示。
- 跑一条明显破坏性的命令(
rm -rf /tmp/test-fixture),确认它仍会弹窗或被拒——说明 deny 规则在生效。 - 跑一个 10 分钟探索性 session 数提示数。修完应该
<= 5次(理想 0-2 次)。
常见坑
- 给复合命令只写一条规则。拆开:每个
&&、|、;旁的子命令各一条。 - 用正则。Claude Code 用简单 glob(
*和 gitignore 风格的**),不是完整正则。 - 忘了
*前的空格。Bash(ls*)会连lsof一起匹配;Bash(ls *)不会。 - 给 Edit 规则用单个前导斜杠、却指望它是绝对路径。绝对路径用
//。 - 想用 Bash pattern 约束
curl的 URL。这很脆(flag、重定向、变量全能绕过)。改成 deny 掉curl/wget,再用WebFetch(domain:...)allow 规则。 - 编辑了错的范围文件、然后断言”settings 不生效”。跑
/permissions看真正加载了什么。
FAQ
Q:“Yes, and don’t ask again”、“Yes”、“No” 有啥区别? A:“Yes, and don’t ask again” 会把规则写进 settings(持久)。单纯的 “Yes” 只放行这一次调用。误点了非持久的那个选项,是反复弹窗最常见的来源。
Q:我批准了一个带 && 的命令,它还在问。为啥?
A:复合命令按子命令拆分。批准一次会给每段各存一条规则(最多 5 条),但如果各次运行里那个子命令的文本有变化,存下的规则就可能错过。给每个子命令预先写一条通配规则,比如 Bash(npm test:*)。
Q:allow pattern 能用完整正则吗?
A:不能。Bash 规则用 * 的 glob;Read/Edit 规则用 gitignore 的 * 和 **。需要更复杂的逻辑就用 PreToolUse hook。
Q:怎么让 Claude Code 在某个项目里彻底不问?
A:在那个项目的 .claude/settings.json 里设 "defaultMode": "acceptEdits",或者每个 session 用 Shift+Tab 切进去。容器里无人值守就用 --dangerously-skip-permissions。两种情况都把破坏性 deny 规则留着。
Q:我的 deny 规则为啥没拦住该拦的命令?
A:deny 规则用的是同一套 pattern 形态。Bash(rm -rf /) 不匹配 Bash(rm -rf /tmp/x)——同族用 Bash(rm -rf *)。记住 deny 在 allow 之前求值,命中就一定赢。
Q:MCP 服务端的工具走同一套系统吗?
A:走。MCP 调用以 mcp__server__tool 形式出现,可以用一样的方式 allow 或 deny,比如 mcp__puppeteer__* 覆盖 puppeteer 服务的全部工具。
相关阅读
外部参考:Claude Code 权限文档 与权限模式。
标签: #Claude Code #agent #排查