PermissionRequest:权限弹窗前的自定义裁决与远端旁路
在 Claude Code 的安全模型中,PermissionRequest 钩子是一个特殊的存在。它允许你介入“系统决定向用户发起权限询问”的那一刻,从而实现比内建模式更细粒度的自定义审批逻辑,甚至支持跨设备的远端审批。
它解决了什么问题
PermissionRequest 本质上是 审批 UI 的逻辑劫持器(Approval UI Hijacker)。
当权限引擎(claude-code-opensource/src/utils/permissions/permissions.ts)发现一个操作既不属于自动放行范围,也不属于已记录的拒绝范围时,它通常会弹出一个终端对话框。该钩子允许开发者在弹窗“震动用户”前,先用代码逻辑跑一遍自定义审计。
它同时也是 MCP 审批旁路(MCP Approval Relay) 的入口:通过该钩子,你可以将本地 CLI 的权限弹窗外接到 Slack、Discord 或自定义的管理后台。
运行时的真相
该钩子的触发流程位于权限引擎的决策中枢:
- 拦截询问信号:在
permissions.ts确定需要发起Ask决策时,它会首先检查是否存在订阅了该工具的PermissionRequest钩子。 - 提供深度元数据:
PermissionRequestHookInput包含工具、参数,以及权限系统生成的suggestedRules(建议添加的规则)。这让钩子可以感知到系统打算如何“学习”这一权限。 - 自定义裁决(Final Decision):钩子可以返回
HookJSONOutput来改写系统的决定:- 提前放行(Approve):如果你的钩子分析后认为该调用(即使它看起来像越界)是安全的,系统将不再弹出对话框。
- 提前拒绝(Deny):如果你的钩子检测到了黑名单敏感信息,用户甚至不会被打扰,操作会直接失败。
- 继续询问(Passthrough):让系统继续走默认的终端弹窗流程。
- 远端审批旁路(Relay Prompts): 对于启用了
claude/channel/permission能力的 MCP 客户端,系统会并行启动一条旁路:- 生成一个短请求 ID(如
abcde)并裁剪工具输入预览。 - 向合格的 Channel 发送结构化的
permission_request。 - 竞速裁决:本地 UI、Bridge 和 Channel Relay 处于竞争状态,谁先给出
allow/deny,谁就赢得最终决策。
- 生成一个短请求 ID(如
使用时的关键约束
- DontAsk 模式的阻断:在
dontAsk模式下,这个钩子会被完全跳过,因为系统会直接将所有询问收敛为拒绝。 - 非交互式限制:Hook 脚本本身是在后台运行的。你不应该尝试在钩子代码里自己去读取
stdin或弹出窗口,这会与系统的Ink渲染器发生冲突。 - 规则库优先级:如果规则库(
settings.json)中已经存了allow或deny,系统会优先使用规则库,而不会触发此钩子。 - Channel Relay 的 capability 依赖:能发 Channel 消息,不代表能批权限。只有显式声明了对应能力且在 allowlist 里的 Channel 客户端才会被激活。
推荐阅读路径
- 如果你想了解基础的权限判断逻辑,请看 权限与沙箱:双层防御体系。
- 如果你关项目级的指令注入,请看 InstructionsLoaded:角色与约束的动态加载。
- 如果你想看 Channel 消息注入的细节,请看 Channels:外部消息注入协议。
源码锚点
claude-code-opensource/src/utils/permissions/permissions.ts— 权限请求发起时的 Hook 触发点。
📄 src/utils/permissions/permissions.ts — 权限请求发起时的 Hook 触发点。L152-152 of 1487
typescript
? `Hook '${decisionReason.hookName}' blocked this action: ${decisionReason.reason}`1
claude-code-opensource/src/services/mcp/channelPermissions.ts— Permission Relay 的短 ID、预览裁剪与 Map 映射。
📄 src/services/mcp/channelPermissions.ts — Permission Relay 的短 ID、预览裁剪与 Map 映射。L2-10 of 241
typescript
* Permission prompts over channels (Telegram, iMessage, Discord).
*
* Mirrors `BridgePermissionCallbacks` — when CC hits a permission dialog,
* it ALSO sends the prompt via active channels and races the reply against
* local UI / bridge / hooks / classifier. First resolver wins via claim().
*
* Inbound is a structured event: the server parses the user's "yes tbxkq"
* reply and emits notifications/claude/channel/permission with
* {request_id, behavior}. CC never sees the reply as text — approval1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
claude-code-opensource/src/hooks/toolPermission/handlers/interactiveHandler.ts— 权限弹窗如何与本地 UI、Hooks、Channel 进行竞速。
📄 src/hooks/toolPermission/handlers/interactiveHandler.ts — 权限弹窗如何与本地 UI、Hooks、Channel 进行竞速。L417-426 of 537
typescript
const hookDecision = await ctx.runHooks(
currentAppState.toolPermissionContext.mode,
result.suggestions,
result.updatedInput,
permissionPromptStartTimeMs,
)
if (!hookDecision || !claim()) return
if (bridgeCallbacks && bridgeRequestId) {
bridgeCallbacks.cancelRequest(bridgeRequestId)
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10