上一篇把工具调用拆成四层:模型产生 tool_usequery() 收集申请,runTools() 调度,runToolUse() 执行单个工具。 这一篇就停在 runToolUse() 中间那道门:工具已经找到了,参数也过了 schema, 但本地动作还没有发生。这个窗口,就是权限系统真正工作的地方。

先说结论:Claude Code 的权限不是“弹窗确认”,而是把所有副作用都转换成 allow、deny、ask 三种决策。 弹窗只是 ask 在交互主线程上的一种处理方式;后台 agent、auto mode、PermissionRequest hook、 swarm worker、bridge 和 channel 都有自己的处理路径。

这一篇的主线可以压成一句话:tool_use 进入工具执行后,先跑 PreToolUse hook, 再通过 resolveHookPermissionDecision() 进入 canUseTool()canUseTool() 读取 ToolPermissionContext、工具自身检查、模式和规则, 最后返回 allow / deny / ask。只要不是 allow,客户端不会执行副作用,而是生成错误型 tool_result

阅读契约:这篇只追工具执行前的副作用边界:规则和 mode 负责什么,工具自己的 checkPermissions() 负责什么,hook / classifier / user 什么时候能改决策,deny 如何回到下一轮模型。 读完应该能回答:为什么 bypass 也不是无条件通行,为什么后台路径不能弹窗,以及为什么拒绝不是沉默失败。

产品层证据来自 Claude Code 的 settings 文档hooks 文档security 文档IAM 文档; 源码层来自 Rememorio/claude-code 公开镜像。 本文只把客户端源码可见的决策顺序写成事实;auto classifier 的模型策略、服务端安全策略和 provider 内部实现不从客户端代码里硬推。

这篇回答五个问题:

  1. ToolPermissionContext 里到底保存了哪些会影响副作用的状态?
  2. hasPermissionsToUseToolInner() 为什么先 deny / ask,再看 bypass / allow?
  3. auto mode、dontAsk、headless agent 和 bypass 分别改变了哪一层?
  4. 交互弹窗为什么要和 hook、classifier、bridge、channel 赛跑?
  5. 权限拒绝以后,为什么还要回填 is_errortool_result
type PermissionDecision = "allow" | "deny" | "ask"

tool_use
  -> PreToolUse hook
  -> canUseTool({ mode, rules, toolCheck, context })
  -> allow | deny | ask
  -> execute tool | return denial result | suspend for user choice
源码形状:权限不是一个 UI 弹窗,而是工具执行路径里的三分支决策。

一、权限上下文是一张小账本

权限链路的状态入口是 ToolPermissionContext。 它不是一个布尔开关,而是一张小账本:当前 mode、allow / deny / ask 规则、额外工作目录、 bypass 是否可用、auto mode 是否可用、是否避免权限弹窗、是否要先等自动检查。

Claude Code ToolPermissionContext 权限账本图,展示 mode、allow rules、deny rules、ask rules、directories 和 no prompts 被 canUseTool 读取
ToolPermissionContext 是副作用边界的配置账本,canUseTool() 每次决策都会读取它。

1.1 mode 不是唯一输入

权限模式类型 里能看到外部可用模式:defaultplanacceptEditsbypassPermissionsdontAsk;内部还可能有 feature gated 的 auto。 但 mode 只是账本的一列。规则、目录、安全检查、工具实现自己的判断,都会影响最后结果。

这也是读权限源码最容易踩的坑:不要看到 bypassPermissions 就以为所有动作都能过。 源码明确把 deny 规则、工具自身 deny、requiresUserInteraction、content-specific ask 和 safety check 放在 bypass 前面。

1.2 权限更新可以改账本,也可以持久化

权限弹窗或 hook 允许时,可能带回 permission updates。 applyPermissionUpdate() 支持改 mode、增加/替换/删除规则、增加/删除额外目录。 如果目的地是 user / project / local settings, 更新还可以持久化

所以“本次允许”和“以后都允许”不是同一件事。前者只返回 allow;后者还会把规则写进账本,甚至写回 settings。

二、真正的决策栈在 hasPermissionsToUseToolInner()

权限判断的内核是 hasPermissionsToUseToolInner()。 这段代码最值得看的不是某个 if,而是顺序。它先检查“必须停”的东西,再看“可以放行”的东西。

