Claude Code 架构全解Claude Code Architecture, in Full第 7 章 · 14 章Chapter 7 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 源码目录逐个解释
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一次完整请求的旅程(全文导航)
    5. 0.5 全文章节索引
  2. 1 · 入口层与启动流程
    1. 1.1 四种启动形态
    2. 1.2 启动的第一个设计:快路径分派
    3. 1.3 命令行参数:60 多个选项
    4. 1.4 --bare:一个值得单独讲的极简模式
    5. 1.5 启动时序
    6. 1.6 系统提示词的三段结构
  3. 2 · 会话层:QueryEngine
    1. 2.1 它解决什么问题
    2. 2.2 它持有哪些状态
    3. 2.3 一次 submitMessage 的完整流程
    4. 2.4 用户消息为什么必须先落盘
    5. 2.5 消费主循环输出:一个大 switch
    6. 2.6 压缩分界点:主动释放内存
    7. 2.7 三种退出结果
    8. 2.8 ask():一次性调用的便捷封装
  4. 3 · 智能体主循环 ★
    1. 3.1 循环的骨架
    2. 3.2 State:把跨轮次状态集中管理
    3. 3.3 transition:只为可测试而存在的字段
    4. 3.4 七条转移边逐条详解
    5. 3.5 错误扣留机制
    6. 3.6 中断处理
    7. 3.7 模型降级:三个动作
    8. 3.8 思考块三定律
    9. 3.9 循环里的其他机制
    10. 3.10 循环的所有退出点
  5. 4 · 工具模型
    1. 4.1 Tool 接口:七组正交能力
    2. 4.2 为什么「安全谓词」值得单独成组
    3. 4.3 失败保守默认值
    4. 4.4 40 个内建工具分类
    5. 4.5 渐进式工具加载
    6. 4.6 工具清单装配:一个关于缓存的隐藏约束
    7. 4.7 backfillObservableInput:一个极致的缓存保护例子
  6. 5 · 工具执行
    1. 5.1 执行链路总览
    2. 5.2 并发分区:贪心算法
    3. 5.3 上下文修改要排队到批次结束
    4. 5.4 单次执行:runToolUse 的完整流程
    5. 5.5 流式工具执行器
    6. 5.6 兄弟中止控制器:最漂亮的一处设计
    7. 5.7 丢弃机制
    8. 5.8 「墓碑」消息
    9. 5.9 结果的最终处理
  7. 6 · 上下文治理 ★
    1. 6.1 五级流水线
    2. 6.2 第 ① 级:工具结果预算
    3. 6.3 第 ③ 级:微压缩与缓存编辑
    4. 6.4 第 ⑤ 级:自动摘要压缩
    5. 6.5 上下文真的超了:三级恢复瀑布
  8. 7 · 权限系统
    1. 7.1 六种权限模式
    2. 7.2 十步决策级联
    3. 7.3 自动模式:模型分类器 + 三级快速通道
    4. 7.4 权限规则的语法
    5. 7.5 沙箱与只读命令判定
    6. 7.6 权限判定的完整数据结构
    7. 7.7 权限决策的可解释性
  9. 8 · 子智能体
    1. 8.1 首要动机是上下文隔离,不是并行
    2. 8.2 三种形态
    3. 8.3 分叉:把提示词缓存用到极致
    4. 8.4 子智能体的工具限制
    5. 8.5 子智能体的上下文构造
    6. 8.6 内建智能体类型
    7. 8.7 后台任务的几种形态
    8. 8.8 中断的级联
  10. 9 · 扩展体系
    1. 9.1 四类扩展点对照
    2. 9.2 技能系统
    3. 9.3 插件系统
    4. 9.4 MCP 客户端
    5. 9.5 钩子:15 类生命周期事件
    6. 9.6 输出样式
  11. 10 · 终端界面层
    1. 10.1 用 React 写终端界面
    2. 10.2 最大的四个组件
    3. 10.3 输入框为什么有 347 KB
    4. 10.4 虚拟消息列表
    5. 10.5 工具结果的六种渲染状态
    6. 10.6 折叠:避免刷屏
    7. 10.7 87 个状态管理单元
    8. 10.8 界面和内核的接口:ToolUseContext 里的回调
    9. 10.9 一个有趣的细节:ANSI 转 PNG
  12. 11 · 持久化与恢复
    1. 11.1 对话记录的格式:JSONL
    2. 11.2 对话记录是一棵树,不是一个列表
    3. 11.3 写入队列
    4. 11.4 子智能体的记录:支链文件
    5. 11.5 恢复:三种方式
    6. 11.6 文件历史:智能体改过的文件可以回滚
    7. 11.7 配置的多来源与迁移
    8. 11.8 记忆目录
  13. 12 · 可观测体系
    1. 12.1 埋点密度
    2. 12.2 事件命名
    3. 12.3 查询链路追踪
    4. 12.4 类型层面的隐私保护
    5. 12.5 缓存断裂检测
    6. 12.6 性能剖析检查点
    7. 12.7 慢操作日志
    8. 12.8 内存错误缓冲区
    9. 12.9 内部错误的响亮日志
    10. 12.10 埋点的成本意识
  14. 13 · 构建与分发
    1. 13.1 Bun 单文件可执行程序
    2. 13.2 编译期特性开关:89 个
    3. 13.3 死代码消除:为什么这不只是「if 判断」
    4. 13.4 编译期宏
    5. 13.5 运行时特性开关:另一套系统
    6. 13.6 版本与更新
    7. 13.7 从构建方式反推的架构约束
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What this software is
    2. 0.2 The source tree, directory by directory
    3. 0.3 Three things you can read off this map
    4. 0.4 The journey of one complete request (a map of the whole book)
    5. 0.5 Chapter index
  2. 1 · The Entry Layer and Startup
    1. 1.1 Four launch modes
    2. 1.2 The first design decision at startup: fast-path dispatch
    3. 1.3 Command-line arguments: 60-plus options
    4. 1.4 --bare: a minimal mode worth its own section
    5. 1.5 Startup sequence
    6. 1.6 The three-part structure of the system prompt
  3. 2 · The Session Layer: QueryEngine
    1. 2.1 The problem it solves
    2. 2.2 What state it holds
    3. 2.3 The full flow of one submitMessage
    4. 2.4 Why the user message must hit disk first
    5. 2.5 Consuming the main loop’s output: one big switch
    6. 2.6 The compact boundary: proactively releasing memory
    7. 2.7 Three kinds of exit result
    8. 2.8 ask(): a convenience wrapper for one-shot calls
  4. 3 · The Agent Main Loop ★
    1. 3.1 The skeleton of the loop
    2. 3.2 State: centralizing cross-iteration state
    3. 3.3 transition: a field that exists purely for testability
    4. 3.4 The seven transition edges, one by one
    5. 3.5 The error-withholding mechanism
    6. 3.6 Interrupt handling
    7. 3.7 Model fallback: three actions
    8. 3.8 The three laws of thinking blocks
    9. 3.9 Other mechanisms in the loop
    10. 3.10 Every exit point of the loop
  5. 4 · The Tool Model
    1. 4.1 The Tool interface: seven orthogonal capability groups
    2. 4.2 Why the “safety predicates” deserve their own group
    3. 4.3 Fail-safe defaults
    4. 4.4 The 40 built-in tools, by category
    5. 4.5 Progressive tool loading
    6. 4.6 Tool list assembly: a hidden constraint about caching
    7. 4.7 backfillObservableInput: an extreme example of cache protection
  6. 5 · Tool Execution
    1. 5.1 The execution pipeline at a glance
    2. 5.2 Concurrency partitioning: a greedy algorithm
    3. 5.3 Context modifications are queued until the batch ends
    4. 5.4 A single execution: the full flow of runToolUse
    5. 5.5 The streaming tool executor
    6. 5.6 The sibling abort controller: the most elegant design in the file
    7. 5.7 The discard mechanism
    8. 5.8 “Tombstone” messages
    9. 5.9 Final processing of results
  7. 6 · Context Management ★
    1. 6.1 The five-tier pipeline
    2. 6.2 Tier ①: the tool result budget
    3. 6.3 Tier ③: micro-compaction and cache edits
    4. 6.4 Tier ⑤: auto summary compaction
    5. 6.5 When the context really does overflow: the three-tier recovery cascade
  8. 7 · The Permission System
    1. 7.1 Six Permission Modes
    2. 7.2 The Ten-Step Decision Cascade
    3. 7.3 Auto Mode: Model Classifier + Three-Tier Fast Path
    4. 7.4 Permission Rule Syntax
    5. 7.5 Sandboxing and Read-Only Command Detection
    6. 7.6 The Full Data Structure Behind a Permission Decision
    7. 7.7 Explainability of Permission Decisions
  9. 8 · Subagents
    1. 8.1 The Primary Motivation Is Context Isolation, Not Parallelism
    2. 8.2 Three Forms
    3. 8.3 Forking: Pushing Prompt Caching to the Limit
    4. 8.4 Tool Restrictions on Subagents
    5. 8.5 Constructing a Subagent's Context
    6. 8.6 Built-in Agent Types
    7. 8.7 The Forms Background Tasks Take
    8. 8.8 Cascading Interruption
  10. 9 · The Extension System
    1. 9.1 The Four Extension Points Compared
    2. 9.2 The Skill System
    3. 9.3 The Plugin System
    4. 9.4 The MCP Client
    5. 9.5 Hooks: 15 Kinds of Lifecycle Events
    6. 9.6 Output Styles
  11. 10 · The Terminal UI Layer
    1. 10.1 Writing a Terminal UI in React
    2. 10.2 The Four Biggest Components
    3. 10.3 Why the Input Box Is 347 KB
    4. 10.4 The Virtualized Message List
    5. 10.5 Six Rendering States for Tool Results
    6. 10.6 Collapsing: Avoiding Screen Flood
    7. 10.7 87 State-Management Units
    8. 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
    9. 10.9 A Fun Detail: ANSI to PNG
  12. 11 · Persistence and Resume
    1. 11.1 The Transcript Format: JSONL
    2. 11.2 The Transcript Is a Tree, Not a List
    3. 11.3 The Write Queue
    4. 11.4 Subagent Records: Sidechain Files
    5. 11.5 Resume: Three Ways
    6. 11.6 File History: Rolling Back Files the Agent Changed
    7. 11.7 Multi-Source Settings and Migrations
    8. 11.8 The Memory Directory
  13. 12 · The Observability System
    1. 12.1 Instrumentation Density
    2. 12.2 Event Naming
    3. 12.3 Query Chain Tracing
    4. 12.4 Privacy Protection at the Type Level
    5. 12.5 Cache-Break Detection
    6. 12.6 Profiling Checkpoints
    7. 12.7 Slow-Operation Logging
    8. 12.8 The In-Memory Error Buffer
    9. 12.9 Loud Logging for Internal Errors
    10. 12.10 Cost-Awareness in Instrumentation
  14. 13 · Build and Distribution
    1. 13.1 The Bun Single-File Executable
    2. 13.2 Compile-Time Feature Flags: 89 of Them
    3. 13.3 Dead-Code Elimination: Why It's Not Just an “if”
    4. 13.4 Compile-Time Macros
    5. 13.5 Runtime Feature Flags: A Separate System
    6. 13.6 Versions and Updates
    7. 13.7 Architectural Constraints Inferred from the Build

