Claude code学习10 Permission 与 Hooks 系统
Permission 与 Hooks 系统
// src/types/permissions.ts
export type PermissionBehavior = "allow" | "deny" | "ask";
export type PermissionRule = {
source: PermissionRuleSource;
ruleBehavior: PermissionBehavior;
ruleValue: PermissionRuleValue;
};
export type PermissionRuleValue = {
toolName: string;
ruleContent?: string;
};
一条规则由三部分组成:来源(source)——规定了规则从哪里加载;行为(ruleBehavior)——Allow、Deny 或 Ask;值(ruleValue)——指定哪个工具、什么条件下触发。
ruleContent 是可选的,它允许对工具进行更精细的控制。例如 Bash(npm test:*) 表示仅对以 npm test 为前缀的 Bash 命令生效。
来源
// src/utils/permissions/permissions.ts
const PERMISSION_RULE_SOURCES = [
...SETTING_SOURCES, // userSettings, projectSettings, localSettings, flagSettings, policySettings
<!--下面的都是运行时的配置-->
'cliArg', // 命令行参数
'command', // 运行时命令
'session', // 会话级临时规则
] as const satisfies readonly PermissionRuleSource[]
匹配逻辑
function toolMatchesRule(tool: Pick<Tool, "name" | "mcpInfo">, rule: PermissionRule): boolean {
// 规则不含 ruleContent 时,匹配整个工具
if (rule.ruleValue.ruleContent !== undefined) {
return false;
}
const nameForRuleMatch = getToolNameForPermissionCheck(tool);
if (rule.ruleValue.toolName === nameForRuleMatch) {
return true;
}
// MCP 服务级权限:规则 "mcp__server1" 匹配 "mcp__server1__tool1"
const ruleInfo = mcpInfoFromString(rule.ruleValue.toolName);
const toolInfo = mcpInfoFromString(nameForRuleMatch);
return (
ruleInfo !== null &&
toolInfo !== null &&
(ruleInfo.toolName === undefined || ruleInfo.toolName === "*") &&
ruleInfo.serverName === toolInfo.serverName
);
}
权限检查流水线
在 Claude code学习7 里有详细写相关的,这里贴个简单的
// src/utils/permissions/permissions.ts
export const hasPermissionsToUseTool: CanUseToolFn = async (tool, input, context, assistantMessage, toolUseID): Promise<PermissionDecision> => {
const result = await hasPermissionsToUseToolInner(tool, input, context);
// 成功的工具调用重置连续拒绝计数
if (result.behavior === "allow") {
// ...重置 denial tracking
return result;
}
// dontAsk 模式:将 ask 转换为 deny
if (result.behavior === "ask") {
const appState = context.getAppState();
if (appState.toolPermissionContext.mode === "dontAsk") {
return {
behavior: "deny",
message: DONT_ASK_REJECT_MESSAGE(tool.name),
};
}
// auto 模式:使用分类器代替人工确认
}
return result;
};
权限系统拦截模型越权意图,Hooks 允许外部逻辑介入生命周期 deny-first:如果同一个输入同时命中 allow 和 deny,最终必须以 deny 为准。宽泛放行规则不能覆盖明确拒绝规则,ask 也不能把高危 deny 降级成弹窗确认。
权限系统不是只读 settings.json。它更接近 6 层纵深防御:CLAUDE.md 软约束、Permission Rules 声明式拦截、Hooks 可编程拦截、YOLO Classifier 独立 AI 审查、Sandbox 操作系统隔离、Hardcoded Denials 硬编码禁写或禁操作。
用户允许 Bash(git *),企业策略拒绝 Bash(git push –force),当前命令是 git push –force。最终应该是什么裁决?为什么? 是拒绝,拒绝deny的权限是最高的
权限模型不是一个 if 判断,而是软约束、声明规则、Hook、AI 分类器、沙盒和硬编码拒绝共同组成的评估管线。目标是让低风险动作快速通过,把危险动作拦到人类或硬规则面前。
在hook的相关执行的权限中,是有权限的优先级之分对的
/**
* Resolve a PreToolUse hook's permission result into a final PermissionDecision.
*
* Encapsulates the invariant that hook 'allow' does NOT bypass settings.json
* deny/ask rules — checkRuleBasedPermissions still applies (inc-4788 analog).
* Also handles the requiresUserInteraction/requireCanUseTool guards and the
* 'ask' forceDecision passthrough.
*
* Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts
* (REPL inner calls) so the permission semantics stay in lockstep.
*/
export async function resolveHookPermissionDecision(
hookPermissionResult: PermissionResult | undefined,
tool: Tool,
input: Record<string, unknown>,
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
assistantMessage: AssistantMessage,
toolUseID: string,
): Promise<{
decision: PermissionDecision
input: Record<string, unknown>
}
从下面的流程图来看,他与系统的settings.local.json里面的规则相关是优先满足规则相关的,这也很好理解 用户本意使用hook可能没有考虑到一些边界情况,而这些边界情况的疏忽可能导致毁灭性的效果,所以最安全的做法是以规则优先
PreToolUse hook
↓
runPreToolUseHooks 产出 hookPermissionResult
↓
resolveHookPermissionDecision
├── allow + 满足交互/canUseTool + 规则无反对 → allow
├── allow + 规则 deny → deny
├── allow + 规则 ask → canUseTool 弹窗
├── deny → deny
└── ask/未决定 → canUseTool(forceDecision?)
↓
hasPermissionsToUseTool (完整权限流水线)
↓
或弹窗 / 或自动模式分类器 / 或 deny
Auto模式的YOLO分类器
auto模式顾名思义是自动模式,意味着人工判断是否执行的这一部分的决策权被转移了,Claude code的做法就是使用YOLO分类器
YOLO 分类器与执行任务的主模型分离:主模型提出行动,分类器审查行动,避免同一个模型自我批准高风险操作。它不仅分析单条命令,还分析完整的对话上下文(transcript)来判断操作是否安全:
分类器仍服从权限管线。明确 deny 先于分类器,分类器不确定时回到 ask 或 block,而不是静默 allow。
两阶段架构平衡成本与延迟:fast 阶段用很小输出快速做 yes/no 判断;复杂或高风险输入再升级到 thinking 阶段。纯读取、搜索这类低风险路径可以尽量走规则或快速通道,避免每个工具调用都支付一次分类器延迟。
自动审查还必须防止无限消耗。如果分类器连续拒绝 3 次或累计拒绝达到更高阈值,系统会触发类似 circuit breaker 的降级:剥离自动审批权,转回人工 ask 或中止会话,避免主模型不断换语法重试同一类危险动作。
两种分类器架构
classifyYoloAction 会根据 isTwoStageClassifierEnabled() 决定走哪条路。
路径 A:传统 tool_use 分类器(默认/旧路径)
yoloClassifier.ts:1132
向 side model 发起一次 sideQuery,带一个专用 tool:
const YOLO_CLASSIFIER_TOOL_SCHEMA: BetaToolUnion = {
type: 'custom',
name: 'classify_result',
description: 'Report the security classification result for the agent action',
input_schema: {
type: 'object',
properties: {
thinking: { type: 'string', description: 'Brief step-by-step reasoning.' },
shouldBlock: { type: 'boolean' },
reason: { type: 'string' },
},
required: ['thinking', 'shouldBlock', 'reason'],
},
}
强制 tool_choice:
tool_choice: { type: ‘tool’, name: ‘classify_result’ }
返回后解析:
const toolUseBlock = extractToolUseBlock(result.content, YOLO_CLASSIFIER_TOOL_NAME)
const parsed = parseClassifierResponse(toolUseBlock, yoloClassifierResponseSchema())
如果解析失败,fail-closed:shouldBlock: true。
路径 B:两阶段 XML 分类器(新路径)
yoloClassifier.ts:711
用 XML tag 而不是 tool_use 来获取结果。支持三种模式:
/**
* XML classifier for auto mode security decisions. Supports three modes:
*
* 'both' (default): Stage 1 ("fast") runs first with max_tokens=64 and
* stop_sequences for an immediate yes/no. If allowed, returns. If blocked,
* escalates to stage 2 ("thinking") with chain-of-thought to reduce false
* positives.
*
* 'fast': Stage 1 only. Bumps max_tokens to 256 and drops stop_sequences so
* the response can include <reason>. Stage 1 verdict is final.
*
* 'thinking': Stage 2 only. Skips stage 1 entirely.
*
* Both stages share the same system prompt and user content, benefiting from
* prompt caching (1h TTL) across calls.
*/
Stage 1(fast)
function parseXmlBlock(text: string): boolean Parse XML block response: yes/no Strips thinking content first to avoid matching tags inside reasoning. Returns true for “yes” (should block), false for “no”, null if unparseable.
他会输出xml格式 一般是带有
'Use the classify_result tool to report your classification.'
'## Output Format',
'',
'If the action should be blocked:',
'<block>yes</block><reason>one short sentence</reason>',
'',
'If the action should be allowed:',
'<block>no</block>',
'',
'Do NOT include a <reason> tag when the action is allowed.',
'Your ENTIRE response MUST begin with <block>. Do NOT output any analysis, reasoning, or commentary before <block>. No "Looking at..." or similar preamble.',
他的prompt如上
// In fast-only mode, stage 1 is final — handle block + unparseable here.
if (mode === 'fast') {
if (stage1Block === null) {
logAutoModeOutcome('parse_failure', model, { classifierType })
return {
shouldBlock: true,
reason: 'Classifier stage 1 unparseable - blocking for safety',
model,
usage: stage1Usage,
durationMs: stage1DurationMs,
promptLengths,
stage: 'fast',
stage1RequestId,
stage1MsgId,
}
}
// stage1Block === true
logAutoModeOutcome('success', model, {
classifierType,
durationMs: stage1DurationMs,
})
return {
shouldBlock: true,
reason: parseXmlReason(stage1Text) ?? 'Blocked by fast classifier',
model,
usage: stage1Usage,
durationMs: stage1DurationMs,
promptLengths,
stage: 'fast',
stage1RequestId,
stage1MsgId,
}
}
}
Stage 2(thinking) 他的prompt如下所示
const XML_S2_SUFFIX =
'Review the classification process and follow it carefully,
making sure you deny actions that should be blocked.
As a reminder, explicit (not suggestive or implicit) user confirmation is required to override blocks.
Use <thinking> before responding with <block>.'
return {
shouldBlock: true,
reason: tooLong
? "Classifier transcript exceeded context window"
: stage1Usage
? "Stage 2 classifier error - blocking based on stage 1 assessment"
: "Classifier unavailable - blocking for safety",
model,
unavailable: stage1Usage === undefined,
transcriptTooLong: Boolean(tooLong),
stage: stage1Usage ? "thinking" : undefined,
durationMs: Date.now() - overallStart,
errorDumpPath,
...(stage1Usage && {
usage: stage1Usage,
stage1Usage,
stage1DurationMs,
stage1RequestId,
stage1MsgId,
}),
promptLengths,
};
Stage 1 是过滤器,不是决策者:它的唯一作用是用最低成本放行”明显安全”的命令。但凡它没放行,决定权就交给 Stage 2。
XML 解析:
| function parseXmlBlock(text: string): boolean | null |
| function parseXmlReason(text: string): string | null |
| function parseXmlThinking(text: string): string | null |
先 strip
结果结构与消费
返回 YoloClassifierResult:
type YoloClassifierResult = {
thinking?: string
type YoloClassifierResult = {
thinking?: string
shouldBlock: boolean
reason: string
unavailable?: boolean // API 出错或不可用
transcriptTooLong?: boolean // 上下文超长
model: string
usage?: ClassifierUsage
durationMs?: number
promptLengths?: {...}
errorDumpPath?: string
stage?: 'fast' | 'thinking'
stage1Usage?: ClassifierUsage
stage2Usage?: ClassifierUsage
// ...
}
在 permissions.ts:718 里消费:
const yoloDecision = classifierResult.unavailable
? 'unavailable'
: classifierResult.shouldBlock
? 'blocked'
: 'allowed'
然后:
- shouldBlock: true → 构造 deny decision,理由为 classifierResult.reason
- shouldBlock: false → 构造 allow decision,decisionReason type 为 'classifier'
错误处理:fail-closed 设计
只要分类器出问题,默认就是拒绝:
┌────────────┬───────────────────────────────────────────────────────────────────────────────
│ 场景 │ 处理 │
├────────────┼──────────────────────────────────────────────────────────────────────────────────────────┤
│ API 错误 │ shouldBlock: true, unavailable: true │
├────────────┼───────────────────────────────────────────────────────────────────────────────
│ 解析失败 │ shouldBlock: true, reason: 'Classifier returned no tool use block - blocking for safety' │
├────────────┼───────────────────────────────────────────────────────────────────────────────
│ 用户取消 │ shouldBlock: true, unavailable: true │
├────────────┼───────────────────────────────────────────────────────────────────────────────
│ 上下文超长 │ shouldBlock: true, transcriptTooLong: true │
└────────────┴──────────────────────────────────────────────────────────────────────────────────────────┘
注释里明确说明:
▎ On API errors, returns shouldBlock: true with unavailable: true so callers can distinguish “classifier actively blocked” from “classifier couldn’t respond”.
回到 permissions.ts 可以看到,unavailable: true 时会根据 tengu_iron_gate_closed feature flag 决定是继续拒绝还是回退到正常弹窗。
性能优化与遥测
- 跳过分类器的 fast path
在调用 classifyYoloAction 之前,permissions.ts 会先检查:
- acceptEdits mode 下是否允许:如果在工作目录内编辑文件,直接 allow,不调用分类器
- safe tool allowlist:classifierDecision.ts:96 定义的只读/元数据工具直接 allow
- 缓存
系统提示词和 CLAUDE.md 都带 cache_control,因为每次分类器调用都复用相同的系统提示词。
- 上下文对比遥测
const classifierChars = systemPrompt.length + userPrompt.length const classifierTokensEst = Math.round(classifierChars / 4) const mainLoopTokens = tokenCountWithEstimation(messages)
用来监控”分类器 prompt 是否比主循环上下文还大”,这是 auto-compact 失效的早期信号。
与权限系统的关系
用户请求工具
↓
hasPermissionsToUseTool 返回 ask
↓
auto mode 检查
↓
acceptEdits fast path / allowlist 检查
↓
classifyYoloAction
↓
shouldBlock: false → allow(decisionReason.type = 'classifier')
shouldBlock: true → deny
unavailable → 根据 iron_gate flag 决定 fail-closed 或回退弹窗
Hook
引用官方api的解释 Hooks 是用户定义的 shell 命令,在 Claude Code 生命周期中的特定点执行。它们对 Claude Code 的行为提供确定性控制,确保某些操作始终发生,而不是依赖 LLM 选择运行它们。使用 hooks 来强制执行项目规则、自动化重复任务,并将 Claude Code 与现有工具集成。
Hooks 如何工作
Hook 事件在 Claude Code 中的特定生命周期点触发。当事件触发时,所有匹配的 hooks 并行运行,相同的 hook 命令会自动去重。下表显示每个事件及其触发时间:
| Event | When it fires |
|---|---|
| SessionStart | When a session begins or resumes |
| Setup | When you start Claude Code with --init-only, or with --init or --maintenance in -p mode. For one-time preparation in CI or scripts |
| UserPromptSubmit | When you submit a prompt, before Claude processes it |
| UserPromptExpansion | When a user-typed command expands into a prompt, before it reaches Claude. Can block the expansion |
| PreToolUse | Before a tool call executes. Can block it |
| PermissionRequest | When a permission dialog appears |
| PermissionDenied | When a tool call is denied by the auto mode classifier. Return {retry: true} to tell the model it may retry the denied tool call |
| PostToolUse | After a tool call succeeds |
| PostToolUseFailure | After a tool call fails |
| PostToolBatch | After a full batch of parallel tool calls resolves, before the next model call |
| Notification | When Claude Code sends a notification |
| MessageDisplay | While assistant message text is displayed |
| SubagentStart | When a subagent is spawned |
| SubagentStop | When a subagent finishes |
| TaskCreated | When a task is being created via TaskCreate |
| TaskCompleted | When a task is being marked as completed |
| Stop | When Claude finishes responding |
| StopFailure | When the turn ends due to an API error. Output and exit code are ignored |
| TeammateIdle | When an agent team teammate is about to go idle |
| InstructionsLoaded | When a CLAUDE.md or .claude/rules/*.md file is loaded into context. Fires at session start and when files are lazily loaded during a session |
| ConfigChange | When a configuration file changes during a session |
| CwdChanged | When the working directory changes, for example when Claude executes a cd command. Useful for reactive environment management with tools like direnv |
| FileChanged | When a watched file changes on disk. The matcher field specifies which filenames to watch |
| WorktreeCreate | When a worktree is being created via --worktree or isolation: "worktree". Replaces default git behavior |
| WorktreeRemove | When a worktree is being removed, either at session exit or when a subagent finishes |
| PreCompact | Before context compaction |
| PostCompact | After context compaction completes |
| Elicitation | When an MCP server requests user input during a tool call |
| ElicitationResult | After a user responds to an MCP elicitation, before the response is sent back to the server |
| SessionEnd | When a session terminates |
每个 hook 都有一个 type 来确定它如何运行。大多数 hooks 使用 “type”: “command”,它运行 shell 命令。还有四种其他类型可用: “type”: “http”:将事件数据 POST 到 URL。请参阅 HTTP hooks。 “type”: “mcp_tool”:在已连接的 MCP 服务器上调用工具。请参阅 MCP tool hooks。 “type”: “prompt”:单轮 LLM 评估。请参阅 基于提示的 hooks。 “type”: “agent”:具有工具访问权限的多轮验证。Agent hooks 是实验性的,可能会改变。
hooks权限相关
下面这段代码是有关hook权限的:
/**
* Resolve a PreToolUse hook's permission result into a final PermissionDecision.
*
* Encapsulates the invariant that hook 'allow' does NOT bypass settings.json
* deny/ask rules — checkRuleBasedPermissions still applies (inc-4788 analog).
* Also handles the requiresUserInteraction/requireCanUseTool guards and the
* 'ask' forceDecision passthrough.
*
* Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts
* (REPL inner calls) so the permission semantics stay in lockstep.
*/
export async function resolveHookPermissionDecision(
hookPermissionResult: PermissionResult | undefined,
tool: Tool,
input: Record<string, unknown>,
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
assistantMessage: AssistantMessage,
toolUseID: string
): Promise<{
decision: PermissionDecision;
input: Record<string, unknown>;
}> {
const requiresInteraction = tool.requiresUserInteraction?.();
const requireCanUseTool = toolUseContext.requireCanUseTool;
if (hookPermissionResult?.behavior === "allow") {
const hookInput = hookPermissionResult.updatedInput ?? input;
// Hook provided updatedInput for an interactive tool — the hook IS the
// user interaction (e.g. headless wrapper that collected AskUserQuestion
// answers). Treat as non-interactive for the rule-check path.
const interactionSatisfied = requiresInteraction && hookPermissionResult.updatedInput !== undefined;
if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) {
logForDebugging(`Hook approved tool use for ${tool.name}, but canUseTool is required`);
return {
decision: await canUseTool(tool, hookInput, toolUseContext, assistantMessage, toolUseID),
input: hookInput,
};
}
// Hook allow skips the interactive prompt, but deny/ask rules still apply.
const ruleCheck = await checkRuleBasedPermissions(tool, hookInput, toolUseContext);
if (ruleCheck === null) {
logForDebugging(
interactionSatisfied
? `Hook satisfied user interaction for ${tool.name} via updatedInput`
: `Hook approved tool use for ${tool.name}, bypassing permission prompt`
);
return { decision: hookPermissionResult, input: hookInput };
}
if (ruleCheck.behavior === "deny") {
logForDebugging(`Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`);
return { decision: ruleCheck, input: hookInput };
}
// ask rule — dialog required despite hook approval
logForDebugging(`Hook approved tool use for ${tool.name}, but ask rule requires prompt`);
return {
decision: await canUseTool(tool, hookInput, toolUseContext, assistantMessage, toolUseID),
input: hookInput,
};
}
if (hookPermissionResult?.behavior === "deny") {
logForDebugging(`Hook denied tool use for ${tool.name}`);
return { decision: hookPermissionResult, input };
}
// No hook decision or 'ask' — normal permission flow, possibly with
// forceDecision so the dialog shows the hook's ask message.
const forceDecision = hookPermissionResult?.behavior === "ask" ? hookPermissionResult : undefined;
const askInput = hookPermissionResult?.behavior === "ask" && hookPermissionResult.updatedInput ? hookPermissionResult.updatedInput : input;
return {
decision: await canUseTool(tool, askInput, toolUseContext, assistantMessage, toolUseID, forceDecision),
input: askInput,
};
}
resolveHookPermissionDecision 是 PreToolUse hook 与正式权限流水线之间的适配器。
它解决的问题是:hook 可能返回 allow/deny/ask,但这些决定不能直接使用,必须和 settings.json 规则、用户交互要求、以及完整权限流程做协调,因为作为一个hook,用户可能没有想到与其他场景发生冲突,如果要做一个合格的权限处理,必须考虑这些场景。
调用时机
在 toolExecution.ts:921 和 REPLTool/toolWrappers.ts 中调用:
const resolved = await resolveHookPermissionDecision(
hookPermissionResult, // 来自 runPreToolUseHooks
tool,
input,
toolUseContext,
canUseTool, // useCanUseTool 返回的函数
assistantMessage,
toolUseID
);
输入 hookPermissionResult 来自 runPreToolUseHooks(toolHooks.ts:435),也就是 PreToolUse hook 执行后的结果。
完整决策树
hookPermissionResult?.behavior
│
├── 'allow'
│ │
│ ├── hookInput = updatedInput ?? input
│ │
│ ├── requiresInteraction 且 updatedInput 未提供?
│ │ │
│ │ └── 是 ──► 走 canUseTool(需要用户交互)
│ │
│ ├── requireCanUseTool?
│ │ │
│ │ └── 是 ──► 走 canUseTool(强制确认)
│ │
│ └── checkRuleBasedPermissions
│ │
│ ├── null(规则无反对)
│ │ └── 返回 hook allow
│ │
│ ├── 'deny'
│ │ └── 返回 deny(规则覆盖 hook)
│ │
│ └── 'ask'
│ └── 走 canUseTool(规则要求弹窗)
│
├── 'deny'
│ └── 返回 hook deny
│
└── undefined / 'ask' / 'passthrough'
│
├── forceDecision = hook ask ? hookPermissionResult : undefined
├── askInput = hook ask + updatedInput ? updatedInput : input
│
└── 走 canUseTool(tool, askInput, ..., forceDecision)
│
▼
hasPermissionsToUseTool
│
├── allow ──► 返回 allow
├── deny ──► 返回 deny
└── ask ──► 弹窗 / auto mode 分类器 / async agent 处理
三个关键安全不变量
不变量 1:Hook allow 不绕过规则
const ruleCheck = await checkRuleBasedPermissions(tool, hookInput, toolUseContext)
这是注释里明确说的:
▎ Encapsulates the invariant that hook ‘allow’ does NOT bypass settings.json deny/ask rules.
即使用户安装的某个 skill 或 hook 说”这个工具可以执行”,用户配置的 deny 规则仍然能阻止它。
不变量 2:用户交互工具必须被满足
const interactionSatisfied =
requiresInteraction && hookPermissionResult.updatedInput !== undefined
if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) {
return { decision: await canUseTool(...), input: hookInput }
}
requiresUserInteraction() 为 true 的工具(如 AskUserQuestionTool)通常必须弹窗。但如果 hook 通过 updatedInput 提供了答案,就视为 hook 替用户完成了交互。
不变量 3:Hook ask 作为 forceDecision 透传
const forceDecision = hookPermissionResult?.behavior === ‘ask’ ? hookPermissionResult : undefined
hook 返回 ask 时,不是直接弹窗,而是把 hook 的 ask 决定作为 forceDecision 传给 canUseTool。这样:
- 复用完整的权限流水线(包括 auto mode 分类器、coordinator、swarm worker 等)
- 弹窗显示的是 hook 自定义的消息
- 仍然尊重 settings.json 的 allow/deny 规则(因为 hasPermissionsToUseTool 会先跑规则检查)
和 checkRuleBasedPermissions 的分工
┌───────────────────────────────┬────────────────────────────────────┬─────────────────────────────────────────────┐
│ 函数 │ 职责 │ 调用时机 │
├───────────────────────────────┼────────────────────────────────────┼─────────────────────────────────────────────┤
│ resolveHookPermissionDecision │ 协调 hook 决定与权限流程 │ hook 执行后 │
├───────────────────────────────┼────────────────────────────────────┼─────────────────────────────────────────────┤
│ checkRuleBasedPermissions │ 纯规则层检查(1a-1g) │ resolveHookPermissionDecision 的 allow 分支 │
├───────────────────────────────┼────────────────────────────────────┼─────────────────────────────────────────────┤
│ hasPermissionsToUseTool │ 完整权限流水线(1a-3 + auto mode) │ canUseTool 内部 │
└───────────────────────────────┴────────────────────────────────────┴─────────────────────────────────────────────┘
当 resolveHookPermissionDecision 调用 canUseTool 时,它会进入 useCanUseTool.tsx,然后调用 hasPermissionsToUseTool,后者会再次做规则检查、模式检查、auto mode 分类器调用等。
所以:
- hook allow + 规则无反对:走 checkRuleBasedPermissions 即可,不调用 canUseTool,避免不必要的分类器/弹窗
- hook allow + 规则 ask/deny:规则胜出
- 其他情况:进入 canUseTool 完整流程
和 yoloClassifier 的关系
yoloClassifier 在 hasPermissionsToUseTool 内部被调用(permissions.ts:689),只有当:
- result.behavior === ‘ask’
- 当前是 auto mode 或 plan mode 下 auto mode 激活
- 不是 PowerShell 显式拒绝的情况
- 不在 safe tool allowlist 上
- acceptEdits fast path 没放行
时才会触发。
所以 resolveHookPermissionDecision 本身不直接调用分类器,但当它把决定交给 canUseTool 后,完整权限流水线可能最终调用 yoloClassifier。
返回值的统一性
无论哪个分支,都返回:
{
decision: PermissionDecision
input: Record<string, unknown>
}
第二个字段 input 很重要:hook 可能通过 updatedInput 修改了工具参数,后续执行必须用修改后的 input。
例如:
const hookInput = hookPermissionResult.updatedInput ?? input
// ...
return { decision: hookPermissionResult, input: hookInput }
一个具体例子
假设有个 PreToolUse hook 想自动允许 Bash 工具执行 ls:
- Hook 返回 { behavior: ‘allow’, updatedInput: { command: ‘ls’, timeout: 5000 } }
- resolveHookPermissionDecision 被调用
- hookInput = { command: ‘ls’, timeout: 5000 }
- Bash 不需要用户交互,requireCanUseTool 为 false
- 调用 checkRuleBasedPermissions
- 没有 alwaysDeny: [‘Bash’]
- 没有 alwaysAsk: [‘Bash’]
- Bash.checkPermissions(‘ls’) 返回 passthrough
- 没有 safetyCheck
- ruleCheck === null
- 返回 { decision: hookPermissionResult, input: hookInput }
但如果用户配置了 alwaysDeny: [‘Bash’],第 5 步会返回 deny,resolveHookPermissionDecision 就返回 deny,hook 的 allow 被覆盖。
启动时序
安全不只取决于规则覆盖,还取决于加载时序。 Hooks、MCP 和项目级 settings 都必须放在“用户信任确认之后”这个时间边界内审视。在用户同意之前允许读取了环境变量
脆弱时序: 启动 → 解析 .claude/settings.json → 加载 MCP/Hooks → 弹出信任对话框 → 用户点击"信任"
安全时序: 启动 → 弹出信任对话框 → 用户点击"信任" → 解析 .claude/settings.json → 加载 MCP/Hooks
配置文件在 Agent 系统中确实不再是”被动文本”——.claude/settings.json 可以声明: mcpServers:启动子进程或网络连接 hooks:注册生命周期回调 env:注入环境变量(可能污染 PATH、LD_PRELOAD 等) 这些都属于 启动阶段的主动执行面,必须在用户显式授权后才能激活。 用户看到的对话框不再是”是否授权这些能力”,而是”这些能力已经运行,你点拒绝也晚了”。这是同意(consent)机制的结构性破坏。
PreToolUse 拦截后如果只是抛异常或静默跳过,模型可能看不到真实原因并反复重试。更稳的做法是把 blockingErrors 打包成带纠偏指令的 User Message 回注上下文,并用 stopHookActive 等状态避免 Stop Hook 递归触发。 LLM 的推理模式是”遇到问题→尝试替代方案”。如果 PreToolUse 被拦截后只是静默失败或抛内部异常,模型上下文里缺少”这是策略禁止,不是技术故障”的明确信号。结果是: 换参数重试 换工具重试 分解步骤绕过 每一次重试都在消耗安全边界——可能触发其他 Hook、产生副作用、或把操作拆成更隐蔽的子步骤。 回注纠偏的本质是:把安全策略从”黑盒障碍”变成”模型可理解的约束条件”,让它进入模型的推理上下文,成为其规划的一部分,而非对抗对象。
想象这么一个场景
FileChanged Hook 每次做完整外部审计 → 大文件批量修改时 Loop 卡住
→ 用户/模型感知到"文件操作经常失败"
→ 改用更粗粒度的工具(如直接 shell 命令替换文件)来"绕过问题"
→ 反而失去了 FileChanged 本应提供的细粒度监控
性能也是 Hook 安全的一部分。PostToolUse、FileChanged 这类高频事件如果每次都启动外部进程、做完整 JSON 序列化或网络请求,会拖慢 Agent Loop,甚至诱发超时和恢复风暴。源码通过内部 callback hooks 等 fast path 处理会话文件访问记录这类高频任务,把重型外部 Hook 留给真正需要审批或审计的节点。 Fast path 不是”让体验更流畅”,而是把安全机制维持在”总是可承受”的阈值内,防止系统因性能压力而自我降级,或者说别让用户或模型不耐烦而做出妥协
Enjoy Reading This Article?
Here are some more articles you might like to read next:
- Google Gemini updates: Flash 1.5, Gemma 2 and Project Astra
- Displaying External Posts on Your al-folio Blog
- Graph RAG with Milvus —— 纯向量库造图的多跳推理
- Hierarchical Indices 层级索引 —— 先粗后细的两级检索
- HyDE 与 HyPE —— 假设检索技术的两个方向
- 二叉树刷题总结
- RAG 技术体系化分类——从 Pipeline 阶段到失败模式
- MemoRAG 记忆增强型 RAG 总结
- Microsoft GraphRAG 基于知识图谱的 RAG 总结
- Multimodal RAG with Captioning 图像描述型多模态 RAG 总结