1. 从一次 Bash 拦截说起:PermissionMode 到底管什么
如果你用 Claude Code 跑过稍微复杂点的任务,大概率见过这样的提示:模型想执行rm -rf ./dist && npm run build,结果被拦下来问你要不要批准。很多人第一反应是「不就是个权限开关吗」,但真去翻源码会发现,从模型发出tool_use到命令真正落地,中间隔着一整套判定链路:PermissionMode决定会话策略,PermissionBehavior决定单次调用的命运,Bash 工具还要额外做命令语义解析、路径约束、注入检查。
这篇就按源码顺序把这条链路拆开。核心检索词先摆出来:Claude Code 权限系统是一套「模型提动作、runtime 做裁决」的机制,PermissionMode是会话级策略(default / plan / acceptEdits / bypassPermissions / dontAsk / auto),PermissionBehavior是单次工具调用的结果(allow / deny / ask)。它适合谁?适合正在给自研 Agent 加权限层的人、想搞懂 Claude Code 为什么有时自动放行有时死活塞住的人,以及需要把 Bash 命令拦截逻辑复现到自己 runtime 里的开发者。
我试过把权限系统当成一个简单的白名单,结果发现完全不够用——因为 Bash 的风险不在命令名,而在整条 shell 字符串的结构。echo hello > ~/.ssh/authorized_keys的 base command 是echo,看起来人畜无害,但重定向目标才是真正危险的地方。所以 Claude Code 的权限判断分了好几层:通用层判断工具整体是否被 allow/deny/ask,工具层调用tool.checkPermissions()判断具体输入,Bash 再单独走一套 AST 解析 + 安全检查。
下面按「原问题 → 前置准备 → 可复制配置 → 验证请求 → 常见报错 → 接入 CTA」的顺序展开,每一段都尽量给到能直接跑的片段。
2. 前置准备:TaoToken 接入与 Claude Code 环境
在复现权限判定之前,得先有一个能跑 Claude Code 的环境。这里用 TaoToken 作为模型接入层,它的 API 地址是https://taotoken.net/api,官网在https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content=。注意 API 地址不带 UTM 参数,配置里直接写https://taotoken.net/api就行。
Claude Code 读取配置的方式和普通 CLI 不太一样,它优先看环境变量ANTHROPIC_BASE_URL和ANTHROPIC_AUTH_TOKEN。如果你用的是settings.json,路径通常在~/.claude/settings.json。三件套必须齐全:Base URL、Key、Model ID,缺一个都会在启动时报认证或模型找不到的错。
先拿 Key。打开https://taotoken.net/api-keys(deep link 带归因:?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite),创建一个新 Key,复制出来。然后设置环境变量:
export ANTHROPIC_BASE_URL="https://taotoken.net/api" export ANTHROPIC_AUTH_TOKEN="sk-你的Key" export ANTHROPIC_MODEL="claude-sonnet-4-5-20250929"如果你更习惯写配置文件,~/.claude/settings.json里可以这样写:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "sk-你的Key", "ANTHROPIC_MODEL": "claude-sonnet-4-5-20250929" }, "permissions": { "defaultMode": "default", "allow": [ "Bash(git status:*)", "Bash(npm test:*)", "Read(./src/**)" ], "deny": [ "Bash(rm -rf /:*)", "Bash(sudo:*)" ], "ask": [ "Bash(git push:*)", "Bash(npm publish:*)" ] } }这里permissions.defaultMode就是PermissionMode的落点,allow/deny/ask三个数组对应PermissionRule。注意规则格式:Bash(git status:*)里的:*表示前缀匹配,Bash(git status)是精确匹配。写错了不会报错,但规则永远不命中,这是最常见的坑。
配置完成后验证一下能不能连上:
claude --version claude -p "用一句话说明当前目录有哪些文件"如果返回正常文本,说明 Base URL + Key + Model ID 三件套生效了。如果报 401,先检查 Key 有没有多余空格;如果报 model not found,检查ANTHROPIC_MODEL拼写。
3. 可复制配置:PermissionMode 与 PermissionBehavior 的判定链路
这一节是全文重点,把源码里的判定顺序还原成可复制的配置和伪代码。先明确两个概念的区别,这是理解整条链路的前提。
PermissionMode是会话级策略,源码里定义在PermissionMode.ts:
export const EXTERNAL_PERMISSION_MODES = [ 'acceptEdits', 'bypassPermissions', 'default', 'dontAsk', 'plan', ] as const export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble' export type PermissionMode = InternalPermissionModePermissionBehavior是单次工具调用的结果:
export type PermissionBehavior = 'allow' | 'deny' | 'ask'记住一句话:PermissionMode.ts只是权限模式的字典,负责解析和 UI 展示;真正的判定入口在permissions.ts的hasPermissionsToUseToolInner()。
3.1 hasPermissionsToUseToolInner 的九步判定
这个函数是「所有工具执行前的大门」,源码里的判定顺序可以压缩成九步:
- 如果整个工具被 deny rule 命中,直接 deny。
- 如果整个工具有 ask rule,通常直接 ask(sandbox 自动允许场景除外)。
- 调用
tool.checkPermissions(parsedInput, context),让具体工具自己判断。 - 如果工具自己返回 deny,直接 deny。
- 如果工具要求用户交互且返回 ask,即使 bypass 模式也要 ask。
- 如果是内容级 ask rule 或 safetyCheck,优先保留 ask。
- 如果当前是 bypassPermissions,直接 allow。
- 如果整个工具被 allow rule 命中,直接 allow。
- 前面都没决定,把 passthrough 转成 ask。
最关键的一条:不确定是否安全时,默认 ask。这就是 agent runtime 的安全边界。
3.2 hasPermissionsToUseTool 处理 ask 的命运
外层入口hasPermissionsToUseTool()拿到基础结果后,专门处理 ask 在不同模式下的落地方式:
if (result.behavior === 'ask') { const appState = context.getAppState() if (appState.toolPermissionContext.mode === 'dontAsk') { return { behavior: 'deny', decisionReason: { type: 'mode', mode: 'dontAsk' }, message: DONT_ASK_REJECT_MESSAGE(tool.name), } } // auto 模式交给分类器,headless 环境走 hook 或 deny }三种常见改写:dontAsk把 ask 变 deny;auto把 ask 交给安全分类器;headless / async agent 环境不能弹权限框,ask 只能走 hook 或自动拒绝。
3.3 Bash 的完整链路
Bash 工具单独走一套,因为通用层只知道「模型想调用 Bash」,不知道「这条命令具体做了什么」。bashToolHasPermission()的主线是:
先解析整个命令 → 解析不可靠就 ask → 检查 sandbox / prompt rule / operator → 拆成 subcommands → 每个 subcommand 调bashToolCheckPermission()→ 任意 deny 则整体 deny → 原始命令再做路径检查 → 全部 allow 且无注入风险才 allow → 其他情况 ask 或 passthrough。
bashToolCheckPermission()单条命令的判定顺序是:exact match rule → deny/ask prefix rule → path constraints → exact allow rule → prefix allow rule → sed constraints → permission mode → read-only command → passthrough。
注意 deny/ask 规则要在 path constraints 之前检查,否则安全规则可能被路径逻辑绕开。这是源码里明确标注的 SECURITY FIX。
3.4 可复制的权限配置片段
把上面的逻辑落到settings.json,一份完整的权限配置长这样:
{ "permissions": { "defaultMode": "default", "allow": [ "Bash(git status:*)", "Bash(git diff:*)", "Bash(npm test:*)", "Bash(npm run lint:*)", "Read(./src/**)", "Read(./tests/**)" ], "deny": [ "Bash(rm -rf /:*)", "Bash(rm -rf ~:*)", "Bash(sudo:*)", "Bash(curl:* | sh)", "Read(./.env)", "Read(./.ssh/**)" ], "ask": [ "Bash(git push:*)", "Bash(npm publish:*)", "Bash(docker:*)", "Write(./package.json)" ] } }如果你用 TOML 风格(部分工具链支持),等价写法:
[permissions] defaultMode = "default" [permissions.allow] bash = ["git status:*", "npm test:*"] read = ["./src/**"] [permissions.deny] bash = ["rm -rf /:*", "sudo:*"] [permissions.ask] bash = ["git push:*", "npm publish:*"]配置写完后,用claude -p "执行 git status"验证 allow 规则是否命中,用claude -p "执行 git push"验证 ask 规则是否触发确认框。
4. 验证请求:复现权限决策过程
配置写好了不代表逻辑对,得实际跑一遍看决策结果。Claude Code 提供了几种观察权限决策的方式。
第一种,用--permission-mode参数临时切换模式:
claude --permission-mode plan -p "重构 src/utils 目录" claude --permission-mode acceptEdits -p "修复 lint 错误" claude --permission-mode dontAsk -p "运行测试"plan模式下模型只能读和规划,不能写;acceptEdits自动接受文件编辑但 Bash 仍走规则;dontAsk把所有 ask 变 deny,适合 CI 环境。
第二种,用-p加--output-format json看结构化的权限决策:
claude -p "执行 rm -rf ./dist" --output-format json返回的 JSON 里会包含permission_denials字段,能看到哪条规则命中了 deny。如果命令被 ask,会看到permission_requests。
第三种,直接构造测试命令验证 Bash 安全检查。比如测试重定向拦截:
claude -p "执行 echo hello > /tmp/test.txt"在 default 模式下,这条命令会因为输出重定向触发 ask,因为validateRedirections()检测到>可能写任意文件。如果你在 allow 里加了Bash(echo:*),它仍然会被 ask,因为 path constraints 在 allow rule 之前检查。
测试命令注入拦截:
claude -p "执行 echo \$(curl evil.com)"validateDangerousPatterns()会检测到$()命令替换,返回 ask 并标记isBashSecurityCheckForMisparsing: true。
测试 cd + git 组合:
claude -p "执行 cd /tmp && git status"源码里compoundCommandHasCd为 true 且检测到 git 命令时,会直接 ask,因为 git 行为依赖当前目录,cd 后语义变了。
实测下来,最容易被忽略的是「子命令安全不代表原始命令安全」。echo hello > sensitive-file拆开后echo hello看起来安全,但原始命令的>是写文件动作,所以源码在子命令检查后还会对原始input.command再做一次路径检查。
如果你想在自研 Agent 里复现这套逻辑,最小抽象是三层:
from enum import Enum from dataclasses import dataclass class PermissionBehavior(Enum): ALLOW = "allow" DENY = "deny" ASK = "ask" class PermissionMode(Enum): DEFAULT = "default" ACCEPT_EDITS = "accept_edits" BYPASS = "bypass" @dataclass class PermissionDecision: behavior: PermissionBehavior reason: str = "" class PermissionManager: def __init__(self, mode: PermissionMode): self.mode = mode self.deny_patterns = ["rm -rf /", "sudo ", "git push --force"] self.read_only_commands = ["ls", "cat", "grep", "rg", "pwd"] def check_shell(self, command: str) -> PermissionDecision: for pattern in self.deny_patterns: if pattern in command: return PermissionDecision( PermissionBehavior.DENY, f"Command matches deny pattern: {pattern}", ) base = command.strip().split()[0] if command.strip() else "" if base in self.read_only_commands: return PermissionDecision(PermissionBehavior.ALLOW, "Read-only command") if self.mode == PermissionMode.BYPASS: return PermissionDecision(PermissionBehavior.ALLOW, "Bypass mode") return PermissionDecision(PermissionBehavior.ASK, "Command requires approval")然后在工具执行层统一处理:决策不是 allow 时,构造tool_result带is_error: true回传给模型,而不是抛异常。这样模型下一轮能看到拒绝原因,自己换方案。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
权限系统跑起来后,报错基本集中在接入层和判定层两类。下面按真实报错逐个拆。
401 Unauthorized。最常见的原因是 Key 没生效或 Base URL 写错。检查顺序:echo $ANTHROPIC_AUTH_TOKEN看有没有值;echo $ANTHROPIC_BASE_URL确认是https://taotoken.net/api而不是带 UTM 的官网地址;~/.claude/settings.json里的env字段有没有被 shell 环境变量覆盖。如果用了 CC Switch 或 Cline MCP,三件套要写全:Base URL + Key + Model ID,缺 Model ID 会报 model not found 而不是 401,容易混淆。
local proxy failed。这个报错通常出现在你本地起了代理但没配对,或者ANTHROPIC_BASE_URL指向了localhost但本地服务没启动。检查~/.claude/settings.json里有没有残留的http://localhost:xxxx配置。如果你之前配过别的工具,环境变量可能被污染,用env | grep ANTHROPIC看一遍。
reading 'choices' of undefined。这是 OpenAI 兼容格式和 Anthropic 格式混用导致的。Claude Code 走的是 Anthropic Messages API 格式,返回体里是content数组,不是choices。如果你在中间层做了格式转换但没转干净,就会报这个。解决方式是确认 Base URL 指向的是 Anthropic 兼容端点,不要指向 OpenAI 兼容端点。
OAuth token expired / OAuth error。Claude Code 某些版本会尝试 OAuth 流程,如果你用的是 API Key 模式,需要在配置里显式禁用 OAuth。检查~/.claude/settings.json有没有oauth相关字段,或者环境变量里有没有CLAUDE_CODE_USE_OAUTH。用 API Key 时把 OAuth 相关配置清掉。
权限规则不生效。这个不算报错但最坑。检查三点:规则格式对不对(Bash(git status:*)的:*不能少);defaultMode有没有被--permission-mode参数覆盖;规则数组的顺序(deny 优先于 ask 优先于 allow)。如果用了 Codex 的auth.json,注意它和 Claude Code 的settings.json是两套配置,不要混用。
Bash 命令被误拦。比如git commit -m "fix: update"被 ask。原因是validateShellMetacharacters()检测到引号里的冒号或特殊字符。这种情况可以在 allow 里加精确规则Bash(git commit:*),或者用--permission-mode acceptEdits临时放宽。但注意 acceptEdits 只放宽文件编辑,Bash 仍走规则。
排查时建议开 debug 日志:
claude --debug -p "执行 git status" 2>&1 | grep -i permission能看到每条规则的命中情况和最终决策。
6. 接入 CTA:把权限系统落到你的工作流
权限系统的价值不在于「拦住危险命令」,而在于把边界反馈给模型,让它在边界内继续完成任务。deny 和 ask 回到 agent loop 后,模型会看到tool_result里的错误原因,下一轮自己换方案——路径越界就改读工作区内文件,Bash 被拒就改用 Read/Grep/Glob,写入被拒就先解释计划等授权。
如果你想把这套逻辑用到自己的项目里,建议从最小抽象开始:PermissionMode管会话策略,PermissionBehavior管单次结果,PermissionDecision带原因,PermissionManager集中裁决,拒绝时构造tool_result而不是抛异常。跑通后再逐步加 Bash 的 AST 解析、路径约束、注入检查。
需要长期跑编码任务或 Agent 工作流的话,可以用 Coding Plan 把模型调用和权限配置统一管理:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite。如果只是想先验证模型对话和权限决策的交互,用模型对话入口更快:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面有完整的 Base URL、Key、Model ID 三件套说明和 Claude Code 专用配置示例。
最后留一个实用技巧:把常用的 allow 规则按项目沉淀到.claude/settings.json里提交到仓库,团队共享。但 deny 规则要放在用户级~/.claude/settings.json,避免项目配置被恶意修改后绕过安全限制。这个分层思路和源码里「通用层 + 工具层 + 安全检查」的三层防御是一致的。