7 · 权限系统

utils/permissions/ 目录下有 21 个文件,核心的 permissions.ts 有 51 KB。这一章讲清楚「凭什么让这个工具调用跑起来」这个问题的完整答案。

7.1 六种权限模式

const PERMISSION_MODE_CONFIG: Partial<Record<PermissionMode, PermissionModeConfig>> = {
  default:           { title: 'Default',             symbol: '',    color: 'text' },
  plan:              { title: 'Plan Mode',           symbol: '⏸',   color: 'planMode' },
  acceptEdits:       { title: 'Accept edits',        symbol: '⏵⏵',  color: 'autoAccept' },
  bypassPermissions: { title: 'Bypass Permissions',  symbol: '⏵⏵',  color: 'error' },
  dontAsk:           { title: "Don't Ask",           symbol: '⏵⏵',  color: 'error' },
  ...(feature('TRANSCRIPT_CLASSIFIER') ? {
  auto:              { title: 'Auto mode',           symbol: '⏵⏵',  color: 'warning' },
  } : {}),
}

claude-code/src/utils/permissions/PermissionMode.ts

模式行为
default默认。危险操作弹确认框问用户
plan
计划模式
只允许只读操作。模型先做调研、出方案,用户批准后才切回执行模式。用来防止「模型理解错了就直接动手」
acceptEdits
接受编辑
文件编辑类操作自动放行,其他仍然要问。比默认宽松,比 bypass 严格
bypassPermissions
跳过权限
对应 --dangerously-skip-permissions但仍有一层绕不过,见 7.2
dontAsk
不要问我
把所有「需要询问」直接转成「拒绝」。和 bypass 相反 —— bypass 是「都放行」,这个是「都拒绝」。适合完全不想被打扰又不想冒险的场景
auto
自动模式
内部版特性。用模型分类器代替人来做安全判断,见 7.3