Claude Code 权限决策栈图,展示 deny rule、ask rule、tool check、safety check、bypass mode、allow rule 和 final ask 的顺序
顺序就是安全边界:deny、ask 和 safety check 先于 bypass / allow,避免模式开关覆盖硬约束。

2.1 deny 和 ask 先于工具默认放行

源码先查整个工具是否命中 deny rule;命中就直接返回 deny。 接着查 ask rule;如果 Bash 沙箱自动允许条件不满足,就返回 ask。 然后才调用工具实现自己的 checkPermissions()。 也就是说,项目或用户配置的硬规则先于工具内部默认策略。

工具自己的检查也不是摆设。如果工具实现返回 deny,直接 deny;如果工具要求 user interaction,即使后面有 bypass 模式也会保留 ask。 这类检查通常承载工具本身更懂的语义,比如路径、命令、沙箱、敏感文件等。

2.2 bypass 也绕不过内容级 ask 和 safety check

两类 ask 会优先于 bypass。 第一类是 content-specific ask rule,比如某个命令前缀必须确认;第二类是 safety check, 注释里明确说 `.git/`、`.claude/`、`.vscode/`、shell configs 这类路径是 bypass-immune。

这背后的不变量很简单:模式可以改变默认体验,但不能覆盖已经明确配置或工具识别出的高风险边界。 bypassPermissions 是“默认不问”,不是“所有安全闸都失效”。

2.3 最后的 passthrough 会变成 ask

走过 deny、ask、工具检查、safety、bypass、allow rule 以后,如果工具结果还是 passthrough, 源码会把它转换成 ask,并生成 permission request message。 这就是 default mode 下最常见的用户确认来源:不是每个工具都显式说“要问”,而是没有足够理由 allow 时,runtime 兜底问。

阶段 更像什么 保护的边界
deny rule / tool deny 硬拒绝 用户、项目或工具明确禁止的动作。
ask rule / safety check 强制人工关口 敏感路径、危险命令、内容级规则。
bypass / allow rule 默认放行 已选择或已配置的低摩擦路径。
passthrough to ask 兜底确认 没有明确证据证明安全时,把决策还给用户或后续自动检查。

三、外层会把 ask 改造成不同路径

hasPermissionsToUseToolInner() 给出初步结果后,外层 hasPermissionsToUseTool() 还会根据运行模式改造 ask。这里正是 dontAsk、auto mode、headless agent 的分水岭。

3.1 dontAsk 把 ask 变成 deny

如果结果是 ask,并且当前 mode 是 dontAsk源码直接返回 deny。 这不是“更严格的 default”,而是明确禁止交互确认:需要确认的动作全部拒绝。

3.2 auto mode 先走便宜路径,再跑 classifier

auto mode 不是所有 ask 都丢给模型分类器。 源码先排除不可自动批准的 safety check 和要求用户交互的工具;PowerShell 在特定 feature gate 未开时也要求显式用户权限。 然后才尝试 acceptEdits fast path、安全工具 allowlist,最后调用 classifyYoloAction()。 classifier blocked、unavailable、transcript too long 都有不同 fallback 或 fail-closed 行为。

这说明 auto mode 更像“自动化权限决策器”,不是“自动允许”。它仍然围着 ask 的结果工作,并且保留拒绝、降级和中止路径。

3.3 没有弹窗能力时,hook 还有一次机会

后台或 headless 路径不能弹窗。源码里如果 shouldAvoidPermissionPrompts 为 true, 会先运行 PermissionRequest hooks; 如果 hook 没给出 allow / deny,才自动 deny。 这让自动化环境有机会通过预先配置的 hook 决策,但不会卡在一个没人能点的弹窗上。

四、交互弹窗其实是一场赛跑

真正需要 ask 且处在交互主线程时,useCanUseTool() 会进入 handleInteractivePermission()。 这时你看到的弹窗只是队列里的一个 ToolUseConfirm;它背后同时存在用户选择、hook、bash classifier、 bridge 回调、channel 回调等多个可能的决策来源。