还有一个类型上的区分很讲究:

export function isExternalPermissionMode(mode: PermissionMode): mode is ExternalPermissionMode {
  if (process.env.USER_TYPE !== 'ant') return true      // 外部用户没有 auto,所以永远为真
  return mode !== 'auto' && mode !== 'bubble'
}

export function toExternalPermissionMode(mode: PermissionMode): ExternalPermissionMode {
  return getModeConfig(mode).external      // auto 对外映射成 default
}

内部模式对外要有一个映射。auto 模式对外部接口报告成 default —— 这样开发工具包的使用者不会看到一个他们理解不了、也无法设置的模式值。

7.2 十步决策级联

权限决策级联
权限决策级联 — 玫红色虚线框内的 1d 到 1g 排在 2a(bypass 模式)之前。这个顺序就是整个设计的关键点击放大

核心函数 hasPermissionsToUseToolInner() 是一条严格有序的判定链,从上到下逐条检查,第一个命中的直接决定结果:

步骤检查什么结果
0中止信号已拉拒绝
1a整个工具被拒绝规则命中DENY
1b整个工具被询问规则命中ASK
例外:如果这条 Bash 命令能在沙箱里安全执行,跳过继续往下
1c调用工具自己的 checkPermissions()拿到工具自己的判断,不直接出结果
↓ ↓ ↓ 以下四步是 bypass 免疫层 ↓ ↓ ↓
1d工具自己明确说了「拒绝」DENY
1e工具声明「必须有人在场」ASK
1f用户显式配了内容级询问规则ASK
1g安全检查:碰到敏感路径ASK
↑ ↑ ↑ 以上四步是 bypass 免疫层 ↑ ↑ ↑
2abypassPermissions 模式ALLOW
2b整个工具被允许规则命中ALLOW
3都没命中ASK(默认落到人工确认)

bypass 免疫层的四条源码注释

// 1d. Tool implementation denied (catches bash subcommand denies wrapped ...)
// 1e. Tool requires user interaction even in bypass mode
// 1f. Content-specific ask rules from tool.checkPermissions take precedence
//     over bypassPermissions mode. When a user explicitly configures a
//     content-specific ask rule (e.g. Bash(npm publish:*)), the tool's
//     checkPermissions returns {behavior:'ask', ...}. This must be respected
//     even in bypass mode, just as deny rules are respected at step 1d.
// 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
//     bypass-immune — they must prompt even in bypassPermissions mode.
这四条各自守什么
步骤守的是什么
1d工具作者的判断。工具最了解自己的操作有多危险,它的拒绝不能被外部覆盖
1e物理必要性。「向用户提问」这个工具,没人在场就无法完成,放行也没意义
1f用户更具体的意图。用户开 bypass 是想说「别拿常规操作烦我」,但他专门配了 Bash(npm publish:*) 要问,说明这一条是他特意留的闸门。更具体的配置优先于更笼统的配置
1g不可挽回的破坏。删掉 .git/ 等于丢掉整个版本历史;改掉 .claude/ 等于智能体自己改自己的权限配置;改 shell 启动脚本等于留后门

「绕过权限」不等于「绕过一切」。这是一个成熟的产品判断:给用户「关掉烦人确认」的自由,但不给「一键自毁」的自由。如果不留这个底座,第一个不小心让智能体删掉自己 .git 的用户,会永久失去对这个产品的信任。

7.3 自动模式:模型分类器 + 三级快速通道

当判定落到 ASK 且当前是自动模式时,Claude Code 不弹窗,而是再调一次模型来判断这个动作安不安全。这个专用调用叫「分类器」。

但分类器不便宜 —— 每个工具调用一次额外的接口请求。所以前面挡了几层:

if (feature('TRANSCRIPT_CLASSIFIER') &&
    (appState.toolPermissionContext.mode === 'auto' ||
     (appState.toolPermissionContext.mode === 'plan' && isAutoModeActive()))) {

  // 拦截 1:安全检查命中、且这类检查"分类器无权批准"
  if (result.decisionReason?.type === 'safetyCheck' &&
      !result.decisionReason.classifierApprovable) {
    if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
      return { behavior:'deny', message: result.message,
               decisionReason: { type:'asyncAgent',
                 reason:'Safety check requires interactive approval and permission '
                      + 'prompts are not available in this context' } }
    }
    return result       // 保持 ASK
  }

  // 拦截 2:工具声明"必须有人在场"
  if (tool.requiresUserInteraction?.() && result.behavior === 'ask') return result

  // 拦截 3:PowerShell(除非编译期开了特殊开关)
  if (tool.name === POWERSHELL_TOOL_NAME && !feature('POWERSHELL_AUTO_MODE')) { ... }

  // 快速通道 ①:伪装成 acceptEdits 模式再问一次工具
  if (result.behavior === 'ask' &&
      tool.name !== AGENT_TOOL_NAME && tool.name !== REPL_TOOL_NAME) {
    const acceptEditsResult = await tool.checkPermissions(parsedInput, {
      ...context,
      getAppState: () => ({ ...context.getAppState(),
        toolPermissionContext: { ...state.toolPermissionContext,
                                 mode: 'acceptEdits' as const } }),
    })
    if (acceptEditsResult.behavior === 'allow') {
      logEvent('tengu_auto_mode_decision', { decision:'allowed', fastPath:'acceptEdits', ... })
      return { behavior:'allow', updatedInput: acceptEditsResult.updatedInput ?? input,
               decisionReason: { type:'mode', mode:'auto' } }
    }
  }

  // 快速通道 ②:安全工具白名单
  if (classifierDecisionModule!.isAutoModeAllowlistedTool(tool.name)) {
    logEvent('tengu_auto_mode_decision', { decision:'allowed', fastPath:'allowlist', ... })
    return { behavior:'allow', updatedInput: input, decisionReason: { type:'mode', mode:'auto' } }
  }

  // 都没命中 → 真的调分类器
  const action = formatActionForClassifier(tool.name, input)
  setClassifierChecking(toolUseID)
  let classifierResult
  try {
    classifierResult = await classifyYoloAction(
      context.messages,                    // ★ 完整对话记录
      action,
      context.options.tools,
      appState.toolPermissionContext,
      context.abortController.signal)
  } finally {
    clearClassifierChecking(toolUseID)
  }
  ...
}

claude-code/src/utils/permissions/permissions.ts

快速通道 ① 的巧妙之处

把权限模式临时伪装成 acceptEdits,再问工具一次。逻辑是:如果连宽松模式都会放行这个操作,说明它确实无害,没必要付费问分类器。

但两个工具被明确排除:

「Skip for Agent and REPL — their checkPermissions returns 'allow' for acceptEdits mode, which would silently bypass the classifier. REPL code can contain VM escapes between inner tool calls; the classifier must see the glue JavaScript, not just the inner tool calls.」

译:跳过 Agent 和 REPL 工具 —— 它们的权限检查在 acceptEdits 模式下返回「允许」,会静默绕过分类器。REPL 工具的代码可能在内部工具调用之间夹带虚拟机逃逸;分类器必须看到那些胶水 JavaScript 代码,而不只是内部的工具调用。

这是「组合安全 ≠ 单元安全」的绝佳例子。A 安全、B 安全,不代表「先 A 再 B,中间夹一段自定义逻辑」也安全。

分类器看的是完整对话记录

注意 classifyYoloAction(context.messages, ...) 的第一个参数:整个对话记录,不只是这一条命令。

这很关键。一条 rm -rf build/ 命令,在「用户要求清理构建产物」的语境下是安全的,脱离语境就无法判断。但这也带来一个问题:完整对话记录可能有几万 token,塞进分类器会爆。

所以工具接口里有一个专门为此服务的方法:

/**
 * Returns a compact representation of this tool use for the auto-mode
 * security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content`
 * for Edit. Return '' to skip this tool in the classifier transcript
 * (e.g. tools with no security relevance). May return an object to avoid
 * double-encoding when the caller JSON-wraps the value.
 */
toAutoClassifierInput(input: z.infer<Input>): unknown

每个工具自己提供只保留安全语义的压缩表示:Bash 给命令行文本,Edit 给「路径 + 新内容」,没有安全含义的工具返回空串直接不进视野。

连续拒绝追踪:打破僵局

// 任何一次成功放行都重置连续拒绝计数
if (result.behavior === 'allow') {
  const currentDenialState = context.localDenialTracking ?? appState.denialTracking
  if (appState.toolPermissionContext.mode === 'auto' &&
      currentDenialState && currentDenialState.consecutiveDenials > 0) {
    const newDenialState = recordSuccess(currentDenialState)
    persistDenialState(context, newDenialState)
  }
  return result
}

连续被拒达到阈值时,系统不再信任分类器,回退到人工确认。防的是这种僵局:分类器因某种误判一直拒绝,模型不明白为什么,就一直换写法重试 —— 双方都在烧钱但永远推进不了。

注意 context.localDenialTracking ?? appState.denialTracking 这个回退:

「Local denial tracking state for async subagents whose setAppState is a no-op. Without this, the denial counter never accumulates and the fallback-to-prompting threshold is never reached.」

译:为异步子智能体准备的本地拒绝追踪状态 —— 它们的全局状态写入函数是空操作。没有这个,拒绝计数永远不会累加,「回退到人工确认」的阈值也就永远达不到。

这是一个典型的「架构隔离带来的副作用」:为了让子智能体不污染主线程状态,它的 setAppState 被设成了空操作。但这也意味着任何依赖状态累加的机制在子智能体里都失效了。所以要给它一个本地的副本。

7.4 权限规则的语法

用户可以在配置文件里写权限规则。语法有两个层次:

写法含义
Bash整工具级。匹配所有 Bash 调用
Bash(git:*)内容级。只匹配 git 开头的命令
Bash(npm publish:*)只匹配 npm publish 开头的命令
Edit(src/**)只匹配 src 目录下的文件编辑
mcp__serverMCP 服务级前缀。匹配该服务下的所有工具

三类规则:alwaysAllowRules(总是允许)、alwaysDenyRules(总是拒绝)、alwaysAskRules(总是询问)。

整工具级的拒绝规则会在「模型看到工具之前」就生效

/**
 * Filters out tools that are blanket-denied by the permission context.
 * A tool is filtered out if there's a deny rule matching its name with no
 * ruleContent (i.e., a blanket deny for that tool).
 *
 * Uses the same matcher as the runtime permission check (step 1a), so MCP
 * server-prefix rules like `mcp__server` strip all tools from that server
 * before the model sees them — not just at call time.
 */
export function filterToolsByDenyRules<T>(tools, permissionContext): T[] {
  return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}

这是一个重要的区分:「整工具级拒绝」不是在调用时拦截,而是让这个工具根本不出现在模型的工具清单里

两者的差别很大:
· 调用时拦截 → 模型会尝试调用、被拒、然后困惑地换个方式再试,浪费好几轮
· 不出现在清单里 → 模型压根不知道有这个能力,直接走别的路

而内容级规则(Bash(git:*))无法在清单层面过滤 —— 因为 Bash 工具本身要保留,只是某些参数要拦。所以它只能在调用时判定。

影子规则检测

utils/permissions/ 里有一个文件叫 shadowedRuleDetection.ts(影子规则检测)。它解决的问题是:

用户配了两条规则: 1. 允许 Bash(git:*) —— 允许所有 git 命令 2. 询问 Bash(git push:*) —— 但 git push 要问我一次 如果匹配顺序不对,第 2 条就永远不会生效 —— 因为第 1 条更笼统,会先命中并放行。 这条规则就被"遮蔽"(shadowed)了。 影子规则检测负责发现这种配置错误并提醒用户。

7.5 沙箱与只读命令判定

只读命令自动放行

utils/shell/readOnlyCommandValidation.ts,66.7 KB。它的职责是判断「这条 shell 命令是不是只读的」。如果是,就可以自动放行,不打扰用户。

这件事比看起来难得多,因为要处理:

  • 管道和重定向 —— ls | grep foo 是只读的,ls > out.txt 不是
  • 命令替换 —— echo $(rm -rf /) 里面藏着写操作
  • 复合命令 —— cd /tmp && ls 里有两条命令,都要判断
  • 别名和函数 —— 用户可能把 ls 别名成了别的东西

所以 utils/bash/ 目录下有一个完整的 shell 语法解析器:bashParser.ts(128 KB)+ ast.ts(109 KB)。它把 shell 命令解析成抽象语法树,然后在树上做分析,而不是用正则匹配字符串。

而且还有一个实验性的替代实现:编译期开关里能看到 TREE_SITTER_BASHTREE_SITTER_BASH_SHADOW —— 后者的命名(shadow,影子)说明他们在用影子模式验证新解析器:两个解析器同时跑,结果不一致时记录下来,但仍然用旧的那个的结果。这样可以在零风险的前提下收集新实现的准确率数据。

操作系统级沙箱

在 macOS 上,Claude Code 使用系统自带的 sandbox-exec 机制(也叫 seatbelt)。它可以在进程启动时施加一份策略文件,限制这个进程能访问哪些路径、能不能联网。

权限判定链的第 1b 步有一个特殊分支就和沙箱有关:

// 1b. Check if the entire tool should always ask for permission
const askRule = getAskRuleForTool(...)
if (askRule) {
  // 当"沙箱内自动放行"开启时,能被沙箱化的命令跳过询问规则,
  // 通过 Bash 的 checkPermissions 自动放行。
  // 那些不会被沙箱化的命令(排除列表里的、显式禁用沙箱的)仍然遵守询问规则。
  if (!canSandboxAutoAllow) {
    return { behavior:'ask', ... }
  }
  // 否则继续往下,让 Bash 的 checkPermissions 处理具体命令的规则
}

逻辑是:如果这条命令会在沙箱里跑,那么即使它「看起来危险」也没关系 —— 沙箱会兜住。所以可以跳过询问。这是「用更强的隔离手段换取更少的打扰」。

7.6 权限判定的完整数据结构

export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  alwaysAllowRules: ToolPermissionRulesBySource
  alwaysDenyRules:  ToolPermissionRulesBySource
  alwaysAskRules:   ToolPermissionRulesBySource
  isBypassPermissionsModeAvailable: boolean
  isAutoModeAvailable?: boolean
  strippedDangerousRules?: ToolPermissionRulesBySource   // ★ 被剥离的危险规则
  shouldAvoidPermissionPrompts?: boolean                 // 后台任务:弹不出框
  awaitAutomatedChecksBeforeDialog?: boolean
  prePlanMode?: PermissionMode                           // 进计划模式前的模式,用于恢复
}>

两个字段值得注意:

strippedDangerousRules:被系统主动剥离的规则

用户配置里可能有一些「过于宽泛以至于危险」的规则。系统会在加载时把它们剥离掉,并把剥离的内容记录下来(这样界面上可以提示用户「你的这条规则被忽略了,因为它太宽泛」)。

源码里能看到具体的剥离逻辑,比如:

  • isOverlyBroadPowerShellAllowRule —— 剥离 PowerShell(*) 这种放行一切的规则
  • isDangerousPowerShellPermission —— 剥离 iex(下载执行)、Start-Process 等前缀的放行规则

DeepImmutable:类型层面的不可变

这个包装类型让整个权限上下文在类型系统层面完全只读 —— 任何试图修改它的代码都通不过编译。权限状态的修改必须走专门的 applyPermissionUpdates() 函数,从而保证所有修改都经过统一的校验和持久化路径。

7.7 权限决策的可解释性

每一个权限决策都带一个 decisionReason(决策原因)字段:

{ type: 'rule',        rule: {...} }            // 命中了某条规则
{ type: 'mode',        mode: 'auto' }           // 因为当前模式
{ type: 'hook',        hookName: 'PermissionRequest', reason: ... }  // 钩子决定的
{ type: 'safetyCheck', classifierApprovable: false }   // 安全检查
{ type: 'asyncAgent',  reason: '...' }          // 后台任务无法交互

而且有一个专门的模块 permissionExplainer.ts 负责把这些原因翻译成人话展示给用户。

可解释性对权限系统是刚需,不是锦上添花。

当用户看到「这个操作被拒绝了」而不知道为什么时,他的第一反应是把整个权限系统关掉。而如果他看到「因为你在 ~/.claude/settings.json 第 12 行配了 deny: Bash(rm:*)」,他就知道该改哪里。

一个无法解释自己决策的安全系统,最终会被用户绕过。

7 · The Permission System

The utils/permissions/ directory holds 21 files, and the core permissions.ts alone is 51 KB. This chapter gives the complete answer to one question: “on what grounds does this tool call get to run?”

7.1 Six Permission Modes

const PERMISSION_MODE_CONFIG: Partial<Record<PermissionMode, PermissionModeConfig>> = {
  default:           { title: 'Default',             symbol: '',    color: 'text' },
  plan:              { title: 'Plan Mode',           symbol: '⏸',   color: 'planMode' },
  acceptEdits:       { title: 'Accept edits',        symbol: '⏵⏵',  color: 'autoAccept' },
  bypassPermissions: { title: 'Bypass Permissions',  symbol: '⏵⏵',  color: 'error' },
  dontAsk:           { title: "Don't Ask",           symbol: '⏵⏵',  color: 'error' },
  ...(feature('TRANSCRIPT_CLASSIFIER') ? {
  auto:              { title: 'Auto mode',           symbol: '⏵⏵',  color: 'warning' },
  } : {}),
}

claude-code/src/utils/permissions/PermissionMode.ts

ModeBehavior
defaultThe default. Dangerous operations pop a confirmation dialog for the user
plan
Plan mode
Read-only operations only. The model researches first and produces a plan; only after the user approves does it switch back to an executing mode. Exists to prevent “the model misunderstood and just started making changes”
acceptEdits
Accept edits
File-editing operations go through automatically; everything else still asks. Looser than default, stricter than bypass
bypassPermissions
Bypass permissions
Corresponds to --dangerously-skip-permissions. But one layer still can't be bypassed — see 7.2
dontAsk
Don't ask me
Turns every “needs to ask” straight into “deny.” The opposite of bypass — bypass says “allow everything,” this says “deny everything.” For when you don't want to be interrupted and don't want to take risks either
auto
Auto mode
Internal-only feature. A model classifier makes the safety call instead of a human; see 7.3

There's also a carefully drawn distinction at the type level:

export function isExternalPermissionMode(mode: PermissionMode): mode is ExternalPermissionMode {
  if (process.env.USER_TYPE !== 'ant') return true      // external users have no auto, so always true
  return mode !== 'auto' && mode !== 'bubble'
}

export function toExternalPermissionMode(mode: PermissionMode): ExternalPermissionMode {
  return getModeConfig(mode).external      // auto maps to default externally
}

Internal modes need an external mapping. The auto mode reports itself to external interfaces as default — so SDK users never see a mode value they can't understand and can't set.

7.2 The Ten-Step Decision Cascade

The permission decision cascade
The permission decision cascade — steps 1d through 1g inside the magenta dashed box come before 2a (bypass mode). That ordering is the key to the whole designClick to enlarge

The core function hasPermissionsToUseToolInner() is a strictly ordered chain of checks, evaluated top to bottom; the first one that matches decides the outcome:

StepWhat it checksOutcome
0Abort signal has been raisedDeny
1aThe whole tool matches a deny ruleDENY
1bThe whole tool matches an ask ruleASK
Exception: if this Bash command can run safely inside the sandbox, skip this and keep going
1cCall the tool's own checkPermissions()Collects the tool's own verdict; doesn't produce a result directly
↓ ↓ ↓ the next four steps are the bypass-immune layer ↓ ↓ ↓
1dThe tool itself explicitly said “deny”DENY
1eThe tool declares “a human must be present”ASK
1fThe user explicitly configured a content-level ask ruleASK
1gSafety check: a sensitive path is touchedASK
↑ ↑ ↑ the four steps above are the bypass-immune layer ↑ ↑ ↑
2abypassPermissions modeALLOW
2bThe whole tool matches an allow ruleALLOW
3Nothing matchedASK (falls through to human confirmation by default)

The four source comments on the bypass-immune layer

// 1d. Tool implementation denied (catches bash subcommand denies wrapped ...)
// 1e. Tool requires user interaction even in bypass mode
// 1f. Content-specific ask rules from tool.checkPermissions take precedence
//     over bypassPermissions mode. When a user explicitly configures a
//     content-specific ask rule (e.g. Bash(npm publish:*)), the tool's
//     checkPermissions returns {behavior:'ask', ...}. This must be respected
//     even in bypass mode, just as deny rules are respected at step 1d.
// 1g. Safety checks (e.g. .git/, .claude/, .vscode/, shell configs) are
//     bypass-immune — they must prompt even in bypassPermissions mode.
What each of the four protects
StepWhat it protects
1dThe tool author's judgment. The tool knows best how dangerous its own operations are; its deny can't be overridden from outside
1ePhysical necessity. The “ask the user a question” tool can't complete without a person present; allowing it would be meaningless
1fThe user's more specific intent. A user turning on bypass is saying “stop bothering me about routine operations,” but if they specifically configured Bash(npm publish:*) to ask, that's a gate they deliberately left in place. The more specific configuration wins over the more general one
1gIrreversible damage. Deleting .git/ means losing the entire version history; modifying .claude/ means the agent rewriting its own permission config; modifying shell startup scripts means planting a backdoor

“Bypass permissions” does not mean “bypass everything.” This is a mature product judgment: give users the freedom to turn off annoying confirmations, but not the freedom to self-destruct with one keystroke. Without this floor, the first user who accidentally let the agent delete their own .git would lose trust in the product for good.

7.3 Auto Mode: Model Classifier + Three-Tier Fast Path

When the verdict lands on ASK and the current mode is auto, Claude Code doesn't pop a dialog. Instead it calls the model one more time to judge whether the action is safe. This dedicated call is the “classifier.”

But the classifier isn't cheap — it's an extra API request per tool call. So several layers stand in front of it:

if (feature('TRANSCRIPT_CLASSIFIER') &&
    (appState.toolPermissionContext.mode === 'auto' ||
     (appState.toolPermissionContext.mode === 'plan' && isAutoModeActive()))) {

  // Intercept 1: a safety check fired, and it's one the classifier "isn't allowed to approve"
  if (result.decisionReason?.type === 'safetyCheck' &&
      !result.decisionReason.classifierApprovable) {
    if (appState.toolPermissionContext.shouldAvoidPermissionPrompts) {
      return { behavior:'deny', message: result.message,
               decisionReason: { type:'asyncAgent',
                 reason:'Safety check requires interactive approval and permission '
                      + 'prompts are not available in this context' } }
    }
    return result       // stays ASK
  }

  // Intercept 2: the tool declares "a human must be present"
  if (tool.requiresUserInteraction?.() && result.behavior === 'ask') return result

  // Intercept 3: PowerShell (unless a special compile-time flag is on)
  if (tool.name === POWERSHELL_TOOL_NAME && !feature('POWERSHELL_AUTO_MODE')) { ... }

  // Fast path ①: pose as acceptEdits mode and ask the tool again
  if (result.behavior === 'ask' &&
      tool.name !== AGENT_TOOL_NAME && tool.name !== REPL_TOOL_NAME) {
    const acceptEditsResult = await tool.checkPermissions(parsedInput, {
      ...context,
      getAppState: () => ({ ...context.getAppState(),
        toolPermissionContext: { ...state.toolPermissionContext,
                                 mode: 'acceptEdits' as const } }),
    })
    if (acceptEditsResult.behavior === 'allow') {
      logEvent('tengu_auto_mode_decision', { decision:'allowed', fastPath:'acceptEdits', ... })
      return { behavior:'allow', updatedInput: acceptEditsResult.updatedInput ?? input,
               decisionReason: { type:'mode', mode:'auto' } }
    }
  }

  // Fast path ②: allowlist of safe tools
  if (classifierDecisionModule!.isAutoModeAllowlistedTool(tool.name)) {
    logEvent('tengu_auto_mode_decision', { decision:'allowed', fastPath:'allowlist', ... })
    return { behavior:'allow', updatedInput: input, decisionReason: { type:'mode', mode:'auto' } }
  }

  // Nothing matched → actually call the classifier
  const action = formatActionForClassifier(tool.name, input)
  setClassifierChecking(toolUseID)
  let classifierResult
  try {
    classifierResult = await classifyYoloAction(
      context.messages,                    // ★ the full transcript
      action,
      context.options.tools,
      appState.toolPermissionContext,
      context.abortController.signal)
  } finally {
    clearClassifierChecking(toolUseID)
  }
  ...
}

claude-code/src/utils/permissions/permissions.ts

What's clever about fast path ①

It temporarily disguises the permission mode as acceptEdits and asks the tool again. The logic: if even the permissive mode would allow this operation, it's genuinely harmless, and there's no need to pay for a classifier call.

But two tools are explicitly excluded:

“Skip for Agent and REPL — their checkPermissions returns 'allow' for acceptEdits mode, which would silently bypass the classifier. REPL code can contain VM escapes between inner tool calls; the classifier must see the glue JavaScript, not just the inner tool calls.”

Put plainly: skip the Agent and REPL tools — their permission checks return “allow” under acceptEdits, which would silently sidestep the classifier. REPL code can smuggle VM escapes in between the inner tool calls; the classifier has to see the glue JavaScript, not just the inner tool calls.

This is a textbook example of “composition-safe ≠ unit-safe.” A is safe and B is safe, but that doesn't make “A, then B, with custom logic in between” safe.

The classifier sees the full transcript

Note the first argument to classifyYoloAction(context.messages, ...): the entire transcript, not just this one command.

This matters. An rm -rf build/ is safe in the context of “the user asked to clean up build artifacts” and impossible to judge without that context. But it raises a problem: the full transcript can run to tens of thousands of tokens, and stuffing it into the classifier would blow it up.

So the tool interface has a method built specifically for this:

/**
 * Returns a compact representation of this tool use for the auto-mode
 * security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content`
 * for Edit. Return '' to skip this tool in the classifier transcript
 * (e.g. tools with no security relevance). May return an object to avoid
 * double-encoding when the caller JSON-wraps the value.
 */
toAutoClassifierInput(input: z.infer<Input>): unknown

Each tool supplies its own compressed representation that keeps only the security-relevant semantics: Bash gives the command-line text, Edit gives “path + new content,” and tools with no security implications return an empty string and drop out of view entirely.

Consecutive-denial tracking: breaking deadlocks

// any successful allow resets the consecutive-denial counter
if (result.behavior === 'allow') {
  const currentDenialState = context.localDenialTracking ?? appState.denialTracking
  if (appState.toolPermissionContext.mode === 'auto' &&
      currentDenialState && currentDenialState.consecutiveDenials > 0) {
    const newDenialState = recordSuccess(currentDenialState)
    persistDenialState(context, newDenialState)
  }
  return result
}

Once consecutive denials hit a threshold, the system stops trusting the classifier and falls back to human confirmation. It guards against this deadlock: the classifier keeps denying because of some misjudgment, the model doesn't understand why and keeps rephrasing and retrying — both sides burn money and nothing ever moves forward.

Note the fallback in context.localDenialTracking ?? appState.denialTracking:

“Local denial tracking state for async subagents whose setAppState is a no-op. Without this, the denial counter never accumulates and the fallback-to-prompting threshold is never reached.”

Put plainly: a local denial-tracking state for async subagents, whose global-state setter is a no-op. Without it, the denial counter never accumulates, and the “fall back to human confirmation” threshold is never reached.

This is a classic “side effect of architectural isolation”: to keep subagents from polluting main-thread state, their setAppState was made a no-op. But that also means any mechanism that relies on accumulating state stops working inside a subagent. So it gets a local copy.

7.4 Permission Rule Syntax

Users can write permission rules in the config file. The syntax has two levels:

SyntaxMeaning
BashWhole-tool level. Matches every Bash call
Bash(git:*)Content level. Matches only commands starting with git
Bash(npm publish:*)Matches only commands starting with npm publish
Edit(src/**)Matches only file edits under the src directory
mcp__serverMCP server-prefix level. Matches every tool from that server

Three kinds of rules: alwaysAllowRules (always allow), alwaysDenyRules (always deny), and alwaysAskRules (always ask).

Whole-tool deny rules take effect “before the model ever sees the tool”

/**
 * Filters out tools that are blanket-denied by the permission context.
 * A tool is filtered out if there's a deny rule matching its name with no
 * ruleContent (i.e., a blanket deny for that tool).
 *
 * Uses the same matcher as the runtime permission check (step 1a), so MCP
 * server-prefix rules like `mcp__server` strip all tools from that server
 * before the model sees them — not just at call time.
 */
export function filterToolsByDenyRules<T>(tools, permissionContext): T[] {
  return tools.filter(tool => !getDenyRuleForTool(permissionContext, tool))
}

This is an important distinction: a “whole-tool deny” doesn't intercept at call time; it keeps the tool out of the model's tool list altogether.

The difference is big:
· Intercept at call time → the model tries the call, gets denied, then gets confused and tries another way, wasting several turns
· Absent from the list → the model doesn't know the capability exists and takes a different route from the start

Content-level rules (Bash(git:*)) can't be filtered at the list level — the Bash tool itself has to stay; only certain arguments need blocking. So they can only be judged at call time.

Shadowed-rule detection

utils/permissions/ has a file called shadowedRuleDetection.ts. The problem it solves:

The user configures two rules: 1. allow Bash(git:*) — allow every git command 2. ask Bash(git push:*) — but ask me first for git push If the match order is wrong, rule 2 never takes effect — because rule 1 is broader, matches first, and allows. Rule 2 has been "shadowed." Shadowed-rule detection finds this kind of misconfiguration and warns the user.

7.5 Sandboxing and Read-Only Command Detection

Read-only commands are allowed automatically

utils/shell/readOnlyCommandValidation.ts, 66.7 KB. Its job is to decide “is this shell command read-only?” If so, it can be allowed automatically without bothering the user.

That's much harder than it looks, because it has to handle:

  • Pipes and redirectsls | grep foo is read-only; ls > out.txt is not
  • Command substitutionecho $(rm -rf /) hides a write inside
  • Compound commandscd /tmp && ls contains two commands, and both need judging
  • Aliases and functions — the user may have aliased ls to something else

So the utils/bash/ directory contains a complete shell grammar parser: bashParser.ts (128 KB) + ast.ts (109 KB). It parses shell commands into an abstract syntax tree and analyzes the tree, instead of regex-matching strings.

And there's an experimental alternative implementation: the compile-time flags include TREE_SITTER_BASH and TREE_SITTER_BASH_SHADOW — the latter's name (shadow) shows they're validating the new parser in shadow mode: both parsers run at the same time, disagreements are logged, but the old parser's result is still the one used. That collects accuracy data on the new implementation at zero risk.

OS-level sandboxing

On macOS, Claude Code uses the system's built-in sandbox-exec mechanism (also known as seatbelt). It applies a policy file when a process launches, restricting which paths the process can access and whether it can reach the network.

Step 1b of the permission chain has a special branch tied to the sandbox:

// 1b. Check if the entire tool should always ask for permission
const askRule = getAskRuleForTool(...)
if (askRule) {
  // When "auto-allow inside the sandbox" is on, sandboxable commands skip the ask rule
  // and are auto-allowed through Bash's checkPermissions.
  // Commands that won't be sandboxed (on the exclude list, or with sandboxing explicitly off) still obey the ask rule.
  if (!canSandboxAutoAllow) {
    return { behavior:'ask', ... }
  }
  // otherwise fall through and let Bash's checkPermissions handle the per-command rules
}

The logic: if this command will run inside the sandbox, it doesn't matter that it “looks dangerous” — the sandbox will contain it. So the ask can be skipped. That's “trading stronger isolation for fewer interruptions.”

7.6 The Full Data Structure Behind a Permission Decision

export type ToolPermissionContext = DeepImmutable<{
  mode: PermissionMode
  additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
  alwaysAllowRules: ToolPermissionRulesBySource
  alwaysDenyRules:  ToolPermissionRulesBySource
  alwaysAskRules:   ToolPermissionRulesBySource
  isBypassPermissionsModeAvailable: boolean
  isAutoModeAvailable?: boolean
  strippedDangerousRules?: ToolPermissionRulesBySource   // ★ dangerous rules that were stripped
  shouldAvoidPermissionPrompts?: boolean                 // background tasks: can't show a dialog
  awaitAutomatedChecksBeforeDialog?: boolean
  prePlanMode?: PermissionMode                           // mode before entering plan mode, for restoring
}>

Two fields deserve attention:

strippedDangerousRules: rules the system actively strips out

A user's config may contain rules that are “so broad they're dangerous.” The system strips them at load time and records what it stripped (so the UI can tell the user “this rule of yours was ignored because it's too broad”).

The concrete stripping logic is visible in the source, for example:

  • isOverlyBroadPowerShellAllowRule — strips allow-everything rules like PowerShell(*)
  • isDangerousPowerShellPermission — strips allow rules with prefixes like iex (download-and-execute) and Start-Process

DeepImmutable: immutability at the type level

This wrapper type makes the entire permission context completely read-only at the type-system level — any code that tries to mutate it fails to compile. Changes to permission state have to go through the dedicated applyPermissionUpdates() function, which guarantees every change passes through one unified validation and persistence path.

7.7 Explainability of Permission Decisions

Every permission decision carries a decisionReason field:

{ type: 'rule',        rule: {...} }            // a rule matched
{ type: 'mode',        mode: 'auto' }           // because of the current mode
{ type: 'hook',        hookName: 'PermissionRequest', reason: ... }  // decided by a hook
{ type: 'safetyCheck', classifierApprovable: false }   // safety check
{ type: 'asyncAgent',  reason: '...' }          // background task can't interact

And a dedicated module, permissionExplainer.ts, translates these reasons into plain language for the user.

Explainability is a hard requirement for a permission system, not a nice-to-have.

When a user sees “this operation was denied” with no idea why, their first instinct is to turn the whole permission system off. But if they see “because line 12 of your ~/.claude/settings.json has deny: Bash(rm:*),” they know exactly what to change.

A security system that can't explain its own decisions eventually gets bypassed by its users.