Claude Code 交互权限赛跑图,展示 user choice、PermissionRequest hook、bash classifier、bridge 和 channel 共同竞争 resolve once
交互权限不是单线程等待用户点按钮;hook、classifier、bridge、channel 都可能先到,但只能有一个 winner。

4.1 resolve-once 保护“只决策一次”

createResolveOnce()claim() / resolve() 防止多个异步来源同时完成。 弹窗队列项 提供 onAllowonRejectonAbortrecheckPermission 等回调; 每个回调在真正 resolve 前都要先 claim。

这个设计保护的不是 UI 漂亮,而是副作用不能被重复提交。想象一个 Bash 权限请求:用户点了允许,classifier 又晚一步返回允许, 如果没有 resolve-once,工具可能被执行两次,或者记录出两个互相矛盾的决策。

4.2 hook 和 classifier 可以抢在用户之前完成

如果不是 coordinator worker,交互处理会异步跑 PermissionRequest hook; 如果是 Bash 且存在 pending classifier check,还会异步跑 classifier check。 只要用户还没交互、请求还没 resolve,hook 或 classifier 的 allow/deny 可以先完成并关闭队列项。

4.3 coordinator 和 swarm 是不同的非主线程处理

Coordinator worker 的路径会先等待 hook,再等待 classifier,仍然不成才落回交互弹窗; 这在 handleCoordinatorPermission() 的注释里写得很清楚。Swarm worker 则先试 classifier,再把请求通过 mailbox 转给 leader, 这条路径在 handleSwarmWorkerPermission()

所以 ask 并不天然等于“当前终端弹窗”。ask 是一个需要决策的状态,至于谁来决策,要看当前线程、worker 类型、是否有 UI、是否有 bridge 或 channel。

五、拒绝也要回到模型视图

权限结果回到 toolExecution.ts 后,如果不是 allow, 客户端不会执行工具。 它会构造一条 user message,里面包含 is_error: truetool_result, 并且使用原来的 toolUseID。也就是说,拒绝不是沉默失败,而是模型下一轮能看到的事实。

Claude Code 权限拒绝回填图,展示 tool_use A 被 permission deny 后,生成 is_error 的 tool_result A,并进入 next query
权限拒绝会被写成配对的错误型 tool_result,让下一轮模型知道动作没有发生以及原因。

5.1 deny 的 message 是给下一轮模型的约束

permissionDecision.message 会进入 tool_result 的 content。 如果 PreToolUse hook 阻止继续且没有详细消息,源码会生成类似“Execution stopped by PreToolUse hook”的错误文本。 用户拒绝时的反馈、hook 拒绝原因、auto classifier 拒绝原因,也都可以成为下一轮模型的输入约束。

5.2 PermissionDenied hook 可以给 retry hint

auto mode classifier 拒绝后,源码还会跑 PermissionDenied hooks。 如果 hook 返回 retry,客户端会额外给模型一条 meta user message,提示它可以重试。 这说明“拒绝”也不是一个绝对终点:它可以改变下一轮策略,但仍然不能越过当前这次副作用。

六、把权限链路压成几条规则

看到的现象 源码里的真实边界 别误读成
弹出权限确认 ask 在交互主线程上的 UI 处理。 权限系统只有弹窗。
bypassPermissions 放行默认路径,但 deny、content ask、safety check 仍优先。 所有副作用无条件允许。
dontAsk 把 ask 转成 deny。 静默允许或静默跳过。
auto mode 先走 fast path 和 allowlist,再用 classifier 决策。 自动允许所有命令。
后台 agent 无弹窗 PermissionRequest hook 先决策,没人决策就自动 deny。 后台路径可以无限等待用户。
权限拒绝 生成 is_errortool_result 回到下一轮。 只是 UI 上取消了一次操作。

到这里再回头看工具调用,就能分清三件事:模型能提出什么、客户端能执行什么、用户信任边界允许什么。 Claude Code 的权限系统不是为了让用户多点几次按钮,而是让本地副作用始终有一个可追溯、可拒绝、可恢复的决策点。

后面读命令、skills 和 MCP 时,还会遇到同一个问题:能力越多,进入模型视图的 schema 越多,权限账本就越重要。 因为真正危险的不是“模型知道一个工具名”,而是某个工具名背后能否改变本地状态。

参考源码与文档